aboutsummaryrefslogtreecommitdiffhomepage
path: root/crates/mozart-core
diff options
context:
space:
mode:
authornsfisis <nsfisis@gmail.com>2026-05-11 19:41:30 +0900
committernsfisis <nsfisis@gmail.com>2026-05-11 19:41:30 +0900
commit2aceeb116150b6d6e6d3f371c2af509902ceafea (patch)
tree9b5dda22606bcdd12a715c972c440d9f30645b6d /crates/mozart-core
parent4e99773a3d203e73b8bf6464490d05649a269fa7 (diff)
downloadphp-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')
-rw-r--r--crates/mozart-core/src/config.rs254
-rw-r--r--crates/mozart-core/src/factory.rs3
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)