aboutsummaryrefslogtreecommitdiffhomepage
path: root/crates
diff options
context:
space:
mode:
Diffstat (limited to 'crates')
-rw-r--r--crates/mozart-core/src/package.rs20
-rw-r--r--crates/mozart-registry/Cargo.toml1
-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
-rw-r--r--crates/mozart/src/commands/init.rs6
-rw-r--r--crates/mozart/tests/installer.rs2
8 files changed, 206 insertions, 2 deletions
diff --git a/crates/mozart-core/src/package.rs b/crates/mozart-core/src/package.rs
index 8bda13d..0a5c0fb 100644
--- a/crates/mozart-core/src/package.rs
+++ b/crates/mozart-core/src/package.rs
@@ -549,6 +549,23 @@ pub struct RawRepository {
/// `Composer\Repository\PackageRepository`'s schema.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub package: Option<serde_json::Value>,
+
+ /// `only: ["name", ...]` — restrict this repository to the listed package
+ /// names (glob `*` allowed). Mirrors `Composer\Repository\FilterRepository`.
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ pub only: Option<Vec<String>>,
+
+ /// `exclude: ["name", ...]` — drop the listed package names from this
+ /// repository. Mutually exclusive with `only` per `FilterRepository`.
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ pub exclude: Option<Vec<String>>,
+
+ /// `canonical: false` — packages from this repo enter the pool but do
+ /// not claim authoritative ownership of their names, so lower-priority
+ /// repositories can still answer for the same name. Mirrors
+ /// `FilterRepository::loadPackages`'s `namesFound = []` reset.
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ pub canonical: Option<bool>,
}
/// Default root-package name when `composer.json` omits the `name` field.
@@ -657,6 +674,9 @@ mod tests {
repo_type: "vcs".to_string(),
url: Some("https://github.com/acme/repo".to_string()),
package: None,
+ only: None,
+ exclude: None,
+ canonical: None,
}];
let mut psr4 = BTreeMap::new();
diff --git a/crates/mozart-registry/Cargo.toml b/crates/mozart-registry/Cargo.toml
index 83e42b5..578a08c 100644
--- a/crates/mozart-registry/Cargo.toml
+++ b/crates/mozart-registry/Cargo.toml
@@ -15,6 +15,7 @@ filetime.workspace = true
flate2.workspace = true
indexmap.workspace = true
md5.workspace = true
+regex.workspace = true
reqwest.workspace = true
serde.workspace = true
serde_json.workspace = true
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);
+ }
+}
diff --git a/crates/mozart/src/commands/init.rs b/crates/mozart/src/commands/init.rs
index 49176ab..1a6df37 100644
--- a/crates/mozart/src/commands/init.rs
+++ b/crates/mozart/src/commands/init.rs
@@ -704,6 +704,9 @@ fn parse_repositories(repos: &[String]) -> anyhow::Result<Vec<RawRepository>> {
repo_type,
url: Some(url),
package: None,
+ only: None,
+ exclude: None,
+ canonical: None,
});
} else {
// Plain URL
@@ -711,6 +714,9 @@ fn parse_repositories(repos: &[String]) -> anyhow::Result<Vec<RawRepository>> {
repo_type: "vcs".to_string(),
url: Some(repo.clone()),
package: None,
+ only: None,
+ exclude: None,
+ canonical: None,
});
}
}
diff --git a/crates/mozart/tests/installer.rs b/crates/mozart/tests/installer.rs
index 8564d0c..cd00f88 100644
--- a/crates/mozart/tests/installer.rs
+++ b/crates/mozart/tests/installer.rs
@@ -333,7 +333,7 @@ installer_fixture!(replaced_packages_should_not_be_installed_when_installing_fro
installer_fixture!(replacer_satisfies_its_own_requirement);
installer_fixture!(repositories_priorities);
installer_fixture!(repositories_priorities2);
-installer_fixture!(repositories_priorities3, ignore);
+installer_fixture!(repositories_priorities3);
installer_fixture!(repositories_priorities4);
installer_fixture!(repositories_priorities5);
installer_fixture!(root_alias_change_with_circular_dep, ignore);