aboutsummaryrefslogtreecommitdiffhomepage
path: root/crates/mozart/src/commands/validate.rs
diff options
context:
space:
mode:
authornsfisis <nsfisis@gmail.com>2026-02-21 11:45:56 +0900
committernsfisis <nsfisis@gmail.com>2026-02-21 11:45:56 +0900
commita70d703f1dea24a1c2ee189f85ab98ff77f89ff3 (patch)
treea1082eadc623d59d3817c62075716b9743ab9330 /crates/mozart/src/commands/validate.rs
parentce38ae24012a23cd278aada6fbc226d33583024c (diff)
downloadphp-mozart-a70d703f1dea24a1c2ee189f85ab98ff77f89ff3.tar.gz
php-mozart-a70d703f1dea24a1c2ee189f85ab98ff77f89ff3.tar.zst
php-mozart-a70d703f1dea24a1c2ee189f85ab98ff77f89ff3.zip
feat(validate): implement validate command with composer.json checks
Check JSON validity, name format, license presence, version field, deprecated types, require/require-dev overlap, provide/replace overlap, commit references, empty PSR prefixes, minimum-stability, and lock file freshness. Supports --strict, --no-check-publish, --no-check-lock, --no-check-version flags with Composer-compatible exit codes. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Diffstat (limited to 'crates/mozart/src/commands/validate.rs')
-rw-r--r--crates/mozart/src/commands/validate.rs968
1 files changed, 966 insertions, 2 deletions
diff --git a/crates/mozart/src/commands/validate.rs b/crates/mozart/src/commands/validate.rs
index 7e821c2..7a01761 100644
--- a/crates/mozart/src/commands/validate.rs
+++ b/crates/mozart/src/commands/validate.rs
@@ -1,4 +1,5 @@
use clap::Args;
+use std::path::{Path, PathBuf};
#[derive(Args)]
pub struct ValidateArgs {
@@ -34,6 +35,969 @@ pub struct ValidateArgs {
pub strict: bool,
}
-pub fn execute(_args: &ValidateArgs, _cli: &super::Cli) -> anyhow::Result<()> {
- todo!()
+// ─── Result accumulator ─────────────────────────────────────────────────────
+
+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()
+ }
+}
+
+// ─── Entry point ─────────────────────────────────────────────────────────────
+
+pub fn execute(args: &ValidateArgs, cli: &super::Cli) -> anyhow::Result<()> {
+ let working_dir = match &cli.working_dir {
+ Some(dir) => PathBuf::from(dir),
+ None => std::env::current_dir()?,
+ };
+
+ // Determine which file to validate
+ let file = match &args.file {
+ Some(f) => PathBuf::from(f),
+ None => working_dir.join("composer.json"),
+ };
+
+ // Check file exists
+ if !file.exists() {
+ eprintln!(
+ "{}",
+ crate::console::error(&format!("{} not found.", file.display()))
+ );
+ std::process::exit(3);
+ }
+
+ // Read file content
+ let content = match std::fs::read_to_string(&file) {
+ Ok(c) => c,
+ Err(_) => {
+ eprintln!(
+ "{}",
+ crate::console::error(&format!("{} is not readable.", file.display()))
+ );
+ std::process::exit(3);
+ }
+ };
+
+ // Parse JSON syntax
+ let json_value: serde_json::Value = match serde_json::from_str(&content) {
+ Ok(v) => v,
+ Err(e) => {
+ eprintln!(
+ "{}",
+ crate::console::error(&format!("{} does not contain valid JSON", file.display()))
+ );
+ eprintln!("{e}");
+ std::process::exit(2);
+ }
+ };
+
+ // Run manifest validations
+ let mut result = ValidationResult::new();
+ validate_manifest(&json_value, args, &mut result);
+
+ // Check lock file freshness
+ let mut lock_errors: Vec<String> = Vec::new();
+ let check_lock = !args.no_check_lock || args.check_lock;
+ if check_lock {
+ check_lock_freshness(&content, &file, &mut lock_errors);
+ }
+
+ // Output results
+ let check_publish = !args.no_check_publish;
+ output_result(&file, &result, check_publish, check_lock, &lock_errors);
+
+ // Stub for --with-dependencies
+ if args.with_dependencies {
+ eprintln!("The --with-dependencies option is not yet implemented");
+ }
+
+ let exit_code = compute_exit_code(
+ &result,
+ &lock_errors,
+ check_publish,
+ check_lock,
+ args.strict,
+ );
+ if exit_code != 0 {
+ std::process::exit(exit_code);
+ }
+
+ Ok(())
+}
+
+// ─── Manifest validation ─────────────────────────────────────────────────────
+
+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);
+}
+
+// ─── Individual checks ───────────────────────────────────────────────────────
+
+/// 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.to_lowercase();
+ 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()
+ && !crate::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: warn if absent.
+fn check_license(obj: &serde_json::Map<String, serde_json::Value>, result: &mut ValidationResult) {
+ if obj.get("license").is_none() {
+ result.warnings.push(
+ "No license specified, it is recommended to do so. \
+ For closed-source software you may use \"proprietary\" as license."
+ .to_string(),
+ );
+ }
+}
+
+/// 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())
+ && !crate::validation::validate_stability(stability)
+ {
+ result.errors.push(format!(
+ "The minimum-stability \"{stability}\" is invalid. \
+ Must be one of: dev, alpha, beta, rc, stable."
+ ));
+ }
+}
+
+// ─── Lock file freshness ─────────────────────────────────────────────────────
+
+fn check_lock_freshness(
+ composer_json_content: &str,
+ composer_json_path: &Path,
+ lock_errors: &mut Vec<String>,
+) {
+ let lock_path = composer_json_path
+ .parent()
+ .unwrap_or(Path::new("."))
+ .join("composer.lock");
+
+ if !lock_path.exists() {
+ // No lock file is not an error for validate — it's optional
+ return;
+ }
+
+ match crate::lockfile::LockFile::read_from_file(&lock_path) {
+ Ok(lock) => {
+ if !lock.is_fresh(composer_json_content) {
+ lock_errors.push(
+ "- The lock file is not up to date with the latest changes in composer.json, \
+ it is recommended that you run `mozart update` or `mozart update <package name>`."
+ .to_string(),
+ );
+ }
+ }
+ Err(e) => {
+ lock_errors.push(format!("- The lock file could not be read: {e}"));
+ }
+ }
+}
+
+// ─── Output ──────────────────────────────────────────────────────────────────
+
+fn output_result(
+ file: &Path,
+ result: &ValidationResult,
+ check_publish: bool,
+ check_lock: bool,
+ lock_errors: &[String],
+) {
+ let name = file.display().to_string();
+
+ // Print header message
+ if result.has_errors() {
+ eprintln!(
+ "{}",
+ crate::console::error(&format!(
+ "{name} is invalid, the following errors/warnings were found:"
+ ))
+ );
+ } else if result.has_publish_errors() && check_publish {
+ eprintln!(
+ "{}",
+ crate::console::info(&format!(
+ "{name} is valid for simple usage with Composer but has"
+ ))
+ );
+ eprintln!(
+ "{}",
+ crate::console::info("strict errors that make it unable to be published as a package")
+ );
+ eprintln!(
+ "{}",
+ crate::console::warning(
+ "See https://getcomposer.org/doc/04-schema.md for details on the schema"
+ )
+ );
+ } else if result.has_warnings() {
+ eprintln!(
+ "{}",
+ crate::console::info(&format!("{name} is valid, but with a few warnings"))
+ );
+ eprintln!(
+ "{}",
+ crate::console::warning(
+ "See https://getcomposer.org/doc/04-schema.md for details on the schema"
+ )
+ );
+ } else if !lock_errors.is_empty() {
+ let kind = if check_lock { "errors" } else { "warnings" };
+ println!(
+ "{}",
+ crate::console::info(&format!(
+ "{name} is valid but your composer.lock has some {kind}"
+ ))
+ );
+ } else {
+ println!("{}", crate::console::info(&format!("{name} is valid")));
+ }
+
+ // Collect error and warning message lines
+ let mut all_errors: Vec<String> = Vec::new();
+ let mut all_warnings: Vec<String> = Vec::new();
+
+ if !result.errors.is_empty() {
+ all_errors.push("# General errors".to_string());
+ for e in &result.errors {
+ all_errors.push(format!("- {e}"));
+ }
+ }
+
+ if !result.warnings.is_empty() {
+ all_warnings.push("# General warnings".to_string());
+ for w in &result.warnings {
+ all_warnings.push(format!("- {w}"));
+ }
+ }
+
+ // Publish errors: shown as errors if check_publish is true
+ if check_publish && !result.publish_errors.is_empty() {
+ all_errors.push("# Publish errors".to_string());
+ for e in &result.publish_errors {
+ all_errors.push(format!("- {e}"));
+ }
+ }
+
+ // Lock errors: shown as errors or warnings depending on check_lock
+ if !lock_errors.is_empty() {
+ if check_lock {
+ all_errors.push("# Lock file errors".to_string());
+ all_errors.extend_from_slice(lock_errors);
+ } else {
+ all_warnings.push("# Lock file warnings".to_string());
+ all_warnings.extend_from_slice(lock_errors);
+ }
+ }
+
+ // Print errors
+ for msg in &all_errors {
+ if msg.starts_with('#') {
+ eprintln!("{}", crate::console::error(msg));
+ } else {
+ eprintln!("{msg}");
+ }
+ }
+
+ // Print warnings
+ for msg in &all_warnings {
+ if msg.starts_with('#') {
+ eprintln!("{}", crate::console::warning(msg));
+ } else {
+ eprintln!("{msg}");
+ }
+ }
+}
+
+// ─── Exit code ───────────────────────────────────────────────────────────────
+
+/// Compute the exit code following Composer's convention:
+/// 0 = valid, 1 = warnings (only with --strict), 2 = errors, 3 = file unreadable (handled earlier)
+fn compute_exit_code(
+ result: &ValidationResult,
+ lock_errors: &[String],
+ check_publish: bool,
+ check_lock: bool,
+ strict: bool,
+) -> i32 {
+ let has_errors = result.has_errors()
+ || (check_publish && result.has_publish_errors())
+ || (check_lock && !lock_errors.is_empty());
+
+ if has_errors {
+ return 2;
+ }
+
+ let has_warnings = result.has_warnings() || (!check_lock && !lock_errors.is_empty());
+
+ if strict && has_warnings {
+ return 1;
+ }
+
+ 0
+}
+
+// ─── Tests ───────────────────────────────────────────────────────────────────
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ fn make_args() -> ValidateArgs {
+ ValidateArgs {
+ file: None,
+ no_check_all: false,
+ check_lock: false,
+ no_check_lock: false,
+ no_check_publish: false,
+ no_check_version: false,
+ with_dependencies: false,
+ strict: false,
+ }
+ }
+
+ 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
+ }
+
+ // ── check_name ─────────────────────────────────────────────────────────
+
+ #[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_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"));
+ }
+
+ // ── check_license ──────────────────────────────────────────────────────
+
+ #[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")));
+ }
+
+ // ── check_version_field ────────────────────────────────────────────────
+
+ #[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")));
+ }
+
+ // ── check_package_type ─────────────────────────────────────────────────
+
+ #[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"))
+ );
+ }
+
+ // ── check_require_overlap ──────────────────────────────────────────────
+
+ #[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")));
+ }
+
+ // ── check_provide_replace_overlap ──────────────────────────────────────
+
+ #[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"))
+ );
+ }
+
+ // ── check_commit_references ────────────────────────────────────────────
+
+ #[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")));
+ }
+
+ // ── check_empty_psr_prefixes ───────────────────────────────────────────
+
+ #[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")));
+ }
+
+ // ── check_minimum_stability ────────────────────────────────────────────
+
+ #[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"
+ );
+ }
+ }
+
+ // ── validate_manifest with non-object ──────────────────────────────────
+
+ #[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")));
+ }
+
+ // ── compute_exit_code ─────────────────────────────────────────────────
+
+ #[test]
+ fn test_compute_exit_code_no_issues() {
+ let result = ValidationResult::new();
+ assert_eq!(compute_exit_code(&result, &[], true, true, false), 0);
+ }
+
+ #[test]
+ fn test_compute_exit_code_errors() {
+ let mut result = ValidationResult::new();
+ result.errors.push("some error".to_string());
+ assert_eq!(compute_exit_code(&result, &[], true, true, false), 2);
+ }
+
+ #[test]
+ fn test_compute_exit_code_publish_errors_counted() {
+ let mut result = ValidationResult::new();
+ result.publish_errors.push("publish error".to_string());
+ assert_eq!(compute_exit_code(&result, &[], true, true, false), 2);
+ }
+
+ #[test]
+ fn test_compute_exit_code_publish_errors_not_checked() {
+ let mut result = ValidationResult::new();
+ result.publish_errors.push("publish error".to_string());
+ // check_publish = false → publish errors don't count
+ assert_eq!(compute_exit_code(&result, &[], false, true, false), 0);
+ }
+
+ #[test]
+ fn test_compute_exit_code_lock_errors_counted() {
+ let result = ValidationResult::new();
+ let lock_errors = vec!["lock stale".to_string()];
+ assert_eq!(
+ compute_exit_code(&result, &lock_errors, true, true, false),
+ 2
+ );
+ }
+
+ #[test]
+ fn test_compute_exit_code_lock_errors_not_checked() {
+ let result = ValidationResult::new();
+ let lock_errors = vec!["lock stale".to_string()];
+ // check_lock = false → lock errors become warnings, not counted unless strict
+ assert_eq!(
+ compute_exit_code(&result, &lock_errors, true, false, false),
+ 0
+ );
+ }
+
+ #[test]
+ fn test_compute_exit_code_strict_warnings() {
+ let mut result = ValidationResult::new();
+ result.warnings.push("some warning".to_string());
+ assert_eq!(compute_exit_code(&result, &[], true, true, true), 1);
+ }
+
+ #[test]
+ fn test_compute_exit_code_warnings_not_strict() {
+ let mut result = ValidationResult::new();
+ result.warnings.push("some warning".to_string());
+ assert_eq!(compute_exit_code(&result, &[], true, true, false), 0);
+ }
+
+ // ── check_lock_freshness ───────────────────────────────────────────────
+
+ #[test]
+ fn test_check_lock_freshness_no_lock_file() {
+ use tempfile::tempdir;
+ let dir = tempdir().unwrap();
+ let composer_json_path = dir.path().join("composer.json");
+ let content = r#"{"name": "vendor/pkg", "require": {}}"#;
+ std::fs::write(&composer_json_path, content).unwrap();
+
+ let mut lock_errors: Vec<String> = Vec::new();
+ check_lock_freshness(content, &composer_json_path, &mut lock_errors);
+ // No lock file → no errors
+ assert!(lock_errors.is_empty());
+ }
+
+ #[test]
+ fn test_check_lock_freshness_fresh_lock() {
+ use crate::lockfile::LockFile;
+ use tempfile::tempdir;
+
+ let dir = tempdir().unwrap();
+ let composer_json_path = dir.path().join("composer.json");
+ let content = r#"{"name": "vendor/pkg", "require": {"php": ">=8.1"}}"#;
+ std::fs::write(&composer_json_path, content).unwrap();
+
+ let hash = LockFile::compute_content_hash(content).unwrap();
+ let lock = LockFile {
+ readme: LockFile::default_readme(),
+ content_hash: hash,
+ packages: vec![],
+ packages_dev: Some(vec![]),
+ 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,
+ };
+ let lock_path = dir.path().join("composer.lock");
+ lock.write_to_file(&lock_path).unwrap();
+
+ let mut lock_errors: Vec<String> = Vec::new();
+ check_lock_freshness(content, &composer_json_path, &mut lock_errors);
+ assert!(
+ lock_errors.is_empty(),
+ "fresh lock should produce no errors"
+ );
+ }
+
+ #[test]
+ fn test_check_lock_freshness_stale_lock() {
+ use crate::lockfile::LockFile;
+ use tempfile::tempdir;
+
+ let dir = tempdir().unwrap();
+ let composer_json_path = dir.path().join("composer.json");
+ let original_content = r#"{"name": "vendor/pkg", "require": {"php": ">=8.1"}}"#;
+ let modified_content = r#"{"name": "vendor/pkg", "require": {"php": ">=8.2"}}"#;
+
+ // Write original content
+ std::fs::write(&composer_json_path, original_content).unwrap();
+
+ // Create lock file based on original content
+ let hash = LockFile::compute_content_hash(original_content).unwrap();
+ let lock = LockFile {
+ readme: LockFile::default_readme(),
+ content_hash: hash,
+ packages: vec![],
+ packages_dev: Some(vec![]),
+ 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,
+ };
+ let lock_path = dir.path().join("composer.lock");
+ lock.write_to_file(&lock_path).unwrap();
+
+ // Now check against modified content (lock is stale)
+ let mut lock_errors: Vec<String> = Vec::new();
+ check_lock_freshness(modified_content, &composer_json_path, &mut lock_errors);
+ assert!(
+ !lock_errors.is_empty(),
+ "stale lock should produce a lock error"
+ );
+ assert!(lock_errors[0].contains("not up to date"));
+ }
+
+ // ── Full manifest: valid package ───────────────────────────────────────
+
+ #[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"
+ );
+ }
}