diff options
| author | nsfisis <nsfisis@gmail.com> | 2026-05-11 19:41:30 +0900 |
|---|---|---|
| committer | nsfisis <nsfisis@gmail.com> | 2026-05-11 19:41:30 +0900 |
| commit | 2aceeb116150b6d6e6d3f371c2af509902ceafea (patch) | |
| tree | 9b5dda22606bcdd12a715c972c440d9f30645b6d /crates/mozart-core/src | |
| parent | 4e99773a3d203e73b8bf6464490d05649a269fa7 (diff) | |
| download | php-mozart-2aceeb116150b6d6e6d3f371c2af509902ceafea.tar.gz php-mozart-2aceeb116150b6d6e6d3f371c2af509902ceafea.tar.zst php-mozart-2aceeb116150b6d6e6d3f371c2af509902ceafea.zip | |
feat(config): parse and merge top-level repositories field
Mirrors Composer\Config's repositories handling: name/positional keys,
`false` to disable, BC `packagist` alias, and auto-disable of the
default packagist.org entry when redefined.
Diffstat (limited to 'crates/mozart-core/src')
| -rw-r--r-- | crates/mozart-core/src/config.rs | 254 | ||||
| -rw-r--r-- | crates/mozart-core/src/factory.rs | 3 |
2 files changed, 257 insertions, 0 deletions
diff --git a/crates/mozart-core/src/config.rs b/crates/mozart-core/src/config.rs index 1220dee..58d1d17 100644 --- a/crates/mozart-core/src/config.rs +++ b/crates/mozart-core/src/config.rs @@ -5,6 +5,7 @@ //! known properties. Unknown properties are captured in the `extra` map so //! that round-tripping through serde is lossless. +use indexmap::IndexMap; use serde::{Deserialize, Serialize}; use std::collections::BTreeMap; @@ -94,6 +95,18 @@ pub struct Config { /// `false` (disable all) or a `{plugin: bool}` map. pub allow_plugins: serde_json::Value, + /// Repositories declared at the `composer.json` top level (and merged + /// from global config), keyed by name. Mirrors + /// `Composer\Config::$repositories`. Unnamed entries from the list + /// shape get integer-string keys. Each value is a repository + /// definition object, or `false` to disable a named repository. + /// + /// Sits outside the inner `config: { ... }` serde representation + /// (Composer keeps it as a sibling property), so it is `#[serde(skip)]` + /// and preserved across [`Config::merge`] manually. + #[serde(skip)] + pub repositories: IndexMap<String, serde_json::Value>, + /// Catch-all for properties not explicitly listed above. #[serde(flatten)] pub extra: BTreeMap<String, serde_json::Value>, @@ -132,6 +145,13 @@ impl Default for Config { htaccess_protect: true, secure_http: true, allow_plugins: serde_json::json!({}), + repositories: IndexMap::from([( + "packagist.org".to_string(), + serde_json::json!({ + "type": "composer", + "url": "https://repo.packagist.org", + }), + )]), extra: BTreeMap::new(), } } @@ -154,10 +174,120 @@ impl Config { for (k, v) in overrides { map.insert(k.clone(), v.clone()); } + let preserved_repositories = std::mem::take(&mut self.repositories); *self = serde_json::from_value(serde_json::Value::Object(map))?; + self.repositories = preserved_repositories; Ok(()) } + /// Merge a `repositories` block (from a composer.json or config.json) + /// into [`Self::repositories`]. Mirrors the repositories branch of + /// `Composer\Config::merge` (composer/src/Composer/Config.php lines + /// 243-284): + /// + /// - Accepts either a JSON object (name-keyed) or array (positional). + /// - `false` disables the named repository. + /// - A single-key `{name: false}` entry also disables the named repo. + /// - Redefining a `packagist.org`-like composer repo auto-disables the + /// default `packagist.org` entry. + /// - The reverse-merge dance puts new repositories ahead of existing + /// ones in priority order. + /// - Preserves the `packagist` → `packagist.org` BC alias. + pub fn merge_repositories(&mut self, repos: &serde_json::Value) { + enum Key { + Named(String), + Positional(usize), + } + + let new_repos: Vec<(Key, serde_json::Value)> = match repos { + serde_json::Value::Object(obj) => obj + .iter() + .map(|(k, v)| (Key::Named(k.clone()), v.clone())) + .collect(), + serde_json::Value::Array(arr) => arr + .iter() + .enumerate() + .map(|(i, v)| (Key::Positional(i), v.clone())) + .collect(), + _ => return, + }; + if new_repos.is_empty() { + return; + } + + self.repositories.reverse(); + for (key, repo) in new_repos.into_iter().rev() { + // `false` value → disable by name (only meaningful for named keys) + if matches!(repo, serde_json::Value::Bool(false)) { + if let Key::Named(n) = &key { + self.disable_repo_by_name(n); + } + continue; + } + + // Single-key `{name: false}` → disable by inner name + if let serde_json::Value::Object(o) = &repo + && o.len() == 1 + && let Some((inner_name, inner_val)) = o.iter().next() + && matches!(inner_val, serde_json::Value::Bool(false)) + { + self.disable_repo_by_name(inner_name); + continue; + } + + // Auto-disable the default packagist.org repo if it gets redefined + if let serde_json::Value::Object(o) = &repo + && o.get("type").and_then(|v| v.as_str()) == Some("composer") + && let Some(url) = o.get("url").and_then(|v| v.as_str()) + && is_packagist_url(url) + { + self.disable_repo_by_name("packagist.org"); + } + + match key { + Key::Positional(i) => { + let candidate = i.to_string(); + let stored_key = if self.repositories.contains_key(&candidate) { + self.next_positional_key() + } else { + candidate + }; + self.repositories.insert(stored_key, repo); + } + Key::Named(n) if n == "packagist" => { + // BC: legacy `packagist` name maps to `packagist.org` + self.repositories.insert("packagist.org".to_string(), repo); + } + Key::Named(n) => { + self.repositories.insert(n, repo); + } + } + } + self.repositories.reverse(); + } + + fn disable_repo_by_name(&mut self, name: &str) { + if self.repositories.shift_remove(name).is_some() { + return; + } + // BC: `packagist` aliases the default `packagist.org` repo + if name == "packagist" { + self.repositories.shift_remove("packagist.org"); + } + } + + fn next_positional_key(&self) -> String { + let mut max: i64 = -1; + for k in self.repositories.keys() { + if let Ok(n) = k.parse::<i64>() + && n > max + { + max = n; + } + } + (max + 1).to_string() + } + /// Return the effective value for a single key, or `None` if absent. pub fn get(&self, key: &str) -> Option<serde_json::Value> { match key { @@ -368,3 +498,127 @@ fn substitute(s: &str, vendor_dir: &str, home: &str, cache_dir: &str) -> String .replace("{$home}", home) .replace("{$cache-dir}", cache_dir) } + +/// Mirrors Composer's `{^https?://(?:[a-z0-9-.]+\.)?packagist.org(/|$)}` +/// match used to detect a redefinition of the default packagist repo. +fn is_packagist_url(url: &str) -> bool { + let lower = url.to_ascii_lowercase(); + let rest = if let Some(s) = lower.strip_prefix("https://") { + s + } else if let Some(s) = lower.strip_prefix("http://") { + s + } else { + return false; + }; + let host = rest.split('/').next().unwrap_or(""); + host == "packagist.org" || host.ends_with(".packagist.org") +} + +#[cfg(test)] +mod tests { + use super::*; + + fn keys(c: &Config) -> Vec<&str> { + c.repositories.keys().map(String::as_str).collect() + } + + #[test] + fn default_repositories_holds_packagist_org() { + let c = Config::default(); + assert_eq!(keys(&c), vec!["packagist.org"]); + assert_eq!( + c.repositories.get("packagist.org"), + Some(&serde_json::json!({ + "type": "composer", + "url": "https://repo.packagist.org", + })), + ); + } + + #[test] + fn merge_preserves_repositories_across_round_trip() { + let mut c = Config::default(); + c.merge(&BTreeMap::from([( + "vendor-dir".to_string(), + serde_json::json!("deps"), + )])) + .unwrap(); + assert_eq!(c.vendor_dir, "deps"); + assert_eq!(keys(&c), vec!["packagist.org"]); + } + + #[test] + fn merge_repositories_disable_by_named_false() { + let mut c = Config::default(); + c.merge_repositories(&serde_json::json!({"packagist.org": false})); + assert!(c.repositories.is_empty()); + } + + #[test] + fn merge_repositories_disable_via_packagist_bc_alias() { + let mut c = Config::default(); + c.merge_repositories(&serde_json::json!({"packagist": false})); + assert!(c.repositories.is_empty()); + } + + #[test] + fn merge_repositories_disable_via_anonymous_single_key_false() { + let mut c = Config::default(); + c.merge_repositories(&serde_json::json!([{"packagist.org": false}])); + assert!(c.repositories.is_empty()); + } + + #[test] + fn merge_repositories_packagist_bc_alias_renames_to_packagist_org() { + let mut c = Config::default(); + c.merge_repositories(&serde_json::json!({ + "packagist": {"type": "composer", "url": "https://example.test"} + })); + // BC alias collapses onto the existing packagist.org entry. + assert_eq!(keys(&c), vec!["packagist.org"]); + assert_eq!( + c.repositories.get("packagist.org"), + Some(&serde_json::json!({"type": "composer", "url": "https://example.test"})), + ); + } + + #[test] + fn merge_repositories_redefining_packagist_url_disables_default() { + let mut c = Config::default(); + c.merge_repositories(&serde_json::json!([ + {"type": "composer", "url": "https://repo.packagist.org"} + ])); + // Default packagist.org gone, replaced by the new positional entry. + assert_eq!(keys(&c), vec!["0"]); + } + + #[test] + fn merge_repositories_new_entries_take_priority_over_defaults() { + let mut c = Config::default(); + c.merge_repositories(&serde_json::json!([ + {"type": "vcs", "url": "https://example.test/a.git"}, + {"type": "vcs", "url": "https://example.test/b.git"}, + ])); + // New repos appear before the default packagist.org, preserving their + // original order (priority a > b > packagist.org). + assert_eq!(keys(&c), vec!["0", "1", "packagist.org"]); + } + + #[test] + fn merge_repositories_ignores_non_object_non_array_input() { + let mut c = Config::default(); + c.merge_repositories(&serde_json::Value::Null); + c.merge_repositories(&serde_json::json!("ignored")); + assert_eq!(keys(&c), vec!["packagist.org"]); + } + + #[test] + fn is_packagist_url_matches_subdomains_and_paths() { + assert!(is_packagist_url("https://repo.packagist.org")); + assert!(is_packagist_url("https://packagist.org/")); + assert!(is_packagist_url("http://repo.packagist.org/p2/foo.json")); + assert!(!is_packagist_url("https://example.com")); + assert!(!is_packagist_url("ftp://packagist.org")); + assert!(!is_packagist_url("https://notpackagist.org")); + } +} diff --git a/crates/mozart-core/src/factory.rs b/crates/mozart-core/src/factory.rs index 39024c8..28ea680 100644 --- a/crates/mozart-core/src/factory.rs +++ b/crates/mozart-core/src/factory.rs @@ -146,6 +146,9 @@ pub fn create_config() -> anyhow::Result<Config> { obj.iter().map(|(k, v)| (k.clone(), v.clone())).collect(); config.merge(&overrides)?; } + if let Some(repos) = json.get("repositories") { + config.merge_repositories(repos); + } } Ok(config) |
