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-core/src/config_validator.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-core/src/config_validator.rs')
| -rw-r--r-- | crates/mozart-core/src/config_validator.rs | 1135 |
1 files changed, 1135 insertions, 0 deletions
diff --git a/crates/mozart-core/src/config_validator.rs b/crates/mozart-core/src/config_validator.rs new file mode 100644 index 0000000..2907d74 --- /dev/null +++ b/crates/mozart-core/src/config_validator.rs @@ -0,0 +1,1135 @@ +//! Manifest validator. Rust port of `Composer\Util\ConfigValidator`. +//! +//! Holds the cross-command checks that both `mozart validate` and +//! `mozart diagnose` run against a `composer.json`. The split mirrors +//! Composer's: `ValidateCommand` and `DiagnoseCommand` each `new +//! ConfigValidator(...)`; neither depends on the other. + +use std::collections::HashSet; +use std::sync::LazyLock; + +use regex::Regex; + +use crate::validation as v; + +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()); + +/// Per-call validator flags, mirroring Composer's `$flags` bitfield on +/// `ConfigValidator::validate`. +#[derive(Debug, Clone, Copy)] +pub struct ValidatorOptions { + /// Mirrors Composer's `CHECK_VERSION` flag — when set, warn that the + /// `version` field is present (since Packagist derives versions from + /// VCS tags). + pub check_version: bool, +} + +impl Default for ValidatorOptions { + fn default() -> Self { + // Composer defaults to `CHECK_VERSION` enabled. + Self { + check_version: true, + } + } +} + +/// Validation outcome: `(errors, publishErrors, warnings)` in Composer's +/// PHP wording. `errors` block install/usage; `publish_errors` only block +/// publishing on Packagist; `warnings` are advisory. +#[derive(Debug, Default, Clone)] +pub struct ValidationResult { + pub errors: Vec<String>, + pub publish_errors: Vec<String>, + pub warnings: Vec<String>, +} + +impl ValidationResult { + pub fn new() -> Self { + Self::default() + } + + pub fn has_errors(&self) -> bool { + !self.errors.is_empty() + } + + pub fn has_publish_errors(&self) -> bool { + !self.publish_errors.is_empty() + } + + pub fn has_warnings(&self) -> bool { + !self.warnings.is_empty() + } +} + +/// Run every per-manifest check. Mirrors the body of +/// `Composer\Util\ConfigValidator::validate` after JSON parse. +pub fn validate_manifest( + manifest: &serde_json::Value, + options: &ValidatorOptions, +) -> ValidationResult { + let mut result = ValidationResult::new(); + let obj = match manifest.as_object() { + Some(o) => o, + None => { + result + .errors + .push("composer.json must be a JSON object".to_string()); + return result; + } + }; + + check_name(obj, &mut result); + check_license(obj, &mut result); + + if options.check_version { + check_version_field(obj, &mut result); + } + + check_package_type(obj, &mut result); + check_require_overlap(obj, &mut result); + check_provide_replace_overlap(obj, &mut result); + check_commit_references(obj, &mut result); + check_empty_psr_prefixes(obj, &mut result); + check_minimum_stability(obj, &mut result); + check_scripts_orphans(obj, &mut result); + + 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) => { + if name.chars().any(|c| c.is_ascii_uppercase()) { + let suggested = name + .split('/') + .map(v::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." + )); + } + + if !name.is_empty() && !v::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. +/// * 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(); + + 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() + } + 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; + } + }; + + 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() + )), + } + } + + 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); + } + + 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 v::validate_license(&to_validate) { + continue; + } + let quoted = serde_json::to_string(license).unwrap_or_else(|_| format!("\"{license}\"")); + if v::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)?; + + 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; + 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()) + && !v::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: HashSet<&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\"" + )); + } + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn parse_and_validate(json: &str, options: &ValidatorOptions) -> ValidationResult { + let value: serde_json::Value = serde_json::from_str(json).unwrap(); + validate_manifest(&value, options) + } + + fn default_options() -> ValidatorOptions { + ValidatorOptions::default() + } + + #[test] + fn test_validate_missing_name_is_publish_error() { + let json = r#"{"require": {"php": ">=8.1"}, "license": "MIT"}"#; + let result = parse_and_validate(json, &default_options()); + 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, &default_options()); + 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, &default_options()); + 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, &default_options()); + 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, &default_options()); + 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, &default_options()); + 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, &default_options()); + 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, &default_options()); + 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, &default_options()); + 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, &default_options()); + assert!(!result.warnings.iter().any(|w| w.contains("license"))); + } + + #[test] + fn test_validate_unknown_license_no_deprecation_warning() { + let json = r#"{"name": "vendor/pkg", "license": "totally-not-a-license"}"#; + let result = parse_and_validate(json, &default_options()); + 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, &default_options()); + 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, &default_options()); + 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, &default_options()); + 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() { + let json = r#"{"name": "vendor/pkg", "license": "eCos-2.0"}"#; + let result = parse_and_validate(json, &default_options()); + 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, &default_options()); + 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, &default_options()); + 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, &default_options()); + 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, &default_options()); + 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, &default_options()); + 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() { + let json = r#"{"name": "vendor/pkg", "license": " MIT "}"#; + let result = parse_and_validate(json, &default_options()); + 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() { + let json = r#"{"name": "vendor/pkg", "license": "(MIT OR proprietary)"}"#; + let result = parse_and_validate(json, &default_options()); + 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() { + let json = r#"{ + "name": "vendor/pkg", + "license": "totally-not-a-license", + "time": "1970-01-01 00:00:00" + }"#; + let result = parse_and_validate(json, &default_options()); + 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() { + let json = r#"{ + "name": "vendor/pkg", + "license": "totally-not-a-license", + "time": "9999-01-01 00:00:00" + }"#; + let result = parse_and_validate(json, &default_options()); + 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() { + let json = r#"{ + "name": "vendor/pkg", + "license": "totally-not-a-license", + "time": "not-a-date" + }"#; + let result = parse_and_validate(json, &default_options()); + 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() { + assert_eq!( + parse_iso_time_to_unix("2023-12-15T13:45:30+05:00"), + Some(1_702_647_930 - 5 * 3600) + ); + 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, &default_options()); + assert!(result.warnings.iter().any(|w| w.contains("version field"))); + } + + #[test] + fn test_validate_check_version_disabled_suppresses_warning() { + let json = r#"{"name": "vendor/pkg", "license": "MIT", "version": "1.0.0"}"#; + let options = ValidatorOptions { + check_version: false, + }; + let result = parse_and_validate(json, &options); + 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, &default_options()); + 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, &default_options()); + 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, &default_options()); + 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, &default_options()); + 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, &default_options()); + 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, &default_options()); + 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, &default_options()); + 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, &default_options()); + 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, &default_options()); + 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, &default_options()); + 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, &default_options()); + 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, &default_options()); + 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 result = validate_manifest(&value, &default_options()); + assert!(result.errors.iter().any(|e| e.contains("JSON object"))); + } + + #[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, &default_options()); + assert!( + result + .warnings + .iter() + .any(|w| w.contains("nonexistent") && w.contains("scripts-descriptions")), + ); + 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, &default_options()); + assert!( + result + .warnings + .iter() + .any(|w| w.contains("ghost") && w.contains("scripts-aliases")), + ); + 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, &default_options()); + 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_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, &default_options()); + assert!(result.errors.is_empty(), "errors: {:?}", result.errors); + assert!( + result.publish_errors.is_empty(), + "publish errors: {:?}", + result.publish_errors + ); + assert!( + !result.warnings.iter().any(|w| w.contains("version field")), + "unexpected version warning" + ); + } +} |
