aboutsummaryrefslogtreecommitdiffhomepage
path: root/crates
diff options
context:
space:
mode:
Diffstat (limited to 'crates')
-rw-r--r--crates/mozart-core/src/config_source.rs480
-rw-r--r--crates/mozart-core/src/lib.rs1
-rw-r--r--crates/mozart/src/commands.rs1
-rw-r--r--crates/mozart/src/commands/base_config.rs35
-rw-r--r--crates/mozart/src/commands/config_helpers.rs55
-rw-r--r--crates/mozart/src/commands/repository.rs488
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());
}
}