diff options
| author | nsfisis <nsfisis@gmail.com> | 2026-05-08 21:17:07 +0900 |
|---|---|---|
| committer | nsfisis <nsfisis@gmail.com> | 2026-05-08 21:17:07 +0900 |
| commit | f20a342ecb96734418d0817f841ea14fd9a448e3 (patch) | |
| tree | fc7c31942579684a13fdb5972216302b1d13eb3a /crates/mozart/src/commands/validate.rs | |
| parent | ee17433f9beb95071f37aa6cfe659f14b81ce503 (diff) | |
| download | php-mozart-f20a342ecb96734418d0817f841ea14fd9a448e3.tar.gz php-mozart-f20a342ecb96734418d0817f841ea14fd9a448e3.tar.zst php-mozart-f20a342ecb96734418d0817f841ea14fd9a448e3.zip | |
fix(diagnose): align with Composer's DiagnoseCommand orchestration
Restructures diagnose to mirror Composer's 17-step DiagnoseCommand:
adds composer.json schema validation, custom composer-repo
connectivity, COMPOSER_IPRESOLVE warning, and the
checkConnectivityAndComposerNetworkHttpEnablement preflight; drops
Mozart-only extras (cache-dir, lock freshness, trailing summary).
Extracts the manifest validator into mozart-core::config_validator
so both ValidateCommand and DiagnoseCommand depend on the shared
module rather than each other -- the same shape Composer uses with
Util\\ConfigValidator. Adds a thin HttpDownloader wrapper in
mozart-core::http, shadowing Composer's Util\\HttpDownloader.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Diffstat (limited to 'crates/mozart/src/commands/validate.rs')
| -rw-r--r-- | crates/mozart/src/commands/validate.rs | 1135 |
1 files changed, 6 insertions, 1129 deletions
diff --git a/crates/mozart/src/commands/validate.rs b/crates/mozart/src/commands/validate.rs index 44e3f5e..77aecaa 100644 --- a/crates/mozart/src/commands/validate.rs +++ b/crates/mozart/src/commands/validate.rs @@ -1,15 +1,8 @@ use clap::Args; +use mozart_core::config_validator::{ValidationResult, ValidatorOptions, validate_manifest}; use mozart_core::console_format; use mozart_core::console_writeln; -use regex::Regex; use std::path::{Path, PathBuf}; -use std::sync::LazyLock; - -static DEPRECATED_GPL_OR_LATER_RE: LazyLock<Regex> = - LazyLock::new(|| Regex::new(r"(?i)^[AL]?GPL-[123](\.[01])?\+$").unwrap()); - -static DEPRECATED_GPL_BARE_RE: LazyLock<Regex> = - LazyLock::new(|| Regex::new(r"(?i)^[AL]?GPL-[123](\.[01])?$").unwrap()); #[derive(Args)] pub struct ValidateArgs { @@ -45,31 +38,9 @@ pub struct ValidateArgs { pub strict: bool, } -struct ValidationResult { - errors: Vec<String>, - publish_errors: Vec<String>, - warnings: Vec<String>, -} - -impl ValidationResult { - fn new() -> Self { - Self { - errors: Vec::new(), - publish_errors: Vec::new(), - warnings: Vec::new(), - } - } - - fn has_errors(&self) -> bool { - !self.errors.is_empty() - } - - fn has_publish_errors(&self) -> bool { - !self.publish_errors.is_empty() - } - - fn has_warnings(&self) -> bool { - !self.warnings.is_empty() +fn options_from_args(args: &ValidateArgs) -> ValidatorOptions { + ValidatorOptions { + check_version: !args.no_check_version, } } @@ -132,8 +103,7 @@ pub async fn execute( }; // Run manifest validations - let mut result = ValidationResult::new(); - validate_manifest(&json_value, args, &mut result); + let result = validate_manifest(&json_value, &options_from_args(args)); // Check lock file freshness let mut lock_errors: Vec<String> = Vec::new(); @@ -189,454 +159,6 @@ pub async fn execute( Ok(()) } -fn validate_manifest( - manifest: &serde_json::Value, - args: &ValidateArgs, - result: &mut ValidationResult, -) { - let obj = match manifest.as_object() { - Some(o) => o, - None => { - result - .errors - .push("composer.json must be a JSON object".to_string()); - return; - } - }; - - check_name(obj, result); - check_license(obj, result); - - if !args.no_check_version { - check_version_field(obj, result); - } - - check_package_type(obj, result); - check_require_overlap(obj, result); - check_provide_replace_overlap(obj, result); - check_commit_references(obj, result); - check_empty_psr_prefixes(obj, result); - check_minimum_stability(obj, result); - check_scripts_orphans(obj, result); -} - -/// Check the "name" field: must be present (for published packages) and lowercase. -fn check_name(obj: &serde_json::Map<String, serde_json::Value>, result: &mut ValidationResult) { - match obj.get("name").and_then(|v| v.as_str()) { - None => { - result.publish_errors.push( - "The name property is not set. This is required for published packages." - .to_string(), - ); - } - Some(name) => { - // Uppercase characters are a publish error - if name.chars().any(|c| c.is_ascii_uppercase()) { - let suggested = name - .split('/') - .map(mozart_core::validation::sanitize_package_name_component) - .collect::<Vec<_>>() - .join("/"); - result.publish_errors.push(format!( - "Name \"{name}\" does not match the best practice (e.g. lower-cased/with-dashes). \ - We suggest using \"{suggested}\" instead. As such you will not be able to submit it to Packagist." - )); - } - - // Must contain a slash (vendor/package format) - if !name.is_empty() - && !mozart_core::validation::validate_package_name(name) - && !name.contains('/') - { - result.errors.push(format!( - "The name \"{name}\" is invalid, it should be in the format \"vendor/package\"." - )); - } - } - } -} - -/// Check the "license" field. Mirrors: -/// * Composer's `Util\ConfigValidator::validate()` — "No license" + deprecation -/// warnings (see fix B for these). -/// * Composer's `Package\Loader\ValidatingArrayLoader::load()` license block — -/// type-shape warnings, SPDX expression validity, and extra-spaces detection. -/// The validity/extra-spaces checks are gated on the `time` field: only -/// releases without a date or within the last 8 days are checked. -fn check_license(obj: &serde_json::Map<String, serde_json::Value>, result: &mut ValidationResult) { - let no_license_msg = "No license specified, it is recommended to do so. \ - For closed-source software you may use \"proprietary\" as license." - .to_string(); - - // Composer's `empty($manifest['license'])` is true for missing, null, "", and []. - let raw_entries: Vec<&serde_json::Value> = match obj.get("license") { - None | Some(serde_json::Value::Null) => { - result.warnings.push(no_license_msg); - return; - } - Some(v @ serde_json::Value::String(s)) => { - if s.is_empty() { - result.warnings.push(no_license_msg); - return; - } - vec![v] - } - Some(serde_json::Value::Array(arr)) => { - if arr.is_empty() { - result.warnings.push(no_license_msg); - return; - } - arr.iter().collect() - } - // ValidatingArrayLoader: license must be a string or array of strings. - Some(other) => { - result.warnings.push(format!( - "License must be a string or array of strings, got {}.", - serde_json::to_string(other).unwrap_or_default() - )); - return; - } - }; - - // ValidatingArrayLoader: each entry must be a string. Non-string entries - // are dropped from the working set with a per-entry warning. - let mut licenses: Vec<&str> = Vec::with_capacity(raw_entries.len()); - for v in raw_entries { - match v.as_str() { - Some(s) => licenses.push(s), - None => result.warnings.push(format!( - "License {} should be a string.", - serde_json::to_string(v).unwrap_or_default() - )), - } - } - - // ConfigValidator: deprecated identifier warnings. - let spdx = mozart_spdx_licenses::spdx(); - for license in &licenses { - if *license == "proprietary" { - continue; - } - let Some(info) = spdx.get_license_by_identifier(license) else { - continue; - }; - if !info.deprecated { - continue; - } - let warning = if DEPRECATED_GPL_OR_LATER_RE.is_match(license) { - let suggested = format!("{}-or-later", license.replace('+', "")); - format!( - "License \"{license}\" is a deprecated SPDX license identifier, use \"{suggested}\" instead" - ) - } else if DEPRECATED_GPL_BARE_RE.is_match(license) { - format!( - "License \"{license}\" is a deprecated SPDX license identifier, use \"{license}-only\" or \"{license}-or-later\" instead" - ) - } else { - format!( - "License \"{license}\" is a deprecated SPDX license identifier, see https://spdx.org/licenses/" - ) - }; - result.warnings.push(warning); - } - - // ValidatingArrayLoader: SPDX expression validity, gated on the 8-day - // release window. If `time` is absent or unparseable, releaseDate is - // treated as null (PHP `try/catch` semantics) → validation runs. - let release_ts = obj - .get("time") - .and_then(|v| v.as_str()) - .and_then(parse_iso_time_to_unix); - let cutoff = current_unix_time().saturating_sub(8 * 86_400); - let in_window = release_ts.is_none_or(|ts| ts >= cutoff); - if !in_window { - return; - } - for license in &licenses { - if *license == "proprietary" { - continue; - } - let to_validate = license.replace("proprietary", "MIT"); - if mozart_core::validation::validate_license(&to_validate) { - continue; - } - // PHP `json_encode($license)` for a string is `"escaped"`; serde matches. - let quoted = serde_json::to_string(license).unwrap_or_else(|_| format!("\"{license}\"")); - if mozart_core::validation::validate_license(to_validate.trim()) { - result.warnings.push(format!( - "License {quoted} must not contain extra spaces, make sure to trim it." - )); - } else { - result.warnings.push(format!( - "License {quoted} is not a valid SPDX license identifier, see https://spdx.org/licenses/ if you use an open license.\n\ - If the software is closed-source, you may use \"proprietary\" as license." - )); - } - } -} - -/// Current time as a Unix timestamp (UTC seconds since epoch). 0 if the -/// system clock is set before the epoch. -fn current_unix_time() -> i64 { - std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .map(|d| d.as_secs() as i64) - .unwrap_or(0) -} - -/// Parse a Composer-style `time` string into a Unix timestamp. -/// -/// Accepts `YYYY-MM-DD HH:MM:SS` (Composer's typical output) and -/// `YYYY-MM-DDTHH:MM:SS` with an optional `Z` or numeric offset suffix. The -/// timezone suffix is parsed when present; absent suffixes are treated as -/// UTC, matching `new DateTime($time, new DateTimeZone('UTC'))`. -fn parse_iso_time_to_unix(s: &str) -> Option<i64> { - let bytes = s.as_bytes(); - if bytes.len() < 19 { - return None; - } - let n = |start: usize, len: usize| -> Option<i64> { - std::str::from_utf8(&bytes[start..start + len]) - .ok()? - .parse() - .ok() - }; - let year = n(0, 4)? as i32; - if bytes[4] != b'-' { - return None; - } - let month = n(5, 2)? as i32; - if bytes[7] != b'-' { - return None; - } - let day = n(8, 2)? as i32; - if bytes[10] != b' ' && bytes[10] != b'T' { - return None; - } - let hour = n(11, 2)?; - if bytes[13] != b':' { - return None; - } - let minute = n(14, 2)?; - if bytes[16] != b':' { - return None; - } - let second = n(17, 2)?; - - // Optional timezone suffix. - let mut tz_offset_seconds: i64 = 0; - if bytes.len() > 19 { - let suffix = &s[19..]; - if suffix == "Z" { - tz_offset_seconds = 0; - } else { - let body = suffix - .strip_prefix('+') - .or_else(|| suffix.strip_prefix('-'))?; - let sign = if suffix.starts_with('+') { 1 } else { -1 }; - let body: String = body.chars().filter(|c| *c != ':').collect(); - if body.len() < 4 { - return None; - } - let oh: i64 = body.get(0..2)?.parse().ok()?; - let om: i64 = body.get(2..4)?.parse().ok()?; - tz_offset_seconds = sign * (oh * 3600 + om * 60); - } - } - - let utc = days_from_civil(year, month, day) * 86_400 + hour * 3600 + minute * 60 + second - - tz_offset_seconds; - Some(utc) -} - -/// Howard Hinnant's `days_from_civil`: returns days since 1970-01-01 for a -/// proleptic Gregorian (year, month, day). Handles negative years correctly. -fn days_from_civil(y: i32, m: i32, d: i32) -> i64 { - let y = if m <= 2 { y - 1 } else { y }; - let era = if y >= 0 { y / 400 } else { (y - 399) / 400 }; - let yoe = (y - era * 400) as i64; // [0, 399] - let m_adj = if m > 2 { m - 3 } else { m + 9 }; - let doy = (153 * m_adj as i64 + 2) / 5 + d as i64 - 1; - let doe = yoe * 365 + yoe / 4 - yoe / 100 + doy; - (era as i64) * 146_097 + doe - 719_468 -} - -/// Warn if the "version" field is present. -fn check_version_field( - obj: &serde_json::Map<String, serde_json::Value>, - result: &mut ValidationResult, -) { - if obj.contains_key("version") { - result.warnings.push( - "The version field is present, it is recommended to leave it out \ - if the package is published on Packagist." - .to_string(), - ); - } -} - -/// Warn if the package type is the deprecated "composer-installer". -fn check_package_type( - obj: &serde_json::Map<String, serde_json::Value>, - result: &mut ValidationResult, -) { - if let Some(pkg_type) = obj.get("type").and_then(|v| v.as_str()) - && pkg_type == "composer-installer" - { - result.warnings.push( - "The package type 'composer-installer' is deprecated. \ - Please distribute your custom installers as plugins from now on. \ - See https://getcomposer.org/doc/articles/plugins.md for plugin documentation." - .to_string(), - ); - } -} - -/// Warn if the same package appears in both require and require-dev. -fn check_require_overlap( - obj: &serde_json::Map<String, serde_json::Value>, - result: &mut ValidationResult, -) { - let require = obj.get("require").and_then(|v| v.as_object()); - let require_dev = obj.get("require-dev").and_then(|v| v.as_object()); - - if let (Some(req), Some(req_dev)) = (require, require_dev) { - let mut overlaps: Vec<&str> = Vec::new(); - for key in req.keys() { - if req_dev.contains_key(key) { - overlaps.push(key.as_str()); - } - } - if !overlaps.is_empty() { - let plural = if overlaps.len() > 1 { "are" } else { "is" }; - result.warnings.push(format!( - "{} {plural} required both in require and require-dev, \ - this can lead to unexpected behavior", - overlaps.join(", "), - )); - } - } -} - -/// Warn if a package listed in provide/replace is also in require/require-dev. -fn check_provide_replace_overlap( - obj: &serde_json::Map<String, serde_json::Value>, - result: &mut ValidationResult, -) { - for link_type in &["provide", "replace"] { - if let Some(links) = obj.get(*link_type).and_then(|v| v.as_object()) { - for require_type in &["require", "require-dev"] { - if let Some(requires) = obj.get(*require_type).and_then(|v| v.as_object()) { - for provide_name in links.keys() { - if requires.contains_key(provide_name) { - result.warnings.push(format!( - "The package {provide_name} in {require_type} is also listed in \ - {link_type} which satisfies the requirement. Remove it from \ - {link_type} if you wish to install it." - )); - } - } - } - } - } - } -} - -/// Warn about version constraints containing '#' (commit references). -fn check_commit_references( - obj: &serde_json::Map<String, serde_json::Value>, - result: &mut ValidationResult, -) { - for section in &["require", "require-dev"] { - if let Some(deps) = obj.get(*section).and_then(|v| v.as_object()) { - for (package, version) in deps { - if let Some(v) = version.as_str() - && v.contains('#') - { - result.warnings.push(format!( - "The package \"{package}\" is pointing to a commit-ref, \ - this is bad practice and can cause unforeseen issues." - )); - } - } - } - } -} - -/// Warn about empty PSR-0/PSR-4 namespace prefixes (performance impact). -fn check_empty_psr_prefixes( - obj: &serde_json::Map<String, serde_json::Value>, - result: &mut ValidationResult, -) { - if let Some(autoload) = obj.get("autoload").and_then(|v| v.as_object()) { - if let Some(psr0) = autoload.get("psr-0").and_then(|v| v.as_object()) - && psr0.contains_key("") - { - result.warnings.push( - "Defining autoload.psr-0 with an empty namespace prefix is a bad idea \ - for performance" - .to_string(), - ); - } - if let Some(psr4) = autoload.get("psr-4").and_then(|v| v.as_object()) - && psr4.contains_key("") - { - result.warnings.push( - "Defining autoload.psr-4 with an empty namespace prefix is a bad idea \ - for performance" - .to_string(), - ); - } - } -} - -/// Check minimum-stability value if present. -fn check_minimum_stability( - obj: &serde_json::Map<String, serde_json::Value>, - result: &mut ValidationResult, -) { - if let Some(stability) = obj.get("minimum-stability").and_then(|v| v.as_str()) - && !mozart_core::validation::validate_stability(stability) - { - result.errors.push(format!( - "The minimum-stability \"{stability}\" is invalid. \ - Must be one of: dev, alpha, beta, rc, stable." - )); - } -} - -/// Warn about keys in scripts-descriptions or scripts-aliases that have no matching script. -fn check_scripts_orphans( - obj: &serde_json::Map<String, serde_json::Value>, - result: &mut ValidationResult, -) { - let script_keys: indexmap::IndexSet<&str> = obj - .get("scripts") - .and_then(|v| v.as_object()) - .map(|m| m.keys().map(|k| k.as_str()).collect()) - .unwrap_or_default(); - - if let Some(descriptions) = obj.get("scripts-descriptions").and_then(|v| v.as_object()) { - for key in descriptions.keys() { - if !script_keys.contains(key.as_str()) { - result.warnings.push(format!( - "Description for non-existent script \"{key}\" found in \"scripts-descriptions\"" - )); - } - } - } - - if let Some(aliases) = obj.get("scripts-aliases").and_then(|v| v.as_object()) { - for key in aliases.keys() { - if !script_keys.contains(key.as_str()) { - result.warnings.push(format!( - "Aliases for non-existent script \"{key}\" found in \"scripts-aliases\"" - )); - } - } - } -} - fn validate_dependencies( vendor_dir: &Path, args: &ValidateArgs, @@ -690,8 +212,7 @@ fn validate_dependencies( continue; }; - let mut result = ValidationResult::new(); - validate_manifest(&json_value, args, &mut result); + let result = validate_manifest(&json_value, &options_from_args(args)); dep_count += 1; @@ -891,556 +412,6 @@ mod tests { } } - fn parse_and_validate(json: &str, args: &ValidateArgs) -> ValidationResult { - let value: serde_json::Value = serde_json::from_str(json).unwrap(); - let mut result = ValidationResult::new(); - validate_manifest(&value, args, &mut result); - result - } - - #[test] - fn test_validate_missing_name_is_publish_error() { - let json = r#"{"require": {"php": ">=8.1"}, "license": "MIT"}"#; - let result = parse_and_validate(json, &make_args()); - assert!(result.errors.is_empty()); - assert!(!result.publish_errors.is_empty()); - assert!(result.publish_errors[0].contains("name property is not set")); - } - - #[test] - fn test_validate_uppercase_name_publish_error() { - let json = r#"{"name": "Vendor/Package", "license": "MIT"}"#; - let result = parse_and_validate(json, &make_args()); - assert!(!result.publish_errors.is_empty()); - assert!(result.publish_errors[0].contains("does not match the best practice")); - assert!(result.publish_errors[0].contains("vendor/package")); - } - - #[test] - fn test_validate_uppercase_name_camel_case_to_dashes() { - let json = r#"{"name": "MyCompany/MyLibrary", "license": "MIT"}"#; - let result = parse_and_validate(json, &make_args()); - assert!(!result.publish_errors.is_empty()); - assert!( - result.publish_errors[0].contains("my-company/my-library"), - "expected CamelCase-to-dashes conversion, got: {}", - result.publish_errors[0] - ); - } - - #[test] - fn test_validate_valid_name_no_publish_error() { - let json = r#"{"name": "vendor/package", "license": "MIT"}"#; - let result = parse_and_validate(json, &make_args()); - assert!(result.publish_errors.is_empty()); - assert!(result.errors.is_empty()); - } - - #[test] - fn test_validate_name_without_slash_is_error() { - let json = r#"{"name": "novendor", "license": "MIT"}"#; - let result = parse_and_validate(json, &make_args()); - assert!(!result.errors.is_empty()); - assert!(result.errors[0].contains("vendor/package")); - } - - #[test] - fn test_validate_missing_license_warns() { - let json = r#"{"name": "vendor/pkg"}"#; - let result = parse_and_validate(json, &make_args()); - assert!(!result.warnings.is_empty()); - assert!(result.warnings.iter().any(|w| w.contains("No license"))); - } - - #[test] - fn test_validate_present_license_no_warning() { - let json = r#"{"name": "vendor/pkg", "license": "MIT"}"#; - let result = parse_and_validate(json, &make_args()); - assert!(!result.warnings.iter().any(|w| w.contains("No license"))); - } - - #[test] - fn test_validate_empty_license_string_warns() { - let json = r#"{"name": "vendor/pkg", "license": ""}"#; - let result = parse_and_validate(json, &make_args()); - assert!(result.warnings.iter().any(|w| w.contains("No license"))); - } - - #[test] - fn test_validate_empty_license_array_warns() { - let json = r#"{"name": "vendor/pkg", "license": []}"#; - let result = parse_and_validate(json, &make_args()); - assert!(result.warnings.iter().any(|w| w.contains("No license"))); - } - - #[test] - fn test_validate_proprietary_license_no_warning() { - let json = r#"{"name": "vendor/pkg", "license": "proprietary"}"#; - let result = parse_and_validate(json, &make_args()); - assert!(!result.warnings.iter().any(|w| w.contains("license"))); - } - - #[test] - fn test_validate_unknown_license_no_deprecation_warning() { - // Unknown identifiers don't get a deprecation warning here (Composer's - // ConfigValidator only checks deprecation, not validity). - let json = r#"{"name": "vendor/pkg", "license": "totally-not-a-license"}"#; - let result = parse_and_validate(json, &make_args()); - assert!( - !result - .warnings - .iter() - .any(|w| w.contains("deprecated SPDX")) - ); - } - - #[test] - fn test_validate_deprecated_gpl_bare_warns() { - let json = r#"{"name": "vendor/pkg", "license": "GPL-2.0"}"#; - let result = parse_and_validate(json, &make_args()); - let warning = result - .warnings - .iter() - .find(|w| w.contains("deprecated SPDX")) - .expect("expected deprecation warning"); - assert!(warning.contains(r#""GPL-2.0""#), "got: {warning}"); - assert!(warning.contains("\"GPL-2.0-only\""), "got: {warning}"); - assert!(warning.contains("\"GPL-2.0-or-later\""), "got: {warning}"); - } - - #[test] - fn test_validate_deprecated_gpl_or_later_warns() { - let json = r#"{"name": "vendor/pkg", "license": "GPL-3.0+"}"#; - let result = parse_and_validate(json, &make_args()); - let warning = result - .warnings - .iter() - .find(|w| w.contains("deprecated SPDX")) - .expect("expected deprecation warning"); - assert!(warning.contains(r#""GPL-3.0+""#), "got: {warning}"); - assert!(warning.contains("\"GPL-3.0-or-later\""), "got: {warning}"); - } - - #[test] - fn test_validate_deprecated_agpl_bare_warns() { - let json = r#"{"name": "vendor/pkg", "license": "AGPL-1.0"}"#; - let result = parse_and_validate(json, &make_args()); - let warning = result - .warnings - .iter() - .find(|w| w.contains("deprecated SPDX")) - .expect("expected deprecation warning"); - assert!(warning.contains("\"AGPL-1.0-only\""), "got: {warning}"); - assert!(warning.contains("\"AGPL-1.0-or-later\""), "got: {warning}"); - } - - #[test] - fn test_validate_deprecated_non_gpl_uses_generic_message() { - // Pick any non-GPL deprecated identifier; eCos-2.0 was deprecated in SPDX. - // If this changes upstream, replace with another deprecated id. - let json = r#"{"name": "vendor/pkg", "license": "eCos-2.0"}"#; - let result = parse_and_validate(json, &make_args()); - if let Some(warning) = result - .warnings - .iter() - .find(|w| w.contains("deprecated SPDX")) - { - assert!( - warning.contains("https://spdx.org/licenses/"), - "expected generic message, got: {warning}" - ); - } - } - - #[test] - fn test_validate_array_license_checks_each() { - let json = r#"{"name": "vendor/pkg", "license": ["MIT", "GPL-2.0"]}"#; - let result = parse_and_validate(json, &make_args()); - assert!( - result - .warnings - .iter() - .any(|w| w.contains("deprecated SPDX") && w.contains("GPL-2.0")), - "expected deprecation warning for GPL-2.0 in array form, got: {:?}", - result.warnings, - ); - } - - #[test] - fn test_validate_non_deprecated_license_no_warning() { - let json = r#"{"name": "vendor/pkg", "license": "MIT"}"#; - let result = parse_and_validate(json, &make_args()); - assert!( - !result - .warnings - .iter() - .any(|w| w.contains("deprecated SPDX")), - "MIT is not deprecated, should not warn", - ); - } - - #[test] - fn test_validate_license_wrong_type_warns() { - let json = r#"{"name": "vendor/pkg", "license": 42}"#; - let result = parse_and_validate(json, &make_args()); - assert!( - result.warnings.iter().any(|w| w - .contains("License must be a string or array of strings") - && w.contains("42")), - "got: {:?}", - result.warnings - ); - } - - #[test] - fn test_validate_license_array_non_string_entry_warns() { - let json = r#"{"name": "vendor/pkg", "license": ["MIT", 42]}"#; - let result = parse_and_validate(json, &make_args()); - assert!( - result - .warnings - .iter() - .any(|w| w.contains("License 42 should be a string")), - "got: {:?}", - result.warnings - ); - } - - #[test] - fn test_validate_invalid_spdx_license_warns() { - let json = r#"{"name": "vendor/pkg", "license": "totally-not-a-license"}"#; - let result = parse_and_validate(json, &make_args()); - assert!( - result - .warnings - .iter() - .any(|w| w.contains("\"totally-not-a-license\"") - && w.contains("not a valid SPDX license identifier")), - "got: {:?}", - result.warnings - ); - } - - #[test] - fn test_validate_license_extra_spaces_warns() { - // Composer's expression regex tolerates one ASCII space on each end — - // the warning only fires when there are more (or non-space whitespace). - let json = r#"{"name": "vendor/pkg", "license": " MIT "}"#; - let result = parse_and_validate(json, &make_args()); - assert!( - result - .warnings - .iter() - .any(|w| w.contains("must not contain extra spaces")), - "got: {:?}", - result.warnings - ); - assert!( - !result - .warnings - .iter() - .any(|w| w.contains("not a valid SPDX")), - "extra-spaces case should not also emit invalid-SPDX, got: {:?}", - result.warnings - ); - } - - #[test] - fn test_validate_license_proprietary_in_expression_validates() { - // Composer replaces "proprietary" with "MIT" before validating, so - // expressions mixing "proprietary" with real identifiers are accepted. - let json = r#"{"name": "vendor/pkg", "license": "(MIT OR proprietary)"}"#; - let result = parse_and_validate(json, &make_args()); - assert!( - !result - .warnings - .iter() - .any(|w| w.contains("not a valid SPDX")), - "got: {:?}", - result.warnings - ); - } - - #[test] - fn test_validate_license_old_release_skips_validity_check() { - // Release older than 8 days → SPDX validity check is skipped, but - // deprecation warnings (ConfigValidator's path) still fire. - let json = r#"{ - "name": "vendor/pkg", - "license": "totally-not-a-license", - "time": "1970-01-01 00:00:00" - }"#; - let result = parse_and_validate(json, &make_args()); - assert!( - !result - .warnings - .iter() - .any(|w| w.contains("not a valid SPDX")), - "old release should not produce SPDX validity warning, got: {:?}", - result.warnings - ); - } - - #[test] - fn test_validate_license_recent_release_validates() { - // A future "time" is within the cutoff (>= now-8days) → validate. - let json = r#"{ - "name": "vendor/pkg", - "license": "totally-not-a-license", - "time": "9999-01-01 00:00:00" - }"#; - let result = parse_and_validate(json, &make_args()); - assert!( - result - .warnings - .iter() - .any(|w| w.contains("not a valid SPDX")), - "recent release should produce SPDX validity warning, got: {:?}", - result.warnings - ); - } - - #[test] - fn test_validate_license_unparseable_time_treated_as_null() { - // Unparseable time → releaseDate stays null → validation runs. - let json = r#"{ - "name": "vendor/pkg", - "license": "totally-not-a-license", - "time": "not-a-date" - }"#; - let result = parse_and_validate(json, &make_args()); - assert!( - result - .warnings - .iter() - .any(|w| w.contains("not a valid SPDX")), - "unparseable time should be treated as null → validate, got: {:?}", - result.warnings - ); - } - - #[test] - fn test_parse_iso_time_basic() { - assert_eq!(parse_iso_time_to_unix("1970-01-01 00:00:00"), Some(0)); - assert_eq!( - parse_iso_time_to_unix("2023-12-15 13:45:30"), - Some(1_702_647_930) - ); - } - - #[test] - fn test_parse_iso_time_t_separator() { - assert_eq!( - parse_iso_time_to_unix("2023-12-15T13:45:30"), - Some(1_702_647_930) - ); - assert_eq!( - parse_iso_time_to_unix("2023-12-15T13:45:30Z"), - Some(1_702_647_930) - ); - } - - #[test] - fn test_parse_iso_time_with_offset() { - // 13:45:30+05:00 = 08:45:30 UTC - assert_eq!( - parse_iso_time_to_unix("2023-12-15T13:45:30+05:00"), - Some(1_702_647_930 - 5 * 3600) - ); - // 13:45:30-05:00 = 18:45:30 UTC - assert_eq!( - parse_iso_time_to_unix("2023-12-15T13:45:30-05:00"), - Some(1_702_647_930 + 5 * 3600) - ); - } - - #[test] - fn test_parse_iso_time_invalid() { - assert_eq!(parse_iso_time_to_unix(""), None); - assert_eq!(parse_iso_time_to_unix("not-a-date"), None); - assert_eq!(parse_iso_time_to_unix("2023-12-15"), None); - assert_eq!(parse_iso_time_to_unix("2023/12/15 13:45:30"), None); - } - - #[test] - fn test_validate_version_field_warns() { - let json = r#"{"name": "vendor/pkg", "license": "MIT", "version": "1.0.0"}"#; - let result = parse_and_validate(json, &make_args()); - assert!(result.warnings.iter().any(|w| w.contains("version field"))); - } - - #[test] - fn test_validate_no_check_version_suppresses_warning() { - let json = r#"{"name": "vendor/pkg", "license": "MIT", "version": "1.0.0"}"#; - let mut args = make_args(); - args.no_check_version = true; - let result = parse_and_validate(json, &args); - assert!(!result.warnings.iter().any(|w| w.contains("version field"))); - } - - #[test] - fn test_validate_deprecated_type_warns() { - let json = r#"{"name": "vendor/pkg", "license": "MIT", "type": "composer-installer"}"#; - let result = parse_and_validate(json, &make_args()); - assert!( - result - .warnings - .iter() - .any(|w| w.contains("composer-installer")) - ); - } - - #[test] - fn test_validate_normal_type_no_warning() { - let json = r#"{"name": "vendor/pkg", "license": "MIT", "type": "library"}"#; - let result = parse_and_validate(json, &make_args()); - assert!( - !result - .warnings - .iter() - .any(|w| w.contains("composer-installer")) - ); - } - - #[test] - fn test_validate_require_overlap_warns() { - let json = r#"{ - "name": "vendor/pkg", - "license": "MIT", - "require": {"monolog/monolog": "^3.0"}, - "require-dev": {"monolog/monolog": "^3.0"} - }"#; - let result = parse_and_validate(json, &make_args()); - assert!( - result - .warnings - .iter() - .any(|w| w.contains("required both in require and require-dev")) - ); - } - - #[test] - fn test_validate_no_require_overlap_no_warning() { - let json = r#"{ - "name": "vendor/pkg", - "license": "MIT", - "require": {"monolog/monolog": "^3.0"}, - "require-dev": {"phpunit/phpunit": "^10.0"} - }"#; - let result = parse_and_validate(json, &make_args()); - assert!(!result.warnings.iter().any(|w| w.contains("required both"))); - } - - #[test] - fn test_validate_provide_replace_overlap_warns() { - let json = r#"{ - "name": "vendor/pkg", - "license": "MIT", - "require": {"psr/log": "^3.0"}, - "provide": {"psr/log": "^3.0"} - }"#; - let result = parse_and_validate(json, &make_args()); - assert!( - result - .warnings - .iter() - .any(|w| w.contains("also listed in provide")) - ); - } - - #[test] - fn test_validate_commit_ref_warns() { - let json = r#"{ - "name": "vendor/pkg", - "license": "MIT", - "require": {"foo/bar": "dev-main#abc123"} - }"#; - let result = parse_and_validate(json, &make_args()); - assert!(result.warnings.iter().any(|w| w.contains("commit-ref"))); - } - - #[test] - fn test_validate_normal_constraint_no_commit_warning() { - let json = r#"{ - "name": "vendor/pkg", - "license": "MIT", - "require": {"foo/bar": "^1.0"} - }"#; - let result = parse_and_validate(json, &make_args()); - assert!(!result.warnings.iter().any(|w| w.contains("commit-ref"))); - } - - #[test] - fn test_validate_empty_psr4_prefix_warns() { - let json = r#"{ - "name": "vendor/pkg", - "license": "MIT", - "autoload": {"psr-4": {"": "src/"}} - }"#; - let result = parse_and_validate(json, &make_args()); - assert!(result.warnings.iter().any(|w| w.contains("psr-4"))); - } - - #[test] - fn test_validate_empty_psr0_prefix_warns() { - let json = r#"{ - "name": "vendor/pkg", - "license": "MIT", - "autoload": {"psr-0": {"": "src/"}} - }"#; - let result = parse_and_validate(json, &make_args()); - assert!(result.warnings.iter().any(|w| w.contains("psr-0"))); - } - - #[test] - fn test_validate_named_psr4_prefix_no_warning() { - let json = r#"{ - "name": "vendor/pkg", - "license": "MIT", - "autoload": {"psr-4": {"Vendor\\Pkg\\": "src/"}} - }"#; - let result = parse_and_validate(json, &make_args()); - assert!(!result.warnings.iter().any(|w| w.contains("psr-4"))); - } - - #[test] - fn test_validate_invalid_stability_errors() { - let json = r#"{"name": "vendor/pkg", "license": "MIT", "minimum-stability": "invalid"}"#; - let result = parse_and_validate(json, &make_args()); - assert!(!result.errors.is_empty()); - assert!( - result - .errors - .iter() - .any(|e| e.contains("minimum-stability")) - ); - } - - #[test] - fn test_validate_valid_stability_no_error() { - for stab in &["dev", "alpha", "beta", "rc", "stable"] { - let json = format!( - r#"{{"name": "vendor/pkg", "license": "MIT", "minimum-stability": "{stab}"}}"# - ); - let result = parse_and_validate(&json, &make_args()); - assert!( - !result - .errors - .iter() - .any(|e| e.contains("minimum-stability")), - "stability '{stab}' should be valid" - ); - } - } - - #[test] - fn test_validate_non_object_json_errors() { - let value = serde_json::json!([1, 2, 3]); - let mut result = ValidationResult::new(); - validate_manifest(&value, &make_args(), &mut result); - assert!(result.errors.iter().any(|e| e.contains("JSON object"))); - } - #[test] fn test_compute_exit_code_no_issues() { let result = ValidationResult::new(); @@ -1597,78 +568,6 @@ mod tests { } #[test] - fn test_validate_scripts_descriptions_orphan_warns() { - let json = r#"{ - "name": "vendor/pkg", - "license": "MIT", - "scripts": {"build": "make build"}, - "scripts-descriptions": {"build": "Build the project", "nonexistent": "Ghost script"} - }"#; - let result = parse_and_validate(json, &make_args()); - assert!( - result - .warnings - .iter() - .any(|w| w.contains("nonexistent") && w.contains("scripts-descriptions")), - "expected orphan warning for scripts-descriptions, got: {:?}", - result.warnings - ); - assert!( - !result - .warnings - .iter() - .any(|w| w.contains("\"build\"") && w.contains("scripts-descriptions")), - "should not warn about existing script 'build'" - ); - } - - #[test] - fn test_validate_scripts_aliases_orphan_warns() { - let json = r#"{ - "name": "vendor/pkg", - "license": "MIT", - "scripts": {"build": "make build"}, - "scripts-aliases": {"build": ["b"], "ghost": ["g"]} - }"#; - let result = parse_and_validate(json, &make_args()); - assert!( - result - .warnings - .iter() - .any(|w| w.contains("ghost") && w.contains("scripts-aliases")), - "expected orphan warning for scripts-aliases, got: {:?}", - result.warnings - ); - assert!( - !result - .warnings - .iter() - .any(|w| w.contains("\"build\"") && w.contains("scripts-aliases")), - "should not warn about existing script 'build'" - ); - } - - #[test] - fn test_validate_scripts_valid_no_orphan_warning() { - let json = r#"{ - "name": "vendor/pkg", - "license": "MIT", - "scripts": {"build": "make build", "test": "phpunit"}, - "scripts-descriptions": {"build": "Build the project", "test": "Run tests"}, - "scripts-aliases": {"build": ["b"], "test": ["t"]} - }"#; - let result = parse_and_validate(json, &make_args()); - assert!( - !result - .warnings - .iter() - .any(|w| w.contains("scripts-descriptions") || w.contains("scripts-aliases")), - "should produce no orphan warnings when all keys match, got: {:?}", - result.warnings - ); - } - - #[test] fn test_should_check_lock_config_false_disables() { let args = make_args(); let manifest = serde_json::json!({"config": {"lock": false}}); @@ -1689,26 +588,4 @@ mod tests { let manifest = serde_json::json!({"name": "vendor/pkg"}); assert!(should_check_lock(&args, &manifest)); } - - #[test] - fn test_validate_no_errors_on_valid_package() { - let json = r#"{ - "name": "vendor/package", - "description": "A test package", - "license": "MIT", - "require": {"php": ">=8.1"} - }"#; - let result = parse_and_validate(json, &make_args()); - assert!(result.errors.is_empty(), "errors: {:?}", result.errors); - assert!( - result.publish_errors.is_empty(), - "publish errors: {:?}", - result.publish_errors - ); - // Only the version-field warning might appear — but we have no version field here - assert!( - !result.warnings.iter().any(|w| w.contains("version field")), - "unexpected version warning" - ); - } } |
