aboutsummaryrefslogtreecommitdiffhomepage
path: root/crates
diff options
context:
space:
mode:
Diffstat (limited to 'crates')
-rw-r--r--crates/mozart-core/src/config_validator.rs1135
-rw-r--r--crates/mozart-core/src/http.rs78
-rw-r--r--crates/mozart-core/src/lib.rs1
-rw-r--r--crates/mozart/src/commands/diagnose.rs888
-rw-r--r--crates/mozart/src/commands/validate.rs1135
5 files changed, 1578 insertions, 1659 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"
+ );
+ }
+}
diff --git a/crates/mozart-core/src/http.rs b/crates/mozart-core/src/http.rs
index 907b6a7..43de55d 100644
--- a/crates/mozart-core/src/http.rs
+++ b/crates/mozart-core/src/http.rs
@@ -135,6 +135,84 @@ pub fn default_client() -> reqwest::Client {
.expect("failed to build default HTTP client")
}
+/// Thin wrapper around [`reqwest::Client`] that mirrors the relevant slice of
+/// `Composer\Util\HttpDownloader`: a project-shared client used for plain
+/// `GET` requests against package metadata URLs.
+///
+/// Today this is only the bits the `diagnose` command needs (a pre-built
+/// client, a single `get` method, and `exception_hints`). The intention is
+/// for `mozart-registry`'s download pipeline to migrate onto the same
+/// wrapper later.
+#[derive(Clone)]
+pub struct HttpDownloader {
+ client: reqwest::Client,
+}
+
+impl HttpDownloader {
+ /// Build a downloader using the standard Mozart client (User-Agent +
+ /// configured root certificates).
+ pub fn new() -> Self {
+ Self {
+ client: default_client(),
+ }
+ }
+
+ /// Build a downloader with a custom timeout, used by health checks where
+ /// hangs would mask the failure mode the user is actually trying to
+ /// diagnose.
+ pub fn with_timeout(timeout: std::time::Duration) -> Result<Self> {
+ let client = client_builder()
+ .timeout(timeout)
+ .build()
+ .context("failed to build HTTP client")?;
+ Ok(Self { client })
+ }
+
+ /// Issue a `GET` against `url`. Mirrors `HttpDownloader::get` in role,
+ /// but returns the raw [`reqwest::Response`] so callers can decide
+ /// what to do with the body.
+ pub async fn get(&self, url: &str) -> Result<reqwest::Response, reqwest::Error> {
+ self.client.get(url).send().await
+ }
+
+ /// Underlying client, exposed so callers that need to set additional
+ /// request-level options can build off it. Try not to use this from
+ /// new code — prefer extending `HttpDownloader` itself.
+ pub fn client(&self) -> &reqwest::Client {
+ &self.client
+ }
+}
+
+impl Default for HttpDownloader {
+ fn default() -> Self {
+ Self::new()
+ }
+}
+
+/// Mirror of `HttpDownloader::getExceptionHints` from PHP — best-effort
+/// human-readable hints for a transport failure. Today this only surfaces
+/// the few cases reqwest can distinguish (timeout, connect, decode); we
+/// can extend it as we encounter more failure modes in the wild.
+pub fn exception_hints(err: &reqwest::Error) -> Vec<String> {
+ let mut hints = Vec::new();
+ if err.is_timeout() {
+ hints.push(
+ "The request timed out. Check your network connection or any HTTP proxy settings."
+ .to_string(),
+ );
+ }
+ if err.is_connect() {
+ hints.push(
+ "Could not establish a connection. Check that the host is reachable and that no firewall is blocking outbound HTTPS."
+ .to_string(),
+ );
+ }
+ if err.is_decode() {
+ hints.push("The response body could not be decoded.".to_string());
+ }
+ hints
+}
+
#[cfg(test)]
mod tests {
use super::*;
diff --git a/crates/mozart-core/src/lib.rs b/crates/mozart-core/src/lib.rs
index ab6bfe0..0aefbb6 100644
--- a/crates/mozart-core/src/lib.rs
+++ b/crates/mozart-core/src/lib.rs
@@ -1,5 +1,6 @@
pub mod composer;
pub mod config;
+pub mod config_validator;
pub mod console;
pub mod exit_code;
pub mod factory;
diff --git a/crates/mozart/src/commands/diagnose.rs b/crates/mozart/src/commands/diagnose.rs
index 8ea2098..fd2297a 100644
--- a/crates/mozart/src/commands/diagnose.rs
+++ b/crates/mozart/src/commands/diagnose.rs
@@ -2,295 +2,216 @@ use clap::Args;
use colored::Colorize;
use mozart_core::MOZART_VERSION;
use mozart_core::composer::Composer;
+use mozart_core::config::Config;
+use mozart_core::config_validator::{ValidatorOptions, validate_manifest};
use mozart_core::console::Console;
use mozart_core::console_writeln;
use mozart_core::factory::create_config;
+use mozart_core::http::HttpDownloader;
use std::borrow::Cow;
-use std::path::{Path, PathBuf};
+use std::path::Path;
#[derive(Args)]
pub struct DiagnoseArgs {}
+/// Result of a single check, mirroring the `string|true|string[]|\Exception`
+/// shape of `Composer\Command\DiagnoseCommand`'s private `checkX` methods.
enum CheckResult {
- /// OK, with optional detail string.
+ /// `<info>OK</info>` with optional detail string. Equivalent to PHP `true`.
Ok(Option<String>),
- /// WARNING + message.
- Warning(String),
- /// FAIL + message.
- Fail(String),
- /// SKIP + reason.
+ /// `<warning>WARNING</warning>` + message lines.
+ Warning(Vec<String>),
+ /// `<error>FAIL</error>` + message lines.
+ Fail(Vec<String>),
+ /// `<info>SKIP</info>` + reason. Composer emits this inline for the
+ /// `allow_url_fopen` / `COMPOSER_DISABLE_NETWORK` cases via the same
+ /// `outputResult` path.
Skip(String),
- /// Informational line (no pass/fail prefix).
- Info(String),
}
-/// Print "Checking {label}: OK/WARNING/FAIL/SKIP" and ratchet exit_code.
+impl CheckResult {
+ fn ok() -> Self {
+ CheckResult::Ok(None)
+ }
+
+ fn ok_with(detail: impl Into<String>) -> Self {
+ CheckResult::Ok(Some(detail.into()))
+ }
+
+ fn warn(msg: impl Into<String>) -> Self {
+ CheckResult::Warning(vec![msg.into()])
+ }
+
+ fn fail(msg: impl Into<String>) -> Self {
+ CheckResult::Fail(vec![msg.into()])
+ }
+}
+
+/// Mirror of `DiagnoseCommand::outputResult`. Writes the leading
+/// `Checking <label>: ` and then `<info>OK</>`, `<warning>WARNING</>` +
+/// messages, `<error>FAIL</>` + messages, or `<info>SKIP</>` + reason.
///
-/// Exit code ratchet: Warning → 1 (if currently 0), Fail → 2 (always overrides 1).
-fn print_check(label: &str, result: &CheckResult, exit_code: &mut i32, console: &Console) {
+/// Ratchets `exit_code`: `Warning` → 1 (if currently 0), `Fail` → 2 (always).
+fn output_result(label: &str, result: &CheckResult, exit_code: &mut i32, console: &Console) {
+ let prefix = format!("Checking {label}: ");
match result {
CheckResult::Ok(detail) => {
- let ok_str = "OK".green().bold();
+ let ok = "OK".green().bold();
match detail {
- Some(d) => {
- console_writeln!(console, &format!("Checking {label}: {ok_str} ({d})"),);
- }
- None => {
- console_writeln!(console, &format!("Checking {label}: {ok_str}"),);
- }
+ Some(d) => console_writeln!(
+ console,
+ &format!("{prefix}{ok} {}", format!("({d})").bright_black())
+ ),
+ None => console_writeln!(console, &format!("{prefix}{ok}")),
}
}
- CheckResult::Warning(msg) => {
- let warn_str = "WARNING".yellow().bold();
- console_writeln!(console, &format!("Checking {label}: {warn_str}"),);
- console_writeln!(console, &format!(" {}", msg.yellow()));
+ CheckResult::Warning(msgs) => {
+ console_writeln!(console, &format!("{prefix}{}", "WARNING".yellow().bold()));
+ for msg in msgs {
+ console_writeln!(console, &format!("{}", msg.yellow()));
+ }
if *exit_code < 1 {
*exit_code = 1;
}
}
- CheckResult::Fail(msg) => {
- let fail_str = "FAIL".red().bold();
- console_writeln!(console, &format!("Checking {label}: {fail_str}"),);
- console_writeln!(console, &format!(" {}", msg.red()));
+ CheckResult::Fail(msgs) => {
+ console_writeln!(console, &format!("{prefix}{}", "FAIL".red().bold()));
+ for msg in msgs {
+ console_writeln!(console, &format!("{}", msg.red()));
+ }
*exit_code = 2;
}
CheckResult::Skip(reason) => {
- let skip_str = "SKIP".cyan().bold();
- console_writeln!(console, &format!("Checking {label}: {skip_str} ({reason})"),);
- }
- CheckResult::Info(_) => {
- // Info results are not "checked" — use print_info_line instead.
- }
- }
-}
-
-/// Print an informational line (not a check result).
-fn print_info_line(result: &CheckResult, console: &Console) {
- if let CheckResult::Info(msg) = result {
- console_writeln!(console, msg);
- }
-}
-
-/// Check 1: Mozart version info (informational).
-fn check_version() -> CheckResult {
- CheckResult::Info(format!("Mozart version {MOZART_VERSION}"))
-}
-
-/// Check 2 & 3: HTTP/HTTPS connectivity to Packagist.
-///
-/// Returns Ok if reachable, Fail if not, Skip if network is disabled.
-async fn check_http_connectivity(url: &str) -> CheckResult {
- if std::env::var("COMPOSER_DISABLE_NETWORK").is_ok() {
- return CheckResult::Skip("COMPOSER_DISABLE_NETWORK is set".to_string());
- }
-
- let client = match mozart_core::http::client_builder()
- .timeout(std::time::Duration::from_secs(10))
- .build()
- {
- Ok(c) => c,
- Err(e) => return CheckResult::Fail(format!("Could not build HTTP client: {e}")),
- };
-
- match client.get(url).send().await {
- Ok(resp) => {
- let status = resp.status();
- if status.is_success() || status.is_redirection() {
- CheckResult::Ok(Some(format!("HTTP {}", status.as_u16())))
- } else {
- CheckResult::Warning(format!("Received HTTP {} from {url}", status.as_u16()))
- }
+ console_writeln!(
+ console,
+ &format!(
+ "{prefix}{} {}",
+ "SKIP".cyan().bold(),
+ format!("({reason})").bright_black()
+ )
+ );
}
- Err(e) => CheckResult::Fail(format!("Could not reach {url}: {e}")),
}
}
-/// Check 4: GitHub API connectivity.
-async fn check_github_api() -> CheckResult {
- if std::env::var("COMPOSER_DISABLE_NETWORK").is_ok() {
- return CheckResult::Skip("COMPOSER_DISABLE_NETWORK is set".to_string());
- }
-
- let client = match mozart_core::http::client_builder()
- .timeout(std::time::Duration::from_secs(10))
- .build()
- {
- Ok(c) => c,
- Err(e) => return CheckResult::Fail(format!("Could not build HTTP client: {e}")),
- };
+// -----------------------------------------------------------------------
+// Connectivity preflight (mirrors checkConnectivity / checkComposerNetworkHttpEnablement)
+// -----------------------------------------------------------------------
- let url = "https://api.github.com/";
- match client.get(url).send().await {
- Ok(resp) => {
- let status = resp.status();
- if status.is_success() || status.is_redirection() {
- CheckResult::Ok(Some(format!("HTTP {}", status.as_u16())))
- } else {
- CheckResult::Warning(format!("Received HTTP {} from GitHub API", status.as_u16()))
- }
- }
- Err(e) => CheckResult::Fail(format!("Could not reach GitHub API: {e}")),
+/// Mirrors `DiagnoseCommand::checkComposerNetworkHttpEnablement` — returns a
+/// `Skip` result when `COMPOSER_DISABLE_NETWORK` is set.
+fn check_composer_network_http_enablement() -> Option<CheckResult> {
+ if std::env::var("COMPOSER_DISABLE_NETWORK").is_ok_and(|v| !v.is_empty()) {
+ return Some(CheckResult::Skip(
+ "Network is disabled by COMPOSER_DISABLE_NETWORK.".to_string(),
+ ));
}
+ None
}
-/// Check 5: HTTP proxy configuration.
+/// Mirrors `DiagnoseCommand::checkConnectivityAndComposerNetworkHttpEnablement`.
///
-/// Reports any configured proxy environment variables as informational.
-fn check_http_proxy() -> CheckResult {
- let proxy_vars = [
- "HTTP_PROXY",
- "HTTPS_PROXY",
- "http_proxy",
- "https_proxy",
- "NO_PROXY",
- "no_proxy",
- ];
-
- let mut found: Vec<String> = Vec::new();
- for var in &proxy_vars {
- if let Ok(val) = std::env::var(var) {
- found.push(format!("{var}={val}"));
- }
- }
-
- if found.is_empty() {
- CheckResult::Ok(Some("no proxy configured".to_string()))
- } else {
- CheckResult::Ok(Some(found.join(", ")))
- }
+/// Mozart has no `allow_url_fopen` analogue (we use reqwest directly), so the
+/// upstream `checkConnectivity` half is a no-op here — only the network-disabled
+/// gate fires.
+fn check_connectivity_and_network_http_enablement() -> Option<CheckResult> {
+ check_composer_network_http_enablement()
}
-/// Check 6: composer.json validation.
-///
-/// Checks that it exists, is valid JSON, and has a `name` field.
-fn check_composer_json(working_dir: &Path) -> CheckResult {
- let path = working_dir.join("composer.json");
+// -----------------------------------------------------------------------
+// Individual checks
+// -----------------------------------------------------------------------
- if !path.exists() {
- return CheckResult::Warning(format!(
- "composer.json not found in {}",
- working_dir.display()
- ));
- }
-
- let content = match std::fs::read_to_string(&path) {
+/// Mirrors `DiagnoseCommand::checkComposerSchema`. Both Composer's
+/// `ValidateCommand` and `DiagnoseCommand` instantiate the same
+/// `Composer\Util\ConfigValidator`; we mirror that by calling
+/// [`validate_manifest`] directly. Publish errors are intentionally
+/// elided — Composer's diagnose discards them too via
+/// `[$errors, , $warnings] = $validator->validate(...)`.
+fn check_composer_schema(working_dir: &Path) -> CheckResult {
+ let composer_json = working_dir.join("composer.json");
+ let content = match std::fs::read_to_string(&composer_json) {
Ok(c) => c,
- Err(e) => return CheckResult::Fail(format!("Could not read composer.json: {e}")),
+ Err(e) => {
+ return CheckResult::fail(format!("could not read {}: {e}", composer_json.display()));
+ }
};
-
let value: serde_json::Value = match serde_json::from_str(&content) {
Ok(v) => v,
Err(e) => {
- return CheckResult::Fail(format!("composer.json is not valid JSON: {e}"));
- }
- };
-
- let obj = match value.as_object() {
- Some(o) => o,
- None => {
- return CheckResult::Fail(
- "composer.json must be a JSON object at the top level".to_string(),
- );
+ return CheckResult::fail(format!(
+ "{} does not contain valid JSON: {e}",
+ composer_json.display()
+ ));
}
};
- if !obj.contains_key("name") {
- return CheckResult::Warning("composer.json is missing the \"name\" field".to_string());
+ let result = validate_manifest(&value, &ValidatorOptions::default());
+ if result.errors.is_empty() && result.warnings.is_empty() {
+ CheckResult::ok()
+ } else if !result.errors.is_empty() {
+ let mut msgs = result.errors;
+ msgs.extend(result.warnings);
+ CheckResult::Fail(msgs)
+ } else {
+ CheckResult::Warning(result.warnings)
}
-
- CheckResult::Ok(None)
}
-/// Check 7: composer.lock freshness.
-///
-/// If composer.lock exists, verify its content-hash matches the current composer.json.
-fn check_composer_lock(working_dir: &Path) -> CheckResult {
- let lock_path = working_dir.join("composer.lock");
-
- if !lock_path.exists() {
- return CheckResult::Skip("composer.lock not found".to_string());
- }
-
- let composer_json_path = working_dir.join("composer.json");
- let composer_json_content = match std::fs::read_to_string(&composer_json_path) {
- Ok(c) => c,
- Err(_) => {
- return CheckResult::Skip(
- "could not read composer.json to compare against lock file".to_string(),
- );
- }
- };
-
- let lock = match mozart_registry::lockfile::LockFile::read_from_file(&lock_path) {
- Ok(l) => l,
- Err(e) => return CheckResult::Fail(format!("composer.lock is invalid: {e}")),
- };
-
- if lock.is_fresh(&composer_json_content) {
- CheckResult::Ok(None)
- } else {
- CheckResult::Warning(
- "composer.lock is out of date; run \"mozart update\" or \"mozart install\" to refresh it".to_string(),
- )
- }
+/// Mirrors `DiagnoseCommand::checkComposerLockSchema`. Mozart does not have
+/// a JSON-schema validator for `composer.lock` yet, so this currently emits
+/// a `SKIP` placeholder rather than asserting compliance.
+fn check_composer_lock_schema(_lock_path: &Path) -> CheckResult {
+ CheckResult::Skip(
+ "composer.lock schema validation is not yet implemented in Mozart".to_string(),
+ )
}
-/// Check 8: Git availability and minimum version.
-///
-/// Warns if git is not found or is older than 2.24.0.
+/// Mirrors `DiagnoseCommand::checkGit`.
fn check_git() -> CheckResult {
let output = match std::process::Command::new("git").arg("--version").output() {
Ok(o) => o,
- Err(_) => {
- return CheckResult::Warning(
- "git not found in PATH; some features may not work".to_string(),
- );
- }
+ Err(_) => return CheckResult::warn("No git process found"),
};
if !output.status.success() {
- return CheckResult::Warning("git --version returned a non-zero exit code".to_string());
+ return CheckResult::warn("git --version returned a non-zero exit code");
}
- // Check color.ui setting before parsing the version
if let Ok(color_output) = std::process::Command::new("git")
.args(["config", "color.ui"])
.output()
{
let color_val = String::from_utf8_lossy(&color_output.stdout);
if color_val.trim().eq_ignore_ascii_case("always") {
- return CheckResult::Warning(
+ return CheckResult::warn(
"Your git color.ui setting is set to always, this is known to create issues. \
- Use \"git config --global color.ui true\" to set it correctly."
- .to_string(),
+ Use \"git config --global color.ui true\" to set it correctly.",
);
}
}
let stdout = String::from_utf8_lossy(&output.stdout);
- let version_str = stdout.trim();
+ let raw = stdout.trim();
+ let version_only = raw.strip_prefix("git version ").unwrap_or(raw);
- // Parse version from output like "git version 2.39.1"
- match parse_git_version(version_str) {
+ match parse_git_version(raw) {
Some((major, minor, _patch)) => {
- // Require >= 2.24.0
if major < 2 || (major == 2 && minor < 24) {
- CheckResult::Warning(format!(
- "git {version_str} is older than the recommended minimum 2.24.0"
+ CheckResult::warn(format!(
+ "Your git version ({version_only}) is too old and possibly will cause issues. \
+ Please upgrade to git 2.24 or above"
))
} else {
- CheckResult::Ok(Some(version_str.to_string()))
+ CheckResult::ok_with(format!("git version {version_only}"))
}
}
- None => CheckResult::Ok(Some(version_str.to_string())),
+ None => CheckResult::ok_with(version_only.to_string()),
}
}
-/// Parse git version output (e.g. "git version 2.39.1") into (major, minor, patch).
fn parse_git_version(output: &str) -> Option<(u64, u64, u64)> {
- // Extract the version number portion after "git version "
let version_part = output.strip_prefix("git version ").unwrap_or(output);
- // Take only the first part before any space (e.g. "2.39.1.windows.1" → "2.39.1")
let first_part = version_part.split_whitespace().next()?;
let mut parts = first_part.split('.');
let major: u64 = parts.next()?.parse().ok()?;
@@ -299,91 +220,135 @@ fn parse_git_version(output: &str) -> Option<(u64, u64, u64)> {
Some((major, minor, patch))
}
-/// Check 9: Disk free space for a path.
-///
-/// Warns if < 1 MiB free. Uses `df -P` for portable output.
-fn check_disk_space(path: &Path, label: &str) -> CheckResult {
- // Ensure the path exists before calling df
- if !path.exists() {
- return CheckResult::Skip(format!("{} does not exist", path.display()));
+/// Mirrors `DiagnoseCommand::checkHttp(proto, $config)`.
+async fn check_http(proto: &str, http_downloader: &HttpDownloader, config: &Config) -> CheckResult {
+ if let Some(skip) = check_connectivity_and_network_http_enablement() {
+ return skip;
}
- let output = match std::process::Command::new("df")
- .arg("-P")
- .arg(path)
- .output()
- {
- Ok(o) => o,
- Err(_) => {
- return CheckResult::Skip("df not available on this platform".to_string());
+ let mut errors: Vec<String> = Vec::new();
+ let mut warnings: Vec<String> = Vec::new();
+
+ if proto == "https" && !config.secure_http {
+ warnings.push(
+ "Composer is configured to disable SSL/TLS protection. \
+ This will leave remote HTTPS requests vulnerable to Man-In-The-Middle attacks."
+ .to_string(),
+ );
+ }
+
+ let url = format!("{proto}://repo.packagist.org/packages.json");
+ if let Err(err) = http_downloader.get(&url).await {
+ for hint in mozart_core::http::exception_hints(&err) {
+ errors.push(hint);
}
- };
+ errors.push(format!("[reqwest] {err}"));
+ }
- if !output.status.success() {
- return CheckResult::Skip(format!("df -P failed for {}", path.display()));
+ if !errors.is_empty() {
+ errors.extend(warnings);
+ CheckResult::Fail(errors)
+ } else if !warnings.is_empty() {
+ CheckResult::Warning(warnings)
+ } else {
+ CheckResult::ok()
}
+}
- let stdout = String::from_utf8_lossy(&output.stdout);
- match parse_df_available_kib(&stdout) {
- Some(avail_kib) => {
- let avail_mib = avail_kib / 1024;
- let one_mib_kib = 1024u64;
- if avail_kib < one_mib_kib {
- CheckResult::Warning(format!(
- "Low disk space on {label}: only {}KiB available",
- avail_kib
- ))
- } else {
- CheckResult::Ok(Some(format!("{avail_mib}MiB free on {label}")))
- }
+/// Mirrors `DiagnoseCommand::checkComposerRepo`.
+async fn check_composer_repo(
+ url: &str,
+ http_downloader: &HttpDownloader,
+ config: &Config,
+) -> CheckResult {
+ if let Some(skip) = check_connectivity_and_network_http_enablement() {
+ return skip;
+ }
+
+ let mut errors: Vec<String> = Vec::new();
+ let mut warnings: Vec<String> = Vec::new();
+
+ if url.starts_with("https://") && !config.secure_http {
+ warnings.push(
+ "Composer is configured to disable SSL/TLS protection. \
+ This will leave remote HTTPS requests vulnerable to Man-In-The-Middle attacks."
+ .to_string(),
+ );
+ }
+
+ if let Err(err) = http_downloader.get(url).await {
+ for hint in mozart_core::http::exception_hints(&err) {
+ errors.push(hint);
}
- None => CheckResult::Skip("could not parse df output".to_string()),
+ errors.push(format!("[reqwest] {err}"));
+ }
+
+ if !errors.is_empty() {
+ errors.extend(warnings);
+ CheckResult::Fail(errors)
+ } else if !warnings.is_empty() {
+ CheckResult::Warning(warnings)
+ } else {
+ CheckResult::ok()
}
}
-/// Parse the "Available" column (4th column) of `df -P` output, returning KiB.
-///
-/// The -P (POSIX) format guarantees 1024-byte blocks in the "Available" column.
+/// Mirrors `DiagnoseCommand::checkDiskSpace($config)`. Single check that
+/// flags the first of `home` / `vendor-dir` to fall under 1MiB free.
+fn check_disk_space(config: &Config) -> CheckResult {
+ let home = config
+ .get("home")
+ .and_then(|v| v.as_str().map(|s| s.to_string()));
+ let vendor = config
+ .get("vendor-dir")
+ .and_then(|v| v.as_str().map(|s| s.to_string()));
+
+ let min_bytes: u64 = 1024 * 1024;
+
+ for dir in [home, vendor].into_iter().flatten() {
+ let path = Path::new(&dir);
+ if !path.exists() {
+ continue;
+ }
+ if let Some(b) = disk_free_bytes(path)
+ && b < min_bytes
+ {
+ return CheckResult::fail(format!("The disk hosting {} is full", path.display()));
+ }
+ }
+
+ CheckResult::ok()
+}
+
+/// Returns free space in bytes for the filesystem hosting `path`. `None` when
+/// the platform's `df` is unavailable or its output cannot be parsed.
+fn disk_free_bytes(path: &Path) -> Option<u64> {
+ let output = std::process::Command::new("df")
+ .arg("-P")
+ .arg(path)
+ .output()
+ .ok()?;
+ if !output.status.success() {
+ return None;
+ }
+ let stdout = String::from_utf8_lossy(&output.stdout);
+ let kib = parse_df_available_kib(&stdout)?;
+ Some(kib.saturating_mul(1024))
+}
+
+/// Parse the "Available" column of `df -P` output (KiB).
fn parse_df_available_kib(df_output: &str) -> Option<u64> {
- // Skip the header line, then read the first data line
let data_line = df_output.lines().nth(1)?;
let mut cols = data_line.split_whitespace();
- // Columns: Filesystem, 1024-blocks, Used, Available, Capacity%, Mounted
cols.next()?; // Filesystem
cols.next()?; // 1024-blocks
cols.next()?; // Used
- let available = cols.next()?;
- available.parse::<u64>().ok()
+ cols.next()?.parse::<u64>().ok()
}
-/// Check 10: Cache directory status.
-///
-/// Checks that the cache directory exists and is writable.
-fn check_cache_dir(cache_dir: &Path) -> CheckResult {
- if !cache_dir.exists() {
- // Try to create it
- if let Err(e) = std::fs::create_dir_all(cache_dir) {
- return CheckResult::Fail(format!(
- "Cache directory {} does not exist and could not be created: {e}",
- cache_dir.display()
- ));
- }
- return CheckResult::Ok(Some(format!("created {}", cache_dir.display())));
- }
-
- // Check writability by attempting to create a temp file
- let test_file = cache_dir.join(".mozart_write_test");
- match std::fs::write(&test_file, b"test") {
- Ok(()) => {
- let _ = std::fs::remove_file(&test_file);
- CheckResult::Ok(Some(cache_dir.display().to_string()))
- }
- Err(e) => CheckResult::Fail(format!(
- "Cache directory {} is not writable: {e}",
- cache_dir.display()
- )),
- }
-}
+// -----------------------------------------------------------------------
+// Orchestrator
+// -----------------------------------------------------------------------
pub async fn execute(
_args: &DiagnoseArgs,
@@ -395,102 +360,127 @@ pub async fn execute(
let mut exit_code: i32 = 0;
let composer = Composer::try_load(&working_dir)?;
- let config = if let Some(composer) = &composer {
- Cow::Borrowed(composer.config())
+ let config: Cow<'_, Config> = if let Some(c) = &composer {
+ Cow::Borrowed(c.config())
} else {
Cow::Owned(create_config()?)
};
- let cache_dir = PathBuf::from(&config.cache_dir);
- // 1. Mozart version info
- print_info_line(&check_version(), console);
- console_writeln!(console, "");
+ let http_downloader = HttpDownloader::with_timeout(std::time::Duration::from_secs(10))?;
- // 2. HTTPS connectivity to Packagist
- let https_result = check_http_connectivity("https://repo.packagist.org/packages.json").await;
- print_check(
- "https connectivity to packagist",
- &https_result,
+ // Step 4 (pubkey check) is phar-only — Mozart is not distributed as a phar.
+ // Step 4b (`checkVersion`) is deferred until self-update lands.
+
+ // Step 5: Mozart version line.
+ console_writeln!(console, &format!("Mozart version {MOZART_VERSION}"));
+
+ // Step 6: Mozart and its dependencies for vulnerabilities. Deferred — needs
+ // a Mozart Auditor port.
+ output_result(
+ "Mozart and its dependencies for vulnerabilities",
+ &CheckResult::Skip("audit is not yet implemented in Mozart".to_string()),
&mut exit_code,
console,
);
- // 3. HTTP connectivity to Packagist
- let http_result = check_http_connectivity("http://repo.packagist.org/packages.json").await;
- print_check(
- "http connectivity to packagist",
- &http_result,
+ // Steps 7-8 (PHP/OpenSSL/curl/zip detection) are PHP-runtime concerns
+ // and do not apply to Mozart. Composer's "Active plugins" line is also
+ // omitted (Mozart has no plugin system).
+
+ if composer.is_some() {
+ output_result(
+ "composer.json",
+ &check_composer_schema(&working_dir),
+ &mut exit_code,
+ console,
+ );
+
+ let lock_path = working_dir.join("composer.lock");
+ if lock_path.exists() {
+ output_result(
+ "composer.lock",
+ &check_composer_lock_schema(&lock_path),
+ &mut exit_code,
+ console,
+ );
+ }
+ }
+
+ // Step 10: platform settings — PHP-runtime probe; deferred.
+ output_result(
+ "platform settings",
+ &CheckResult::Skip("platform settings checks are not applicable to Mozart".to_string()),
&mut exit_code,
console,
);
- // 4. GitHub API connectivity
- let github_result = check_github_api().await;
- print_check(
- "github.com connectivity",
- &github_result,
+ // Step 11: git settings.
+ output_result("git settings", &check_git(), &mut exit_code, console);
+
+ // Step 12: HTTP / HTTPS connectivity to packagist.
+ output_result(
+ "http connectivity to packagist",
+ &check_http("http", &http_downloader, &config).await,
&mut exit_code,
console,
);
-
- // 5. HTTP proxy config
- let proxy_result = check_http_proxy();
- print_check("http proxy", &proxy_result, &mut exit_code, console);
-
- // 6. composer.json validation
- let composer_json_result = check_composer_json(&working_dir);
- print_check(
- "composer.json",
- &composer_json_result,
+ output_result(
+ "https connectivity to packagist",
+ &check_http("https", &http_downloader, &config).await,
&mut exit_code,
console,
);
- // 7. composer.lock freshness
- let lock_result = check_composer_lock(&working_dir);
- print_check("composer.lock", &lock_result, &mut exit_code, console);
+ // Step 13: every additional `composer`-type repo.
+ if let Some(composer) = &composer {
+ for repo in composer.package().repositories.iter() {
+ if repo.repo_type != "composer" {
+ continue;
+ }
+ let Some(url) = repo.url.as_deref() else {
+ continue;
+ };
+ if !url.starts_with("http") {
+ continue;
+ }
+ if url.starts_with("https://repo.packagist.org") {
+ continue;
+ }
+ output_result(
+ &format!("connectivity to {url}"),
+ &check_composer_repo(url, &http_downloader, &config).await,
+ &mut exit_code,
+ console,
+ );
+ }
+ }
- // 8. Git availability
- let git_result = check_git();
- print_check("git", &git_result, &mut exit_code, console);
+ // Step 14: HTTP proxy probe — Mozart does not yet have a ProxyManager
+ // port. Deferred.
- // 9. Disk space — working directory
- let disk_wd_result = check_disk_space(&working_dir, "working directory");
- print_check(
- "disk free space (working directory)",
- &disk_wd_result,
- &mut exit_code,
- console,
- );
+ // Step 15: GitHub OAuth + rate limit — deferred until auth subsystem lands.
- // 9b. Disk space — cache directory
- let disk_cache_result = check_disk_space(&cache_dir, "cache directory");
- print_check(
- "disk free space (cache directory)",
- &disk_cache_result,
+ // Step 16: disk free space.
+ output_result(
+ "disk free space",
+ &check_disk_space(&config),
&mut exit_code,
console,
);
- // 10. Cache directory status
- let cache_result = check_cache_dir(&cache_dir);
- print_check("cache directory", &cache_result, &mut exit_code, console);
-
- console_writeln!(console, "");
- if exit_code == 0 {
- console_writeln!(console, &format!("{}", "No issues found.".green()),);
- } else if exit_code == 1 {
+ // Mirrors the `COMPOSER_IPRESOLVE` warning emitted by `checkPlatform`.
+ if let Ok(val) = std::env::var("COMPOSER_IPRESOLVE")
+ && (val == "4" || val == "6")
+ {
console_writeln!(
console,
&format!(
"{}",
- "Some warnings were found. See above for details.".yellow()
- ),
- );
- } else {
- console_writeln!(
- console,
- &format!("{}", "Some errors were found. See above for details.".red()),
+ format!(
+ "The COMPOSER_IPRESOLVE env var is set to {val} which may result in network failures below."
+ )
+ .yellow()
+ )
);
}
@@ -511,184 +501,76 @@ mod tests {
assert_eq!(parse_git_version("git version 2.39.1"), Some((2, 39, 1)));
assert_eq!(parse_git_version("git version 2.24.0"), Some((2, 24, 0)));
assert_eq!(parse_git_version("git version 1.9.5"), Some((1, 9, 5)));
- // Windows-style suffix
assert_eq!(
parse_git_version("git version 2.40.1.windows.1"),
Some((2, 40, 1))
);
- // No patch component
assert_eq!(parse_git_version("git version 2.39"), Some((2, 39, 0)));
- // Bare version (no "git version" prefix)
assert_eq!(parse_git_version("3.0.0"), Some((3, 0, 0)));
}
#[test]
- fn test_check_composer_json_valid() {
+ fn test_check_composer_schema_valid() {
let dir = tempdir().unwrap();
fs::write(
dir.path().join("composer.json"),
- r#"{"name": "test/project", "require": {}}"#,
+ r#"{"name": "test/project", "license": "MIT", "require": {}}"#,
)
.unwrap();
-
- let result = check_composer_json(dir.path());
- assert!(
- matches!(result, CheckResult::Ok(_)),
- "expected Ok for valid composer.json"
- );
- }
-
- #[test]
- fn test_check_composer_json_missing() {
- let dir = tempdir().unwrap();
- // Do not write a composer.json
-
- let result = check_composer_json(dir.path());
- assert!(
- matches!(result, CheckResult::Warning(_)),
- "expected Warning when composer.json is missing"
- );
+ let result = check_composer_schema(dir.path());
+ assert!(matches!(result, CheckResult::Ok(_)));
}
#[test]
- fn test_check_composer_json_invalid_json() {
+ fn test_check_composer_schema_invalid_json() {
let dir = tempdir().unwrap();
fs::write(dir.path().join("composer.json"), b"{ this is not json ").unwrap();
-
- let result = check_composer_json(dir.path());
- assert!(
- matches!(result, CheckResult::Fail(_)),
- "expected Fail for invalid JSON"
- );
- }
-
- #[test]
- fn test_check_composer_lock_fresh() {
- use mozart_registry::lockfile::LockFile;
-
- let dir = tempdir().unwrap();
-
- let composer_json = r#"{"name": "test/project", "require": {"php": ">=8.1"}}"#;
- fs::write(dir.path().join("composer.json"), composer_json).unwrap();
-
- let hash = LockFile::compute_content_hash(composer_json).unwrap();
- let lock = LockFile {
- readme: LockFile::default_readme(),
- content_hash: hash,
- packages: vec![],
- packages_dev: None,
- aliases: vec![],
- minimum_stability: "stable".to_string(),
- stability_flags: serde_json::json!({}),
- prefer_stable: false,
- prefer_lowest: false,
- platform: serde_json::json!({}),
- platform_dev: serde_json::json!({}),
- plugin_api_version: None,
- };
- lock.write_to_file(&dir.path().join("composer.lock"))
- .unwrap();
-
- let result = check_composer_lock(dir.path());
- assert!(
- matches!(result, CheckResult::Ok(_)),
- "expected Ok for fresh lock file"
- );
- }
-
- #[test]
- fn test_check_composer_lock_stale() {
- use mozart_registry::lockfile::LockFile;
-
- let dir = tempdir().unwrap();
-
- let composer_json = r#"{"name": "test/project", "require": {"php": ">=8.1"}}"#;
- fs::write(dir.path().join("composer.json"), composer_json).unwrap();
-
- // Deliberately use a stale/wrong hash
- let lock = LockFile {
- readme: LockFile::default_readme(),
- content_hash: "stale_hash_that_does_not_match".to_string(),
- packages: vec![],
- packages_dev: None,
- aliases: vec![],
- minimum_stability: "stable".to_string(),
- stability_flags: serde_json::json!({}),
- prefer_stable: false,
- prefer_lowest: false,
- platform: serde_json::json!({}),
- platform_dev: serde_json::json!({}),
- plugin_api_version: None,
- };
- lock.write_to_file(&dir.path().join("composer.lock"))
- .unwrap();
-
- let result = check_composer_lock(dir.path());
- assert!(
- matches!(result, CheckResult::Warning(_)),
- "expected Warning for stale lock file"
- );
- }
-
- #[test]
- fn test_check_composer_lock_missing() {
- let dir = tempdir().unwrap();
- // Do not write a composer.lock
-
- let result = check_composer_lock(dir.path());
- assert!(
- matches!(result, CheckResult::Skip(_)),
- "expected Skip when composer.lock is missing"
- );
+ let result = check_composer_schema(dir.path());
+ assert!(matches!(result, CheckResult::Fail(_)));
}
#[test]
- fn test_check_disk_space_ok() {
+ fn test_check_composer_schema_warns_on_missing_license() {
let dir = tempdir().unwrap();
- // Temp directories should always have plenty of free space
- let result = check_disk_space(dir.path(), "temp");
- // Accept Ok or Skip (on platforms where df isn't available)
- assert!(
- matches!(result, CheckResult::Ok(_) | CheckResult::Skip(_)),
- "expected Ok or Skip for disk space check on temp directory"
- );
+ fs::write(
+ dir.path().join("composer.json"),
+ r#"{"name": "test/project"}"#,
+ )
+ .unwrap();
+ let result = check_composer_schema(dir.path());
+ assert!(matches!(result, CheckResult::Warning(_)));
}
#[test]
- fn test_check_result_exit_code_ratcheting() {
+ fn test_output_result_exit_code_ratcheting() {
let console = Console::new(0, false, false, false, false);
let mut exit_code = 0i32;
- // Ok does not change exit code
- print_check("label", &CheckResult::Ok(None), &mut exit_code, &console);
+ output_result("label", &CheckResult::ok(), &mut exit_code, &console);
assert_eq!(exit_code, 0);
- // Warning raises to 1
- print_check(
+ output_result(
"label",
- &CheckResult::Warning("warn".to_string()),
+ &CheckResult::warn("warn"),
&mut exit_code,
&console,
);
assert_eq!(exit_code, 1);
- // Another Ok does not lower from 1
- print_check("label", &CheckResult::Ok(None), &mut exit_code, &console);
+ output_result("label", &CheckResult::ok(), &mut exit_code, &console);
assert_eq!(exit_code, 1);
- // Fail raises to 2
- print_check(
+ output_result(
"label",
- &CheckResult::Fail("fail".to_string()),
+ &CheckResult::fail("fail"),
&mut exit_code,
&console,
);
assert_eq!(exit_code, 2);
- // Warning does not lower from 2
- print_check(
+ output_result(
"label",
- &CheckResult::Warning("another warn".to_string()),
+ &CheckResult::warn("another warn"),
&mut exit_code,
&console,
);
@@ -696,65 +578,11 @@ mod tests {
}
#[test]
- fn test_check_http_proxy_none_set() {
- // Remove all proxy vars for this test
- let proxy_vars = [
- "HTTP_PROXY",
- "HTTPS_PROXY",
- "http_proxy",
- "https_proxy",
- "NO_PROXY",
- "no_proxy",
- ];
- for var in &proxy_vars {
- // SAFETY: tests run single-threaded for env mutation purposes.
- // We save and restore values to avoid polluting other tests.
- unsafe { std::env::remove_var(var) };
- }
-
- let result = check_http_proxy();
- match &result {
- CheckResult::Ok(detail) => {
- let detail_str = detail.as_deref().unwrap_or("");
- assert!(
- detail_str.contains("no proxy"),
- "expected 'no proxy configured' detail, got: {detail_str:?}"
- );
- }
- other => panic!(
- "expected Ok for proxy check with no proxy set, got: {:?}",
- std::mem::discriminant(other)
- ),
- }
- }
-
- #[tokio::test]
- #[ignore]
- async fn test_check_https_packagist_connectivity() {
- let result = check_http_connectivity("https://repo.packagist.org/packages.json").await;
- assert!(
- matches!(result, CheckResult::Ok(_)),
- "expected Ok for HTTPS Packagist connectivity"
- );
- }
-
- #[tokio::test]
- #[ignore]
- async fn test_check_http_packagist_connectivity() {
- let result = check_http_connectivity("http://repo.packagist.org/packages.json").await;
- assert!(
- matches!(result, CheckResult::Ok(_) | CheckResult::Warning(_)),
- "expected Ok or Warning for HTTP Packagist connectivity"
- );
- }
-
- #[tokio::test]
- #[ignore]
- async fn test_check_github_api_connectivity() {
- let result = check_github_api().await;
- assert!(
- matches!(result, CheckResult::Ok(_)),
- "expected Ok for GitHub API connectivity"
- );
+ fn test_check_composer_network_http_enablement_skips_when_disabled() {
+ // SAFETY: tests that mutate env vars are inherently process-wide.
+ unsafe { std::env::set_var("COMPOSER_DISABLE_NETWORK", "1") };
+ let result = check_composer_network_http_enablement();
+ assert!(matches!(result, Some(CheckResult::Skip(_))));
+ unsafe { std::env::remove_var("COMPOSER_DISABLE_NETWORK") };
}
}
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"
- );
- }
}