aboutsummaryrefslogtreecommitdiffhomepage
path: root/crates/mozart
diff options
context:
space:
mode:
authornsfisis <nsfisis@gmail.com>2026-02-21 14:11:18 +0900
committernsfisis <nsfisis@gmail.com>2026-02-21 14:11:18 +0900
commit294bd3dd425a374eda13a52b925a2cd0c4db7f0a (patch)
treef63a008b88113ac2548affbd0a662879f14b1ed7 /crates/mozart
parent48e88e9e204a38d1e31483412003f1492fa8fdcf (diff)
downloadphp-mozart-294bd3dd425a374eda13a52b925a2cd0c4db7f0a.tar.gz
php-mozart-294bd3dd425a374eda13a52b925a2cd0c4db7f0a.tar.zst
php-mozart-294bd3dd425a374eda13a52b925a2cd0c4db7f0a.zip
feat(depends): implement depends and prohibits commands with shared dependency logic
Add the `depends` (why) and `prohibits` (why-not) commands that query the dependency graph to answer "which packages require X?" and "which packages prevent version Y of X from being installed?" respectively. Introduces the shared `dependency` module with package loading from lock file or installed.json, forward/inverted dependency graph walking, recursive traversal with cycle detection, and table/tree output formatters. Adds `conflict` field to LockedPackage for conflict-based prohibition detection, updating all struct literals across install, remove, require, and update test helpers. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Diffstat (limited to 'crates/mozart')
-rw-r--r--crates/mozart/src/commands.rs1
-rw-r--r--crates/mozart/src/commands/dependency.rs789
-rw-r--r--crates/mozart/src/commands/depends.rs41
-rw-r--r--crates/mozart/src/commands/install.rs1
-rw-r--r--crates/mozart/src/commands/prohibits.rs53
-rw-r--r--crates/mozart/src/commands/remove.rs1
-rw-r--r--crates/mozart/src/commands/require.rs1
-rw-r--r--crates/mozart/src/commands/update.rs1
-rw-r--r--crates/mozart/src/lockfile.rs7
9 files changed, 891 insertions, 4 deletions
diff --git a/crates/mozart/src/commands.rs b/crates/mozart/src/commands.rs
index 8232a86..83ff7b8 100644
--- a/crates/mozart/src/commands.rs
+++ b/crates/mozart/src/commands.rs
@@ -7,6 +7,7 @@ pub mod check_platform_reqs;
pub mod clear_cache;
pub mod config;
pub mod create_project;
+pub mod dependency;
pub mod depends;
pub mod diagnose;
pub mod dump_autoload;
diff --git a/crates/mozart/src/commands/dependency.rs b/crates/mozart/src/commands/dependency.rs
new file mode 100644
index 0000000..0ae8bb4
--- /dev/null
+++ b/crates/mozart/src/commands/dependency.rs
@@ -0,0 +1,789 @@
+//! Shared logic for `depends` and `prohibits` commands.
+//!
+//! `depends` (aka `why`) answers: "Which packages require package X?"
+//! `prohibits` (aka `why-not`) answers: "Which packages prevent version X of package Y from being
+//! installed?"
+
+use std::collections::{BTreeMap, HashSet};
+use std::path::Path;
+
+use anyhow::Result;
+
+// ─────────────────────────────────────────────────────────────────────────────
+// Core types
+// ─────────────────────────────────────────────────────────────────────────────
+
+/// Normalised view of a package's dependency information.
+#[derive(Debug, Clone)]
+pub struct PackageInfo {
+ pub name: String,
+ pub version: String,
+ /// Runtime requirements (`require` section).
+ pub require: BTreeMap<String, String>,
+ /// Dev requirements (`require-dev`) — only non-empty for the root package.
+ pub require_dev: BTreeMap<String, String>,
+ /// Conflict declarations (`conflict` section).
+ pub conflict: BTreeMap<String, String>,
+ /// Whether this is the root `composer.json` package.
+ pub is_root: bool,
+}
+
+/// A single result node in the dependency graph walk.
+#[derive(Debug, Clone)]
+pub struct DependencyResult {
+ /// Name of the package that has the link.
+ pub package_name: String,
+ /// Version of the package that has the link.
+ pub package_version: String,
+ /// Human-readable link type: "requires", "requires (dev)", or "conflicts".
+ pub link_description: String,
+ /// The target package name (the one being queried).
+ pub link_target: String,
+ /// The constraint string from the link (e.g. "^1.0").
+ pub link_constraint: String,
+ /// Children found during a recursive walk (empty for flat results).
+ pub children: Vec<DependencyResult>,
+}
+
+// ─────────────────────────────────────────────────────────────────────────────
+// Package loading
+// ─────────────────────────────────────────────────────────────────────────────
+
+/// Load all packages relevant to the dependency query.
+///
+/// When `locked` is true (or the lock file exists), reads from `composer.lock`.
+/// Otherwise falls back to `vendor/composer/installed.json`.
+/// The root `composer.json` is always added as a synthetic entry.
+pub fn load_packages(working_dir: &Path, locked: bool) -> Result<Vec<PackageInfo>> {
+ let lock_path = working_dir.join("composer.lock");
+ let composer_json_path = working_dir.join("composer.json");
+
+ // Load locked / installed packages
+ let mut packages: Vec<PackageInfo> = if locked || lock_path.exists() {
+ load_from_lockfile(&lock_path)?
+ } else {
+ load_from_installed(working_dir)?
+ };
+
+ // Add the root package (composer.json) as a synthetic entry
+ if composer_json_path.exists()
+ && let Ok(root) = crate::package::read_from_file(&composer_json_path)
+ {
+ // Extract conflict from extra_fields if present
+ let conflict: BTreeMap<String, String> = root
+ .extra_fields
+ .get("conflict")
+ .and_then(|v| v.as_object())
+ .map(|obj| {
+ obj.iter()
+ .filter_map(|(k, v)| v.as_str().map(|s| (k.clone(), s.to_string())))
+ .collect()
+ })
+ .unwrap_or_default();
+
+ packages.push(PackageInfo {
+ name: root.name.clone(),
+ version: "ROOT".to_string(),
+ require: root.require,
+ require_dev: root.require_dev,
+ conflict,
+ is_root: true,
+ });
+ }
+
+ Ok(packages)
+}
+
+fn load_from_lockfile(lock_path: &Path) -> Result<Vec<PackageInfo>> {
+ if !lock_path.exists() {
+ anyhow::bail!("composer.lock not found — run `mozart install` first or omit --locked");
+ }
+ let lock = crate::lockfile::LockFile::read_from_file(lock_path)?;
+
+ let mut packages: Vec<PackageInfo> = Vec::new();
+
+ for pkg in &lock.packages {
+ packages.push(PackageInfo {
+ name: pkg.name.clone(),
+ version: pkg.version.clone(),
+ require: pkg.require.clone(),
+ require_dev: BTreeMap::new(), // locked packages don't expose require-dev
+ conflict: pkg.conflict.clone(),
+ is_root: false,
+ });
+ }
+
+ if let Some(ref dev_pkgs) = lock.packages_dev {
+ for pkg in dev_pkgs {
+ packages.push(PackageInfo {
+ name: pkg.name.clone(),
+ version: pkg.version.clone(),
+ require: pkg.require.clone(),
+ require_dev: BTreeMap::new(),
+ conflict: pkg.conflict.clone(),
+ is_root: false,
+ });
+ }
+ }
+
+ Ok(packages)
+}
+
+fn load_from_installed(working_dir: &Path) -> Result<Vec<PackageInfo>> {
+ let vendor_dir = working_dir.join("vendor");
+ let installed = crate::installed::InstalledPackages::read(&vendor_dir)?;
+
+ let packages = installed
+ .packages
+ .iter()
+ .map(|p| {
+ // InstalledPackageEntry uses extra_fields for require/conflict; we do a best-effort
+ // extraction since installed.json doesn't always carry full dep info.
+ let require = p
+ .extra_fields
+ .get("require")
+ .and_then(|v| v.as_object())
+ .map(|obj| {
+ obj.iter()
+ .filter_map(|(k, v)| v.as_str().map(|s| (k.clone(), s.to_string())))
+ .collect()
+ })
+ .unwrap_or_default();
+
+ let conflict = p
+ .extra_fields
+ .get("conflict")
+ .and_then(|v| v.as_object())
+ .map(|obj| {
+ obj.iter()
+ .filter_map(|(k, v)| v.as_str().map(|s| (k.clone(), s.to_string())))
+ .collect()
+ })
+ .unwrap_or_default();
+
+ PackageInfo {
+ name: p.name.clone(),
+ version: p.version.clone(),
+ require,
+ require_dev: BTreeMap::new(),
+ conflict,
+ is_root: false,
+ }
+ })
+ .collect();
+
+ Ok(packages)
+}
+
+// ─────────────────────────────────────────────────────────────────────────────
+// Core algorithm
+// ─────────────────────────────────────────────────────────────────────────────
+
+/// Find all packages that have a dependency relationship with the needle(s).
+///
+/// * `packages` — the full set of packages to search through.
+/// * `needles` — package names to look for (usually just one).
+/// * `constraint` — when `Some`, used for the `prohibits` check (see below).
+/// * `inverted` — if `true`, run the "prohibits" logic instead of "depends":
+/// - A package *prohibits* version V of package P if:
+/// a) it **requires** P with a constraint that does NOT match V, OR
+/// b) it **conflicts** with P at a constraint that matches V.
+/// * `recursive` — walk transitively up to the root.
+pub fn get_dependents(
+ packages: &[PackageInfo],
+ needles: &[String],
+ constraint: Option<&crate::constraint::VersionConstraint>,
+ inverted: bool,
+ recursive: bool,
+) -> Result<Vec<DependencyResult>> {
+ if inverted {
+ get_prohibitors(packages, needles, constraint, recursive)
+ } else {
+ get_dependents_forward(packages, needles, recursive)
+ }
+}
+
+// ── Forward (depends) ─────────────────────────────────────────────────────────
+
+fn get_dependents_forward(
+ packages: &[PackageInfo],
+ needles: &[String],
+ recursive: bool,
+) -> Result<Vec<DependencyResult>> {
+ let needle_set: HashSet<String> = needles.iter().map(|n| n.to_lowercase()).collect();
+
+ // Build name→PackageInfo lookup
+ let pkg_map: BTreeMap<String, &PackageInfo> = packages
+ .iter()
+ .map(|p| (p.name.to_lowercase(), p))
+ .collect();
+
+ if recursive {
+ // Recursive: BFS from needles upward to root, building a tree
+ let mut visited: HashSet<String> = HashSet::new();
+ let mut results: Vec<DependencyResult> = Vec::new();
+
+ for needle in needles {
+ let needle_lower = needle.to_lowercase();
+ let direct = collect_direct_requires(packages, &needle_lower);
+ for mut result in direct {
+ let pkg_lower = result.package_name.to_lowercase();
+ if visited.insert(pkg_lower.clone()) {
+ // Recurse: who requires this package?
+ result.children = recurse_dependents(
+ packages,
+ &pkg_lower,
+ &pkg_map,
+ &mut visited,
+ &needle_set,
+ );
+ results.push(result);
+ }
+ }
+ }
+ Ok(results)
+ } else {
+ // Flat: just direct dependents
+ let mut results: Vec<DependencyResult> = Vec::new();
+ for needle in needles {
+ let needle_lower = needle.to_lowercase();
+ results.extend(collect_direct_requires(packages, &needle_lower));
+ }
+ Ok(results)
+ }
+}
+
+/// Collect all packages that directly require `needle`.
+fn collect_direct_requires(packages: &[PackageInfo], needle: &str) -> Vec<DependencyResult> {
+ let mut results = Vec::new();
+ for pkg in packages {
+ // Check `require`
+ if let Some((target, constraint)) =
+ pkg.require.iter().find(|(k, _)| k.to_lowercase() == needle)
+ {
+ results.push(DependencyResult {
+ package_name: pkg.name.clone(),
+ package_version: pkg.version.clone(),
+ link_description: "requires".to_string(),
+ link_target: target.clone(),
+ link_constraint: constraint.clone(),
+ children: vec![],
+ });
+ }
+ // Check `require-dev` (root package only)
+ if pkg.is_root
+ && let Some((target, constraint)) = pkg
+ .require_dev
+ .iter()
+ .find(|(k, _)| k.to_lowercase() == needle)
+ {
+ results.push(DependencyResult {
+ package_name: pkg.name.clone(),
+ package_version: pkg.version.clone(),
+ link_description: "requires (dev)".to_string(),
+ link_target: target.clone(),
+ link_constraint: constraint.clone(),
+ children: vec![],
+ });
+ }
+ }
+ results
+}
+
+/// Recursively find who requires `needle` (used by recursive depends).
+fn recurse_dependents(
+ packages: &[PackageInfo],
+ needle: &str,
+ pkg_map: &BTreeMap<String, &PackageInfo>,
+ visited: &mut HashSet<String>,
+ _original_needles: &HashSet<String>,
+) -> Vec<DependencyResult> {
+ let _ = pkg_map; // kept for potential future use
+ let direct = collect_direct_requires(packages, needle);
+ let mut results = Vec::new();
+ for mut result in direct {
+ let pkg_lower = result.package_name.to_lowercase();
+ if visited.insert(pkg_lower.clone()) {
+ result.children =
+ recurse_dependents(packages, &pkg_lower, pkg_map, visited, _original_needles);
+ results.push(result);
+ }
+ }
+ results
+}
+
+// ── Inverted (prohibits) ──────────────────────────────────────────────────────
+
+fn get_prohibitors(
+ packages: &[PackageInfo],
+ needles: &[String],
+ constraint: Option<&crate::constraint::VersionConstraint>,
+ _recursive: bool,
+) -> Result<Vec<DependencyResult>> {
+ let mut results: Vec<DependencyResult> = Vec::new();
+
+ for needle in needles {
+ let needle_lower = needle.to_lowercase();
+ for pkg in packages {
+ // Case 1: package requires the needle, but the required constraint
+ // does NOT match the requested version (i.e. it would reject it).
+ if let Some((target, req_constraint_str)) = pkg
+ .require
+ .iter()
+ .find(|(k, _)| k.to_lowercase() == needle_lower)
+ && let Some(requested_version) = constraint
+ && let Ok(pkg_constraint) =
+ crate::constraint::VersionConstraint::parse(req_constraint_str)
+ {
+ // The package requires `needle` but with a different
+ // (incompatible) constraint — it blocks the requested version.
+ // We check: does any version satisfying the requested constraint
+ // NOT satisfy the package's constraint?
+ if constraint_prohibits(requested_version, &pkg_constraint) {
+ results.push(DependencyResult {
+ package_name: pkg.name.clone(),
+ package_version: pkg.version.clone(),
+ link_description: "requires".to_string(),
+ link_target: target.clone(),
+ link_constraint: req_constraint_str.clone(),
+ children: vec![],
+ });
+ }
+ }
+
+ // Also check require-dev for root
+ if pkg.is_root
+ && let Some((target, req_constraint_str)) = pkg
+ .require_dev
+ .iter()
+ .find(|(k, _)| k.to_lowercase() == needle_lower)
+ && let Some(requested_version) = constraint
+ && let Ok(pkg_constraint) =
+ crate::constraint::VersionConstraint::parse(req_constraint_str)
+ && constraint_prohibits(requested_version, &pkg_constraint)
+ {
+ results.push(DependencyResult {
+ package_name: pkg.name.clone(),
+ package_version: pkg.version.clone(),
+ link_description: "requires (dev)".to_string(),
+ link_target: target.clone(),
+ link_constraint: req_constraint_str.clone(),
+ children: vec![],
+ });
+ }
+
+ // Case 2: package *conflicts* with the needle at a version that
+ // overlaps with the requested version (i.e. the conflict blocks it).
+ if let Some((target, conflict_constraint_str)) = pkg
+ .conflict
+ .iter()
+ .find(|(k, _)| k.to_lowercase() == needle_lower)
+ && let Some(requested_version) = constraint
+ && let Ok(conflict_constraint) =
+ crate::constraint::VersionConstraint::parse(conflict_constraint_str)
+ {
+ // If the conflict constraint overlaps with (matches) the
+ // requested version range, this package conflicts with it.
+ if constraint_overlaps(requested_version, &conflict_constraint) {
+ results.push(DependencyResult {
+ package_name: pkg.name.clone(),
+ package_version: pkg.version.clone(),
+ link_description: "conflicts".to_string(),
+ link_target: target.clone(),
+ link_constraint: conflict_constraint_str.clone(),
+ children: vec![],
+ });
+ }
+ }
+ }
+ }
+
+ Ok(results)
+}
+
+/// Returns `true` if `requested` (the version the user wants to install) is
+/// **not** matched by `pkg_constraint` (the constraint the installed package
+/// requires), meaning the installed package would block installation.
+///
+/// We sample a set of "representative versions" from the requested constraint
+/// and check whether none of them satisfy the package's constraint.
+fn constraint_prohibits(
+ requested: &crate::constraint::VersionConstraint,
+ pkg_constraint: &crate::constraint::VersionConstraint,
+) -> bool {
+ // We try to determine if there is any version satisfying *requested* that
+ // does NOT satisfy *pkg_constraint*.
+ // Strategy: collect "probe" versions that the requested constraint implies,
+ // then check if any probe is rejected by pkg_constraint.
+ let probes = sample_versions_from_constraint(requested);
+ if probes.is_empty() {
+ // Cannot determine — report as prohibiting
+ return true;
+ }
+ // If ANY probe satisfies the requested constraint but NOT pkg_constraint → prohibits
+ probes
+ .iter()
+ .any(|v| requested.matches(v) && !pkg_constraint.matches(v))
+}
+
+/// Returns `true` if the conflict constraint overlaps with the requested version.
+/// That is, if the conflict constraint matches at least one version that the
+/// requested constraint also matches.
+fn constraint_overlaps(
+ requested: &crate::constraint::VersionConstraint,
+ conflict_constraint: &crate::constraint::VersionConstraint,
+) -> bool {
+ let probes = sample_versions_from_constraint(requested);
+ if probes.is_empty() {
+ return true;
+ }
+ probes
+ .iter()
+ .any(|v| requested.matches(v) && conflict_constraint.matches(v))
+}
+
+/// Generate a small set of concrete `Version` values that probe the shape of a
+/// constraint. These are used for the "does this constraint overlap/prohibit
+/// that constraint?" heuristic.
+fn sample_versions_from_constraint(
+ constraint: &crate::constraint::VersionConstraint,
+) -> Vec<crate::constraint::Version> {
+ use crate::constraint::Version;
+
+ // Broad grid of versions to probe
+ let candidates: &[&str] = &[
+ "0.0.1",
+ "0.1.0",
+ "0.9.0",
+ "1.0.0",
+ "1.0.1",
+ "1.1.0",
+ "1.2.0",
+ "1.5.0",
+ "1.9.0",
+ "1.9.9",
+ "2.0.0",
+ "2.1.0",
+ "2.5.0",
+ "2.9.9",
+ "3.0.0",
+ "3.1.0",
+ "3.9.9",
+ "4.0.0",
+ "4.9.0",
+ "5.0.0",
+ "6.0.0",
+ "7.0.0",
+ "8.0.0",
+ "9.0.0",
+ "10.0.0",
+ "0.0.1-alpha1",
+ "1.0.0-beta1",
+ "1.0.0-RC1",
+ ];
+
+ candidates
+ .iter()
+ .filter_map(|s| Version::parse(s).ok())
+ .filter(|v| constraint.matches(v))
+ .collect()
+}
+
+// ─────────────────────────────────────────────────────────────────────────────
+// Output helpers
+// ─────────────────────────────────────────────────────────────────────────────
+
+/// Print results as a flat table.
+///
+/// Columns: package name | version | link description | link constraint
+pub fn print_table(results: &[DependencyResult]) {
+ if results.is_empty() {
+ println!("{}", crate::console::info("No relationships found."));
+ return;
+ }
+
+ // Column widths
+ let name_w = results
+ .iter()
+ .map(|r| r.package_name.len())
+ .max()
+ .unwrap_or(0);
+ let ver_w = results
+ .iter()
+ .map(|r| r.package_version.len())
+ .max()
+ .unwrap_or(0);
+ let desc_w = results
+ .iter()
+ .map(|r| r.link_description.len())
+ .max()
+ .unwrap_or(0);
+
+ for r in results {
+ println!(
+ "{:<name_w$} {:<ver_w$} {:<desc_w$} {}",
+ crate::console::info(&r.package_name),
+ crate::console::comment(&r.package_version),
+ r.link_description,
+ crate::console::comment(&r.link_constraint),
+ name_w = name_w,
+ ver_w = ver_w,
+ desc_w = desc_w,
+ );
+ }
+}
+
+/// Print results as a nested tree using box-drawing characters.
+///
+/// Example output:
+///
+/// ```text
+/// vendor/a 1.0.0 requires ^1.0
+/// └─ vendor/b 2.0.0 requires ^2.0
+/// └─ root/project ROOT requires ^2.0
+/// ```
+pub fn print_tree(results: &[DependencyResult], depth: usize) {
+ if results.is_empty() && depth == 0 {
+ println!("{}", crate::console::info("No relationships found."));
+ return;
+ }
+
+ let count = results.len();
+ for (i, r) in results.iter().enumerate() {
+ let is_last = i + 1 == count;
+ let prefix = tree_prefix(depth, is_last);
+
+ println!(
+ "{}{:<} {} {} {}",
+ prefix,
+ crate::console::info(&r.package_name),
+ crate::console::comment(&r.package_version),
+ r.link_description,
+ crate::console::comment(&r.link_constraint),
+ );
+
+ if !r.children.is_empty() {
+ print_tree(&r.children, depth + 1);
+ }
+ }
+}
+
+fn tree_prefix(depth: usize, is_last: bool) -> String {
+ if depth == 0 {
+ return String::new();
+ }
+ let indent = " ".repeat(depth - 1);
+ let branch = if is_last { "└─ " } else { "├─ " };
+ format!("{indent}{branch}")
+}
+
+// ─────────────────────────────────────────────────────────────────────────────
+// Tests
+// ─────────────────────────────────────────────────────────────────────────────
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ fn make_pkg(
+ name: &str,
+ version: &str,
+ require: &[(&str, &str)],
+ conflict: &[(&str, &str)],
+ is_root: bool,
+ ) -> PackageInfo {
+ PackageInfo {
+ name: name.to_string(),
+ version: version.to_string(),
+ require: require
+ .iter()
+ .map(|(k, v)| (k.to_string(), v.to_string()))
+ .collect(),
+ require_dev: BTreeMap::new(),
+ conflict: conflict
+ .iter()
+ .map(|(k, v)| (k.to_string(), v.to_string()))
+ .collect(),
+ is_root,
+ }
+ }
+
+ // ── depends tests ─────────────────────────────────────────────────────────
+
+ #[test]
+ fn test_forward_dependency() {
+ // root requires A, A requires B → depends B returns A (and root not A)
+ let packages = vec![
+ make_pkg("root/project", "ROOT", &[("vendor/a", "^1.0")], &[], true),
+ make_pkg("vendor/a", "1.0.0", &[("vendor/b", "^2.0")], &[], false),
+ make_pkg("vendor/b", "2.0.0", &[], &[], false),
+ ];
+ let needles = vec!["vendor/b".to_string()];
+ let results = get_dependents(&packages, &needles, None, false, false).unwrap();
+ assert_eq!(results.len(), 1, "Only A requires B directly");
+ assert_eq!(results[0].package_name, "vendor/a");
+ assert_eq!(results[0].link_description, "requires");
+ assert_eq!(results[0].link_constraint, "^2.0");
+ }
+
+ #[test]
+ fn test_recursive_dependency() {
+ // root requires A, A requires B → depends B --recursive returns A, with root as child
+ let packages = vec![
+ make_pkg("root/project", "ROOT", &[("vendor/a", "^1.0")], &[], true),
+ make_pkg("vendor/a", "1.0.0", &[("vendor/b", "^2.0")], &[], false),
+ make_pkg("vendor/b", "2.0.0", &[], &[], false),
+ ];
+ let needles = vec!["vendor/b".to_string()];
+ let results = get_dependents(&packages, &needles, None, false, true).unwrap();
+ // A is found as direct dependent of B
+ assert!(!results.is_empty());
+ let a_result = results.iter().find(|r| r.package_name == "vendor/a");
+ assert!(a_result.is_some(), "vendor/a should be found");
+ // root should appear as a child of vendor/a
+ let children = &a_result.unwrap().children;
+ assert!(
+ children.iter().any(|c| c.package_name == "root/project"),
+ "root/project should be a child of vendor/a"
+ );
+ }
+
+ #[test]
+ fn test_no_dependents() {
+ // Nothing requires X
+ let packages = vec![
+ make_pkg("root/project", "ROOT", &[("vendor/a", "^1.0")], &[], true),
+ make_pkg("vendor/a", "1.0.0", &[], &[], false),
+ ];
+ let needles = vec!["vendor/x".to_string()];
+ let results = get_dependents(&packages, &needles, None, false, false).unwrap();
+ assert!(results.is_empty());
+ }
+
+ #[test]
+ fn test_circular_detection() {
+ // A requires B, B requires A — should not loop forever
+ let packages = vec![
+ make_pkg("vendor/a", "1.0.0", &[("vendor/b", "^1.0")], &[], false),
+ make_pkg("vendor/b", "1.0.0", &[("vendor/a", "^1.0")], &[], false),
+ ];
+ let needles = vec!["vendor/b".to_string()];
+ // Should terminate without stack overflow
+ let results = get_dependents(&packages, &needles, None, false, true).unwrap();
+ // vendor/a requires vendor/b → found; vendor/b would recurse back to vendor/a
+ // but visited set prevents infinite loop
+ assert!(!results.is_empty());
+ }
+
+ // ── prohibits tests ───────────────────────────────────────────────────────
+
+ #[test]
+ fn test_prohibits_basic() {
+ // root requires A ^1.0; user asks "who prohibits A 2.0"
+ // → root requires A ^1.0 which doesn't match 2.0 → root prohibits it
+ let packages = vec![
+ make_pkg("root/project", "ROOT", &[("vendor/a", "^1.0")], &[], true),
+ make_pkg("vendor/a", "1.0.0", &[], &[], false),
+ ];
+ let constraint = crate::constraint::VersionConstraint::parse("2.0.0").unwrap();
+ let needles = vec!["vendor/a".to_string()];
+ let results = get_dependents(&packages, &needles, Some(&constraint), true, false).unwrap();
+ assert!(!results.is_empty(), "root should prohibit vendor/a 2.0");
+ assert_eq!(results[0].package_name, "root/project");
+ assert_eq!(results[0].link_description, "requires");
+ }
+
+ #[test]
+ fn test_prohibits_conflict_field() {
+ // pkg/b conflicts with vendor/a ^2.0 → prohibits vendor/a 2.0
+ let packages = vec![
+ make_pkg(
+ "root/project",
+ "ROOT",
+ &[("vendor/a", "^1.0"), ("vendor/b", "^1.0")],
+ &[],
+ true,
+ ),
+ make_pkg("vendor/a", "1.0.0", &[], &[], false),
+ make_pkg(
+ "vendor/b",
+ "1.0.0",
+ &[],
+ &[("vendor/a", "^2.0")], // conflicts with vendor/a ^2.0
+ false,
+ ),
+ ];
+ let constraint = crate::constraint::VersionConstraint::parse("2.0.0").unwrap();
+ let needles = vec!["vendor/a".to_string()];
+ let results = get_dependents(&packages, &needles, Some(&constraint), true, false).unwrap();
+ // vendor/b conflicts with vendor/a ^2.0 which covers 2.0.0
+ let conflict_result = results.iter().find(|r| r.package_name == "vendor/b");
+ assert!(
+ conflict_result.is_some(),
+ "vendor/b should prohibit vendor/a 2.0 via conflict"
+ );
+ assert_eq!(conflict_result.unwrap().link_description, "conflicts");
+ }
+
+ #[test]
+ fn test_prohibits_no_issue() {
+ // root requires A ^2.0; user asks "who prohibits A 2.5"
+ // → root's constraint ^2.0 DOES match 2.5 → nobody prohibits it
+ let packages = vec![
+ make_pkg("root/project", "ROOT", &[("vendor/a", "^2.0")], &[], true),
+ make_pkg("vendor/a", "2.0.0", &[], &[], false),
+ ];
+ let constraint = crate::constraint::VersionConstraint::parse("2.5.0").unwrap();
+ let needles = vec!["vendor/a".to_string()];
+ let results = get_dependents(&packages, &needles, Some(&constraint), true, false).unwrap();
+ assert!(
+ results.is_empty(),
+ "Nobody prohibits vendor/a 2.5 when root requires ^2.0"
+ );
+ }
+
+ // ── print helpers (smoke tests) ───────────────────────────────────────────
+
+ #[test]
+ fn test_print_table_empty() {
+ print_table(&[]);
+ }
+
+ #[test]
+ fn test_print_table_single() {
+ let results = vec![DependencyResult {
+ package_name: "vendor/a".to_string(),
+ package_version: "1.0.0".to_string(),
+ link_description: "requires".to_string(),
+ link_target: "vendor/b".to_string(),
+ link_constraint: "^2.0".to_string(),
+ children: vec![],
+ }];
+ print_table(&results);
+ }
+
+ #[test]
+ fn test_print_tree_empty() {
+ print_tree(&[], 0);
+ }
+
+ #[test]
+ fn test_print_tree_nested() {
+ let results = vec![DependencyResult {
+ package_name: "vendor/a".to_string(),
+ package_version: "1.0.0".to_string(),
+ link_description: "requires".to_string(),
+ link_target: "vendor/b".to_string(),
+ link_constraint: "^2.0".to_string(),
+ children: vec![DependencyResult {
+ package_name: "root/project".to_string(),
+ package_version: "ROOT".to_string(),
+ link_description: "requires".to_string(),
+ link_target: "vendor/a".to_string(),
+ link_constraint: "^1.0".to_string(),
+ children: vec![],
+ }],
+ }];
+ print_tree(&results, 0);
+ }
+}
diff --git a/crates/mozart/src/commands/depends.rs b/crates/mozart/src/commands/depends.rs
index ecdbb24..fa84f7d 100644
--- a/crates/mozart/src/commands/depends.rs
+++ b/crates/mozart/src/commands/depends.rs
@@ -1,4 +1,5 @@
use clap::Args;
+use std::path::PathBuf;
#[derive(Args)]
pub struct DependsArgs {
@@ -18,6 +19,42 @@ pub struct DependsArgs {
pub locked: bool,
}
-pub fn execute(_args: &DependsArgs, _cli: &super::Cli) -> anyhow::Result<()> {
- todo!()
+pub fn execute(args: &DependsArgs, cli: &super::Cli) -> anyhow::Result<()> {
+ let working_dir = match &cli.working_dir {
+ Some(dir) => PathBuf::from(dir),
+ None => std::env::current_dir()?,
+ };
+
+ let packages = super::dependency::load_packages(&working_dir, args.locked)?;
+
+ if packages.is_empty() {
+ println!(
+ "{}",
+ crate::console::info("No packages found. Run `mozart install` first.")
+ );
+ return Ok(());
+ }
+
+ let target = args.package.to_lowercase();
+
+ // Verify the target package is known
+ let target_known = packages.iter().any(|p| p.name.to_lowercase() == target);
+ if !target_known {
+ anyhow::bail!(
+ "Package '{}' not found in the dependency graph.",
+ args.package
+ );
+ }
+
+ let recursive = args.tree || args.recursive;
+ let needles = vec![target];
+ let results = super::dependency::get_dependents(&packages, &needles, None, false, recursive)?;
+
+ if args.tree {
+ super::dependency::print_tree(&results, 0);
+ } else {
+ super::dependency::print_table(&results);
+ }
+
+ Ok(())
}
diff --git a/crates/mozart/src/commands/install.rs b/crates/mozart/src/commands/install.rs
index c652569..ff53d1e 100644
--- a/crates/mozart/src/commands/install.rs
+++ b/crates/mozart/src/commands/install.rs
@@ -462,6 +462,7 @@ mod tests {
dist: None,
require: BTreeMap::new(),
require_dev: BTreeMap::new(),
+ conflict: BTreeMap::new(),
suggest: None,
package_type: Some("library".to_string()),
autoload: None,
diff --git a/crates/mozart/src/commands/prohibits.rs b/crates/mozart/src/commands/prohibits.rs
index 8f87644..d30e57f 100644
--- a/crates/mozart/src/commands/prohibits.rs
+++ b/crates/mozart/src/commands/prohibits.rs
@@ -1,4 +1,5 @@
use clap::Args;
+use std::path::PathBuf;
#[derive(Args)]
pub struct ProhibitsArgs {
@@ -21,6 +22,54 @@ pub struct ProhibitsArgs {
pub locked: bool,
}
-pub fn execute(_args: &ProhibitsArgs, _cli: &super::Cli) -> anyhow::Result<()> {
- todo!()
+pub fn execute(args: &ProhibitsArgs, cli: &super::Cli) -> anyhow::Result<()> {
+ let working_dir = match &cli.working_dir {
+ Some(dir) => PathBuf::from(dir),
+ None => std::env::current_dir()?,
+ };
+
+ let packages = super::dependency::load_packages(&working_dir, args.locked)?;
+
+ if packages.is_empty() {
+ println!(
+ "{}",
+ crate::console::info("No packages found. Run `mozart install` first.")
+ );
+ return Ok(());
+ }
+
+ // Parse the version constraint the user is asking about
+ let version_constraint = crate::constraint::VersionConstraint::parse(&args.version)
+ .map_err(|e| anyhow::anyhow!("Invalid version constraint '{}': {}", args.version, e))?;
+
+ let recursive = args.tree || args.recursive;
+ let target = args.package.to_lowercase();
+ let needles = vec![target];
+
+ let results = super::dependency::get_dependents(
+ &packages,
+ &needles,
+ Some(&version_constraint),
+ true, // inverted = prohibits mode
+ recursive,
+ )?;
+
+ if results.is_empty() {
+ println!(
+ "{}",
+ crate::console::info(&format!(
+ "{} {} can be installed.",
+ args.package, args.version
+ ))
+ );
+ return Ok(());
+ }
+
+ if args.tree {
+ super::dependency::print_tree(&results, 0);
+ } else {
+ super::dependency::print_table(&results);
+ }
+
+ Ok(())
}
diff --git a/crates/mozart/src/commands/remove.rs b/crates/mozart/src/commands/remove.rs
index 3c238ad..350ea39 100644
--- a/crates/mozart/src/commands/remove.rs
+++ b/crates/mozart/src/commands/remove.rs
@@ -444,6 +444,7 @@ mod tests {
dist: None,
require: BTreeMap::new(),
require_dev: BTreeMap::new(),
+ conflict: BTreeMap::new(),
suggest: None,
package_type: Some("library".to_string()),
autoload: None,
diff --git a/crates/mozart/src/commands/require.rs b/crates/mozart/src/commands/require.rs
index 0bbd351..ee40d54 100644
--- a/crates/mozart/src/commands/require.rs
+++ b/crates/mozart/src/commands/require.rs
@@ -504,6 +504,7 @@ mod tests {
dist: None,
require: BTreeMap::new(),
require_dev: BTreeMap::new(),
+ conflict: BTreeMap::new(),
suggest: None,
package_type: Some("library".to_string()),
autoload: None,
diff --git a/crates/mozart/src/commands/update.rs b/crates/mozart/src/commands/update.rs
index 36260a0..ba21432 100644
--- a/crates/mozart/src/commands/update.rs
+++ b/crates/mozart/src/commands/update.rs
@@ -648,6 +648,7 @@ mod tests {
dist: None,
require: BTreeMap::new(),
require_dev: BTreeMap::new(),
+ conflict: BTreeMap::new(),
suggest: None,
package_type: Some("library".to_string()),
autoload: None,
diff --git a/crates/mozart/src/lockfile.rs b/crates/mozart/src/lockfile.rs
index 9b5b996..7c945b8 100644
--- a/crates/mozart/src/lockfile.rs
+++ b/crates/mozart/src/lockfile.rs
@@ -78,6 +78,9 @@ pub struct LockedPackage {
)]
pub require_dev: BTreeMap<String, String>,
+ #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
+ pub conflict: BTreeMap<String, String>,
+
#[serde(skip_serializing_if = "Option::is_none")]
pub suggest: Option<BTreeMap<String, String>>,
@@ -281,6 +284,7 @@ fn packagist_version_to_locked_package(name: &str, pv: &PackagistVersion) -> Loc
dist: pv.dist.as_ref().map(packagist_dist_to_locked),
require: pv.require.clone(),
require_dev: pv.require_dev.clone(),
+ conflict: pv.conflict.clone(),
suggest: pv.suggest.clone(),
package_type: pv.package_type.clone(),
autoload: pv.autoload.clone(),
@@ -527,6 +531,7 @@ mod tests {
}),
require: BTreeMap::new(),
require_dev: BTreeMap::new(),
+ conflict: BTreeMap::new(),
suggest: None,
package_type: Some("library".to_string()),
autoload: None,
@@ -954,6 +959,7 @@ mod tests {
dist: None,
require: BTreeMap::new(),
require_dev: BTreeMap::new(),
+ conflict: BTreeMap::new(),
suggest: None,
package_type: None,
autoload: None,
@@ -976,6 +982,7 @@ mod tests {
dist: None,
require: BTreeMap::new(),
require_dev: BTreeMap::new(),
+ conflict: BTreeMap::new(),
suggest: None,
package_type: None,
autoload: None,