diff options
Diffstat (limited to 'crates')
| -rw-r--r-- | crates/mozart-core/src/config_source.rs | 480 | ||||
| -rw-r--r-- | crates/mozart-core/src/lib.rs | 1 | ||||
| -rw-r--r-- | crates/mozart/src/commands.rs | 1 | ||||
| -rw-r--r-- | crates/mozart/src/commands/base_config.rs | 35 | ||||
| -rw-r--r-- | crates/mozart/src/commands/config_helpers.rs | 55 | ||||
| -rw-r--r-- | crates/mozart/src/commands/repository.rs | 488 |
6 files changed, 775 insertions, 285 deletions
diff --git a/crates/mozart-core/src/config_source.rs b/crates/mozart-core/src/config_source.rs new file mode 100644 index 0000000..e5c3536 --- /dev/null +++ b/crates/mozart-core/src/config_source.rs @@ -0,0 +1,480 @@ +use std::path::{Path, PathBuf}; + +use anyhow::anyhow; + +pub struct JsonConfigSource { + path: PathBuf, + auth_config: bool, +} + +impl JsonConfigSource { + pub fn new(path: impl Into<PathBuf>, auth_config: bool) -> Self { + Self { + path: path.into(), + auth_config, + } + } + + pub fn name(&self) -> &Path { + &self.path + } + + pub fn read(&self) -> anyhow::Result<serde_json::Value> { + if !self.path.exists() { + return if self.auth_config { + Ok(serde_json::json!({})) + } else { + Ok(serde_json::json!({"config": {}})) + }; + } + let content = std::fs::read_to_string(&self.path)?; + serde_json::from_str(&content) + .map_err(|e| anyhow!("Failed to parse JSON from {}: {}", self.path.display(), e)) + } + + fn write(&self, value: &serde_json::Value) -> anyhow::Result<()> { + if let Some(parent) = self.path.parent() + && !parent.as_os_str().is_empty() + { + std::fs::create_dir_all(parent)?; + } + crate::package::write_to_file(value, &self.path) + } + + /// Convert assoc-keyed `repositories` object to list format in-place. + /// Mirrors the normalization in Composer's `addRepository` / `insertRepository` fallback. + fn normalize_to_list(root: &mut serde_json::Value) { + if !root["repositories"].is_object() { + return; + } + let obj = root["repositories"].as_object().unwrap().clone(); + let list: Vec<serde_json::Value> = obj + .iter() + .map(|(key, val)| { + if let Some(inner) = val.as_object() { + let mut entry = serde_json::Map::new(); + if !inner.contains_key("name") { + entry.insert( + "name".to_string(), + serde_json::Value::String(key.clone()), + ); + } + for (k, v) in inner { + entry.insert(k.clone(), v.clone()); + } + serde_json::Value::Object(entry) + } else { + let mut m = serde_json::Map::new(); + m.insert(key.clone(), val.clone()); + serde_json::Value::Object(m) + } + }) + .collect(); + root["repositories"] = serde_json::Value::Array(list); + } + + fn make_disabled(name: &str) -> serde_json::Value { + let mut m = serde_json::Map::new(); + m.insert(name.to_string(), serde_json::Value::Bool(false)); + serde_json::Value::Object(m) + } + + fn is_disabled_entry(val: &serde_json::Value, name: &str) -> bool { + val.as_object() + .map(|obj| obj.len() == 1 && obj.get(name) == Some(&serde_json::Value::Bool(false))) + .unwrap_or(false) + } + + fn cleanup_empty_repos(root: &mut serde_json::Value) { + let is_empty = match &root["repositories"] { + serde_json::Value::Array(a) => a.is_empty(), + serde_json::Value::Object(o) => o.is_empty(), + _ => false, + }; + if is_empty + && let Some(obj) = root.as_object_mut() + { + obj.remove("repositories"); + } + } + + /// Mirror of Composer's `JsonConfigSource::addRepository`. + /// + /// When `config` is `Value::Bool(false)`, writes a `{name: false}` disable entry. + /// Otherwise injects `"name"` into the config object if absent, removes duplicate + /// entries by name, then prepends or appends depending on `append`. + pub fn add_repository( + &self, + name: &str, + config: &serde_json::Value, + append: bool, + ) -> anyhow::Result<()> { + // TODO: JsonManipulator fast path to preserve original formatting + let mut root = self.read()?; + Self::normalize_to_list(&mut root); + + if !root["repositories"].is_array() { + root["repositories"] = serde_json::json!([]); + } + + if config == &serde_json::Value::Bool(false) { + // Find any existing entry that has the repo by name, or an existing disable entry + let (match_by_name, already_disabled) = { + let repos = root["repositories"].as_array().unwrap(); + let mut by_name: Option<usize> = None; + let mut disabled = false; + for (i, repo) in repos.iter().enumerate() { + if repo.get("name").and_then(|n| n.as_str()) == Some(name) { + by_name = Some(i); + break; + } + if Self::is_disabled_entry(repo, name) { + disabled = true; + break; + } + } + (by_name, disabled) + }; + + if already_disabled { + return Ok(()); + } + if let Some(idx) = match_by_name { + root["repositories"][idx] = Self::make_disabled(name); + } else { + root["repositories"] + .as_array_mut() + .unwrap() + .push(Self::make_disabled(name)); + } + } else { + let mut entry = config.clone(); + if let Some(obj) = config.as_object() + && !obj.contains_key("name") + && !name.is_empty() + { + let mut new_map = serde_json::Map::new(); + new_map.insert( + "name".to_string(), + serde_json::Value::String(name.to_string()), + ); + for (k, v) in obj { + new_map.insert(k.clone(), v.clone()); + } + entry = serde_json::Value::Object(new_map); + } + + let repos = root["repositories"].as_array_mut().unwrap(); + repos.retain(|val| { + val.get("name").and_then(|n| n.as_str()) != Some(name) + && !Self::is_disabled_entry(val, name) + }); + if append { + repos.push(entry); + } else { + repos.insert(0, entry); + } + } + + Self::cleanup_empty_repos(&mut root); + self.write(&root) + } + + /// Mirror of Composer's `JsonConfigSource::insertRepository`. + /// + /// `offset = 0` inserts before `reference_name`; `offset = 1` inserts after. + pub fn insert_repository( + &self, + name: &str, + config: &serde_json::Value, + reference_name: &str, + offset: u32, + ) -> anyhow::Result<()> { + // TODO: JsonManipulator fast path to preserve original formatting + let mut root = self.read()?; + Self::normalize_to_list(&mut root); + + if !root["repositories"].is_array() { + root["repositories"] = serde_json::json!([]); + } + + { + let repos = root["repositories"].as_array_mut().unwrap(); + repos.retain(|val| { + val.get("name").and_then(|n| n.as_str()) != Some(name) + && !Self::is_disabled_entry(val, name) + }); + } + + let index_to_insert = { + let repos = root["repositories"].as_array().unwrap(); + repos + .iter() + .position(|repo| { + repo.get("name").and_then(|n| n.as_str()) == Some(reference_name) + || Self::is_disabled_entry(repo, reference_name) + }) + .ok_or_else(|| { + anyhow!( + "The referenced repository \"{}\" does not exist.", + reference_name + ) + })? + }; + + let mut entry = config.clone(); + if let Some(obj) = config.as_object() + && !obj.contains_key("name") + && !name.is_empty() + { + let mut new_map = serde_json::Map::new(); + new_map.insert( + "name".to_string(), + serde_json::Value::String(name.to_string()), + ); + for (k, v) in obj { + new_map.insert(k.clone(), v.clone()); + } + entry = serde_json::Value::Object(new_map); + } + + root["repositories"] + .as_array_mut() + .unwrap() + .insert(index_to_insert + offset as usize, entry); + + Self::cleanup_empty_repos(&mut root); + self.write(&root) + } + + /// Mirror of Composer's `JsonConfigSource::setRepositoryUrl`. + /// + /// Handles both assoc-keyed and list-format repositories without converting + /// between the two shapes (preserves existing format). + pub fn set_repository_url(&self, name: &str, url: &str) -> anyhow::Result<()> { + // TODO: JsonManipulator fast path to preserve original formatting + let mut root = self.read()?; + let url_val = serde_json::Value::String(url.to_string()); + + // Assoc-keyed fast path (mirrors Composer's `if ($name === $index)` branch) + let in_assoc = root["repositories"] + .as_object() + .and_then(|obj| obj.get(name)) + .and_then(|v| v.as_object()) + .is_some(); + if in_assoc { + root["repositories"][name]["url"] = url_val; + return self.write(&root); + } + + // List format: find entry by `name` field + let idx = root["repositories"].as_array().and_then(|repos| { + repos.iter().position(|repo| { + repo.get("name").and_then(|n| n.as_str()) == Some(name) + }) + }); + + match idx { + Some(i) => { + root["repositories"][i]["url"] = url_val; + self.write(&root) + } + None => Err(anyhow!("Repository \"{}\" not found", name)), + } + } + + /// Mirror of Composer's `JsonConfigSource::removeRepository`. + /// + /// Handles assoc-keyed and list-format repositories. Removes the `repositories` + /// key entirely when the list becomes empty (mirrors Composer L219–221). + pub fn remove_repository(&self, name: &str) -> anyhow::Result<()> { + // TODO: JsonManipulator fast path to preserve original formatting + let mut root = self.read()?; + + // Assoc-keyed format + let in_assoc = root["repositories"] + .as_object() + .map(|obj| obj.contains_key(name)) + .unwrap_or(false); + if in_assoc { + root["repositories"].as_object_mut().unwrap().remove(name); + Self::cleanup_empty_repos(&mut root); + return self.write(&root); + } + + // List format + if let Some(repos) = root["repositories"].as_array_mut() { + repos.retain(|val| { + val.get("name").and_then(|n| n.as_str()) != Some(name) + && !Self::is_disabled_entry(val, name) + }); + } + + Self::cleanup_empty_repos(&mut root); + self.write(&root) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use tempfile::TempDir; + + fn source(dir: &TempDir, filename: &str) -> (JsonConfigSource, std::path::PathBuf) { + let path = dir.path().join(filename); + (JsonConfigSource::new(path.clone(), false), path) + } + + #[test] + fn add_repository_prepend() { + let dir = TempDir::new().unwrap(); + let (src, path) = source(&dir, "composer.json"); + std::fs::write(&path, r#"{"repositories":[{"name":"a","type":"vcs","url":"https://a.com"}]}"#).unwrap(); + src.add_repository( + "b", + &serde_json::json!({"type": "vcs", "url": "https://b.com"}), + false, + ) + .unwrap(); + let json: serde_json::Value = + serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap(); + assert_eq!(json["repositories"][0]["name"], "b"); + assert_eq!(json["repositories"][1]["name"], "a"); + } + + #[test] + fn add_repository_append() { + let dir = TempDir::new().unwrap(); + let (src, path) = source(&dir, "composer.json"); + std::fs::write(&path, r#"{"repositories":[{"name":"a","type":"vcs","url":"https://a.com"}]}"#).unwrap(); + src.add_repository( + "b", + &serde_json::json!({"type": "vcs", "url": "https://b.com"}), + true, + ) + .unwrap(); + let json: serde_json::Value = + serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap(); + assert_eq!(json["repositories"][0]["name"], "a"); + assert_eq!(json["repositories"][1]["name"], "b"); + } + + #[test] + fn add_repository_disable() { + let dir = TempDir::new().unwrap(); + let (src, path) = source(&dir, "composer.json"); + std::fs::write(&path, "{}").unwrap(); + src.add_repository("packagist.org", &serde_json::Value::Bool(false), true) + .unwrap(); + let json: serde_json::Value = + serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap(); + assert_eq!(json["repositories"][0]["packagist.org"], false); + } + + #[test] + fn add_repository_disable_already_disabled_is_noop() { + let dir = TempDir::new().unwrap(); + let (src, path) = source(&dir, "composer.json"); + std::fs::write( + &path, + r#"{"repositories":[{"packagist.org":false}]}"#, + ) + .unwrap(); + src.add_repository("packagist.org", &serde_json::Value::Bool(false), true) + .unwrap(); + let json: serde_json::Value = + serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap(); + // Still just one entry + assert_eq!(json["repositories"].as_array().unwrap().len(), 1); + } + + #[test] + fn remove_repository_list_format() { + let dir = TempDir::new().unwrap(); + let (src, path) = source(&dir, "composer.json"); + std::fs::write( + &path, + r#"{"repositories":[{"name":"foo","type":"vcs","url":"https://foo.com"}]}"#, + ) + .unwrap(); + src.remove_repository("foo").unwrap(); + let json: serde_json::Value = + serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap(); + assert!(json.get("repositories").is_none()); + } + + #[test] + fn remove_repository_assoc_format() { + let dir = TempDir::new().unwrap(); + let (src, path) = source(&dir, "composer.json"); + std::fs::write( + &path, + r#"{"repositories":{"foo":{"type":"vcs","url":"https://foo.com"}}}"#, + ) + .unwrap(); + src.remove_repository("foo").unwrap(); + let json: serde_json::Value = + serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap(); + assert!(json.get("repositories").is_none()); + } + + #[test] + fn insert_repository_before() { + let dir = TempDir::new().unwrap(); + let (src, path) = source(&dir, "composer.json"); + std::fs::write( + &path, + r#"{"repositories":[{"name":"a","type":"vcs","url":"https://a.com"},{"name":"b","type":"vcs","url":"https://b.com"}]}"#, + ) + .unwrap(); + src.insert_repository( + "new", + &serde_json::json!({"type": "vcs", "url": "https://new.com"}), + "b", + 0, + ) + .unwrap(); + let json: serde_json::Value = + serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap(); + assert_eq!(json["repositories"][0]["name"], "a"); + assert_eq!(json["repositories"][1]["name"], "new"); + assert_eq!(json["repositories"][2]["name"], "b"); + } + + #[test] + fn insert_repository_after() { + let dir = TempDir::new().unwrap(); + let (src, path) = source(&dir, "composer.json"); + std::fs::write( + &path, + r#"{"repositories":[{"name":"a","type":"vcs","url":"https://a.com"},{"name":"b","type":"vcs","url":"https://b.com"}]}"#, + ) + .unwrap(); + src.insert_repository( + "new", + &serde_json::json!({"type": "vcs", "url": "https://new.com"}), + "a", + 1, + ) + .unwrap(); + let json: serde_json::Value = + serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap(); + assert_eq!(json["repositories"][0]["name"], "a"); + assert_eq!(json["repositories"][1]["name"], "new"); + assert_eq!(json["repositories"][2]["name"], "b"); + } + + #[test] + fn insert_repository_reference_not_found() { + let dir = TempDir::new().unwrap(); + let (src, path) = source(&dir, "composer.json"); + std::fs::write(&path, r#"{"repositories":[]}"#).unwrap(); + let result = src.insert_repository( + "new", + &serde_json::json!({"type": "vcs", "url": "https://new.com"}), + "nonexistent", + 0, + ); + assert!(result.is_err()); + } +} diff --git a/crates/mozart-core/src/lib.rs b/crates/mozart-core/src/lib.rs index d898b62..7403d46 100644 --- a/crates/mozart-core/src/lib.rs +++ b/crates/mozart-core/src/lib.rs @@ -2,6 +2,7 @@ extern crate self as mozart_core; pub mod composer; pub mod config; +pub mod config_source; pub mod config_validator; pub mod console; pub mod exit_code; diff --git a/crates/mozart/src/commands.rs b/crates/mozart/src/commands.rs index 44ca07d..bf98bee 100644 --- a/crates/mozart/src/commands.rs +++ b/crates/mozart/src/commands.rs @@ -1,5 +1,6 @@ pub mod about; pub mod archive; +pub(crate) mod base_config; pub mod audit; pub mod browse; pub mod bump; diff --git a/crates/mozart/src/commands/base_config.rs b/crates/mozart/src/commands/base_config.rs new file mode 100644 index 0000000..be663d5 --- /dev/null +++ b/crates/mozart/src/commands/base_config.rs @@ -0,0 +1,35 @@ +use std::path::PathBuf; + +use mozart_core::config_source::JsonConfigSource; + +use super::config_helpers::composer_home; + +/// Mirrors Composer's `BaseConfigCommand`: resolves the target config file path +/// and enforces the `--file` ↔ `--global` mutual exclusivity. +pub(crate) struct BaseConfigContext { + pub config_source: JsonConfigSource, +} + +impl BaseConfigContext { + pub fn initialize( + global: bool, + file: Option<&str>, + cli: &super::Cli, + ) -> anyhow::Result<Self> { + if global && file.is_some() { + anyhow::bail!("--file and --global can not be combined"); + } + + let path: PathBuf = if global { + composer_home().join("config.json") + } else if let Some(f) = file { + PathBuf::from(f) + } else { + cli.working_dir()?.join("composer.json") + }; + + Ok(Self { + config_source: JsonConfigSource::new(path, false), + }) + } +} diff --git a/crates/mozart/src/commands/config_helpers.rs b/crates/mozart/src/commands/config_helpers.rs index c5cd187..f0aacbb 100644 --- a/crates/mozart/src/commands/config_helpers.rs +++ b/crates/mozart/src/commands/config_helpers.rs @@ -112,28 +112,6 @@ pub(crate) fn normalize_repositories(value: &serde_json::Value) -> Vec<serde_jso } } -/// Convert a Composer-style associative `repositories` object to Mozart's -/// array-of-objects format in-place. If `repositories` is already an array -/// (or absent), this is a no-op. -pub(crate) fn ensure_repositories_array(json: &mut serde_json::Value) { - if json["repositories"].is_object() { - let normalized = normalize_repositories(&json["repositories"].clone()); - json["repositories"] = serde_json::Value::Array(normalized); - } -} - -/// Find the index of a repository entry by name in a slice of normalized -/// repository values. Matches against the `"name"` field. -pub(crate) fn find_repo_by_name(repos: &[serde_json::Value], name: &str) -> Option<usize> { - repos.iter().position(|entry| { - entry - .get("name") - .and_then(|n| n.as_str()) - .map(|n| n == name) - .unwrap_or(false) - }) -} - /// Add a repository entry to the `repositories` array in json. /// If `append` is true, push to end; otherwise insert at beginning. /// Removes any existing entry with the same name first. @@ -174,39 +152,6 @@ pub(crate) fn remove_repository(json: &mut serde_json::Value, name: &str) { } } -/// Insert a repository entry before or after a named repository. -/// Returns an error if the target repository is not found. -pub(crate) fn insert_repository( - json: &mut serde_json::Value, - name: &str, - config: serde_json::Value, - target: &str, - before: bool, -) -> anyhow::Result<()> { - if !json["repositories"].is_array() { - json["repositories"] = serde_json::json!([]); - } - - remove_repository(json, name); - - let repos = json["repositories"].as_array_mut().unwrap(); - - let pos = repos - .iter() - .position(|entry| { - entry.get("name").and_then(|n| n.as_str()) == Some(target) - || entry - .as_object() - .map(|obj| obj.contains_key(target)) - .unwrap_or(false) - }) - .ok_or_else(|| anyhow!("Repository \"{target}\" not found"))?; - - let insert_pos = if before { pos } else { pos + 1 }; - repos.insert(insert_pos, config); - Ok(()) -} - /// Render a `serde_json::Value` as a human-readable string suitable for /// single-line display (matching Composer's behaviour). pub(crate) fn render_value(v: &serde_json::Value) -> String { diff --git a/crates/mozart/src/commands/repository.rs b/crates/mozart/src/commands/repository.rs index c2d2bd8..318450a 100644 --- a/crates/mozart/src/commands/repository.rs +++ b/crates/mozart/src/commands/repository.rs @@ -1,12 +1,9 @@ use anyhow::anyhow; use clap::Args; use mozart_core::console_writeln; -use std::path::PathBuf; -use super::config_helpers::{ - add_repository, composer_home, ensure_repositories_array, find_repo_by_name, insert_repository, - normalize_repositories, read_json_file, remove_repository, render_value, write_json_file, -}; +use super::base_config::BaseConfigContext; +use super::config_helpers::{normalize_repositories, render_value}; #[derive(Args)] pub struct RepositoryArgs { @@ -43,61 +40,75 @@ pub struct RepositoryArgs { pub after: Option<String>, } -fn resolve_file_path(args: &RepositoryArgs, cli: &super::Cli) -> anyhow::Result<PathBuf> { - if args.global && args.file.is_some() { - anyhow::bail!("Cannot combine --global and --file"); - } - if args.global { - return Ok(composer_home().join("config.json")); - } - if let Some(ref file) = args.file { - return Ok(PathBuf::from(file)); - } - Ok(cli.working_dir()?.join("composer.json")) -} - pub async fn execute( args: &RepositoryArgs, cli: &super::Cli, console: &mozart_core::console::Console, ) -> anyhow::Result<()> { let action = args.action.as_deref().unwrap_or("list"); + let ctx = BaseConfigContext::initialize(args.global, args.file.as_deref(), cli)?; match action { - "list" | "ls" | "show" => execute_list(args, cli, console), - "add" => execute_add(args, cli), - "remove" | "rm" | "delete" => execute_remove(args, cli), - "set-url" | "seturl" => execute_set_url(args, cli), - "get-url" | "geturl" => execute_get_url(args, cli, console), - "disable" => execute_disable(args, cli), - "enable" => execute_enable(args, cli), + "list" | "ls" | "show" => list_repositories(&ctx, console), + "add" => execute_add(&ctx, args), + "remove" | "rm" | "delete" => execute_remove(&ctx, args), + "set-url" | "seturl" => execute_set_url(&ctx, args), + "get-url" | "geturl" => execute_get_url(&ctx, args, console), + "disable" => execute_disable(&ctx, args), + "enable" => execute_enable(&ctx, args), _ => Err(anyhow!( - "Unknown action \"{action}\". Expected one of: list, add, remove, set-url, get-url, enable, disable" + "Unknown action \"{action}\". Use list, add, remove, set-url, get-url, enable, disable" )), } } -fn execute_list( - args: &RepositoryArgs, - cli: &super::Cli, +/// Mirror of Composer's `RepositoryCommand::listRepositories`. +/// +/// Synthesises a `[packagist.org] <disabled>` line only when no `composer`-type +/// repository with a host ending in `packagist.org` is already in the list. +fn list_repositories( + ctx: &BaseConfigContext, console: &mozart_core::console::Console, ) -> anyhow::Result<()> { - let file_path = resolve_file_path(args, cli)?; - let json = read_json_file(&file_path, args.global)?; + let json = ctx.config_source.read()?; + let repos_raw = &json["repositories"]; + let repos = normalize_repositories(repos_raw); - let mut has_packagist_disable = false; + let packagist_present = repos.iter().any(|entry| { + entry.get("type").and_then(|t| t.as_str()) == Some("composer") + && entry + .get("url") + .and_then(|u| u.as_str()) + .map(host_ends_with_packagist_org) + .unwrap_or(false) + }); - let repos = normalize_repositories(&json["repositories"]); - for entry in &repos { - if let Some(obj) = entry.as_object() { - // Check for disabled repo entry like {"packagist.org": false} - if let Some((key, _)) = obj.iter().find(|(_, v)| v == &&serde_json::json!(false)) { - console_writeln!(console, &format!("[{key}] disabled"),); - if key == "packagist.org" { - has_packagist_disable = true; - } - continue; - } + // When no packagist.org-hosted composer repo is present, synthesise the + // disabled-packagist line exactly as Composer does (appending it to the list + // for display purposes only — not written to disk). + let mut display_repos = repos; + if !packagist_present { + let mut m = serde_json::Map::new(); + m.insert( + "packagist.org".to_string(), + serde_json::Value::Bool(false), + ); + display_repos.push(serde_json::Value::Object(m)); + } + + if display_repos.is_empty() { + console_writeln!(console, "No repositories configured"); + return Ok(()); + } + + for entry in &display_repos { + if let Some(obj) = entry.as_object() + && obj.len() == 1 + && let Some((key, val)) = obj.iter().next() + && val == &serde_json::Value::Bool(false) + { + console_writeln!(console, &format!("[{key}] disabled")); + continue; } let name = entry @@ -108,220 +119,159 @@ fn execute_list( .get("type") .and_then(|t| t.as_str()) .unwrap_or("unknown"); - let url = entry.get("url").and_then(|u| u.as_str()).unwrap_or(""); - - console_writeln!(console, &format!("[{name}] {repo_type} {url}"),); - } + let url = entry + .get("url") + .map(render_value) + .unwrap_or_default(); - if !has_packagist_disable { - console_writeln!( - console, - "[packagist.org] composer https://repo.packagist.org", - ); + console_writeln!(console, &format!("[{name}] {repo_type} {url}")); } Ok(()) } -fn execute_add(args: &RepositoryArgs, cli: &super::Cli) -> anyhow::Result<()> { - let name = args - .name - .as_deref() - .ok_or_else(|| anyhow!("Repository name is required for \"add\""))?; +fn host_ends_with_packagist_org(url: &str) -> bool { + let after_scheme = url.split("://").nth(1).unwrap_or(url); + let host_port = after_scheme.split('/').next().unwrap_or(""); + let host = host_port.split(':').next().unwrap_or(""); + host == "packagist.org" || host.ends_with(".packagist.org") +} - if args.before.is_some() && args.after.is_some() { - anyhow::bail!("Cannot combine --before and --after"); - } +fn execute_add(ctx: &BaseConfigContext, args: &RepositoryArgs) -> anyhow::Result<()> { + let name = args.name.as_deref().ok_or_else(|| { + anyhow!("You must pass a repository name. Example: mozart repo add foo vcs https://example.org") + })?; - let entry = match (&args.arg1, &args.arg2) { - (Some(type_or_json), Some(url)) => { - // type + url - serde_json::json!({ - "name": name, - "type": type_or_json, - "url": url, - }) - } - (Some(json_str), None) => { - // Try to parse as JSON - match serde_json::from_str::<serde_json::Value>(json_str) { - Ok(mut parsed) => { - // Inject the name if not already present - if let Some(obj) = parsed.as_object_mut() - && !obj.contains_key("name") - { - obj.insert("name".to_string(), serde_json::json!(name)); - } - parsed - } - Err(_) => { - anyhow::bail!( - "Invalid JSON for repository config. Expected: <type> <url> or a JSON string" - ); - } - } - } - _ => { - anyhow::bail!( - "Missing arguments for \"add\". Expected: <name> <type> <url> or <name> <json>" - ); - } + let arg1 = args.arg1.as_deref().ok_or_else(|| { + anyhow!("You must pass the type and a url, or a JSON string.") + })?; + + // Mirror Composer's `Preg::isMatch('{^\s*\{}', $arg1)` check. + let repo_config = if arg1.trim_start().starts_with('{') { + serde_json::from_str::<serde_json::Value>(arg1) + .map_err(|e| anyhow!("Invalid JSON: {}", e))? + } else { + let url = args.arg2.as_deref().ok_or_else(|| { + anyhow!("You must pass the type and a url. Example: mozart repo add foo vcs https://example.org") + })?; + serde_json::json!({"type": arg1, "url": url}) }; - let file_path = resolve_file_path(args, cli)?; - let mut json = read_json_file(&file_path, args.global)?; + if args.before.is_some() && args.after.is_some() { + anyhow::bail!("You can not combine --before and --after"); + } if let Some(ref target) = args.before { - insert_repository(&mut json, name, entry, target, true)?; + ctx.config_source + .insert_repository(name, &repo_config, target, 0)?; } else if let Some(ref target) = args.after { - insert_repository(&mut json, name, entry, target, false)?; + ctx.config_source + .insert_repository(name, &repo_config, target, 1)?; } else { - add_repository(&mut json, name, entry, args.append); + ctx.config_source + .add_repository(name, &repo_config, args.append)?; } - write_json_file(&file_path, &json)?; Ok(()) } -fn execute_remove(args: &RepositoryArgs, cli: &super::Cli) -> anyhow::Result<()> { +fn execute_remove(ctx: &BaseConfigContext, args: &RepositoryArgs) -> anyhow::Result<()> { let name = args .name .as_deref() - .ok_or_else(|| anyhow!("Repository name is required for \"remove\""))?; - - let file_path = resolve_file_path(args, cli)?; - let mut json = read_json_file(&file_path, args.global)?; - - ensure_repositories_array(&mut json); + .ok_or_else(|| anyhow!("You must pass the repository name to remove."))?; + ctx.config_source.remove_repository(name)?; if name == "packagist.org" || name == "packagist" { - // Removing packagist.org means disabling it - remove_repository(&mut json, "packagist.org"); - let disable_entry = serde_json::json!({"packagist.org": false}); - add_repository(&mut json, "packagist.org", disable_entry, args.append); - } else { - remove_repository(&mut json, name); - } - - // Clean up empty repositories array - if json["repositories"] - .as_array() - .map(|a| a.is_empty()) - .unwrap_or(false) - && let Some(obj) = json.as_object_mut() - { - obj.remove("repositories"); + // Removing packagist means disabling it (Composer behaviour). + // Default append=false so the disable entry goes to the front when + // the user didn't pass --append. + ctx.config_source + .add_repository("packagist.org", &serde_json::Value::Bool(false), args.append)?; } - write_json_file(&file_path, &json)?; Ok(()) } -fn execute_set_url(args: &RepositoryArgs, cli: &super::Cli) -> anyhow::Result<()> { +fn execute_set_url(ctx: &BaseConfigContext, args: &RepositoryArgs) -> anyhow::Result<()> { let name = args .name .as_deref() - .ok_or_else(|| anyhow!("Repository name is required for \"set-url\""))?; + .ok_or_else(|| anyhow!("Usage: mozart repo set-url <name> <new-url>"))?; let new_url = args .arg1 .as_deref() - .ok_or_else(|| anyhow!("New URL is required for \"set-url\""))?; + .ok_or_else(|| anyhow!("Usage: mozart repo set-url <name> <new-url>"))?; - let file_path = resolve_file_path(args, cli)?; - let mut json = read_json_file(&file_path, args.global)?; - - ensure_repositories_array(&mut json); - - let found = json["repositories"].as_array_mut().and_then(|repos| { - repos - .iter_mut() - .find(|entry| entry.get("name").and_then(|n| n.as_str()) == Some(name)) - }); - - match found { - Some(entry) => { - entry["url"] = serde_json::json!(new_url); - write_json_file(&file_path, &json)?; - Ok(()) - } - None => Err(anyhow!("Repository \"{name}\" not found")), - } + ctx.config_source.set_repository_url(name, new_url)?; + Ok(()) } fn execute_get_url( + ctx: &BaseConfigContext, args: &RepositoryArgs, - cli: &super::Cli, console: &mozart_core::console::Console, ) -> anyhow::Result<()> { let name = args .name .as_deref() - .ok_or_else(|| anyhow!("Repository name is required for \"get-url\""))?; + .ok_or_else(|| anyhow!("Usage: mozart repo get-url <name>"))?; - let file_path = resolve_file_path(args, cli)?; - let json = read_json_file(&file_path, args.global)?; + let json = ctx.config_source.read()?; + let repos_raw = &json["repositories"]; - let repos = normalize_repositories(&json["repositories"]); - - match find_repo_by_name(&repos, name) { - Some(idx) => { - let entry = &repos[idx]; - match entry.get("url") { - Some(url_val) => { - console_writeln!(console, &render_value(url_val),); - Ok(()) - } - None => Err(anyhow!("The \"{name}\" repository does not have a URL")), - } + // Assoc-keyed fast path (mirrors Composer's `isset($repos[$name])` check). + if let Some(repo) = repos_raw.as_object().and_then(|obj| obj.get(name)) { + if let Some(url) = repo.get("url").and_then(|u| u.as_str()) { + console_writeln!(console, url); + return Ok(()); } - None => Err(anyhow!("There is no \"{name}\" repository defined")), + anyhow::bail!("The {} repository does not have a URL", name); } -} -fn execute_disable(args: &RepositoryArgs, cli: &super::Cli) -> anyhow::Result<()> { - let name = args.name.as_deref().unwrap_or("packagist.org"); - - if name != "packagist.org" && name != "packagist" { - anyhow::bail!("Only \"packagist.org\" can be disabled with this action"); + // List-format scan (mirrors Composer's fallback `foreach ($repos as $val)`). + let repos = normalize_repositories(repos_raw); + for repo in &repos { + if repo.get("name").and_then(|n| n.as_str()) == Some(name) { + if let Some(url) = repo.get("url").and_then(|u| u.as_str()) { + console_writeln!(console, url); + return Ok(()); + } + anyhow::bail!("The {} repository does not have a URL", name); + } } - let file_path = resolve_file_path(args, cli)?; - let mut json = read_json_file(&file_path, args.global)?; - - // Remove any existing packagist.org disable entry first - remove_repository(&mut json, "packagist.org"); - - let disable_entry = serde_json::json!({"packagist.org": false}); - add_repository(&mut json, "packagist.org", disable_entry, args.append); - - write_json_file(&file_path, &json)?; - Ok(()) + Err(anyhow!("There is no {} repository defined", name)) } -fn execute_enable(args: &RepositoryArgs, cli: &super::Cli) -> anyhow::Result<()> { - let name = args.name.as_deref().unwrap_or("packagist.org"); +fn execute_disable(ctx: &BaseConfigContext, args: &RepositoryArgs) -> anyhow::Result<()> { + let name = args + .name + .as_deref() + .ok_or_else(|| anyhow!("Usage: mozart repo disable packagist.org"))?; - if name != "packagist.org" && name != "packagist" { - anyhow::bail!("Only \"packagist.org\" can be enabled with this action"); + if name == "packagist.org" || name == "packagist" { + ctx.config_source + .add_repository("packagist.org", &serde_json::Value::Bool(false), args.append)?; + return Ok(()); } - let file_path = resolve_file_path(args, cli)?; - let mut json = read_json_file(&file_path, args.global)?; + anyhow::bail!("Only packagist.org can be enabled/disabled using this command. Use add/remove for other repositories."); +} - remove_repository(&mut json, "packagist.org"); +fn execute_enable(ctx: &BaseConfigContext, args: &RepositoryArgs) -> anyhow::Result<()> { + let name = args + .name + .as_deref() + .ok_or_else(|| anyhow!("Usage: mozart repo enable packagist.org"))?; - // Clean up empty repositories array - if json["repositories"] - .as_array() - .map(|a| a.is_empty()) - .unwrap_or(false) - && let Some(obj) = json.as_object_mut() - { - obj.remove("repositories"); + if name == "packagist.org" || name == "packagist" { + // Just remove the disable override; Composer does nothing else here. + ctx.config_source.remove_repository("packagist.org")?; + return Ok(()); } - write_json_file(&file_path, &json)?; - Ok(()) + anyhow::bail!("Only packagist.org can be enabled/disabled using this command."); } #[cfg(test)] @@ -363,7 +313,7 @@ mod tests { let cli = make_cli(); let console = mozart_core::console::Console::new(0, false, false, false, false); - // Should succeed and print packagist.org + // Empty repos → synthesises [packagist.org] disabled let result = execute(&args, &cli, &console).await; assert!(result.is_ok()); } @@ -402,6 +352,27 @@ mod tests { } #[tokio::test] + async fn test_list_no_packagist_synth_when_composer_type_present() { + // When a composer-type repo pointing at packagist.org is present, + // no synthesised [packagist.org] disabled line should appear. + let dir = tempfile::TempDir::new().unwrap(); + let file = dir.path().join("composer.json"); + std::fs::write( + &file, + r#"{"repositories": [{"name": "packagist.org", "type": "composer", "url": "https://repo.packagist.org"}]}"#, + ) + .unwrap(); + + let mut args = make_args(Some("list"), None, None, None); + args.file = Some(file.to_str().unwrap().to_string()); + + let cli = make_cli(); + let console = mozart_core::console::Console::new(0, false, false, false, false); + let result = execute(&args, &cli, &console).await; + assert!(result.is_ok()); + } + + #[tokio::test] async fn test_add_type_url() { let dir = tempfile::TempDir::new().unwrap(); let file = dir.path().join("composer.json"); @@ -825,12 +796,13 @@ mod tests { } #[tokio::test] - async fn test_disable_without_name_defaults_to_packagist() { + async fn test_disable_packagist_idempotent() { + // Calling disable twice should not create a duplicate entry. let dir = tempfile::TempDir::new().unwrap(); let file = dir.path().join("composer.json"); - std::fs::write(&file, "{}").unwrap(); + std::fs::write(&file, r#"{"repositories": [{"packagist.org": false}]}"#).unwrap(); - let mut args = make_args(Some("disable"), None, None, None); + let mut args = make_args(Some("disable"), Some("packagist.org"), None, None); args.file = Some(file.to_str().unwrap().to_string()); let cli = make_cli(); @@ -840,6 +812,7 @@ mod tests { let json: serde_json::Value = serde_json::from_str(&std::fs::read_to_string(&file).unwrap()).unwrap(); let repos = json["repositories"].as_array().unwrap(); + assert_eq!(repos.len(), 1, "should still be just one disable entry"); assert_eq!(repos[0]["packagist.org"], false); } @@ -859,6 +832,22 @@ mod tests { } #[tokio::test] + async fn test_disable_without_name_error() { + // Composer requires a name for disable; Mozart mirrors that. + let dir = tempfile::TempDir::new().unwrap(); + let file = dir.path().join("composer.json"); + std::fs::write(&file, "{}").unwrap(); + + let mut args = make_args(Some("disable"), None, None, None); + args.file = Some(file.to_str().unwrap().to_string()); + + let cli = make_cli(); + let console = mozart_core::console::Console::new(0, false, false, false, false); + let result = execute(&args, &cli, &console).await; + assert!(result.is_err()); + } + + #[tokio::test] async fn test_enable_packagist() { let dir = tempfile::TempDir::new().unwrap(); let file = dir.path().join("composer.json"); @@ -971,7 +960,8 @@ mod tests { } #[tokio::test] - async fn test_set_url_composer_format_converts_and_updates() { + async fn test_set_url_composer_format_keeps_assoc_shape() { + // Composer's setRepositoryUrl mutates in place without converting assoc → list. let dir = tempfile::TempDir::new().unwrap(); let file = dir.path().join("composer.json"); std::fs::write( @@ -994,10 +984,9 @@ mod tests { let json: serde_json::Value = serde_json::from_str(&std::fs::read_to_string(&file).unwrap()).unwrap(); - // After conversion it should be an array - let repos = json["repositories"].as_array().unwrap(); - assert_eq!(repos[0]["url"], "https://new.com"); - assert_eq!(repos[0]["name"], "my-repo"); + // Format is preserved: still an assoc object. + let repos = json["repositories"].as_object().unwrap(); + assert_eq!(repos["my-repo"]["url"], "https://new.com"); } #[tokio::test] @@ -1082,47 +1071,86 @@ mod tests { assert!(result.is_err()); } - #[test] - fn test_insert_before() { - let mut json = serde_json::json!({ - "repositories": [ - {"name": "a", "type": "vcs", "url": "https://a.com"}, - {"name": "b", "type": "vcs", "url": "https://b.com"}, - ] - }); + #[tokio::test] + async fn test_insert_before() { + let dir = tempfile::TempDir::new().unwrap(); + let file = dir.path().join("composer.json"); + std::fs::write( + &file, + r#"{"repositories":[{"name":"a","type":"vcs","url":"https://a.com"},{"name":"b","type":"vcs","url":"https://b.com"}]}"#, + ) + .unwrap(); + + let mut args = make_args( + Some("add"), + Some("new"), + Some("vcs"), + Some("https://new.com"), + ); + args.file = Some(file.to_str().unwrap().to_string()); + args.before = Some("b".to_string()); - let entry = serde_json::json!({"name": "new", "type": "vcs", "url": "https://new.com"}); - insert_repository(&mut json, "new", entry, "b", true).unwrap(); + let cli = make_cli(); + let console = mozart_core::console::Console::new(0, false, false, false, false); + execute(&args, &cli, &console).await.unwrap(); + let json: serde_json::Value = + serde_json::from_str(&std::fs::read_to_string(&file).unwrap()).unwrap(); let repos = json["repositories"].as_array().unwrap(); assert_eq!(repos[0]["name"], "a"); assert_eq!(repos[1]["name"], "new"); assert_eq!(repos[2]["name"], "b"); } - #[test] - fn test_insert_after() { - let mut json = serde_json::json!({ - "repositories": [ - {"name": "a", "type": "vcs", "url": "https://a.com"}, - {"name": "b", "type": "vcs", "url": "https://b.com"}, - ] - }); + #[tokio::test] + async fn test_insert_after() { + let dir = tempfile::TempDir::new().unwrap(); + let file = dir.path().join("composer.json"); + std::fs::write( + &file, + r#"{"repositories":[{"name":"a","type":"vcs","url":"https://a.com"},{"name":"b","type":"vcs","url":"https://b.com"}]}"#, + ) + .unwrap(); - let entry = serde_json::json!({"name": "new", "type": "vcs", "url": "https://new.com"}); - insert_repository(&mut json, "new", entry, "a", false).unwrap(); + let mut args = make_args( + Some("add"), + Some("new"), + Some("vcs"), + Some("https://new.com"), + ); + args.file = Some(file.to_str().unwrap().to_string()); + args.after = Some("a".to_string()); + let cli = make_cli(); + let console = mozart_core::console::Console::new(0, false, false, false, false); + execute(&args, &cli, &console).await.unwrap(); + + let json: serde_json::Value = + serde_json::from_str(&std::fs::read_to_string(&file).unwrap()).unwrap(); let repos = json["repositories"].as_array().unwrap(); assert_eq!(repos[0]["name"], "a"); assert_eq!(repos[1]["name"], "new"); assert_eq!(repos[2]["name"], "b"); } - #[test] - fn test_insert_target_not_found() { - let mut json = serde_json::json!({"repositories": []}); - let entry = serde_json::json!({"name": "new", "type": "vcs", "url": "https://new.com"}); - let result = insert_repository(&mut json, "new", entry, "nonexistent", true); + #[tokio::test] + async fn test_insert_target_not_found() { + let dir = tempfile::TempDir::new().unwrap(); + let file = dir.path().join("composer.json"); + std::fs::write(&file, r#"{"repositories": []}"#).unwrap(); + + let mut args = make_args( + Some("add"), + Some("new"), + Some("vcs"), + Some("https://new.com"), + ); + args.file = Some(file.to_str().unwrap().to_string()); + args.before = Some("nonexistent".to_string()); + + let cli = make_cli(); + let console = mozart_core::console::Console::new(0, false, false, false, false); + let result = execute(&args, &cli, &console).await; assert!(result.is_err()); } } |
