diff options
| -rw-r--r-- | crates/mozart-core/src/lib.rs | 3 | ||||
| -rw-r--r-- | crates/mozart-core/src/package_info.rs | 124 | ||||
| -rw-r--r-- | crates/mozart-core/src/package_sorter.rs | 53 | ||||
| -rw-r--r-- | crates/mozart-core/src/repository_utils.rs | 174 | ||||
| -rw-r--r-- | crates/mozart/src/commands/dump_autoload.rs | 2 | ||||
| -rw-r--r-- | crates/mozart/src/commands/init.rs | 361 | ||||
| -rw-r--r-- | crates/mozart/src/commands/licenses.rs | 678 | ||||
| -rw-r--r-- | crates/mozart/src/commands/update.rs | 2 |
8 files changed, 954 insertions, 443 deletions
diff --git a/crates/mozart-core/src/lib.rs b/crates/mozart-core/src/lib.rs index 74f3512..ab6bfe0 100644 --- a/crates/mozart-core/src/lib.rs +++ b/crates/mozart-core/src/lib.rs @@ -5,7 +5,10 @@ pub mod exit_code; pub mod factory; pub mod http; pub mod package; +pub mod package_info; +pub mod package_sorter; pub mod platform; +pub mod repository_utils; pub mod suggest; pub mod validation; pub mod version_bumper; diff --git a/crates/mozart-core/src/package_info.rs b/crates/mozart-core/src/package_info.rs new file mode 100644 index 0000000..d67729a --- /dev/null +++ b/crates/mozart-core/src/package_info.rs @@ -0,0 +1,124 @@ +//! Mirrors `Composer\Util\PackageInfo`. +//! +//! The PHP class exposes two small static helpers that the `licenses`, +//! `show`, `outdated`, and `funding` commands lean on to produce a +//! "view source" link for a package — preferring the explicit +//! `support.source` URL, falling back to the package's `source` URL, +//! and finally the homepage. Empty strings are normalised to `None`. + +/// Minimal contract for the package fields [`view_source_url`] and +/// [`view_source_or_homepage_url`] consult. +pub trait PackageUrls { + /// `support.source` from `composer.json` (mirrors + /// `CompletePackageInterface::getSupport()['source']`). Returns + /// `None` when the support block is absent or the `source` key is + /// missing. + fn support_source(&self) -> Option<&str>; + /// `source.url` (mirrors `PackageInterface::getSourceUrl()`). + fn source_url(&self) -> Option<&str>; + /// `homepage` (mirrors `CompletePackageInterface::getHomepage()`). + fn homepage(&self) -> Option<&str>; +} + +/// Mirror of `PackageInfo::getViewSourceUrl`. +/// +/// PHP returns the support-source URL when it is set and non-empty, +/// otherwise `getSourceUrl()`. Empty strings are treated as absent. +pub fn view_source_url<P: PackageUrls + ?Sized>(package: &P) -> Option<String> { + if let Some(s) = package.support_source().filter(|s| !s.is_empty()) { + return Some(s.to_string()); + } + package + .source_url() + .filter(|s| !s.is_empty()) + .map(String::from) +} + +/// Mirror of `PackageInfo::getViewSourceOrHomepageUrl`. +/// +/// Falls back to the package homepage when no source URL is available. +/// An empty homepage string is normalised to `None`, matching PHP's +/// `if ($url === '') { return null; }` guard. +pub fn view_source_or_homepage_url<P: PackageUrls + ?Sized>(package: &P) -> Option<String> { + view_source_url(package) + .or_else(|| package.homepage().map(String::from)) + .filter(|s| !s.is_empty()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[derive(Default)] + struct P { + support_source: Option<String>, + source_url: Option<String>, + homepage: Option<String>, + } + + impl PackageUrls for P { + fn support_source(&self) -> Option<&str> { + self.support_source.as_deref() + } + fn source_url(&self) -> Option<&str> { + self.source_url.as_deref() + } + fn homepage(&self) -> Option<&str> { + self.homepage.as_deref() + } + } + + #[test] + fn prefers_support_source() { + let p = P { + support_source: Some("https://github.com/foo/bar".to_string()), + source_url: Some("https://example.com/repo".to_string()), + ..Default::default() + }; + assert_eq!( + view_source_url(&p).as_deref(), + Some("https://github.com/foo/bar") + ); + } + + #[test] + fn empty_support_source_falls_through_to_source_url() { + let p = P { + support_source: Some(String::new()), + source_url: Some("https://example.com/repo".to_string()), + ..Default::default() + }; + assert_eq!( + view_source_url(&p).as_deref(), + Some("https://example.com/repo") + ); + } + + #[test] + fn falls_back_to_homepage() { + let p = P { + homepage: Some("https://example.com/".to_string()), + ..Default::default() + }; + assert_eq!( + view_source_or_homepage_url(&p).as_deref(), + Some("https://example.com/") + ); + } + + #[test] + fn empty_homepage_is_none() { + let p = P { + homepage: Some(String::new()), + ..Default::default() + }; + assert!(view_source_or_homepage_url(&p).is_none()); + } + + #[test] + fn no_urls_at_all_returns_none() { + let p = P::default(); + assert!(view_source_url(&p).is_none()); + assert!(view_source_or_homepage_url(&p).is_none()); + } +} diff --git a/crates/mozart-core/src/package_sorter.rs b/crates/mozart-core/src/package_sorter.rs new file mode 100644 index 0000000..f9871a5 --- /dev/null +++ b/crates/mozart-core/src/package_sorter.rs @@ -0,0 +1,53 @@ +//! Mirrors `Composer\Util\PackageSorter`. +//! +//! Composer's helper takes `PackageInterface[]` and sorts in place by +//! `getName()` (the lowercase normalized name), case-sensitive `<=>`. +//! Mozart commands hold a variety of package representations +//! (`InstalledPackageEntry`, `LockedPackage`, `PackageData`, …); rather +//! than force them all behind one trait, the sorter accepts a key +//! extractor closure and is generic over the slice element type. + +/// Mirror of `PackageSorter::sortPackagesAlphabetically`. +/// +/// Composer compares with `getName() <=> getName()`. `getName()` returns +/// the normalized (lowercase) `vendor/name`, so the sort is effectively +/// case-insensitive on the original casing but case-sensitive on the +/// already-normalized form. Use a key extractor that returns the +/// normalized name to match. +pub fn sort_packages_alphabetically<T, F>(packages: &mut [T], name: F) +where + F: Fn(&T) -> &str, +{ + packages.sort_by(|a, b| name(a).cmp(name(b))); +} + +#[cfg(test)] +mod tests { + use super::*; + + #[derive(Debug, PartialEq)] + struct P(&'static str); + + #[test] + fn sorts_by_name_ascending() { + let mut v = vec![P("monolog/monolog"), P("psr/log"), P("a/b")]; + sort_packages_alphabetically(&mut v, |p| p.0); + assert_eq!(v, vec![P("a/b"), P("monolog/monolog"), P("psr/log")]); + } + + #[test] + fn empty_slice_is_noop() { + let mut v: Vec<P> = vec![]; + sort_packages_alphabetically(&mut v, |p| p.0); + assert!(v.is_empty()); + } + + #[test] + fn case_sensitive_on_normalized_form() { + // Already-lowercase names — Composer stores `getName()` lowercased, + // so we never mix cases here, but verify ordering is plain `<=>`. + let mut v = vec![P("zzz/x"), P("aaa/x"), P("aaa/y")]; + sort_packages_alphabetically(&mut v, |p| p.0); + assert_eq!(v, vec![P("aaa/x"), P("aaa/y"), P("zzz/x")]); + } +} diff --git a/crates/mozart-core/src/repository_utils.rs b/crates/mozart-core/src/repository_utils.rs new file mode 100644 index 0000000..ecd5dd7 --- /dev/null +++ b/crates/mozart-core/src/repository_utils.rs @@ -0,0 +1,174 @@ +//! Mirrors `Composer\Repository\RepositoryUtils`. +//! +//! Currently ports `filterRequiredPackages` only; `flattenRepositories` +//! has no Mozart equivalent yet because Mozart does not model nested +//! `CompositeRepository`/`FilterRepository` structures. + +use std::collections::BTreeSet; + +/// Minimal contract for a package that can participate in the require +/// closure walk performed by [`filter_required_packages`]. +/// +/// `package_name` is the normalized `vendor/name`. `requires` returns +/// the require map (`name → constraint`); only the keys are consulted. +/// `package_names` returns every name the package answers for — +/// typically just `package_name`, but Composer's `PackageInterface::getNames()` +/// also includes `provide`/`replace` targets. Implementations may +/// return `None` when those auxiliary names are not yet modelled in +/// Mozart's data layer; the walk falls back to matching on +/// `package_name` only in that case. +pub trait Required { + fn package_name(&self) -> &str; + fn requires(&self) -> &std::collections::BTreeMap<String, String>; + fn package_names(&self) -> Option<Vec<&str>> { + None + } +} + +/// Mirror of `RepositoryUtils::filterRequiredPackages`. +/// +/// Walks the require closure of `requirer_requires` against `packages`, +/// collecting (in input order) every package that is reachable. +/// `requirer_dev_requires`, when `Some`, is merged into the initial +/// require set — matching the `$includeRequireDev` flag, which Composer +/// only honours for the *initial* requirer (transitive walks always +/// look at `getRequires()` only). +/// +/// The returned vector preserves the order in which packages were +/// discovered, matching PHP's `$bucket[] = $candidate;` push pattern. +pub fn filter_required_packages<P>( + packages: &[P], + requirer_requires: &std::collections::BTreeMap<String, String>, + requirer_dev_requires: Option<&std::collections::BTreeMap<String, String>>, +) -> Vec<usize> +where + P: Required, +{ + let mut initial: BTreeSet<&str> = requirer_requires.keys().map(String::as_str).collect(); + if let Some(dev) = requirer_dev_requires { + initial.extend(dev.keys().map(String::as_str)); + } + + let mut bucket: Vec<usize> = Vec::new(); + walk(packages, &initial, &mut bucket); + bucket +} + +fn walk<P>(packages: &[P], requires: &BTreeSet<&str>, bucket: &mut Vec<usize>) +where + P: Required, +{ + for (idx, candidate) in packages.iter().enumerate() { + let names: Vec<&str> = candidate + .package_names() + .unwrap_or_else(|| vec![candidate.package_name()]); + let matches = names.iter().any(|n| requires.contains(n)); + if !matches { + continue; + } + if bucket.contains(&idx) { + continue; + } + bucket.push(idx); + let next: BTreeSet<&str> = candidate.requires().keys().map(String::as_str).collect(); + walk(packages, &next, bucket); + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::collections::BTreeMap; + + struct Pkg { + name: String, + requires: BTreeMap<String, String>, + } + + impl Required for Pkg { + fn package_name(&self) -> &str { + &self.name + } + fn requires(&self) -> &BTreeMap<String, String> { + &self.requires + } + } + + fn pkg(name: &str, requires: &[&str]) -> Pkg { + let mut r = BTreeMap::new(); + for n in requires { + r.insert(n.to_string(), "*".to_string()); + } + Pkg { + name: name.to_string(), + requires: r, + } + } + + fn root_requires(names: &[&str]) -> BTreeMap<String, String> { + let mut m = BTreeMap::new(); + for n in names { + m.insert(n.to_string(), "*".to_string()); + } + m + } + + #[test] + fn filters_to_root_requires_only() { + let packages = vec![ + pkg("a/a", &[]), + pkg("b/b", &[]), + pkg("c/c", &[]), // not required + ]; + let root = root_requires(&["a/a", "b/b"]); + let kept = filter_required_packages(&packages, &root, None); + let names: Vec<&str> = kept.iter().map(|&i| packages[i].name.as_str()).collect(); + assert_eq!(names, vec!["a/a", "b/b"]); + } + + #[test] + fn walks_transitive_requires() { + let packages = vec![ + pkg("a/a", &["b/b"]), + pkg("b/b", &["c/c"]), + pkg("c/c", &[]), + pkg("d/d", &[]), // unreachable + ]; + let root = root_requires(&["a/a"]); + let kept = filter_required_packages(&packages, &root, None); + let names: Vec<&str> = kept.iter().map(|&i| packages[i].name.as_str()).collect(); + assert_eq!(names, vec!["a/a", "b/b", "c/c"]); + } + + #[test] + fn dev_requires_only_apply_at_root() { + let packages = vec![ + pkg("a/a", &[]), + pkg("b/b", &["c/c"]), + pkg("c/c", &[]), // only reachable via a's dev-requires (no dev requires here) + pkg("d/d", &[]), + ]; + let root = root_requires(&["a/a"]); + let dev = root_requires(&["b/b"]); + let kept = filter_required_packages(&packages, &root, Some(&dev)); + let names: Vec<&str> = kept.iter().map(|&i| packages[i].name.as_str()).collect(); + assert_eq!(names, vec!["a/a", "b/b", "c/c"]); + } + + #[test] + fn handles_circular_requires() { + let packages = vec![pkg("a/a", &["b/b"]), pkg("b/b", &["a/a"])]; + let root = root_requires(&["a/a"]); + let kept = filter_required_packages(&packages, &root, None); + let names: Vec<&str> = kept.iter().map(|&i| packages[i].name.as_str()).collect(); + assert_eq!(names, vec!["a/a", "b/b"]); + } + + #[test] + fn empty_requires_yields_nothing() { + let packages = vec![pkg("a/a", &[]), pkg("b/b", &[])]; + let root = BTreeMap::new(); + let kept = filter_required_packages(&packages, &root, None); + assert!(kept.is_empty()); + } +} diff --git a/crates/mozart/src/commands/dump_autoload.rs b/crates/mozart/src/commands/dump_autoload.rs index 6f08b2a..0d12220 100644 --- a/crates/mozart/src/commands/dump_autoload.rs +++ b/crates/mozart/src/commands/dump_autoload.rs @@ -3,7 +3,7 @@ use mozart_autoload::AutoloadGeneratorExt; use mozart_core::composer::{AutoloadDumpOptions, Composer, PlatformRequirementFilter}; use mozart_core::{console_format, console_writeln}; -#[derive(Args)] +#[derive(Args, Default)] pub struct DumpAutoloadArgs { /// Optimizes PSR-0 and PSR-4 packages to be loaded with classmaps #[arg(short, long)] diff --git a/crates/mozart/src/commands/init.rs b/crates/mozart/src/commands/init.rs index 3378755..5d6d501 100644 --- a/crates/mozart/src/commands/init.rs +++ b/crates/mozart/src/commands/init.rs @@ -12,6 +12,7 @@ use std::collections::BTreeMap; use std::io::{BufRead, Write}; use std::path::Path; use std::process::Command; +use std::sync::OnceLock; #[derive(Args)] pub struct InitArgs { @@ -111,7 +112,12 @@ pub async fn execute( package::write_to_file(&composer, &composer_file).context("Failed to write composer.json")?; - // Create autoload directory if specified + let has_dependencies = !composer.require.is_empty() || !composer.require_dev.is_empty(); + + // --autoload — create the source folder. When the project has no + // dependencies, Composer also runs `dump-autoload` so the autoloader is + // immediately usable; failures are downgraded to a warning to mirror + // Composer's try/catch around `runDumpAutoloadCommand`. if let Some(ref autoload) = composer.autoload { for path in autoload.psr4.values() { let dir = working_dir.join(path); @@ -120,6 +126,13 @@ pub async fn execute( .with_context(|| format!("Failed to create directory {}", dir.display()))?; } } + + if !has_dependencies { + let dump_args = super::dump_autoload::DumpAutoloadArgs::default(); + if let Err(e) = super::dump_autoload::execute(&dump_args, cli, console).await { + console.error(&format!("Could not run dump-autoload. ({e})")); + } + } } // Offer to add /vendor/ to .gitignore @@ -134,6 +147,22 @@ pub async fn execute( } } + // Run `composer update` after init when the new project has dependencies + // and the user confirms — Composer's L190-193. + if console.interactive + && has_dependencies + && console.confirm(&console_format!( + "Would you like to install dependencies now [<comment>yes</comment>]?" + )) + { + let update_args = super::update::UpdateArgs::default(); + if let Err(e) = super::update::execute(&update_args, cli, console).await { + console.error(&format!( + "Could not update dependencies. Run `composer update` to see more information. ({e})" + )); + } + } + // Show autoload info if let Some(ref autoload) = composer.autoload && let Some((ns, path)) = autoload.psr4.iter().next() @@ -278,24 +307,32 @@ async fn build_interactive( } }; - // Minimum Stability + // Minimum Stability — Composer's askAndValidate loops until valid (the + // validator throws InvalidArgumentException, Symfony's QuestionHelper + // catches it and re-prompts when maxAttempts is null). let default_stability = args.stability.clone().unwrap_or_default(); - let stability_input = console.ask( - &console_format!( - "Minimum Stability [<comment>{}</comment>]", - &default_stability - ), - &default_stability, - ); + let stability_input = console + .ask_validated( + &console_format!( + "Minimum Stability [<comment>{}</comment>]", + &default_stability + ), + &default_stability, + |val| { + if val.is_empty() || validation::validate_stability(val) { + Ok(()) + } else { + Err(format!( + "Invalid minimum stability \"{val}\". Must be empty or one of: dev, alpha, beta, rc, stable" + )) + } + }, + ) + .map_err(|e| anyhow::anyhow!(e))?; let minimum_stability = if stability_input.is_empty() { None - } else if validation::validate_stability(&stability_input) { - Some(stability_input.to_lowercase()) } else { - console.error(&format!( - "Invalid minimum stability \"{stability_input}\". Using empty." - )); - None + Some(stability_input.to_lowercase()) }; // Package Type @@ -313,28 +350,27 @@ async fn build_interactive( Some(type_input) }; - // License + // License — Composer prompts once, then validates outside the prompt and + // throws on invalid (no retry loop). See InitCommand::interact L364-372. let default_license = args .license .clone() .or_else(|| std::env::var("COMPOSER_DEFAULT_LICENSE").ok()) .unwrap_or_default(); - let license = loop { - let license_input = console.ask( - &console_format!("License [<comment>{}</comment>]", &default_license), - &default_license, + let license_input = console.ask( + &console_format!("License [<comment>{}</comment>]", &default_license), + &default_license, + ); + let license = if license_input.is_empty() { + None + } else if validation::validate_license(&license_input) + || license_input.eq_ignore_ascii_case("proprietary") + { + Some(license_input) + } else { + bail!( + "Invalid license provided: {license_input}. Only SPDX license identifiers (https://spdx.org/licenses/) or \"proprietary\" are accepted." ); - if license_input.is_empty() { - break None; - } else if validation::validate_license(&license_input) - || license_input.eq_ignore_ascii_case("proprietary") - { - break Some(license_input); - } else { - console.error(&format!( - "Invalid license provided: {license_input}. Only SPDX license identifiers (https://spdx.org/licenses/) or \"proprietary\" are accepted." - )); - } }; // Dependencies @@ -347,17 +383,25 @@ async fn build_interactive( console.info(&console_format!("<info>Define your dependencies.</info>")); console.info(""); + // Composer (InitCommand::interact L389-403): if --require was passed, + // skip the confirmation; otherwise ask before entering the discovery loop. let mut require = parse_requirements(&args.require)?; - let interactive_require = interactive_search_packages( - "require", - &require, - preferred_stability, - repo_cache, - console, - ) - .await?; - for (name, constraint) in interactive_require { - require.insert(name, constraint); + if !require.is_empty() + || console.confirm(&console_format!( + "Would you like to define your dependencies (require) interactively [<comment>yes</comment>]?" + )) + { + let interactive_require = interactive_search_packages( + "require", + &require, + preferred_stability, + repo_cache, + console, + ) + .await?; + for (name, constraint) in interactive_require { + require.insert(name, constraint); + } } // Dev Dependencies @@ -368,42 +412,55 @@ async fn build_interactive( console.info(""); let mut require_dev = parse_requirements(&args.require_dev)?; - let all_required: BTreeMap<String, String> = require - .iter() - .chain(require_dev.iter()) - .map(|(k, v)| (k.clone(), v.clone())) - .collect(); - let interactive_dev = interactive_search_packages( - "require-dev", - &all_required, - preferred_stability, - repo_cache, - console, - ) - .await?; - for (name, constraint) in interactive_dev { - require_dev.insert(name, constraint); + if !require_dev.is_empty() + || console.confirm(&console_format!( + "Would you like to define your dev dependencies (require-dev) interactively [<comment>yes</comment>]?" + )) + { + let all_required: BTreeMap<String, String> = require + .iter() + .chain(require_dev.iter()) + .map(|(k, v)| (k.clone(), v.clone())) + .collect(); + let interactive_dev = interactive_search_packages( + "require-dev", + &all_required, + preferred_stability, + repo_cache, + console, + ) + .await?; + for (name, constraint) in interactive_dev { + require_dev.insert(name, constraint); + } } - // PSR-4 Autoload + // PSR-4 Autoload — Composer validates with regex `^[^/][A-Za-z0-9\-_/]+/$` + // via askAndValidate (loops until valid). `n`/`no` skips. let default_autoload = args.autoload.clone().unwrap_or_else(|| "src/".to_string()); let namespace = validation::namespace_from_package_name(&name).unwrap_or_default(); - let autoload_input = console.ask( - &console_format!( - "Add PSR-4 autoload mapping? Maps namespace \"{namespace}\" to the entered relative path. [<comment>{}</comment>, n to skip]", + let autoload_input = console + .ask_validated( + &console_format!( + "Add PSR-4 autoload mapping? Maps namespace \"{namespace}\" to the entered relative path. [<comment>{}</comment>, n to skip]", + &default_autoload, + ), &default_autoload, - ), - &default_autoload, - ); + |val| { + if val == "n" || val == "no" || validation::validate_autoload_path(val) { + Ok(()) + } else { + Err(format!( + "The src folder name \"{val}\" is invalid. Please add a relative path with tailing forward slash. [A-Za-z0-9_-/]+/" + )) + } + }, + ) + .map_err(|e| anyhow::anyhow!(e))?; let autoload = if autoload_input == "n" || autoload_input == "no" { None } else { - let path = if autoload_input.is_empty() { - default_autoload - } else { - autoload_input - }; - build_autoload(&path, &name) + build_autoload(&autoload_input, &name) }; let repositories = parse_repositories(&args.repository)?; @@ -651,21 +708,33 @@ fn get_default_author() -> Option<String> { } } -fn get_git_config_value(key: &str) -> Option<String> { - Command::new("git") - .args(["config", "--get", key]) - .output() - .ok() - .and_then(|output| { - if output.status.success() { - String::from_utf8(output.stdout) - .ok() - .map(|s| s.trim().to_string()) - .filter(|s| !s.is_empty()) - } else { - None +/// `git config -l` parsed into `key=value` pairs and cached for the life of +/// the process. Mirrors Composer's `InitCommand::getGitConfig`, which runs +/// the command once and memoises the parsed map. +fn get_git_config() -> &'static BTreeMap<String, String> { + static GIT_CONFIG: OnceLock<BTreeMap<String, String>> = OnceLock::new(); + GIT_CONFIG.get_or_init(|| { + let mut map = BTreeMap::new(); + let Ok(output) = Command::new("git").args(["config", "-l"]).output() else { + return map; + }; + if !output.status.success() { + return map; + } + let Ok(text) = String::from_utf8(output.stdout) else { + return map; + }; + for line in text.lines() { + if let Some((key, value)) = line.split_once('=') { + map.insert(key.to_string(), value.to_string()); } - }) + } + map + }) +} + +fn get_git_config_value(key: &str) -> Option<String> { + get_git_config().get(key).cloned().filter(|v| !v.is_empty()) } fn parse_requirements(reqs: &[String]) -> anyhow::Result<BTreeMap<String, String>> { @@ -685,39 +754,58 @@ fn build_autoload(path: &str, package_name: &str) -> Option<RawAutoload> { Some(RawAutoload { psr4 }) } +/// Parse `--repository` arguments. Mirrors +/// `Composer\Repository\RepositoryFactory::configFromString`: +/// +/// * `http(s)://...` → `{type: composer, url: $repo}`. +/// * `{...}` → JSON object parsed verbatim into a repository config. +/// * `*.json` file path → composer-type repo file (deferred; not yet supported). +/// * anything else → reject with an error matching Composer's wording. fn parse_repositories(repos: &[String]) -> anyhow::Result<Vec<RawRepository>> { let mut result = Vec::new(); for repo in repos { - if repo.starts_with('{') { - // JSON format - let parsed: serde_json::Value = - serde_json::from_str(repo).context("Invalid repository JSON")?; - let repo_type = parsed["type"].as_str().unwrap_or("vcs").to_string(); - let url = parsed["url"] - .as_str() - .ok_or_else(|| anyhow::anyhow!("Repository JSON must contain a 'url' field"))? - .to_string(); - result.push(RawRepository { - repo_type, - url: Some(url), - package: None, - only: None, - exclude: None, - canonical: None, - security_advisories: None, - }); + let parsed: serde_json::Value = if repo.starts_with("http") { + serde_json::json!({ "type": "composer", "url": repo }) + } else if repo.starts_with('{') { + serde_json::from_str(repo).context("Invalid repository JSON")? } else { - // Plain URL - result.push(RawRepository { - repo_type: "vcs".to_string(), - url: Some(repo.clone()), - package: None, - only: None, - exclude: None, - canonical: None, - security_advisories: None, - }); - } + bail!( + "Invalid repository url ({repo}) given. Has to be a .json file, an http url or a JSON object." + ); + }; + + let repo_type = parsed + .get("type") + .and_then(|v| v.as_str()) + .ok_or_else(|| anyhow::anyhow!("Repository JSON must contain a 'type' field"))? + .to_string(); + let url = parsed + .get("url") + .and_then(|v| v.as_str()) + .map(|s| s.to_string()); + let package = parsed.get("package").cloned(); + let only = parsed.get("only").and_then(|v| v.as_array()).map(|a| { + a.iter() + .filter_map(|x| x.as_str().map(String::from)) + .collect() + }); + let exclude = parsed.get("exclude").and_then(|v| v.as_array()).map(|a| { + a.iter() + .filter_map(|x| x.as_str().map(String::from)) + .collect() + }); + let canonical = parsed.get("canonical").and_then(|v| v.as_bool()); + let security_advisories = parsed.get("security-advisories").cloned(); + + result.push(RawRepository { + repo_type, + url, + package, + only, + exclude, + canonical, + security_advisories, + }); } Ok(result) } @@ -746,3 +834,52 @@ fn add_vendor_ignore(gitignore_path: &Path) -> anyhow::Result<()> { std::fs::write(gitignore_path, contents)?; Ok(()) } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn parse_repositories_http_url_yields_composer_type() { + let repos = parse_repositories(&["https://repo.example.com".to_string()]).unwrap(); + assert_eq!(repos.len(), 1); + assert_eq!(repos[0].repo_type, "composer"); + assert_eq!(repos[0].url.as_deref(), Some("https://repo.example.com")); + } + + #[test] + fn parse_repositories_http_scheme_also_matches() { + let repos = parse_repositories(&["http://example.com".to_string()]).unwrap(); + assert_eq!(repos[0].repo_type, "composer"); + } + + #[test] + fn parse_repositories_json_object_preserved() { + let repos = parse_repositories(&[ + r#"{"type":"vcs","url":"https://github.com/acme/repo"}"#.to_string() + ]) + .unwrap(); + assert_eq!(repos[0].repo_type, "vcs"); + assert_eq!( + repos[0].url.as_deref(), + Some("https://github.com/acme/repo") + ); + } + + #[test] + fn parse_repositories_unknown_form_is_error() { + let err = parse_repositories(&["not-a-url-or-json".to_string()]).unwrap_err(); + assert!( + err.to_string() + .contains("Has to be a .json file, an http url or a JSON object"), + "{err}", + ); + } + + #[test] + fn parse_repositories_json_without_type_is_error() { + let err = + parse_repositories(&[r#"{"url":"https://example.com"}"#.to_string()]).unwrap_err(); + assert!(err.to_string().contains("'type'"), "{err}"); + } +} diff --git a/crates/mozart/src/commands/licenses.rs b/crates/mozart/src/commands/licenses.rs index 1574d79..468fde7 100644 --- a/crates/mozart/src/commands/licenses.rs +++ b/crates/mozart/src/commands/licenses.rs @@ -1,40 +1,76 @@ use clap::Args; -use indexmap::IndexSet; +use indexmap::IndexMap; +use mozart_core::composer::Composer; use mozart_core::console::Console; +use mozart_core::console::hyperlink; use mozart_core::console_format; use mozart_core::console_writeln; +use mozart_core::package_info; +use mozart_core::package_info::PackageUrls; +use mozart_core::package_sorter::sort_packages_alphabetically; +use mozart_core::repository_utils; +use mozart_core::repository_utils::Required; use serde::Serialize; -use std::path::Path; +use std::collections::BTreeMap; #[derive(Args)] pub struct LicensesArgs { - /// Output format (text, json, summary) + /// Format of the output: text, json or summary #[arg(short, long)] pub format: Option<String>, - /// Disables listing of require-dev packages + /// Disables search in require-dev packages. #[arg(long)] pub no_dev: bool, - /// List packages from the lock file + /// Shows licenses from the lock file instead of what's currently installed. #[arg(long)] pub locked: bool, } +/// Unified view over an installed or locked package, carrying the +/// fields the `licenses` command renders. Mirrors the slice of +/// `CompletePackageInterface` consumed by `LicensesCommand` — name, +/// version, license, requires, and the URL bits used to build a +/// `<href>` link in the text output. struct LicenseEntry { + pretty_name: String, name: String, version: String, licenses: Vec<String>, + requires: BTreeMap<String, String>, + support_source: Option<String>, + source_url: Option<String>, + homepage: Option<String>, +} + +impl Required for LicenseEntry { + fn package_name(&self) -> &str { + &self.name + } + fn requires(&self) -> &BTreeMap<String, String> { + &self.requires + } +} + +impl PackageUrls for LicenseEntry { + fn support_source(&self) -> Option<&str> { + self.support_source.as_deref() + } + fn source_url(&self) -> Option<&str> { + self.source_url.as_deref() + } + fn homepage(&self) -> Option<&str> { + self.homepage.as_deref() + } } pub async fn execute( args: &LicensesArgs, cli: &super::Cli, - console: &mozart_core::console::Console, + console: &Console, ) -> anyhow::Result<()> { let working_dir = cli.working_dir()?; - - // Validate format let format = args.format.as_deref().unwrap_or("text"); if format != "text" && format != "json" && format != "summary" { anyhow::bail!( @@ -43,150 +79,190 @@ pub async fn execute( ); } - // Load root package - let composer_json_path = working_dir.join("composer.json"); - if !composer_json_path.exists() { - anyhow::bail!("No composer.json found in {}", working_dir.display()); - } - let root = mozart_core::package::read_from_file(&composer_json_path)?; + let composer = Composer::require(&working_dir)?; - let root_name = root.name.clone(); - let root_version = root - .extra_fields - .get("version") - .and_then(|v| v.as_str()) - .unwrap_or("No version set") - .to_string(); + // TODO(plugins): dispatch CommandEvent for `licenses`. - // Parse root license as Vec<String>: composer.json allows either a string or an array. - let root_licenses: Vec<String> = { - // Read the raw JSON value so we can handle both string and array forms. - let raw_json = std::fs::read_to_string(&composer_json_path)?; - let raw_value: serde_json::Value = serde_json::from_str(&raw_json)?; - match raw_value.get("license") { - Some(serde_json::Value::String(s)) => vec![s.clone()], - Some(serde_json::Value::Array(arr)) => arr - .iter() - .filter_map(|v| v.as_str()) - .map(|s| s.to_string()) - .collect(), - _ => vec![], - } - }; + let root = composer.package(); - // Load dependency entries - let entries = if args.locked { - load_locked_licenses(&working_dir, args.no_dev)? + // RawPackageData stores `license` as `Option<String>` only, so we + // re-parse the composer.json to also accept the array form Composer + // recognises via `RootPackageLoader`'s `(array) $config['license']` + // coercion. Track widening `RawPackageData::license` separately. + let root_licenses = read_root_licenses(&working_dir.join("composer.json"))?; + let root_pretty_name = root.name.clone(); + let root_version = root + .version + .clone() + .unwrap_or_else(|| "No version set".to_string()); + + let mut entries = if args.locked { + load_locked_entries(&working_dir, args.no_dev)? } else { - load_installed_licenses(&working_dir, args.no_dev)? + load_installed_entries(&working_dir, &root.require, args.no_dev)? }; - // Render output + sort_packages_alphabetically(&mut entries, |e| e.name.as_str()); + match format { - "json" => render_json(&root_name, &root_version, &root_licenses, &entries, console)?, + "json" => render_json( + &root_pretty_name, + &root_version, + &root_licenses, + &entries, + console, + )?, "summary" => render_summary(&entries, console), - _ => render_text(&root_name, &root_version, &root_licenses, &entries, console), + _ => render_text( + &root_pretty_name, + &root_version, + &root_licenses, + &entries, + console, + ), } Ok(()) } -fn load_installed_licenses(working_dir: &Path, no_dev: bool) -> anyhow::Result<Vec<LicenseEntry>> { +fn read_root_licenses(composer_json_path: &std::path::Path) -> anyhow::Result<Vec<String>> { + let raw = std::fs::read_to_string(composer_json_path)?; + let value: serde_json::Value = serde_json::from_str(&raw)?; + Ok(match value.get("license") { + Some(serde_json::Value::String(s)) => vec![s.clone()], + Some(serde_json::Value::Array(arr)) => arr + .iter() + .filter_map(|v| v.as_str()) + .map(String::from) + .collect(), + _ => Vec::new(), + }) +} + +fn load_installed_entries( + working_dir: &std::path::Path, + root_requires: &BTreeMap<String, String>, + no_dev: bool, +) -> anyhow::Result<Vec<LicenseEntry>> { let vendor_dir = working_dir.join("vendor"); let installed = mozart_registry::installed::InstalledPackages::read(&vendor_dir)?; - let dev_names: IndexSet<String> = installed - .dev_package_names - .iter() - .map(|n| n.to_lowercase()) - .collect(); + let entries: Vec<LicenseEntry> = installed.packages.iter().map(installed_to_entry).collect(); - let mut entries: Vec<LicenseEntry> = installed - .packages - .iter() - .filter(|p| { - if no_dev && dev_names.contains(&p.name.to_lowercase()) { - return false; + if no_dev { + // Mirrors Composer's `--no-dev` branch in `LicensesCommand`: + // `RepositoryUtils::filterRequiredPackages($repo->getPackages(), $root)` + // — root's `require` only, transitively. Dev-only requires of + // the root, and packages reachable only through them, drop out. + let kept = repository_utils::filter_required_packages(&entries, root_requires, None); + let mut out = Vec::with_capacity(kept.len()); + // We can't `entries[idx].clone()` without Clone; rebuild from + // owned `entries` by index in two passes. + let mut by_idx: Vec<Option<LicenseEntry>> = entries.into_iter().map(Some).collect(); + for idx in kept { + if let Some(e) = by_idx[idx].take() { + out.push(e); } - true - }) - .map(|p| LicenseEntry { - name: p.name.clone(), - version: p.version.clone(), - licenses: extract_installed_licenses(p), - }) - .collect(); - - entries.sort_by_key(|a| a.name.to_lowercase()); - Ok(entries) + } + Ok(out) + } else { + Ok(entries) + } } -fn load_locked_licenses(working_dir: &Path, no_dev: bool) -> anyhow::Result<Vec<LicenseEntry>> { +fn load_locked_entries( + working_dir: &std::path::Path, + no_dev: bool, +) -> anyhow::Result<Vec<LicenseEntry>> { let lock_path = working_dir.join("composer.lock"); if !lock_path.exists() { anyhow::bail!( "Valid composer.json and composer.lock files are required to run this command with --locked" ); } - let lock = mozart_registry::lockfile::LockFile::read_from_file(&lock_path)?; - let mut all_packages: Vec<&mozart_registry::lockfile::LockedPackage> = - lock.packages.iter().collect(); - + // Mirrors `Locker::getLockedRepository(!$noDev)`: the prod-only call + // returns just `packages`, the dev-included call returns the union. + let mut entries: Vec<LicenseEntry> = lock.packages.iter().map(locked_to_entry).collect(); if !no_dev && let Some(ref pkgs_dev) = lock.packages_dev { - all_packages.extend(pkgs_dev.iter()); + entries.extend(pkgs_dev.iter().map(locked_to_entry)); } - - let mut entries: Vec<LicenseEntry> = all_packages - .iter() - .map(|p| LicenseEntry { - name: p.name.clone(), - version: p.version.clone(), - licenses: p.license.clone().unwrap_or_default(), - }) - .collect(); - - entries.sort_by_key(|a| a.name.to_lowercase()); Ok(entries) } -fn extract_installed_licenses( - pkg: &mozart_registry::installed::InstalledPackageEntry, -) -> Vec<String> { - pkg.extra_fields +fn installed_to_entry(pkg: &mozart_registry::installed::InstalledPackageEntry) -> LicenseEntry { + let licenses = pkg + .extra_fields .get("license") .and_then(|v| v.as_array()) .map(|arr| { arr.iter() .filter_map(|v| v.as_str()) - .map(|s| s.to_string()) + .map(String::from) .collect() }) - .unwrap_or_default() -} + .unwrap_or_default(); -fn count_licenses(entries: &[LicenseEntry]) -> Vec<(String, usize)> { - let mut counts: std::collections::BTreeMap<String, usize> = std::collections::BTreeMap::new(); + let requires = pkg + .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(); - for entry in entries { - if entry.licenses.is_empty() { - *counts.entry("none".to_string()).or_insert(0) += 1; - } else { - for lic in &entry.licenses { - *counts.entry(lic.clone()).or_insert(0) += 1; - } - } + let support_source = pkg + .support + .as_ref() + .and_then(|s| s.get("source")) + .and_then(|s| s.as_str()) + .map(String::from); + + let source_url = pkg + .source + .as_ref() + .and_then(|s| s.get("url")) + .and_then(|s| s.as_str()) + .map(String::from); + + LicenseEntry { + pretty_name: pkg.name.clone(), + name: pkg.name.to_lowercase(), + version: pkg.version.clone(), + licenses, + requires, + support_source, + source_url, + homepage: pkg.homepage.clone(), } +} - let mut result: Vec<(String, usize)> = counts.into_iter().collect(); - // Sort by count descending, then by name ascending for stability - result.sort_by(|a, b| b.1.cmp(&a.1).then_with(|| a.0.cmp(&b.0))); - result +fn locked_to_entry(pkg: &mozart_registry::lockfile::LockedPackage) -> LicenseEntry { + let support_source = pkg + .support + .as_ref() + .and_then(|s| s.get("source")) + .and_then(|s| s.as_str()) + .map(String::from); + + LicenseEntry { + pretty_name: pkg.name.clone(), + name: pkg.name.to_lowercase(), + version: pkg.version.clone(), + licenses: pkg.license.clone().unwrap_or_default(), + requires: pkg.require.clone(), + support_source, + source_url: pkg.source.as_ref().map(|s| s.url.clone()), + homepage: pkg.homepage.clone(), + } } fn render_text( - root_name: &str, + root_pretty_name: &str, root_version: &str, root_licenses: &[String], entries: &[LicenseEntry], @@ -199,7 +275,7 @@ fn render_text( }; console_writeln!( console, - &console_format!("Name: <comment>{root_name}</comment>"), + &console_format!("Name: <comment>{root_pretty_name}</comment>"), ); console_writeln!( console, @@ -218,7 +294,7 @@ fn render_text( let name_width = entries .iter() - .map(|e| e.name.len()) + .map(|e| e.pretty_name.len()) .max() .unwrap_or(0) .max("Name".len()); @@ -246,14 +322,18 @@ fn render_text( } else { entry.licenses.join(", ") }; + let padded_name = format!("{:<nw$}", entry.pretty_name, nw = name_width); + let name_cell = match package_info::view_source_or_homepage_url(entry) { + Some(url) => hyperlink(&url, &padded_name, console.decorated), + None => padded_name, + }; console_writeln!( console, &format!( - "{:<nw$} {:<vw$} {}", - entry.name, + "{} {:<vw$} {}", + name_cell, entry.version, license_str, - nw = name_width, vw = version_width ), ); @@ -261,7 +341,7 @@ fn render_text( } fn render_json( - root_name: &str, + root_pretty_name: &str, root_version: &str, root_licenses: &[String], entries: &[LicenseEntry], @@ -280,7 +360,7 @@ fn render_json( .map(|l| serde_json::Value::String(l.clone())) .collect(); dependencies.insert( - entry.name.clone(), + entry.pretty_name.clone(), serde_json::json!({ "version": entry.version, "license": license_arr, @@ -289,7 +369,7 @@ fn render_json( } let output = serde_json::json!({ - "name": root_name, + "name": root_pretty_name, "version": root_version, "license": root_license_arr, "dependencies": dependencies, @@ -304,7 +384,7 @@ fn render_json( } fn render_summary(entries: &[LicenseEntry], console: &Console) { - let counts = count_licenses(entries); + let counts = tally_licenses(entries); if counts.is_empty() { console_writeln!(console, "No dependencies found."); @@ -356,132 +436,133 @@ fn render_summary(entries: &[LicenseEntry], console: &Console) { console_writeln!(console, &format!(" {} {}", border_col1, border_col2),); } +/// Mirror of `LicensesCommand::execute`'s `summary` accumulator. +/// +/// PHP iterates the (already alphabetically sorted) packages, increments +/// `$usedLicenses[$name]++`, then `arsort()` — descending by count, +/// ties resolved in the array's existing order (which is first-seen). +/// `IndexMap` preserves first-seen order; sorting it with a stable +/// `sort_by` reproduces PHP's tie-break exactly. +fn tally_licenses(entries: &[LicenseEntry]) -> Vec<(String, usize)> { + let mut counts: IndexMap<String, usize> = IndexMap::new(); + for entry in entries { + if entry.licenses.is_empty() { + *counts.entry("none".to_string()).or_insert(0) += 1; + } else { + for lic in &entry.licenses { + *counts.entry(lic.clone()).or_insert(0) += 1; + } + } + } + let mut result: Vec<(String, usize)> = counts.into_iter().collect(); + result.sort_by_key(|(_, count)| std::cmp::Reverse(*count)); + result +} + #[cfg(test)] mod tests { use super::*; use std::collections::BTreeMap; - fn make_installed_pkg( - name: &str, - version: &str, - extra: BTreeMap<String, serde_json::Value>, - ) -> mozart_registry::installed::InstalledPackageEntry { - mozart_registry::installed::InstalledPackageEntry { - name: name.to_string(), - version: version.to_string(), - version_normalized: None, - source: None, - dist: None, - package_type: None, - install_path: None, - autoload: None, - aliases: vec![], + fn entry(name: &str, licenses: &[&str]) -> LicenseEntry { + LicenseEntry { + pretty_name: name.to_string(), + name: name.to_lowercase(), + version: "1.0.0".to_string(), + licenses: licenses.iter().map(|s| s.to_string()).collect(), + requires: BTreeMap::new(), + support_source: None, + source_url: None, homepage: None, - support: None, - extra_fields: extra, } } #[test] - fn test_extract_installed_licenses_present() { - let mut extra = BTreeMap::new(); - extra.insert("license".to_string(), serde_json::json!(["MIT"])); - let pkg = make_installed_pkg("vendor/pkg", "1.0.0", extra); - assert_eq!(extract_installed_licenses(&pkg), vec!["MIT"]); - } - - #[test] - fn test_extract_installed_licenses_multiple() { - let mut extra = BTreeMap::new(); - extra.insert( - "license".to_string(), - serde_json::json!(["MIT", "Apache-2.0"]), + fn tally_licenses_orders_by_count_then_first_seen() { + // First MIT entry comes before Apache-2.0; tie-break must keep + // MIT first when their counts collide. + let entries = vec![ + entry("a/a", &["MIT"]), + entry("b/b", &["Apache-2.0"]), + entry("c/c", &["BSD-3-Clause"]), + ]; + let counts = tally_licenses(&entries); + // All three at count 1 — input order preserved. + assert_eq!( + counts, + vec![ + ("MIT".to_string(), 1), + ("Apache-2.0".to_string(), 1), + ("BSD-3-Clause".to_string(), 1), + ] ); - let pkg = make_installed_pkg("vendor/pkg", "1.0.0", extra); - let result = extract_installed_licenses(&pkg); - assert_eq!(result, vec!["MIT", "Apache-2.0"]); } #[test] - fn test_extract_installed_licenses_absent() { - let pkg = make_installed_pkg("vendor/pkg", "1.0.0", BTreeMap::new()); - assert!(extract_installed_licenses(&pkg).is_empty()); - } - - #[test] - fn test_extract_installed_licenses_none_value() { - let mut extra = BTreeMap::new(); - extra.insert("license".to_string(), serde_json::Value::Null); - let pkg = make_installed_pkg("vendor/pkg", "1.0.0", extra); - assert!(extract_installed_licenses(&pkg).is_empty()); - } - - #[test] - fn test_count_licenses() { + fn tally_licenses_count_descending() { let entries = vec![ - LicenseEntry { - name: "a/a".to_string(), - version: "1.0.0".to_string(), - licenses: vec!["MIT".to_string()], - }, - LicenseEntry { - name: "b/b".to_string(), - version: "1.0.0".to_string(), - licenses: vec!["MIT".to_string()], - }, - LicenseEntry { - name: "c/c".to_string(), - version: "1.0.0".to_string(), - licenses: vec!["Apache-2.0".to_string()], - }, + entry("a/a", &["Apache-2.0"]), + entry("b/b", &["MIT"]), + entry("c/c", &["MIT"]), ]; - - let counts = count_licenses(&entries); - assert_eq!(counts.len(), 2); - // MIT should come first (count=2) + let counts = tally_licenses(&entries); assert_eq!(counts[0], ("MIT".to_string(), 2)); assert_eq!(counts[1], ("Apache-2.0".to_string(), 1)); } #[test] - fn test_count_licenses_empty() { - let entries: Vec<LicenseEntry> = vec![]; - let counts = count_licenses(&entries); - assert!(counts.is_empty()); + fn tally_licenses_empty() { + assert!(tally_licenses(&[]).is_empty()); } #[test] - fn test_count_licenses_no_license() { - let entries = vec![LicenseEntry { - name: "a/a".to_string(), - version: "1.0.0".to_string(), - licenses: vec![], - }]; - let counts = count_licenses(&entries); - assert_eq!(counts.len(), 1); - assert_eq!(counts[0], ("none".to_string(), 1)); + fn tally_licenses_no_license_counts_as_none() { + let entries = vec![entry("a/a", &[])]; + let counts = tally_licenses(&entries); + assert_eq!(counts, vec![("none".to_string(), 1)]); } #[test] - fn test_load_installed_licenses_basic() { - use tempfile::tempdir; - - let dir = tempdir().unwrap(); - let working_dir = dir.path(); - let vendor_dir = working_dir.join("vendor"); + fn read_root_licenses_string_form() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("composer.json"); + std::fs::write(&path, r#"{"name": "test/p", "license": "MIT"}"#).unwrap(); + assert_eq!(read_root_licenses(&path).unwrap(), vec!["MIT"]); + } - // Write composer.json (required by execute, but not needed for load_installed_licenses) + #[test] + fn read_root_licenses_array_form() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("composer.json"); std::fs::write( - working_dir.join("composer.json"), - r#"{"name": "test/project"}"#, + &path, + r#"{"name": "test/p", "license": ["MIT", "Apache-2.0"]}"#, ) .unwrap(); + assert_eq!( + read_root_licenses(&path).unwrap(), + vec!["MIT", "Apache-2.0"] + ); + } - // Build installed packages - let mut installed = mozart_registry::installed::InstalledPackages::new(); + #[test] + fn read_root_licenses_absent() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("composer.json"); + std::fs::write(&path, r#"{"name": "test/p"}"#).unwrap(); + assert!(read_root_licenses(&path).unwrap().is_empty()); + } + + #[test] + fn installed_to_entry_extracts_require_and_license() { + use mozart_registry::installed::InstalledPackageEntry; let mut extra = BTreeMap::new(); extra.insert("license".to_string(), serde_json::json!(["MIT"])); - installed.upsert(mozart_registry::installed::InstalledPackageEntry { + extra.insert( + "require".to_string(), + serde_json::json!({"psr/log": "^1.0"}), + ); + let pkg = InstalledPackageEntry { name: "monolog/monolog".to_string(), version: "3.0.0".to_string(), version_normalized: None, @@ -494,39 +575,61 @@ mod tests { homepage: None, support: None, extra_fields: extra, - }); - - installed.write(&vendor_dir).unwrap(); - - let entries = load_installed_licenses(working_dir, false).unwrap(); - assert_eq!(entries.len(), 1); - assert_eq!(entries[0].name, "monolog/monolog"); - assert_eq!(entries[0].version, "3.0.0"); - assert_eq!(entries[0].licenses, vec!["MIT"]); + }; + let e = installed_to_entry(&pkg); + assert_eq!(e.licenses, vec!["MIT"]); + assert_eq!(e.requires.get("psr/log").map(String::as_str), Some("^1.0")); } #[test] - fn test_load_installed_licenses_no_dev() { - use tempfile::tempdir; + fn installed_to_entry_pulls_support_source_and_source_url() { + use mozart_registry::installed::InstalledPackageEntry; + let pkg = InstalledPackageEntry { + name: "vendor/pkg".to_string(), + version: "1.0.0".to_string(), + version_normalized: None, + source: Some(serde_json::json!({"type": "git", "url": "https://example.com/repo.git"})), + dist: None, + package_type: None, + install_path: None, + autoload: None, + aliases: vec![], + homepage: Some("https://example.com/".to_string()), + support: Some(serde_json::json!({"source": "https://github.com/v/p"})), + extra_fields: BTreeMap::new(), + }; + let e = installed_to_entry(&pkg); + assert_eq!(e.support_source.as_deref(), Some("https://github.com/v/p")); + assert_eq!( + e.source_url.as_deref(), + Some("https://example.com/repo.git") + ); + assert_eq!(e.homepage.as_deref(), Some("https://example.com/")); + // PackageInfo helpers should pick support source first. + assert_eq!( + package_info::view_source_or_homepage_url(&e).as_deref(), + Some("https://github.com/v/p"), + ); + } - let dir = tempdir().unwrap(); + #[test] + fn no_dev_filters_to_root_require_closure() { + // Set up: root requires a/a only. b/b is in installed but not + // reachable; should be dropped under --no-dev. + let dir = tempfile::tempdir().unwrap(); let working_dir = dir.path(); let vendor_dir = working_dir.join("vendor"); std::fs::write( working_dir.join("composer.json"), - r#"{"name": "test/project"}"#, + r#"{"name": "test/project", "require": {"a/a": "*"}}"#, ) .unwrap(); let mut installed = mozart_registry::installed::InstalledPackages::new(); - - // Production package - let mut extra_prod = BTreeMap::new(); - extra_prod.insert("license".to_string(), serde_json::json!(["MIT"])); installed.upsert(mozart_registry::installed::InstalledPackageEntry { - name: "monolog/monolog".to_string(), - version: "3.0.0".to_string(), + name: "a/a".to_string(), + version: "1.0.0".to_string(), version_normalized: None, source: None, dist: None, @@ -536,15 +639,11 @@ mod tests { aliases: vec![], homepage: None, support: None, - extra_fields: extra_prod, + extra_fields: BTreeMap::new(), }); - - // Dev package - let mut extra_dev = BTreeMap::new(); - extra_dev.insert("license".to_string(), serde_json::json!(["BSD-3-Clause"])); installed.upsert(mozart_registry::installed::InstalledPackageEntry { - name: "phpunit/phpunit".to_string(), - version: "10.0.0".to_string(), + name: "b/b".to_string(), + version: "1.0.0".to_string(), version_normalized: None, source: None, dist: None, @@ -554,41 +653,35 @@ mod tests { aliases: vec![], homepage: None, support: None, - extra_fields: extra_dev, + extra_fields: BTreeMap::new(), }); - installed - .dev_package_names - .push("phpunit/phpunit".to_string()); - installed.write(&vendor_dir).unwrap(); - // With --no-dev: only production package - let entries = load_installed_licenses(working_dir, true).unwrap(); - assert_eq!(entries.len(), 1); - assert_eq!(entries[0].name, "monolog/monolog"); + let mut root_req = BTreeMap::new(); + root_req.insert("a/a".to_string(), "*".to_string()); + + let kept = load_installed_entries(working_dir, &root_req, true).unwrap(); + let names: Vec<&str> = kept.iter().map(|e| e.name.as_str()).collect(); + assert_eq!(names, vec!["a/a"]); - // Without --no-dev: both packages - let entries_all = load_installed_licenses(working_dir, false).unwrap(); - assert_eq!(entries_all.len(), 2); + // Without --no-dev: both packages are listed. + let all = load_installed_entries(working_dir, &root_req, false).unwrap(); + assert_eq!(all.len(), 2); } #[test] - fn test_load_locked_licenses_basic() { + fn locked_no_dev_drops_packages_dev() { use mozart_registry::lockfile::{LockFile, LockedPackage}; - use tempfile::tempdir; - - let dir = tempdir().unwrap(); + let dir = tempfile::tempdir().unwrap(); let working_dir = dir.path(); - std::fs::write( working_dir.join("composer.json"), r#"{"name": "test/project"}"#, ) .unwrap(); - let lock = LockFile { readme: LockFile::default_readme(), - content_hash: "abc123".to_string(), + content_hash: "abc".to_string(), packages: vec![LockedPackage { name: "psr/log".to_string(), version: "3.0.0".to_string(), @@ -648,87 +741,14 @@ mod tests { platform_dev: serde_json::json!({}), plugin_api_version: Some("2.6.0".to_string()), }; - lock.write_to_file(&working_dir.join("composer.lock")) .unwrap(); - // With --no-dev: only production packages - let entries = load_locked_licenses(working_dir, true).unwrap(); - assert_eq!(entries.len(), 1); - assert_eq!(entries[0].name, "psr/log"); - assert_eq!(entries[0].licenses, vec!["MIT"]); - - // Without --no-dev: both packages - let entries_all = load_locked_licenses(working_dir, false).unwrap(); - assert_eq!(entries_all.len(), 2); - } - - #[test] - fn test_root_license_array_in_json() { - use tempfile::tempdir; - - let dir = tempdir().unwrap(); - let working_dir = dir.path(); - let composer_json_path = working_dir.join("composer.json"); - - // Write a composer.json where "license" is an array - std::fs::write( - &composer_json_path, - r#"{"name": "test/project", "license": ["MIT", "Apache-2.0"]}"#, - ) - .unwrap(); - - let raw_json = std::fs::read_to_string(&composer_json_path).unwrap(); - let raw_value: serde_json::Value = serde_json::from_str(&raw_json).unwrap(); - - let root_licenses: Vec<String> = match raw_value.get("license") { - Some(serde_json::Value::String(s)) => vec![s.clone()], - Some(serde_json::Value::Array(arr)) => arr - .iter() - .filter_map(|v| v.as_str()) - .map(|s| s.to_string()) - .collect(), - _ => vec![], - }; - - assert_eq!(root_licenses, vec!["MIT", "Apache-2.0"]); - } - - #[test] - fn test_render_json_root_license_is_array() { - let entries: Vec<LicenseEntry> = vec![]; - - // Single license string becomes a one-element array in JSON output - let root_licenses = ["MIT".to_string()]; - let root_license_arr: Vec<serde_json::Value> = root_licenses - .iter() - .map(|s| serde_json::Value::String(s.clone())) - .collect(); - let output = serde_json::json!({ - "name": "test/project", - "version": "1.0.0", - "license": root_license_arr, - "dependencies": {}, - }); - assert!(output["license"].is_array()); - assert_eq!(output["license"][0], "MIT"); - - // Multiple licenses are also emitted as an array - let root_licenses_multi = ["MIT".to_string(), "Apache-2.0".to_string()]; - let root_license_arr_multi: Vec<serde_json::Value> = root_licenses_multi - .iter() - .map(|s| serde_json::Value::String(s.clone())) - .collect(); - let output_multi = serde_json::json!({ - "name": "test/project", - "version": "1.0.0", - "license": root_license_arr_multi, - "dependencies": serde_json::json!({}), - }); - assert!(output_multi["license"].is_array()); - assert_eq!(output_multi["license"].as_array().unwrap().len(), 2); + let prod = load_locked_entries(working_dir, true).unwrap(); + assert_eq!(prod.len(), 1); + assert_eq!(prod[0].name, "psr/log"); - // Ensure the helper produces consistent results for empty entries - let _ = entries; + let all = load_locked_entries(working_dir, false).unwrap(); + assert_eq!(all.len(), 2); } } diff --git a/crates/mozart/src/commands/update.rs b/crates/mozart/src/commands/update.rs index 2e93a0c..92a998d 100644 --- a/crates/mozart/src/commands/update.rs +++ b/crates/mozart/src/commands/update.rs @@ -7,7 +7,7 @@ use mozart_registry::resolver::{ self, LockedPackageInfo, PlatformConfig, ResolveRequest, ResolvedPackage, }; -#[derive(Args)] +#[derive(Args, Default)] pub struct UpdateArgs { /// Package(s) to update pub packages: Vec<String>, |
