aboutsummaryrefslogtreecommitdiffhomepage
path: root/crates/mozart-registry/src
diff options
context:
space:
mode:
authornsfisis <nsfisis@gmail.com>2026-05-03 20:03:42 +0900
committernsfisis <nsfisis@gmail.com>2026-05-03 20:03:42 +0900
commitd3cdb9e3f73314e04061d4d18f654e7e80b0dc18 (patch)
tree92dfda3903a7a7f1c631b18eaf544063e9b7f4f6 /crates/mozart-registry/src
parent756e0b9c18af8b2b5887ae1fb265a03187ca9c00 (diff)
downloadphp-mozart-d3cdb9e3f73314e04061d4d18f654e7e80b0dc18.tar.gz
php-mozart-d3cdb9e3f73314e04061d4d18f654e7e80b0dc18.tar.zst
php-mozart-d3cdb9e3f73314e04061d4d18f654e7e80b0dc18.zip
feat(repository): support only/exclude/canonical repo filters
Composer's FilterRepository wraps a repository with three knobs: `only` / `exclude` to drop packages by name, and `canonical: false` to relax the repo's authoritative claim on its package names so lower-priority repos can still answer. Mozart was ignoring all three, so first-listed inline / composer-repo entries always shadowed later repos and `only` / `exclude` lists were silently no-ops. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Diffstat (limited to 'crates/mozart-registry/src')
-rw-r--r--crates/mozart-registry/src/composer_repo.rs25
-rw-r--r--crates/mozart-registry/src/inline_package.rs18
-rw-r--r--crates/mozart-registry/src/lib.rs1
-rw-r--r--crates/mozart-registry/src/repository_filter.rs135
4 files changed, 178 insertions, 1 deletions
diff --git a/crates/mozart-registry/src/composer_repo.rs b/crates/mozart-registry/src/composer_repo.rs
index 28063cc..6594668 100644
--- a/crates/mozart-registry/src/composer_repo.rs
+++ b/crates/mozart-registry/src/composer_repo.rs
@@ -20,6 +20,8 @@
//! exercise the legacy embedded-packages form.
use crate::packagist::PackagistVersion;
+use crate::repository_filter::RepositoryFilter;
+use indexmap::IndexSet;
use mozart_core::package::RawRepository;
use std::path::PathBuf;
@@ -35,6 +37,7 @@ pub struct ComposerRepoPackage {
/// `file://foobar` → `file:///abs/path/to/fixtures/foobar`.
pub fn collect_composer_packages(repositories: &[RawRepository]) -> Vec<ComposerRepoPackage> {
let mut out = Vec::new();
+ let mut claimed: IndexSet<String> = IndexSet::new();
for repo in repositories {
if repo.repo_type != "composer" {
continue;
@@ -55,18 +58,34 @@ pub fn collect_composer_packages(repositories: &[RawRepository]) -> Vec<Composer
let Some(packages) = parsed.get("packages").and_then(|v| v.as_object()) else {
continue;
};
+ let filter = RepositoryFilter::from_repo(repo);
+ let mut names_this_repo: IndexSet<String> = IndexSet::new();
for (name, versions) in packages {
+ if !filter.is_allowed(name) {
+ continue;
+ }
+ if claimed.contains(name) {
+ continue;
+ }
let Some(versions_obj) = versions.as_object() else {
continue;
};
+ let mut emitted = false;
for (_, version_value) in versions_obj {
if let Ok(pv) = serde_json::from_value::<PackagistVersion>(version_value.clone()) {
out.push(ComposerRepoPackage {
name: name.clone(),
version: pv,
});
+ emitted = true;
}
}
+ if emitted {
+ names_this_repo.insert(name.clone());
+ }
+ }
+ if filter.canonical {
+ claimed.extend(names_this_repo);
}
}
out
@@ -98,6 +117,9 @@ mod tests {
repo_type: "composer".to_string(),
url: Some(url),
package: None,
+ only: None,
+ exclude: None,
+ canonical: None,
}
}
@@ -132,6 +154,9 @@ mod tests {
repo_type: "vcs".to_string(),
url: Some("https://example.com/foo.git".to_string()),
package: None,
+ only: None,
+ exclude: None,
+ canonical: None,
}];
assert!(collect_composer_packages(&repos).is_empty());
}
diff --git a/crates/mozart-registry/src/inline_package.rs b/crates/mozart-registry/src/inline_package.rs
index 0cc38d6..509193b 100644
--- a/crates/mozart-registry/src/inline_package.rs
+++ b/crates/mozart-registry/src/inline_package.rs
@@ -6,6 +6,7 @@
//! pool and into the generated lockfile entry verbatim.
use crate::packagist::PackagistVersion;
+use crate::repository_filter::RepositoryFilter;
use indexmap::IndexSet;
use mozart_core::package::RawRepository;
@@ -36,6 +37,7 @@ pub fn collect_inline_packages(repositories: &[RawRepository]) -> Vec<InlinePack
let Some(value) = &repo.package else {
continue;
};
+ let filter = RepositoryFilter::from_repo(repo);
let mut from_this_repo: Vec<InlinePackage> = Vec::new();
match value {
@@ -56,13 +58,21 @@ pub fn collect_inline_packages(repositories: &[RawRepository]) -> Vec<InlinePack
let mut names_this_repo: IndexSet<String> = IndexSet::new();
for pkg in from_this_repo {
+ if !filter.is_allowed(&pkg.name) {
+ continue;
+ }
if claimed.contains(&pkg.name) {
continue;
}
names_this_repo.insert(pkg.name.clone());
packages.push(pkg);
}
- claimed.extend(names_this_repo);
+ // canonical: false → packages enter the pool but the name is not
+ // claimed, so lower-priority repositories may still answer for it.
+ // Mirrors `FilterRepository::loadPackages`'s `namesFound = []` reset.
+ if filter.canonical {
+ claimed.extend(names_this_repo);
+ }
}
packages
}
@@ -101,6 +111,9 @@ mod tests {
repo_type: "package".to_string(),
url: None,
package: Some(value),
+ only: None,
+ exclude: None,
+ canonical: None,
}
}
@@ -135,6 +148,9 @@ mod tests {
repo_type: "vcs".to_string(),
url: Some("https://example.com/foo.git".to_string()),
package: None,
+ only: None,
+ exclude: None,
+ canonical: None,
}];
assert!(collect_inline_packages(&repos).is_empty());
}
diff --git a/crates/mozart-registry/src/lib.rs b/crates/mozart-registry/src/lib.rs
index 17837cd..654252c 100644
--- a/crates/mozart-registry/src/lib.rs
+++ b/crates/mozart-registry/src/lib.rs
@@ -7,6 +7,7 @@ pub mod installer_executor;
pub mod lockfile;
pub mod packagist;
pub mod repository;
+pub mod repository_filter;
pub mod resolver;
pub mod vcs_bridge;
pub mod version;
diff --git a/crates/mozart-registry/src/repository_filter.rs b/crates/mozart-registry/src/repository_filter.rs
new file mode 100644
index 0000000..fcdcfeb
--- /dev/null
+++ b/crates/mozart-registry/src/repository_filter.rs
@@ -0,0 +1,135 @@
+//! Repository-level package filters (`only`, `exclude`, `canonical`).
+//!
+//! Mirrors `Composer\Repository\FilterRepository`: a wrapper around an
+//! underlying repository that drops packages by name and/or removes the
+//! repo's authoritative claim on the names it serves. We model the same
+//! semantics for inline `type: package` and local `type: composer`
+//! repositories, since the installer fixtures rely on them.
+
+use mozart_core::package::RawRepository;
+use regex::Regex;
+
+/// Resolved filter for a single `repositories[]` entry.
+pub struct RepositoryFilter {
+ only: Option<Regex>,
+ exclude: Option<Regex>,
+ /// `canonical: true` (default) — packages from this repo claim their
+ /// names, suppressing lower-priority repos for the same name.
+ /// `canonical: false` — packages enter the pool but lower-priority
+ /// repos may also answer.
+ pub canonical: bool,
+}
+
+impl RepositoryFilter {
+ pub fn from_repo(repo: &RawRepository) -> Self {
+ Self {
+ only: repo.only.as_ref().and_then(|names| build_name_regex(names)),
+ exclude: repo
+ .exclude
+ .as_ref()
+ .and_then(|names| build_name_regex(names)),
+ canonical: repo.canonical.unwrap_or(true),
+ }
+ }
+
+ /// `true` if `name` may pass through this filter.
+ /// Mirrors `FilterRepository::isAllowed`.
+ pub fn is_allowed(&self, name: &str) -> bool {
+ if let Some(only) = &self.only {
+ return only.is_match(name);
+ }
+ if let Some(exclude) = &self.exclude {
+ return !exclude.is_match(name);
+ }
+ true
+ }
+}
+
+/// Build a case-insensitive `^(?:p1|p2|…)$` regex from Composer's pattern
+/// list. Mirrors `BasePackage::packageNamesToRegexp` — `*` becomes `.*`,
+/// every other regex metacharacter is escaped, and the alternation is
+/// anchored to the full string.
+fn build_name_regex(patterns: &[String]) -> Option<Regex> {
+ if patterns.is_empty() {
+ return None;
+ }
+ let parts: Vec<String> = patterns.iter().map(|p| pattern_to_regex(p)).collect();
+ let joined = parts.join("|");
+ Regex::new(&format!(r"(?i)^(?:{joined})$")).ok()
+}
+
+fn pattern_to_regex(pattern: &str) -> String {
+ let escaped = regex::escape(pattern);
+ // `*` was escaped to `\*` — turn it into `.*` so glob semantics match
+ // Composer.
+ escaped.replace(r"\*", ".*")
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ fn repo(
+ only: Option<Vec<String>>,
+ exclude: Option<Vec<String>>,
+ canonical: Option<bool>,
+ ) -> RawRepository {
+ RawRepository {
+ repo_type: "package".to_string(),
+ url: None,
+ package: None,
+ only,
+ exclude,
+ canonical,
+ }
+ }
+
+ #[test]
+ fn no_filter_allows_all() {
+ let f = RepositoryFilter::from_repo(&repo(None, None, None));
+ assert!(f.is_allowed("a/a"));
+ assert!(f.is_allowed("foo/bar"));
+ assert!(f.canonical);
+ }
+
+ #[test]
+ fn only_restricts_to_listed_names() {
+ let f = RepositoryFilter::from_repo(&repo(Some(vec!["foo/b".to_string()]), None, None));
+ assert!(f.is_allowed("foo/b"));
+ assert!(!f.is_allowed("foo/a"));
+ }
+
+ #[test]
+ fn exclude_drops_listed_names() {
+ let f = RepositoryFilter::from_repo(&repo(None, Some(vec!["foo/c".to_string()]), None));
+ assert!(f.is_allowed("foo/a"));
+ assert!(!f.is_allowed("foo/c"));
+ }
+
+ #[test]
+ fn glob_star_expands() {
+ let f = RepositoryFilter::from_repo(&repo(Some(vec!["foo/*".to_string()]), None, None));
+ assert!(f.is_allowed("foo/a"));
+ assert!(f.is_allowed("foo/anything"));
+ assert!(!f.is_allowed("bar/a"));
+ }
+
+ #[test]
+ fn match_is_case_insensitive() {
+ let f = RepositoryFilter::from_repo(&repo(Some(vec!["Foo/Bar".to_string()]), None, None));
+ assert!(f.is_allowed("foo/bar"));
+ assert!(f.is_allowed("FOO/BAR"));
+ }
+
+ #[test]
+ fn canonical_default_is_true() {
+ let f = RepositoryFilter::from_repo(&repo(None, None, None));
+ assert!(f.canonical);
+ }
+
+ #[test]
+ fn canonical_false_honored() {
+ let f = RepositoryFilter::from_repo(&repo(None, None, Some(false)));
+ assert!(!f.canonical);
+ }
+}