diff options
39 files changed, 1195 insertions, 414 deletions
diff --git a/crates/mozart/src/archiver.rs b/crates/mozart/src/archiver.rs index 2deb96f..57985ef 100644 --- a/crates/mozart/src/archiver.rs +++ b/crates/mozart/src/archiver.rs @@ -974,7 +974,12 @@ mod tests { no_ansi: false, }; - execute(&args, &cli).unwrap(); + let console = crate::console::Console { + interactive: false, + verbosity: crate::console::Verbosity::Normal, + decorated: false, + }; + execute(&args, &cli, &console).unwrap(); let archive_path = out.path().join("test-archive.tar"); assert!(archive_path.exists(), "tar archive was not created"); @@ -1038,7 +1043,12 @@ mod tests { no_ansi: false, }; - execute(&args, &cli).unwrap(); + let console = crate::console::Console { + interactive: false, + verbosity: crate::console::Verbosity::Normal, + decorated: false, + }; + execute(&args, &cli, &console).unwrap(); let archive_path = out.path().join("test-archive.zip"); assert!(archive_path.exists(), "zip archive was not created"); @@ -1088,7 +1098,12 @@ mod tests { no_ansi: false, }; - execute(&args, &cli).unwrap(); + let console = crate::console::Console { + interactive: false, + verbosity: crate::console::Verbosity::Normal, + decorated: false, + }; + execute(&args, &cli, &console).unwrap(); assert!(custom_out.path().join("custom.tar").exists()); } @@ -1137,7 +1152,12 @@ mod tests { no_ansi: false, }; - execute(&args, &cli).unwrap(); + let console = crate::console::Console { + interactive: false, + verbosity: crate::console::Verbosity::Normal, + decorated: false, + }; + execute(&args, &cli, &console).unwrap(); assert!(out.path().join("my-custom-name.tar").exists()); } @@ -1190,7 +1210,12 @@ mod tests { no_ansi: false, }; - execute(&args, &cli).unwrap(); + let console = crate::console::Console { + interactive: false, + verbosity: crate::console::Verbosity::Normal, + decorated: false, + }; + execute(&args, &cli, &console).unwrap(); let tar_path = out.path().join("filtered.tar"); assert!(tar_path.exists()); @@ -1255,7 +1280,12 @@ mod tests { no_ansi: false, }; - execute(&args, &cli).unwrap(); + let console = crate::console::Console { + interactive: false, + verbosity: crate::console::Verbosity::Normal, + decorated: false, + }; + execute(&args, &cli, &console).unwrap(); let tar_path = out.path().join("with-excludes.tar"); assert!(tar_path.exists()); @@ -1321,7 +1351,12 @@ mod tests { no_ansi: false, }; - execute(&args, &cli).unwrap(); + let console = crate::console::Console { + interactive: false, + verbosity: crate::console::Verbosity::Normal, + decorated: false, + }; + execute(&args, &cli, &console).unwrap(); let tar_path = out.path().join("unfiltered.tar"); assert!(tar_path.exists()); @@ -1383,7 +1418,12 @@ mod tests { no_ansi: false, }; - let result = execute(&args, &cli); + let console = crate::console::Console { + interactive: false, + verbosity: crate::console::Verbosity::Normal, + decorated: false, + }; + let result = execute(&args, &cli, &console); assert!(result.is_err()); assert!(result.unwrap_err().to_string().contains("rar")); } diff --git a/crates/mozart/src/commands.rs b/crates/mozart/src/commands.rs index 83ff7b8..694b4e1 100644 --- a/crates/mozart/src/commands.rs +++ b/crates/mozart/src/commands.rs @@ -194,38 +194,39 @@ pub enum Commands { } pub fn execute(cli: &Cli) -> anyhow::Result<()> { + let console = crate::console::Console::from_cli(cli); match &cli.command { - Commands::About(args) => about::execute(args, cli), - Commands::Archive(args) => archive::execute(args, cli), - Commands::Audit(args) => audit::execute(args, cli), - Commands::Browse(args) => browse::execute(args, cli), - Commands::Bump(args) => bump::execute(args, cli), - Commands::CheckPlatformReqs(args) => check_platform_reqs::execute(args, cli), - Commands::ClearCache(args) => clear_cache::execute(args, cli), - Commands::Config(args) => config::execute(args, cli), - Commands::CreateProject(args) => create_project::execute(args, cli), - Commands::Depends(args) => depends::execute(args, cli), - Commands::Diagnose(args) => diagnose::execute(args, cli), - Commands::DumpAutoload(args) => dump_autoload::execute(args, cli), - Commands::Exec(args) => exec::execute(args, cli), - Commands::Fund(args) => fund::execute(args, cli), - Commands::Global(args) => global::execute(args, cli), - Commands::Init(args) => init::execute(args, cli), - Commands::Install(args) => install::execute(args, cli), - Commands::Licenses(args) => licenses::execute(args, cli), - Commands::Outdated(args) => outdated::execute(args, cli), - Commands::Prohibits(args) => prohibits::execute(args, cli), - Commands::Reinstall(args) => reinstall::execute(args, cli), - Commands::Remove(args) => remove::execute(args, cli), - Commands::Repository(args) => repository::execute(args, cli), - Commands::Require(args) => require::execute(args, cli), - Commands::RunScript(args) => run_script::execute(args, cli), - Commands::Search(args) => search::execute(args, cli), - Commands::SelfUpdate(args) => self_update::execute(args, cli), - Commands::Show(args) => show::execute(args, cli), - Commands::Status(args) => status::execute(args, cli), - Commands::Suggests(args) => suggests::execute(args, cli), - Commands::Update(args) => update::execute(args, cli), - Commands::Validate(args) => validate::execute(args, cli), + Commands::About(args) => about::execute(args, cli, &console), + Commands::Archive(args) => archive::execute(args, cli, &console), + Commands::Audit(args) => audit::execute(args, cli, &console), + Commands::Browse(args) => browse::execute(args, cli, &console), + Commands::Bump(args) => bump::execute(args, cli, &console), + Commands::CheckPlatformReqs(args) => check_platform_reqs::execute(args, cli, &console), + Commands::ClearCache(args) => clear_cache::execute(args, cli, &console), + Commands::Config(args) => config::execute(args, cli, &console), + Commands::CreateProject(args) => create_project::execute(args, cli, &console), + Commands::Depends(args) => depends::execute(args, cli, &console), + Commands::Diagnose(args) => diagnose::execute(args, cli, &console), + Commands::DumpAutoload(args) => dump_autoload::execute(args, cli, &console), + Commands::Exec(args) => exec::execute(args, cli, &console), + Commands::Fund(args) => fund::execute(args, cli, &console), + Commands::Global(args) => global::execute(args, cli, &console), + Commands::Init(args) => init::execute(args, cli, &console), + Commands::Install(args) => install::execute(args, cli, &console), + Commands::Licenses(args) => licenses::execute(args, cli, &console), + Commands::Outdated(args) => outdated::execute(args, cli, &console), + Commands::Prohibits(args) => prohibits::execute(args, cli, &console), + Commands::Reinstall(args) => reinstall::execute(args, cli, &console), + Commands::Remove(args) => remove::execute(args, cli, &console), + Commands::Repository(args) => repository::execute(args, cli, &console), + Commands::Require(args) => require::execute(args, cli, &console), + Commands::RunScript(args) => run_script::execute(args, cli, &console), + Commands::Search(args) => search::execute(args, cli, &console), + Commands::SelfUpdate(args) => self_update::execute(args, cli, &console), + Commands::Show(args) => show::execute(args, cli, &console), + Commands::Status(args) => status::execute(args, cli, &console), + Commands::Suggests(args) => suggests::execute(args, cli, &console), + Commands::Update(args) => update::execute(args, cli, &console), + Commands::Validate(args) => validate::execute(args, cli, &console), } } diff --git a/crates/mozart/src/commands/about.rs b/crates/mozart/src/commands/about.rs index 4b12c08..d60aecf 100644 --- a/crates/mozart/src/commands/about.rs +++ b/crates/mozart/src/commands/about.rs @@ -4,7 +4,11 @@ use clap::Args; #[derive(Args)] pub struct AboutArgs {} -pub fn execute(_args: &AboutArgs, _cli: &super::Cli) -> anyhow::Result<()> { +pub fn execute( + _args: &AboutArgs, + _cli: &super::Cli, + _console: &console::Console, +) -> anyhow::Result<()> { let version = env!("CARGO_PKG_VERSION"); println!( "{}", diff --git a/crates/mozart/src/commands/archive.rs b/crates/mozart/src/commands/archive.rs index 93a7558..9be45e9 100644 --- a/crates/mozart/src/commands/archive.rs +++ b/crates/mozart/src/commands/archive.rs @@ -82,7 +82,11 @@ impl Drop for PackageMeta { // ─── Main entry point ───────────────────────────────────────────────────────── -pub fn execute(args: &ArchiveArgs, cli: &super::Cli) -> anyhow::Result<()> { +pub fn execute( + args: &ArchiveArgs, + cli: &super::Cli, + console: &crate::console::Console, +) -> anyhow::Result<()> { use crate::archiver::{ ArchiveFormat, collect_archivable_files, create_archive, generate_archive_filename, parse_composer_excludes, parse_gitattributes, parse_gitignore_pattern, @@ -216,7 +220,10 @@ pub fn execute(args: &ArchiveArgs, cli: &super::Cli) -> anyhow::Result<()> { // 9. Create archive let target_path = output_dir.join(format!("{}.{}", filename_base, format.extension())); - eprintln!("Creating the archive into \"{}\".", output_dir.display()); + console.info(&format!( + "Creating the archive into \"{}\".", + output_dir.display() + )); create_archive(&meta.source_dir, &files, &target_path, &format)?; // Print relative path if possible diff --git a/crates/mozart/src/commands/audit.rs b/crates/mozart/src/commands/audit.rs index 3791157..3e69bb3 100644 --- a/crates/mozart/src/commands/audit.rs +++ b/crates/mozart/src/commands/audit.rs @@ -70,7 +70,11 @@ struct AuditResult { // ─── Main entry point ───────────────────────────────────────────────────────── -pub fn execute(args: &AuditArgs, cli: &super::Cli) -> anyhow::Result<()> { +pub fn execute( + args: &AuditArgs, + cli: &super::Cli, + _console: &crate::console::Console, +) -> anyhow::Result<()> { // Validate format let format = args.format.as_str(); if format != "table" && format != "plain" && format != "json" && format != "summary" { diff --git a/crates/mozart/src/commands/browse.rs b/crates/mozart/src/commands/browse.rs index d17c6d0..0a89ae7 100644 --- a/crates/mozart/src/commands/browse.rs +++ b/crates/mozart/src/commands/browse.rs @@ -18,7 +18,11 @@ pub struct BrowseArgs { // ─── Main entry point ──────────────────────────────────────────────────────── -pub fn execute(args: &BrowseArgs, cli: &super::Cli) -> anyhow::Result<()> { +pub fn execute( + args: &BrowseArgs, + cli: &super::Cli, + console: &crate::console::Console, +) -> anyhow::Result<()> { let working_dir = match &cli.working_dir { Some(dir) => PathBuf::from(dir), None => std::env::current_dir()?, @@ -46,21 +50,18 @@ pub fn execute(args: &BrowseArgs, cli: &super::Cli) -> anyhow::Result<()> { if args.show { println!("{}", url); } else { - println!( - "{}", - crate::console::info(&format!("Opening {} in browser.", url)) - ); + console.info(&format!("Opening {} in browser.", url)); open_browser(&url)?; } } None => { - eprintln!( + console.info(&format!( "{}", crate::console::warning(&format!( "No URL found for package \"{}\".", package_name )) - ); + )); exit_code = 1; } } diff --git a/crates/mozart/src/commands/bump.rs b/crates/mozart/src/commands/bump.rs index b27eec6..4c37dd6 100644 --- a/crates/mozart/src/commands/bump.rs +++ b/crates/mozart/src/commands/bump.rs @@ -22,7 +22,11 @@ pub struct BumpArgs { // ─── Main entry point ───────────────────────────────────────────────────────── -pub fn execute(args: &BumpArgs, cli: &super::Cli) -> anyhow::Result<()> { +pub fn execute( + args: &BumpArgs, + cli: &super::Cli, + console: &crate::console::Console, +) -> anyhow::Result<()> { let working_dir = match &cli.working_dir { Some(dir) => PathBuf::from(dir), None => std::env::current_dir()?, @@ -46,13 +50,13 @@ pub fn execute(args: &BumpArgs, cli: &super::Cli) -> anyhow::Result<()> { if let Some(ref pkg_type) = root.package_type && pkg_type != "project" { - eprintln!( + console.info(&format!( "{}", crate::console::warning(&format!( "Warning: Bumping constraints for a non-project package (type=\"{pkg_type}\"). \ Libraries should not pin their dependencies." )) - ); + )); } // Check lock file existence @@ -65,14 +69,11 @@ pub fn execute(args: &BumpArgs, cli: &super::Cli) -> anyhow::Result<()> { // Check lock file freshness if !lock.is_fresh(&composer_json_content) { - eprintln!( - "{}", - crate::console::error( - "composer.lock is not up to date with composer.json. \ - Run `mozart install` or `mozart update` to refresh it." - ) - ); - std::process::exit(2); + return Err(crate::exit_code::bail( + crate::exit_code::LOCK_FILE_INVALID, + "composer.lock is not up to date with composer.json. \ + Run `mozart install` or `mozart update` to refresh it.", + )); } // Build map: package name (lowercase) → (pretty_version, version_normalized) @@ -343,7 +344,12 @@ mod tests { dry_run: false, }; let cli = make_cli(dir.path()); - execute(&args, &cli).unwrap(); + let console = crate::console::Console { + interactive: false, + verbosity: crate::console::Verbosity::Normal, + decorated: false, + }; + execute(&args, &cli, &console).unwrap(); let updated = std::fs::read_to_string(dir.path().join("composer.json")).unwrap(); let parsed: serde_json::Value = serde_json::from_str(&updated).unwrap(); @@ -374,7 +380,12 @@ mod tests { dry_run: true, }; let cli = make_cli(dir.path()); - execute(&args, &cli).unwrap(); + let console = crate::console::Console { + interactive: false, + verbosity: crate::console::Verbosity::Normal, + decorated: false, + }; + execute(&args, &cli, &console).unwrap(); // composer.json should be unchanged let content = std::fs::read_to_string(dir.path().join("composer.json")).unwrap(); @@ -406,7 +417,12 @@ mod tests { dry_run: false, }; let cli = make_cli(dir.path()); - execute(&args, &cli).unwrap(); + let console = crate::console::Console { + interactive: false, + verbosity: crate::console::Verbosity::Normal, + decorated: false, + }; + execute(&args, &cli, &console).unwrap(); // No changes should be made let content = std::fs::read_to_string(dir.path().join("composer.json")).unwrap(); @@ -444,7 +460,12 @@ mod tests { dry_run: false, }; let cli = make_cli(dir.path()); - execute(&args, &cli).unwrap(); + let console = crate::console::Console { + interactive: false, + verbosity: crate::console::Verbosity::Normal, + decorated: false, + }; + execute(&args, &cli, &console).unwrap(); let content = std::fs::read_to_string(dir.path().join("composer.json")).unwrap(); let parsed: serde_json::Value = serde_json::from_str(&content).unwrap(); @@ -484,7 +505,12 @@ mod tests { dry_run: false, }; let cli = make_cli(dir.path()); - execute(&args, &cli).unwrap(); + let console = crate::console::Console { + interactive: false, + verbosity: crate::console::Verbosity::Normal, + decorated: false, + }; + execute(&args, &cli, &console).unwrap(); let content = std::fs::read_to_string(dir.path().join("composer.json")).unwrap(); let parsed: serde_json::Value = serde_json::from_str(&content).unwrap(); @@ -521,8 +547,8 @@ mod tests { }; let _cli = make_cli(dir.path()); - // The execute function calls std::process::exit(2) for stale lock. - // We can't test that directly, but we can verify the lock IS stale + // The execute function returns Err(MozartError) with LOCK_FILE_INVALID for stale lock. + // We verify the lock IS stale here as a prerequisite check. let lock_loaded = LockFile::read_from_file(&dir.path().join("composer.lock")).unwrap(); assert!(!lock_loaded.is_fresh(composer_json)); } @@ -563,7 +589,12 @@ mod tests { dry_run: false, }; let cli = make_cli(dir.path()); - execute(&args, &cli).unwrap(); + let console = crate::console::Console { + interactive: false, + verbosity: crate::console::Verbosity::Normal, + decorated: false, + }; + execute(&args, &cli, &console).unwrap(); // The lock file content-hash should now match the updated composer.json let updated_composer = std::fs::read_to_string(dir.path().join("composer.json")).unwrap(); @@ -605,7 +636,12 @@ mod tests { dry_run: false, }; let cli = make_cli(dir.path()); - execute(&args, &cli).unwrap(); + let console = crate::console::Console { + interactive: false, + verbosity: crate::console::Verbosity::Normal, + decorated: false, + }; + execute(&args, &cli, &console).unwrap(); let content = std::fs::read_to_string(dir.path().join("composer.json")).unwrap(); let parsed: serde_json::Value = serde_json::from_str(&content).unwrap(); diff --git a/crates/mozart/src/commands/check_platform_reqs.rs b/crates/mozart/src/commands/check_platform_reqs.rs index 358df4a..ad7b860 100644 --- a/crates/mozart/src/commands/check_platform_reqs.rs +++ b/crates/mozart/src/commands/check_platform_reqs.rs @@ -52,7 +52,11 @@ struct CheckResult { // ─── Main entry point ──────────────────────────────────────────────────────── -pub fn execute(args: &CheckPlatformReqsArgs, cli: &super::Cli) -> anyhow::Result<()> { +pub fn execute( + args: &CheckPlatformReqsArgs, + cli: &super::Cli, + _console: &crate::console::Console, +) -> anyhow::Result<()> { let working_dir = match &cli.working_dir { Some(dir) => PathBuf::from(dir), None => std::env::current_dir()?, diff --git a/crates/mozart/src/commands/clear_cache.rs b/crates/mozart/src/commands/clear_cache.rs index 819ca9f..59baff3 100644 --- a/crates/mozart/src/commands/clear_cache.rs +++ b/crates/mozart/src/commands/clear_cache.rs @@ -8,7 +8,11 @@ pub struct ClearCacheArgs { pub gc: bool, } -pub fn execute(args: &ClearCacheArgs, cli: &super::Cli) -> anyhow::Result<()> { +pub fn execute( + args: &ClearCacheArgs, + cli: &super::Cli, + console: &crate::console::Console, +) -> anyhow::Result<()> { let config = build_cache_config(cli); if args.gc { @@ -19,8 +23,8 @@ pub fn execute(args: &ClearCacheArgs, cli: &super::Cli) -> anyhow::Result<()> { repo_cache.gc(config.cache_ttl, u64::MAX)?; files_cache.gc(config.cache_files_ttl, config.cache_files_maxsize)?; - eprintln!("Cache garbage collection complete."); - eprintln!("Cache directory: {}", config.cache_dir.display()); + console.info("Cache garbage collection complete."); + console.info(&format!("Cache directory: {}", config.cache_dir.display())); } else { // Full clear of all cache directories let repo_cache = Cache::repo(&config); @@ -44,8 +48,8 @@ pub fn execute(args: &ClearCacheArgs, cli: &super::Cli) -> anyhow::Result<()> { } } - eprintln!("Cache cleared."); - eprintln!("Cache directory: {}", config.cache_dir.display()); + console.info("Cache cleared."); + console.info(&format!("Cache directory: {}", config.cache_dir.display())); } Ok(()) diff --git a/crates/mozart/src/commands/config.rs b/crates/mozart/src/commands/config.rs index 552dd2f..e875a92 100644 --- a/crates/mozart/src/commands/config.rs +++ b/crates/mozart/src/commands/config.rs @@ -603,7 +603,11 @@ fn render_value(v: &serde_json::Value) -> String { // ─── execute() ─────────────────────────────────────────────────────────────── -pub fn execute(args: &ConfigArgs, cli: &super::Cli) -> anyhow::Result<()> { +pub fn execute( + args: &ConfigArgs, + cli: &super::Cli, + _console: &crate::console::Console, +) -> anyhow::Result<()> { // 1. Handle --editor mode if args.editor { return execute_editor(args, cli); diff --git a/crates/mozart/src/commands/create_project.rs b/crates/mozart/src/commands/create_project.rs index fabf7b4..654eb0e 100644 --- a/crates/mozart/src/commands/create_project.rs +++ b/crates/mozart/src/commands/create_project.rs @@ -170,41 +170,45 @@ fn is_dir_non_empty(path: &Path) -> bool { .unwrap_or(false) } -pub fn execute(args: &CreateProjectArgs, cli: &super::Cli) -> anyhow::Result<()> { +pub fn execute( + args: &CreateProjectArgs, + cli: &super::Cli, + console: &crate::console::Console, +) -> anyhow::Result<()> { // --- Handle deprecated / no-op flags --- if args.prefer_source { - eprintln!( + console.info(&format!( "{}", console::warning("Source installs not yet supported, falling back to dist.") - ); + )); } if args.dev { - eprintln!( + console.info(&format!( "{}", console::warning( "The --dev flag is deprecated. Dev packages are installed by default." ) - ); + )); } if args.no_custom_installers { - eprintln!( + console.info(&format!( "{}", console::warning( "The --no-custom-installers flag is deprecated. Use --no-plugins instead." ) - ); + )); } if !args.repository.is_empty() || args.repository_url.is_some() || args.add_repository { - eprintln!( + console.info(&format!( "{}", console::warning( "Custom repository options (--repository, --repository-url, --add-repository) \ are not yet supported and will be ignored." ) - ); + )); } // --- Step 1: Parse package argument --- @@ -268,11 +272,11 @@ pub fn execute(args: &CreateProjectArgs, cli: &super::Cli) -> anyhow::Result<()> }; // --- Step 4: Fetch package versions and find best match --- - eprintln!( + console.info(&format!( "{}", console::info(&format!("Creating project from package {package_name}")) - ); - eprintln!("Loading composer repositories with package information"); + )); + console.info("Loading composer repositories with package information"); let versions = packagist::fetch_package_versions(&package_name, None)?; @@ -306,10 +310,10 @@ pub fn execute(args: &CreateProjectArgs, cli: &super::Cli) -> anyhow::Result<()> let concrete_version = best.version.clone(); - eprintln!( + console.info(&format!( "{}", console::info(&format!("Installing {package_name} ({concrete_version})")) - ); + )); // --- Step 5: Create target directory and download+extract --- std::fs::create_dir_all(&target_dir)?; @@ -337,10 +341,10 @@ pub fn execute(args: &CreateProjectArgs, cli: &super::Cli) -> anyhow::Result<()> other => anyhow::bail!("Unsupported dist type: {other}"), } - eprintln!( + console.info(&format!( "{}", console::info(&format!("Created project in {}", target_dir.display())) - ); + )); // --- Step 7: VCS removal --- // Remove VCS metadata unless --keep-vcs is set. @@ -354,13 +358,13 @@ pub fn execute(args: &CreateProjectArgs, cli: &super::Cli) -> anyhow::Result<()> let composer_path = target_dir.join("composer.json"); if !composer_path.exists() { - eprintln!( + console.info(&format!( "{}", console::warning(&format!( "No composer.json found in {}. Skipping dependency installation.", target_dir.display() )) - ); + )); return Ok(()); } @@ -372,10 +376,10 @@ pub fn execute(args: &CreateProjectArgs, cli: &super::Cli) -> anyhow::Result<()> // --- Step 6 continued: dependency resolution and install --- if args.no_install { - eprintln!( + console.info(&format!( "{}", console::comment("Skipping dependency installation (--no-install).") - ); + )); return Ok(()); } @@ -416,15 +420,14 @@ pub fn execute(args: &CreateProjectArgs, cli: &super::Cli) -> anyhow::Result<()> repo_cache: None, }; - eprintln!("Resolving dependencies..."); + console.info("Resolving dependencies..."); - let resolved = match resolver::resolve(&request) { - Ok(packages) => packages, - Err(e) => { - eprintln!("{}", console::error(&e.to_string())); - std::process::exit(1); - } - }; + let resolved = resolver::resolve(&request).map_err(|e| { + crate::exit_code::bail( + crate::exit_code::DEPENDENCY_RESOLUTION_FAILED, + e.to_string(), + ) + })?; let composer_json_content = std::fs::read_to_string(&composer_path)?; @@ -444,22 +447,22 @@ pub fn execute(args: &CreateProjectArgs, cli: &super::Cli) -> anyhow::Result<()> .filter(|c| matches!(c.kind, super::update::ChangeKind::Install { .. })) .collect(); - eprintln!( + console.info(&format!( "{}", console::info(&format!( "Package operations: {} install{}, 0 updates, 0 removals", installs.len(), if installs.len() == 1 { "" } else { "s" }, )) - ); + )); for change in &changes { if let super::update::ChangeKind::Install { new_version } = &change.kind { - eprintln!(" - Installing {} ({})", change.name, new_version); + console.info(&format!(" - Installing {} ({})", change.name, new_version)); } } - eprintln!("Writing lock file"); + console.info("Writing lock file"); let lock_path = target_dir.join("composer.lock"); new_lock.write_to_file(&lock_path)?; @@ -473,10 +476,10 @@ pub fn execute(args: &CreateProjectArgs, cli: &super::Cli) -> anyhow::Result<()> .map(|s| s.eq_ignore_ascii_case("source")) .unwrap_or(false); if prefer_source { - eprintln!( + console.info(&format!( "{}", console::warning("Source installs are not yet supported. Falling back to dist.") - ); + )); } super::install::install_from_lock( diff --git a/crates/mozart/src/commands/depends.rs b/crates/mozart/src/commands/depends.rs index fa84f7d..80e70f1 100644 --- a/crates/mozart/src/commands/depends.rs +++ b/crates/mozart/src/commands/depends.rs @@ -19,7 +19,11 @@ pub struct DependsArgs { pub locked: bool, } -pub fn execute(args: &DependsArgs, cli: &super::Cli) -> anyhow::Result<()> { +pub fn execute( + args: &DependsArgs, + cli: &super::Cli, + _console: &crate::console::Console, +) -> anyhow::Result<()> { let working_dir = match &cli.working_dir { Some(dir) => PathBuf::from(dir), None => std::env::current_dir()?, diff --git a/crates/mozart/src/commands/diagnose.rs b/crates/mozart/src/commands/diagnose.rs index 51f2127..199ed60 100644 --- a/crates/mozart/src/commands/diagnose.rs +++ b/crates/mozart/src/commands/diagnose.rs @@ -371,7 +371,11 @@ fn check_cache_dir(cache_dir: &Path) -> CheckResult { // ─── Main execute function ───────────────────────────────────────────────────── -pub fn execute(_args: &DiagnoseArgs, cli: &super::Cli) -> anyhow::Result<()> { +pub fn execute( + _args: &DiagnoseArgs, + cli: &super::Cli, + _console: &crate::console::Console, +) -> anyhow::Result<()> { let working_dir = match &cli.working_dir { Some(dir) => PathBuf::from(dir), None => std::env::current_dir()?, diff --git a/crates/mozart/src/commands/dump_autoload.rs b/crates/mozart/src/commands/dump_autoload.rs index 7d5b748..6f38ab9 100644 --- a/crates/mozart/src/commands/dump_autoload.rs +++ b/crates/mozart/src/commands/dump_autoload.rs @@ -48,7 +48,11 @@ pub struct DumpAutoloadArgs { pub strict_ambiguous: bool, } -pub fn execute(args: &DumpAutoloadArgs, cli: &super::Cli) -> anyhow::Result<()> { +pub fn execute( + args: &DumpAutoloadArgs, + cli: &super::Cli, + console: &crate::console::Console, +) -> anyhow::Result<()> { let working_dir = match &cli.working_dir { Some(dir) => PathBuf::from(dir), None => std::env::current_dir()?, @@ -61,7 +65,7 @@ pub fn execute(args: &DumpAutoloadArgs, cli: &super::Cli) -> anyhow::Result<()> let suffix = crate::autoload::determine_suffix(&working_dir, &vendor_dir)?; if args.dry_run { - eprintln!("Dry run: would generate autoload files"); + console.info("Dry run: would generate autoload files"); return Ok(()); } @@ -79,7 +83,7 @@ pub fn execute(args: &DumpAutoloadArgs, cli: &super::Cli) -> anyhow::Result<()> ignore_platform_reqs: args.ignore_platform_reqs, })?; - eprintln!("Generated autoload files"); + console.info("Generated autoload files"); Ok(()) } diff --git a/crates/mozart/src/commands/exec.rs b/crates/mozart/src/commands/exec.rs index 9ef1624..32781a5 100644 --- a/crates/mozart/src/commands/exec.rs +++ b/crates/mozart/src/commands/exec.rs @@ -17,7 +17,11 @@ pub struct ExecArgs { // ─── Main entry point ──────────────────────────────────────────────────────── -pub fn execute(args: &ExecArgs, cli: &super::Cli) -> anyhow::Result<()> { +pub fn execute( + args: &ExecArgs, + cli: &super::Cli, + _console: &crate::console::Console, +) -> anyhow::Result<()> { let working_dir = match &cli.working_dir { Some(dir) => PathBuf::from(dir), None => std::env::current_dir()?, diff --git a/crates/mozart/src/commands/fund.rs b/crates/mozart/src/commands/fund.rs index 19e4825..e8a42f5 100644 --- a/crates/mozart/src/commands/fund.rs +++ b/crates/mozart/src/commands/fund.rs @@ -23,7 +23,11 @@ struct FundingEntry { // ─── Main entry point ─────────────────────────────────────────────────────── -pub fn execute(args: &FundArgs, cli: &super::Cli) -> anyhow::Result<()> { +pub fn execute( + args: &FundArgs, + cli: &super::Cli, + _console: &crate::console::Console, +) -> anyhow::Result<()> { let working_dir = match &cli.working_dir { Some(dir) => PathBuf::from(dir), None => std::env::current_dir()?, diff --git a/crates/mozart/src/commands/global.rs b/crates/mozart/src/commands/global.rs index 42f3703..e6e7bc8 100644 --- a/crates/mozart/src/commands/global.rs +++ b/crates/mozart/src/commands/global.rs @@ -13,7 +13,11 @@ pub struct GlobalArgs { // ─── Main entry point ──────────────────────────────────────────────────────── -pub fn execute(args: &GlobalArgs, cli: &super::Cli) -> anyhow::Result<()> { +pub fn execute( + args: &GlobalArgs, + cli: &super::Cli, + console: &crate::console::Console, +) -> anyhow::Result<()> { use clap::Parser as _; use std::fs; @@ -21,9 +25,7 @@ pub fn execute(args: &GlobalArgs, cli: &super::Cli) -> anyhow::Result<()> { fs::create_dir_all(&home)?; - if !cli.quiet { - eprintln!("Changed current directory to {}", home.display()); - } + console.info(&format!("Changed current directory to {}", home.display())); // SAFETY: single-threaded at this point; no concurrent env access unsafe { diff --git a/crates/mozart/src/commands/init.rs b/crates/mozart/src/commands/init.rs index fb7520f..be104c6 100644 --- a/crates/mozart/src/commands/init.rs +++ b/crates/mozart/src/commands/init.rs @@ -55,9 +55,11 @@ pub struct InitArgs { pub autoload: Option<String>, } -pub fn execute(args: &InitArgs, cli: &super::Cli) -> anyhow::Result<()> { - let console = console::Console::new(cli.no_interaction, cli.quiet); - +pub fn execute( + args: &InitArgs, + cli: &super::Cli, + console: &console::Console, +) -> anyhow::Result<()> { let working_dir = match &cli.working_dir { Some(dir) => PathBuf::from(dir), None => std::env::current_dir().context("Failed to get current directory")?, @@ -78,7 +80,7 @@ pub fn execute(args: &InitArgs, cli: &super::Cli) -> anyhow::Result<()> { } let composer = if console.interactive { - build_interactive(args, &console, &working_dir)? + build_interactive(args, console, &working_dir)? } else { build_non_interactive(args, &working_dir)? }; diff --git a/crates/mozart/src/commands/install.rs b/crates/mozart/src/commands/install.rs index b5f9142..fb7335b 100644 --- a/crates/mozart/src/commands/install.rs +++ b/crates/mozart/src/commands/install.rs @@ -496,58 +496,51 @@ pub fn install_from_lock( Ok(()) } -pub fn execute(args: &InstallArgs, cli: &super::Cli) -> anyhow::Result<()> { +pub fn execute( + args: &InstallArgs, + cli: &super::Cli, + console: &crate::console::Console, +) -> anyhow::Result<()> { // Step 1: Resolve the working directory let working_dir = resolve_working_dir(cli); // Step 2: Validate arguments if !args.packages.is_empty() { let pkgs = args.packages.join(" "); - eprintln!( - "{}", - console::error(&format!( + return Err(crate::exit_code::bail( + crate::exit_code::GENERAL_ERROR, + format!( "Invalid argument {pkgs}. Use \"mozart require {pkgs}\" instead to add packages to your composer.json." - )) - ); - std::process::exit(1); + ), + )); } if args.no_install { - eprintln!( - "{}", - console::error( - "Invalid option \"--no-install\". Use \"mozart update --no-install\" instead if you are trying to update the composer.lock file." - ) - ); - std::process::exit(1); + return Err(crate::exit_code::bail( + crate::exit_code::GENERAL_ERROR, + "Invalid option \"--no-install\". Use \"mozart update --no-install\" instead if you are trying to update the composer.lock file.", + )); } if args.dev { - eprintln!( - "{}", - console::warning( - "The --dev option is deprecated. Dev packages are installed by default." - ) - ); + console.info(&console::warning( + "The --dev option is deprecated. Dev packages are installed by default.", + )); } if args.no_suggest { - eprintln!( - "{}", - console::warning("The --no-suggest option is deprecated and has no effect.") - ); + console.info(&console::warning( + "The --no-suggest option is deprecated and has no effect.", + )); } // Step 3: Read composer.lock let lock_path = working_dir.join("composer.lock"); if !lock_path.exists() { - eprintln!( - "{}", - console::warning( - "No composer.lock file present. Run \"mozart update\" to generate one." - ) - ); - std::process::exit(1); + return Err(crate::exit_code::bail( + crate::exit_code::LOCK_FILE_INVALID, + "No composer.lock file present. Run \"mozart update\" to generate one.", + )); } let lock = lockfile::LockFile::read_from_file(&lock_path)?; @@ -556,12 +549,9 @@ pub fn execute(args: &InstallArgs, cli: &super::Cli) -> anyhow::Result<()> { if composer_json_path.exists() { let content = std::fs::read_to_string(&composer_json_path)?; if !lock.is_fresh(&content) { - eprintln!( - "{}", - console::warning( - "Warning: The lock file is not up to date with the latest changes in composer.json. You may be getting outdated dependencies. It is recommended that you run `mozart update`." - ) - ); + console.info(&console::warning( + "Warning: The lock file is not up to date with the latest changes in composer.json. You may be getting outdated dependencies. It is recommended that you run `mozart update`." + )); } } @@ -573,12 +563,9 @@ pub fn execute(args: &InstallArgs, cli: &super::Cli) -> anyhow::Result<()> { .map(|s| s.eq_ignore_ascii_case("source")) .unwrap_or(false); if prefer_source { - eprintln!( - "{}", - console::warning( - "Warning: Source installs are not yet supported. Falling back to dist." - ) - ); + console.info(&console::warning( + "Warning: Source installs are not yet supported. Falling back to dist.", + )); } // Step 6: Determine dev mode and vendor directory diff --git a/crates/mozart/src/commands/licenses.rs b/crates/mozart/src/commands/licenses.rs index 6a5c295..0703976 100644 --- a/crates/mozart/src/commands/licenses.rs +++ b/crates/mozart/src/commands/licenses.rs @@ -27,7 +27,11 @@ struct LicenseEntry { // ─── Main entry point ─────────────────────────────────────────────────────── -pub fn execute(args: &LicensesArgs, cli: &super::Cli) -> anyhow::Result<()> { +pub fn execute( + args: &LicensesArgs, + cli: &super::Cli, + _console: &crate::console::Console, +) -> anyhow::Result<()> { let working_dir = match &cli.working_dir { Some(dir) => PathBuf::from(dir), None => std::env::current_dir()?, diff --git a/crates/mozart/src/commands/outdated.rs b/crates/mozart/src/commands/outdated.rs index f355e09..b6672c4 100644 --- a/crates/mozart/src/commands/outdated.rs +++ b/crates/mozart/src/commands/outdated.rs @@ -96,7 +96,11 @@ struct OutdatedEntry { // ─── Main entry point ─────────────────────────────────────────────────────── -pub fn execute(args: &OutdatedArgs, cli: &super::Cli) -> anyhow::Result<()> { +pub fn execute( + args: &OutdatedArgs, + cli: &super::Cli, + _console: &crate::console::Console, +) -> anyhow::Result<()> { let working_dir = match &cli.working_dir { Some(dir) => PathBuf::from(dir), None => std::env::current_dir()?, diff --git a/crates/mozart/src/commands/prohibits.rs b/crates/mozart/src/commands/prohibits.rs index d30e57f..f545bb2 100644 --- a/crates/mozart/src/commands/prohibits.rs +++ b/crates/mozart/src/commands/prohibits.rs @@ -22,7 +22,11 @@ pub struct ProhibitsArgs { pub locked: bool, } -pub fn execute(args: &ProhibitsArgs, cli: &super::Cli) -> anyhow::Result<()> { +pub fn execute( + args: &ProhibitsArgs, + cli: &super::Cli, + _console: &crate::console::Console, +) -> anyhow::Result<()> { let working_dir = match &cli.working_dir { Some(dir) => PathBuf::from(dir), None => std::env::current_dir()?, diff --git a/crates/mozart/src/commands/reinstall.rs b/crates/mozart/src/commands/reinstall.rs index 2015d62..78611b4 100644 --- a/crates/mozart/src/commands/reinstall.rs +++ b/crates/mozart/src/commands/reinstall.rs @@ -65,7 +65,11 @@ pub struct ReinstallArgs { // ─── Main entry point ───────────────────────────────────────────────────────── -pub fn execute(args: &ReinstallArgs, cli: &super::Cli) -> anyhow::Result<()> { +pub fn execute( + args: &ReinstallArgs, + cli: &super::Cli, + console: &crate::console::Console, +) -> anyhow::Result<()> { // Step 1: Resolve working directory let working_dir = match &cli.working_dir { Some(dir) => PathBuf::from(dir), @@ -167,7 +171,10 @@ pub fn execute(args: &ReinstallArgs, cli: &super::Cli) -> anyhow::Result<()> { let locked = match locked { Some(lp) => lp, None => { - eprintln!(" Warning: {} is not in the lock file; skipping.", pkg.name); + console.info(&format!( + " Warning: {} is not in the lock file; skipping.", + pkg.name + )); continue; } }; @@ -175,15 +182,18 @@ pub fn execute(args: &ReinstallArgs, cli: &super::Cli) -> anyhow::Result<()> { let dist = match &locked.dist { Some(d) => d, None => { - eprintln!( + console.info(&format!( " Warning: {} has no dist information; skipping.", locked.name - ); + )); continue; } }; - eprintln!(" - Reinstalling {} ({})", locked.name, locked.version); + console.info(&format!( + " - Reinstalling {} ({})", + locked.name, locked.version + )); // Remove vendor directory for this package let pkg_dir = vendor_dir.join(&locked.name); @@ -218,7 +228,7 @@ pub fn execute(args: &ReinstallArgs, cli: &super::Cli) -> anyhow::Result<()> { // Step 9: Regenerate autoloader unless --no-autoloader. if !args.no_autoloader { - eprintln!("Generating autoload files"); + console.info("Generating autoload files"); let dev_mode = !args.no_dev && installed.dev; let suffix = lock.content_hash.clone(); @@ -237,7 +247,7 @@ pub fn execute(args: &ReinstallArgs, cli: &super::Cli) -> anyhow::Result<()> { ignore_platform_reqs: args.ignore_platform_reqs, })?; - eprintln!("Generated autoload files"); + console.info("Generated autoload files"); } Ok(()) diff --git a/crates/mozart/src/commands/remove.rs b/crates/mozart/src/commands/remove.rs index ecfbba8..6969745 100644 --- a/crates/mozart/src/commands/remove.rs +++ b/crates/mozart/src/commands/remove.rs @@ -96,7 +96,11 @@ pub struct RemoveArgs { pub apcu_autoloader_prefix: Option<String>, } -pub fn execute(args: &RemoveArgs, cli: &super::Cli) -> anyhow::Result<()> { +pub fn execute( + args: &RemoveArgs, + cli: &super::Cli, + console: &crate::console::Console, +) -> anyhow::Result<()> { // Step 1: Validate inputs if args.packages.is_empty() && !args.unused { anyhow::bail!("Not enough arguments (missing: \"packages\")."); @@ -104,20 +108,20 @@ pub fn execute(args: &RemoveArgs, cli: &super::Cli) -> anyhow::Result<()> { // Step 2: Handle deprecated flags if args.update_with_dependencies { - eprintln!( + console.info(&format!( "{}", console::warning( "The -w / --update-with-dependencies flag is deprecated. Use --with-all-dependencies instead." ) - ); + )); } if args.update_with_all_dependencies { - eprintln!( + console.info(&format!( "{}", console::warning( "The -W / --update-with-all-dependencies flag is deprecated. Use --with-all-dependencies instead." ) - ); + )); } // Step 3: Resolve working directory and read composer.json @@ -135,12 +139,12 @@ pub fn execute(args: &RemoveArgs, cli: &super::Cli) -> anyhow::Result<()> { // Step 4: Handle --unused flag (deferred implementation) if args.unused { - eprintln!( + console.info(&format!( "{}", console::warning( "--unused is not yet fully implemented. The resolver will naturally prune unreachable packages." ) - ); + )); // Fall through: if no explicit packages were named, nothing to remove. if args.packages.is_empty() { return Ok(()); @@ -168,12 +172,12 @@ pub fn execute(args: &RemoveArgs, cli: &super::Cli) -> anyhow::Result<()> { raw.require_dev.remove(&name); any_removed = true; } else { - eprintln!( + console.info(&format!( "{}", console::warning(&format!( "{name} is not required in require-dev and has not been removed." )) - ); + )); } } else { // Auto-detect: look in require first, then require-dev @@ -192,12 +196,12 @@ pub fn execute(args: &RemoveArgs, cli: &super::Cli) -> anyhow::Result<()> { raw.require_dev.remove(&name); any_removed = true; } else { - eprintln!( + console.info(&format!( "{}", console::warning(&format!( "{name} is not required in your composer.json and has not been removed." )) - ); + )); } } } @@ -272,35 +276,34 @@ pub fn execute(args: &RemoveArgs, cli: &super::Cli) -> anyhow::Result<()> { }; // Print header messages - eprintln!("Loading composer repositories with package information"); + console.info("Loading composer repositories with package information"); if dev_mode { - eprintln!("Updating dependencies (including require-dev)"); + console.info("Updating dependencies (including require-dev)"); } else { - eprintln!("Updating dependencies"); + console.info("Updating dependencies"); } - eprintln!("Resolving dependencies..."); + console.info("Resolving dependencies..."); // Run resolver - let mut resolved = match resolver::resolve(&request) { - Ok(packages) => packages, - Err(e) => { - eprintln!("{}", console::error(&e.to_string())); - std::process::exit(1); - } - }; + let mut resolved = resolver::resolve(&request).map_err(|e| { + crate::exit_code::bail( + crate::exit_code::DEPENDENCY_RESOLUTION_FAILED, + e.to_string(), + ) + })?; // Read old lock file (if any) for change reporting and partial update let old_lock = if lock_path.exists() { match lockfile::LockFile::read_from_file(&lock_path) { Ok(l) => Some(l), Err(e) => { - eprintln!( + console.info(&format!( "{}", console::warning(&format!( "Could not read existing composer.lock: {}. Treating as a fresh install.", e )) - ); + )); None } } @@ -341,12 +344,12 @@ pub fn execute(args: &RemoveArgs, cli: &super::Cli) -> anyhow::Result<()> { // For --minimal-changes, additionally pin packages beyond the allow list if args.minimal_changes { - eprintln!( + console.info(&format!( "{}", console::info( "Minimal changes mode: preserving locked versions for non-removed packages." ) - ); + )); } resolved = super::update::apply_partial_update(resolved, lock, &allow_list); @@ -385,7 +388,7 @@ pub fn execute(args: &RemoveArgs, cli: &super::Cli) -> anyhow::Result<()> { .filter(|c| matches!(c.kind, super::update::ChangeKind::Remove { .. })) .collect(); - eprintln!( + console.info(&format!( "{}", console::info(&format!( "Package operations: {} install{}, {} update{}, {} removal{}", @@ -396,23 +399,29 @@ pub fn execute(args: &RemoveArgs, cli: &super::Cli) -> anyhow::Result<()> { removals.len(), if removals.len() == 1 { "" } else { "s" }, )) - ); + )); // Print individual change lines for change in &changes { match &change.kind { super::update::ChangeKind::Remove { old_version } => { if args.dry_run { - eprintln!(" - Would remove {} ({})", change.name, old_version); + console.info(&format!( + " - Would remove {} ({})", + change.name, old_version + )); } else { - eprintln!(" - Removing {} ({})", change.name, old_version); + console.info(&format!(" - Removing {} ({})", change.name, old_version)); } } super::update::ChangeKind::Install { new_version } => { if args.dry_run { - eprintln!(" - Would install {} ({})", change.name, new_version); + console.info(&format!( + " - Would install {} ({})", + change.name, new_version + )); } else { - eprintln!(" - Installing {} ({})", change.name, new_version); + console.info(&format!(" - Installing {} ({})", change.name, new_version)); } } super::update::ChangeKind::Update { @@ -420,15 +429,15 @@ pub fn execute(args: &RemoveArgs, cli: &super::Cli) -> anyhow::Result<()> { new_version, } => { if args.dry_run { - eprintln!( + console.info(&format!( " - Would update {} ({} => {})", change.name, old_version, new_version - ); + )); } else { - eprintln!( + console.info(&format!( " - Updating {} ({} => {})", change.name, old_version, new_version - ); + )); } } super::update::ChangeKind::Unchanged => {} @@ -437,7 +446,7 @@ pub fn execute(args: &RemoveArgs, cli: &super::Cli) -> anyhow::Result<()> { // Write lock file (unless --dry-run) if !args.dry_run { - eprintln!("Writing lock file"); + console.info("Writing lock file"); new_lock.write_to_file(&lock_path)?; } diff --git a/crates/mozart/src/commands/repository.rs b/crates/mozart/src/commands/repository.rs index 3a094cc..c54601b 100644 --- a/crates/mozart/src/commands/repository.rs +++ b/crates/mozart/src/commands/repository.rs @@ -35,6 +35,10 @@ pub struct RepositoryArgs { pub after: Option<String>, } -pub fn execute(_args: &RepositoryArgs, _cli: &super::Cli) -> anyhow::Result<()> { +pub fn execute( + _args: &RepositoryArgs, + _cli: &super::Cli, + _console: &crate::console::Console, +) -> anyhow::Result<()> { todo!() } diff --git a/crates/mozart/src/commands/require.rs b/crates/mozart/src/commands/require.rs index 7709f86..6e76b6e 100644 --- a/crates/mozart/src/commands/require.rs +++ b/crates/mozart/src/commands/require.rs @@ -343,7 +343,11 @@ fn interactive_search_packages( Ok(selected) } -pub fn execute(args: &RequireArgs, cli: &super::Cli) -> anyhow::Result<()> { +pub fn execute( + args: &RequireArgs, + cli: &super::Cli, + console: &crate::console::Console, +) -> anyhow::Result<()> { // Collect the effective list of packages to add. // If none were provided on the CLI, try interactive search (unless --no-interaction). let cli_packages: Vec<String> = if args.packages.is_empty() { @@ -399,26 +403,19 @@ pub fn execute(args: &RequireArgs, cli: &super::Cli) -> anyhow::Result<()> { // Handle deprecated flags if args.no_suggest { - eprintln!( - "{}", - console::warning("The --no-suggest option is deprecated and has no effect.") - ); + console.info(&console::warning( + "The --no-suggest option is deprecated and has no effect.", + )); } if args.update_with_dependencies { - eprintln!( - "{}", - console::warning( - "The -w / --update-with-dependencies flag is deprecated. Use --with-dependencies instead." - ) - ); + console.info(&console::warning( + "The -w / --update-with-dependencies flag is deprecated. Use --with-dependencies instead." + )); } if args.update_with_all_dependencies { - eprintln!( - "{}", - console::warning( - "The -W / --update-with-all-dependencies flag is deprecated. Use --with-all-dependencies instead." - ) - ); + console.info(&console::warning( + "The -W / --update-with-all-dependencies flag is deprecated. Use --with-all-dependencies instead." + )); } // Resolve working directory @@ -600,20 +597,22 @@ pub fn execute(args: &RequireArgs, cli: &super::Cli) -> anyhow::Result<()> { }; // Print header messages - eprintln!("Loading composer repositories with package information"); + console.info("Loading composer repositories with package information"); if dev_mode { - eprintln!("Updating dependencies (including require-dev)"); + console.info("Updating dependencies (including require-dev)"); } else { - eprintln!("Updating dependencies"); + console.info("Updating dependencies"); } - eprintln!("Resolving dependencies..."); + console.info("Resolving dependencies..."); // Run resolver let mut resolved = match resolver::resolve(&request) { Ok(packages) => packages, Err(e) => { - eprintln!("{}", console::error(&e.to_string())); - std::process::exit(1); + return Err(crate::exit_code::bail( + crate::exit_code::DEPENDENCY_RESOLUTION_FAILED, + e.to_string(), + )); } }; @@ -622,13 +621,10 @@ pub fn execute(args: &RequireArgs, cli: &super::Cli) -> anyhow::Result<()> { match lockfile::LockFile::read_from_file(&lock_path) { Ok(l) => Some(l), Err(e) => { - eprintln!( - "{}", - console::warning(&format!( - "Could not read existing composer.lock: {}. Treating as a fresh install.", - e - )) - ); + console.info(&console::warning(&format!( + "Could not read existing composer.lock: {}. Treating as a fresh install.", + e + ))); None } } @@ -693,34 +689,37 @@ pub fn execute(args: &RequireArgs, cli: &super::Cli) -> anyhow::Result<()> { .filter(|c| matches!(c.kind, super::update::ChangeKind::Remove { .. })) .collect(); - eprintln!( - "{}", - console::info(&format!( - "Package operations: {} install{}, {} update{}, {} removal{}", - installs.len(), - if installs.len() == 1 { "" } else { "s" }, - updates.len(), - if updates.len() == 1 { "" } else { "s" }, - removals.len(), - if removals.len() == 1 { "" } else { "s" }, - )) - ); + console.info(&format!( + "Package operations: {} install{}, {} update{}, {} removal{}", + installs.len(), + if installs.len() == 1 { "" } else { "s" }, + updates.len(), + if updates.len() == 1 { "" } else { "s" }, + removals.len(), + if removals.len() == 1 { "" } else { "s" }, + )); // Print individual change lines for change in &changes { match &change.kind { super::update::ChangeKind::Remove { old_version } => { if args.dry_run { - eprintln!(" - Would remove {} ({})", change.name, old_version); + console.info(&format!( + " - Would remove {} ({})", + change.name, old_version + )); } else { - eprintln!(" - Removing {} ({})", change.name, old_version); + console.info(&format!(" - Removing {} ({})", change.name, old_version)); } } super::update::ChangeKind::Install { new_version } => { if args.dry_run { - eprintln!(" - Would install {} ({})", change.name, new_version); + console.info(&format!( + " - Would install {} ({})", + change.name, new_version + )); } else { - eprintln!(" - Installing {} ({})", change.name, new_version); + console.info(&format!(" - Installing {} ({})", change.name, new_version)); } } super::update::ChangeKind::Update { @@ -728,15 +727,15 @@ pub fn execute(args: &RequireArgs, cli: &super::Cli) -> anyhow::Result<()> { new_version, } => { if args.dry_run { - eprintln!( + console.info(&format!( " - Would update {} ({} => {})", change.name, old_version, new_version - ); + )); } else { - eprintln!( + console.info(&format!( " - Updating {} ({} => {})", change.name, old_version, new_version - ); + )); } } super::update::ChangeKind::Unchanged => {} @@ -745,7 +744,7 @@ pub fn execute(args: &RequireArgs, cli: &super::Cli) -> anyhow::Result<()> { // Write lock file (unless --dry-run) if !args.dry_run { - eprintln!("Writing lock file"); + console.info("Writing lock file"); new_lock.write_to_file(&lock_path)?; } @@ -759,12 +758,9 @@ pub fn execute(args: &RequireArgs, cli: &super::Cli) -> anyhow::Result<()> { .map(|s| s.eq_ignore_ascii_case("source")) .unwrap_or(false); if prefer_source { - eprintln!( - "{}", - crate::console::warning( - "Warning: Source installs are not yet supported. Falling back to dist." - ) - ); + console.info(&crate::console::warning( + "Warning: Source installs are not yet supported. Falling back to dist.", + )); } super::install::install_from_lock( diff --git a/crates/mozart/src/commands/run_script.rs b/crates/mozart/src/commands/run_script.rs index 845d4bd..8f2b5cd 100644 --- a/crates/mozart/src/commands/run_script.rs +++ b/crates/mozart/src/commands/run_script.rs @@ -46,7 +46,11 @@ const INTERNAL_ONLY_EVENTS: &[&str] = &[ // ─── Main entry point ──────────────────────────────────────────────────────── -pub fn execute(args: &RunScriptArgs, cli: &super::Cli) -> anyhow::Result<()> { +pub fn execute( + args: &RunScriptArgs, + cli: &super::Cli, + _console: &crate::console::Console, +) -> anyhow::Result<()> { let working_dir = match &cli.working_dir { Some(dir) => PathBuf::from(dir), None => std::env::current_dir()?, diff --git a/crates/mozart/src/commands/search.rs b/crates/mozart/src/commands/search.rs index e14c4ff..d172976 100644 --- a/crates/mozart/src/commands/search.rs +++ b/crates/mozart/src/commands/search.rs @@ -59,7 +59,11 @@ fn passes_only_vendor(result: &SearchResult, query: &str) -> bool { vendor.eq_ignore_ascii_case(query) } -pub fn execute(args: &SearchArgs, _cli: &super::Cli) -> anyhow::Result<()> { +pub fn execute( + args: &SearchArgs, + _cli: &super::Cli, + _console: &crate::console::Console, +) -> anyhow::Result<()> { let query = args.tokens.join(" "); let (all_results, total) = crate::packagist::search_packages(&query, args.r#type.as_deref())?; diff --git a/crates/mozart/src/commands/self_update.rs b/crates/mozart/src/commands/self_update.rs index 2420a7d..29c67ef 100644 --- a/crates/mozart/src/commands/self_update.rs +++ b/crates/mozart/src/commands/self_update.rs @@ -50,7 +50,11 @@ const BACKUP_EXTENSION: &str = ".old"; // ─── Public entry point ─────────────────────────────────────────────────────── -pub fn execute(args: &SelfUpdateArgs, _cli: &super::Cli) -> anyhow::Result<()> { +pub fn execute( + args: &SelfUpdateArgs, + _cli: &super::Cli, + _console: &crate::console::Console, +) -> anyhow::Result<()> { let current_exe = std::env::current_exe() .map_err(|e| anyhow::anyhow!("Could not determine current executable path: {e}"))?; diff --git a/crates/mozart/src/commands/show.rs b/crates/mozart/src/commands/show.rs index 498d170..a8ae995 100644 --- a/crates/mozart/src/commands/show.rs +++ b/crates/mozart/src/commands/show.rs @@ -99,7 +99,11 @@ pub struct ShowArgs { pub ignore_platform_reqs: bool, } -pub fn execute(args: &ShowArgs, cli: &super::Cli) -> anyhow::Result<()> { +pub fn execute( + args: &ShowArgs, + cli: &super::Cli, + _console: &crate::console::Console, +) -> anyhow::Result<()> { let working_dir = match &cli.working_dir { Some(dir) => PathBuf::from(dir), None => std::env::current_dir()?, diff --git a/crates/mozart/src/commands/status.rs b/crates/mozart/src/commands/status.rs index ae38621..dc26a5f 100644 --- a/crates/mozart/src/commands/status.rs +++ b/crates/mozart/src/commands/status.rs @@ -44,7 +44,11 @@ struct PackageStatus { // ─── Main entry point ──────────────────────────────────────────────────────── -pub fn execute(args: &StatusArgs, cli: &super::Cli) -> anyhow::Result<()> { +pub fn execute( + args: &StatusArgs, + cli: &super::Cli, + _console: &crate::console::Console, +) -> anyhow::Result<()> { let working_dir = match &cli.working_dir { Some(dir) => PathBuf::from(dir), None => std::env::current_dir()?, diff --git a/crates/mozart/src/commands/suggests.rs b/crates/mozart/src/commands/suggests.rs index 8d0898a..df58e47 100644 --- a/crates/mozart/src/commands/suggests.rs +++ b/crates/mozart/src/commands/suggests.rs @@ -38,7 +38,11 @@ struct Suggestion { // ─── Main entry point ──────────────────────────────────────────────────────── -pub fn execute(args: &SuggestsArgs, cli: &super::Cli) -> anyhow::Result<()> { +pub fn execute( + args: &SuggestsArgs, + cli: &super::Cli, + _console: &crate::console::Console, +) -> anyhow::Result<()> { let working_dir = match &cli.working_dir { Some(dir) => PathBuf::from(dir), None => std::env::current_dir()?, diff --git a/crates/mozart/src/commands/update.rs b/crates/mozart/src/commands/update.rs index 930c458..d4056e4 100644 --- a/crates/mozart/src/commands/update.rs +++ b/crates/mozart/src/commands/update.rs @@ -630,57 +630,53 @@ pub fn apply_minimal_changes( // Main execute function // ───────────────────────────────────────────────────────────────────────────── -pub fn execute(args: &UpdateArgs, cli: &super::Cli) -> anyhow::Result<()> { +pub fn execute( + args: &UpdateArgs, + cli: &super::Cli, + console: &crate::console::Console, +) -> anyhow::Result<()> { // Step 1: Resolve the working directory let working_dir = super::install::resolve_working_dir(cli); // Step 2: Handle deprecated flags if args.dev { - eprintln!( - "{}", - console::warning( - "The --dev option is deprecated. Dev packages are updated by default." - ) - ); + console.info(&console::warning( + "The --dev option is deprecated. Dev packages are updated by default.", + )); } if args.no_suggest { - eprintln!( - "{}", - console::warning("The --no-suggest option is deprecated and has no effect.") - ); + console.info(&console::warning( + "The --no-suggest option is deprecated and has no effect.", + )); } // Warn about still-deferred flags if args.patch_only { - eprintln!( - "{}", - console::warning("--patch-only is not yet implemented and will be ignored.") - ); + console.info(&console::warning( + "--patch-only is not yet implemented and will be ignored.", + )); } if args.root_reqs { - eprintln!( - "{}", - console::warning("--root-reqs is not yet implemented and will be ignored.") - ); + console.info(&console::warning( + "--root-reqs is not yet implemented and will be ignored.", + )); } if args.bump_after_update.is_some() { - eprintln!( - "{}", - console::warning("--bump-after-update is not yet implemented and will be ignored.") - ); + console.info(&console::warning( + "--bump-after-update is not yet implemented and will be ignored.", + )); } // Step 3: Read composer.json let composer_json_path = working_dir.join("composer.json"); if !composer_json_path.exists() { - eprintln!( - "{}", - console::error(&format!( + return Err(crate::exit_code::bail( + crate::exit_code::GENERAL_ERROR, + format!( "Composer could not find a composer.json file in {}", working_dir.display() - )) - ); - std::process::exit(1); + ), + )); } let composer_json = package::read_from_file(&composer_json_path)?; let composer_json_content = std::fs::read_to_string(&composer_json_path)?; @@ -690,7 +686,7 @@ pub fn execute(args: &UpdateArgs, cli: &super::Cli) -> anyhow::Result<()> { // Step 4: Handle --lock mode (early return) if args.lock { - return handle_lock_mode(&lock_path, &composer_json_content, args.dry_run); + return handle_lock_mode(&lock_path, &composer_json_content, args.dry_run, console); } let dev_mode = !args.no_dev; @@ -739,19 +735,21 @@ pub fn execute(args: &UpdateArgs, cli: &super::Cli) -> anyhow::Result<()> { }; // Step 6: Print header and run resolver - eprintln!("Loading composer repositories with package information"); + console.info("Loading composer repositories with package information"); if dev_mode { - eprintln!("Updating dependencies (including require-dev)"); + console.info("Updating dependencies (including require-dev)"); } else { - eprintln!("Updating dependencies"); + console.info("Updating dependencies"); } - eprintln!("Resolving dependencies..."); + console.info("Resolving dependencies..."); let mut resolved = match resolver::resolve(&request) { Ok(packages) => packages, Err(e) => { - eprintln!("{}", console::error(&e.to_string())); - std::process::exit(1); + return Err(crate::exit_code::bail( + crate::exit_code::DEPENDENCY_RESOLUTION_FAILED, + e.to_string(), + )); } }; @@ -760,13 +758,10 @@ pub fn execute(args: &UpdateArgs, cli: &super::Cli) -> anyhow::Result<()> { match lockfile::LockFile::read_from_file(&lock_path) { Ok(l) => Some(l), Err(e) => { - eprintln!( - "{}", - console::warning(&format!( - "Could not read existing composer.lock: {}. Treating as a fresh install.", - e - )) - ); + console.info(&console::warning(&format!( + "Could not read existing composer.lock: {}. Treating as a fresh install.", + e + ))); None } } @@ -782,13 +777,10 @@ pub fn execute(args: &UpdateArgs, cli: &super::Cli) -> anyhow::Result<()> { let update_packages: Vec<String> = if !args.packages.is_empty() { match &old_lock { None => { - eprintln!( - "{}", - console::error( - "No lock file found. Cannot perform partial update. Run `mozart update` first." - ) - ); - std::process::exit(1); + return Err(crate::exit_code::bail( + crate::exit_code::NO_LOCK_FILE_FOR_PARTIAL_UPDATE, + "No lock file found. Cannot perform partial update. Run `mozart update` first.", + )); } Some(lock) => { // 1. Expand wildcards @@ -813,10 +805,9 @@ pub fn execute(args: &UpdateArgs, cli: &super::Cli) -> anyhow::Result<()> { if args.interactive { match &old_lock { None => { - eprintln!( - "{}", - console::warning("No lock file found. --interactive mode skipped.") - ); + console.info(&console::warning( + "No lock file found. --interactive mode skipped.", + )); vec![] } Some(lock) => { @@ -843,13 +834,10 @@ pub fn execute(args: &UpdateArgs, cli: &super::Cli) -> anyhow::Result<()> { if !update_packages.is_empty() { match &old_lock { None => { - eprintln!( - "{}", - console::error( - "No lock file found. Cannot perform partial update. Run `mozart update` first." - ) - ); - std::process::exit(1); + return Err(crate::exit_code::bail( + crate::exit_code::NO_LOCK_FILE_FOR_PARTIAL_UPDATE, + "No lock file found. Cannot perform partial update. Run `mozart update` first.", + )); } Some(lock) => { resolved = apply_partial_update(resolved, lock, &update_packages); @@ -859,10 +847,7 @@ pub fn execute(args: &UpdateArgs, cli: &super::Cli) -> anyhow::Result<()> { // Full update with --minimal-changes: pin everything to locked versions // (only updates packages whose constraints have changed in composer.json) if let Some(ref lock) = old_lock { - eprintln!( - "{}", - console::info("Minimal changes mode: preserving locked versions where possible.") - ); + console.info("Minimal changes mode: preserving locked versions where possible."); resolved = apply_minimal_changes(resolved, lock); } } @@ -892,18 +877,15 @@ pub fn execute(args: &UpdateArgs, cli: &super::Cli) -> anyhow::Result<()> { .filter(|c| matches!(c.kind, ChangeKind::Remove { .. })) .collect(); - eprintln!( - "{}", - console::info(&format!( - "Package operations: {} install{}, {} update{}, {} removal{}", - installs.len(), - if installs.len() == 1 { "" } else { "s" }, - updates.len(), - if updates.len() == 1 { "" } else { "s" }, - removals.len(), - if removals.len() == 1 { "" } else { "s" }, - )) - ); + console.info(&format!( + "Package operations: {} install{}, {} update{}, {} removal{}", + installs.len(), + if installs.len() == 1 { "" } else { "s" }, + updates.len(), + if updates.len() == 1 { "" } else { "s" }, + removals.len(), + if removals.len() == 1 { "" } else { "s" }, + )); // Print individual change lines let prefix = if args.dry_run { "Would" } else { "" }; @@ -911,16 +893,22 @@ pub fn execute(args: &UpdateArgs, cli: &super::Cli) -> anyhow::Result<()> { match &change.kind { ChangeKind::Remove { old_version } => { if args.dry_run { - eprintln!(" - {} remove {} ({})", prefix, change.name, old_version); + console.info(&format!( + " - {} remove {} ({})", + prefix, change.name, old_version + )); } else { - eprintln!(" - Removing {} ({})", change.name, old_version); + console.info(&format!(" - Removing {} ({})", change.name, old_version)); } } ChangeKind::Install { new_version } => { if args.dry_run { - eprintln!(" - {} install {} ({})", prefix, change.name, new_version); + console.info(&format!( + " - {} install {} ({})", + prefix, change.name, new_version + )); } else { - eprintln!(" - Installing {} ({})", change.name, new_version); + console.info(&format!(" - Installing {} ({})", change.name, new_version)); } } ChangeKind::Update { @@ -928,15 +916,15 @@ pub fn execute(args: &UpdateArgs, cli: &super::Cli) -> anyhow::Result<()> { new_version, } => { if args.dry_run { - eprintln!( + console.info(&format!( " - {} update {} ({} => {})", prefix, change.name, old_version, new_version - ); + )); } else { - eprintln!( + console.info(&format!( " - Updating {} ({} => {})", change.name, old_version, new_version - ); + )); } } ChangeKind::Unchanged => {} @@ -945,7 +933,7 @@ pub fn execute(args: &UpdateArgs, cli: &super::Cli) -> anyhow::Result<()> { // Step 11: Write lock file (unless --dry-run) if !args.dry_run { - eprintln!("Writing lock file"); + console.info("Writing lock file"); new_lock.write_to_file(&lock_path)?; } @@ -959,12 +947,9 @@ pub fn execute(args: &UpdateArgs, cli: &super::Cli) -> anyhow::Result<()> { .map(|s| s.eq_ignore_ascii_case("source")) .unwrap_or(false); if prefer_source { - eprintln!( - "{}", - crate::console::warning( - "Warning: Source installs are not yet supported. Falling back to dist." - ) - ); + console.info(&crate::console::warning( + "Warning: Source installs are not yet supported. Falling back to dist.", + )); } super::install::install_from_lock( @@ -1001,13 +986,13 @@ fn handle_lock_mode( lock_path: &std::path::Path, composer_json_content: &str, dry_run: bool, + console: &crate::console::Console, ) -> anyhow::Result<()> { if !lock_path.exists() { - eprintln!( - "{}", - console::error("No lock file found. Run `mozart update` to generate one.") - ); - std::process::exit(1); + return Err(crate::exit_code::bail( + crate::exit_code::LOCK_FILE_INVALID, + "No lock file found. Run `mozart update` to generate one.", + )); } let mut lock = lockfile::LockFile::read_from_file(lock_path)?; @@ -1015,7 +1000,7 @@ fn handle_lock_mode( let new_hash = lockfile::LockFile::compute_content_hash(composer_json_content)?; if new_hash == lock.content_hash { - eprintln!("Lock file is already up to date"); + console.info("Lock file is already up to date"); return Ok(()); } @@ -1023,9 +1008,9 @@ fn handle_lock_mode( if !dry_run { lock.write_to_file(lock_path)?; - eprintln!("Lock file hash updated successfully."); + console.info("Lock file hash updated successfully."); } else { - eprintln!("Would update lock file hash."); + console.info("Would update lock file hash."); } Ok(()) @@ -1375,7 +1360,12 @@ mod tests { // Composer.json content that will produce a different hash let composer_json_content = r#"{"name": "test/project", "require": {"psr/log": "^3.0"}}"#; - let result = handle_lock_mode(&lock_path, composer_json_content, false); + let console = crate::console::Console { + interactive: false, + verbosity: crate::console::Verbosity::Normal, + decorated: false, + }; + let result = handle_lock_mode(&lock_path, composer_json_content, false, &console); assert!(result.is_ok()); // Read back and verify hash changed @@ -1398,7 +1388,12 @@ mod tests { lock.content_hash = correct_hash.clone(); lock.write_to_file(&lock_path).unwrap(); - let result = handle_lock_mode(&lock_path, composer_json_content, false); + let console = crate::console::Console { + interactive: false, + verbosity: crate::console::Verbosity::Normal, + decorated: false, + }; + let result = handle_lock_mode(&lock_path, composer_json_content, false, &console); assert!(result.is_ok()); // Hash should not have changed @@ -1417,7 +1412,12 @@ mod tests { let composer_json_content = r#"{"name": "test/project", "require": {"psr/log": "^3.0"}}"#; - let result = handle_lock_mode(&lock_path, composer_json_content, true); + let console = crate::console::Console { + interactive: false, + verbosity: crate::console::Verbosity::Normal, + decorated: false, + }; + let result = handle_lock_mode(&lock_path, composer_json_content, true, &console); assert!(result.is_ok()); // Hash should NOT have changed (dry_run=true) @@ -1717,7 +1717,12 @@ mod tests { let expected_hash = lockfile::LockFile::compute_content_hash(composer_json_content).unwrap(); - handle_lock_mode(&lock_path, composer_json_content, false).unwrap(); + let console = crate::console::Console { + interactive: false, + verbosity: crate::console::Verbosity::Normal, + decorated: false, + }; + handle_lock_mode(&lock_path, composer_json_content, false, &console).unwrap(); let updated = lockfile::LockFile::read_from_file(&lock_path).unwrap(); assert_eq!(updated.content_hash, expected_hash); diff --git a/crates/mozart/src/commands/validate.rs b/crates/mozart/src/commands/validate.rs index 7a01761..1dec3fe 100644 --- a/crates/mozart/src/commands/validate.rs +++ b/crates/mozart/src/commands/validate.rs @@ -67,7 +67,11 @@ impl ValidationResult { // ─── Entry point ───────────────────────────────────────────────────────────── -pub fn execute(args: &ValidateArgs, cli: &super::Cli) -> anyhow::Result<()> { +pub fn execute( + args: &ValidateArgs, + cli: &super::Cli, + console: &crate::console::Console, +) -> anyhow::Result<()> { let working_dir = match &cli.working_dir { Some(dir) => PathBuf::from(dir), None => std::env::current_dir()?, @@ -79,24 +83,28 @@ pub fn execute(args: &ValidateArgs, cli: &super::Cli) -> anyhow::Result<()> { None => working_dir.join("composer.json"), }; + // Validate-specific exit codes (matching Composer's behavior): + // 3 = file not found or not readable + // 2 = JSON parse error + const VALIDATE_FILE_ERROR: i32 = 3; + const VALIDATE_JSON_ERROR: i32 = 2; + // Check file exists if !file.exists() { - eprintln!( - "{}", - crate::console::error(&format!("{} not found.", file.display())) - ); - std::process::exit(3); + return Err(crate::exit_code::bail( + VALIDATE_FILE_ERROR, + format!("{} not found.", file.display()), + )); } // 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); + return Err(crate::exit_code::bail( + VALIDATE_FILE_ERROR, + format!("{} is not readable.", file.display()), + )); } }; @@ -104,12 +112,10 @@ pub fn execute(args: &ValidateArgs, cli: &super::Cli) -> anyhow::Result<()> { 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); + return Err(crate::exit_code::bail( + VALIDATE_JSON_ERROR, + format!("{} does not contain valid JSON: {e}", file.display()), + )); } }; @@ -130,7 +136,7 @@ pub fn execute(args: &ValidateArgs, cli: &super::Cli) -> anyhow::Result<()> { // Stub for --with-dependencies if args.with_dependencies { - eprintln!("The --with-dependencies option is not yet implemented"); + console.info("The --with-dependencies option is not yet implemented"); } let exit_code = compute_exit_code( @@ -141,7 +147,7 @@ pub fn execute(args: &ValidateArgs, cli: &super::Cli) -> anyhow::Result<()> { args.strict, ); if exit_code != 0 { - std::process::exit(exit_code); + return Err(crate::exit_code::bail_silent(exit_code)); } Ok(()) diff --git a/crates/mozart/src/console.rs b/crates/mozart/src/console.rs index 863e9ba..5f108c0 100644 --- a/crates/mozart/src/console.rs +++ b/crates/mozart/src/console.rs @@ -1,5 +1,10 @@ use colored::{ColoredString, Colorize}; use dialoguer::{Confirm, Input}; +use std::io::IsTerminal; + +// --------------------------------------------------------------------------- +// Tag-style color helpers (module-level free functions, unchanged API) +// --------------------------------------------------------------------------- /// `<info>` — green foreground pub fn info(message: &str) -> ColoredString { @@ -31,29 +36,169 @@ pub fn warning(message: &str) -> ColoredString { message.black().on_yellow() } +// --------------------------------------------------------------------------- +// Verbosity +// --------------------------------------------------------------------------- + +/// Output verbosity level, ordered from least to most verbose. +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)] +pub enum Verbosity { + /// `-q` / `--quiet`: suppress all non-error output. + Quiet, + /// Default: normal informational messages. + Normal, + /// `-v`: additional detail (URLs, cache hits, skips). + Verbose, + /// `-vv`: HTTP details, file operations, resolver iterations. + VeryVerbose, + /// `-vvv`: full debug output (headers, raw payloads, timing). + Debug, +} + +impl Verbosity { + /// Construct a `Verbosity` from CLI flag counts. + /// + /// - `quiet == true` → `Quiet` (takes priority over `-v` flags) + /// - `verbose_count == 0` → `Normal` + /// - `verbose_count == 1` → `Verbose` + /// - `verbose_count == 2` → `VeryVerbose` + /// - `verbose_count >= 3` → `Debug` + pub fn from_flags(verbose_count: u8, quiet: bool) -> Self { + if quiet { + return Verbosity::Quiet; + } + match verbose_count { + 0 => Verbosity::Normal, + 1 => Verbosity::Verbose, + 2 => Verbosity::VeryVerbose, + _ => Verbosity::Debug, + } + } +} + +// --------------------------------------------------------------------------- +// Console +// --------------------------------------------------------------------------- + +/// Central IO hub for Mozart commands. +/// +/// Constructed once in `commands::execute()` and passed as `&Console` to every +/// command and library function that needs to produce output. pub struct Console { + /// Whether the user can answer interactive prompts. pub interactive: bool, - pub quiet: bool, + /// Current verbosity level. + pub verbosity: Verbosity, + /// Whether ANSI color codes should be emitted. + pub decorated: bool, } impl Console { - pub fn new(no_interaction: bool, quiet: bool) -> Self { + /// Build a `Console` from the parsed CLI. + /// + /// This is the primary constructor used in production. It reads + /// `cli.verbose`, `cli.quiet`, `cli.ansi`, `cli.no_ansi`, and + /// `cli.no_interaction` to configure all fields. + pub fn from_cli(cli: &crate::commands::Cli) -> Self { + let verbosity = Verbosity::from_flags(cli.verbose, cli.quiet); + let decorated = Self::resolve_decorated(cli.ansi, cli.no_ansi); + colored::control::set_override(decorated); Self { - interactive: !no_interaction, - quiet, + interactive: !cli.no_interaction, + verbosity, + decorated, } } - pub fn info(&self, msg: &str) { - if !self.quiet { + /// Determine whether ANSI color output should be enabled. + /// + /// - `no_ansi == true` → always disable + /// - `ansi == true` → always enable + /// - Otherwise → auto-detect: enabled only when stderr is a TTY + pub fn resolve_decorated(ansi: bool, no_ansi: bool) -> bool { + if no_ansi { + return false; + } + if ansi { + return true; + } + std::io::stderr().is_terminal() + } + + // ----------------------------------------------------------------------- + // Output methods + // ----------------------------------------------------------------------- + + /// Write `msg` to stderr if `self.verbosity >= required`. + pub fn write(&self, msg: &str, required: Verbosity) { + if self.verbosity >= required { eprintln!("{msg}"); } } - pub fn error(&self, msg: &str) { + /// Write `msg` to stdout if `self.verbosity >= required`. + pub fn write_stdout(&self, msg: &str, required: Verbosity) { + if self.verbosity >= required { + println!("{msg}"); + } + } + + /// Write an error to stderr. Always shown, even in quiet mode. + pub fn write_error(&self, msg: &str) { eprintln!("{}", error(msg)); } + // Convenience verbosity-level shortcuts: + + /// Normal-level message (suppressed by `--quiet`). + pub fn info(&self, msg: &str) { + self.write(msg, Verbosity::Normal); + } + + /// Verbose-level message (shown with `-v` or higher). + pub fn verbose(&self, msg: &str) { + self.write(msg, Verbosity::Verbose); + } + + /// Very-verbose-level message (shown with `-vv` or higher). + pub fn very_verbose(&self, msg: &str) { + self.write(msg, Verbosity::VeryVerbose); + } + + /// Debug-level message (shown with `-vvv`). + pub fn debug(&self, msg: &str) { + self.write(msg, Verbosity::Debug); + } + + /// Error message — always shown. + pub fn error(&self, msg: &str) { + self.write_error(msg); + } + + // ----------------------------------------------------------------------- + // Query methods + // ----------------------------------------------------------------------- + + pub fn is_verbose(&self) -> bool { + self.verbosity >= Verbosity::Verbose + } + + pub fn is_very_verbose(&self) -> bool { + self.verbosity >= Verbosity::VeryVerbose + } + + pub fn is_debug(&self) -> bool { + self.verbosity >= Verbosity::Debug + } + + pub fn is_quiet(&self) -> bool { + self.verbosity == Verbosity::Quiet + } + + // ----------------------------------------------------------------------- + // Interactive prompt methods (unchanged from prior implementation) + // ----------------------------------------------------------------------- + pub fn ask(&self, prompt: &str, default: &str) -> String { if !self.interactive { return default.to_string(); @@ -92,7 +237,7 @@ impl Console { match validator(&input) { Ok(()) => return Ok(input), Err(e) => { - self.error(&e); + self.write_error(&e); } } } @@ -110,3 +255,101 @@ impl Console { .unwrap_or(true) } } + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +#[cfg(test)] +mod tests { + use super::*; + + // ── Verbosity::from_flags ─────────────────────────────────────────────── + + #[test] + fn test_verbosity_quiet_takes_priority() { + assert_eq!(Verbosity::from_flags(3, true), Verbosity::Quiet); + assert_eq!(Verbosity::from_flags(0, true), Verbosity::Quiet); + } + + #[test] + fn test_verbosity_normal() { + assert_eq!(Verbosity::from_flags(0, false), Verbosity::Normal); + } + + #[test] + fn test_verbosity_verbose() { + assert_eq!(Verbosity::from_flags(1, false), Verbosity::Verbose); + } + + #[test] + fn test_verbosity_very_verbose() { + assert_eq!(Verbosity::from_flags(2, false), Verbosity::VeryVerbose); + } + + #[test] + fn test_verbosity_debug() { + assert_eq!(Verbosity::from_flags(3, false), Verbosity::Debug); + assert_eq!(Verbosity::from_flags(10, false), Verbosity::Debug); + } + + // ── Verbosity ordering ────────────────────────────────────────────────── + + #[test] + fn test_verbosity_ordering() { + assert!(Verbosity::Quiet < Verbosity::Normal); + assert!(Verbosity::Normal < Verbosity::Verbose); + assert!(Verbosity::Verbose < Verbosity::VeryVerbose); + assert!(Verbosity::VeryVerbose < Verbosity::Debug); + } + + // ── Console::resolve_decorated ────────────────────────────────────────── + + #[test] + fn test_resolve_decorated_no_ansi_wins() { + assert!(!Console::resolve_decorated(true, true)); + assert!(!Console::resolve_decorated(false, true)); + } + + #[test] + fn test_resolve_decorated_ansi_forces_on() { + assert!(Console::resolve_decorated(true, false)); + } + + // ── Console query methods ─────────────────────────────────────────────── + + fn make_console(verbosity: Verbosity) -> Console { + Console { + interactive: false, + verbosity, + decorated: false, + } + } + + #[test] + fn test_is_quiet() { + assert!(make_console(Verbosity::Quiet).is_quiet()); + assert!(!make_console(Verbosity::Normal).is_quiet()); + } + + #[test] + fn test_is_verbose() { + assert!(!make_console(Verbosity::Normal).is_verbose()); + assert!(make_console(Verbosity::Verbose).is_verbose()); + assert!(make_console(Verbosity::VeryVerbose).is_verbose()); + assert!(make_console(Verbosity::Debug).is_verbose()); + } + + #[test] + fn test_is_very_verbose() { + assert!(!make_console(Verbosity::Verbose).is_very_verbose()); + assert!(make_console(Verbosity::VeryVerbose).is_very_verbose()); + assert!(make_console(Verbosity::Debug).is_very_verbose()); + } + + #[test] + fn test_is_debug() { + assert!(!make_console(Verbosity::VeryVerbose).is_debug()); + assert!(make_console(Verbosity::Debug).is_debug()); + } +} diff --git a/crates/mozart/src/exit_code.rs b/crates/mozart/src/exit_code.rs new file mode 100644 index 0000000..bc01cfa --- /dev/null +++ b/crates/mozart/src/exit_code.rs @@ -0,0 +1,114 @@ +/// Exit code: success. +pub const OK: i32 = 0; + +/// Exit code: general / unclassified error. +pub const GENERAL_ERROR: i32 = 1; + +/// Exit code: dependency resolution failed. +pub const DEPENDENCY_RESOLUTION_FAILED: i32 = 2; + +/// Exit code: partial update requested but no lock file exists. +pub const NO_LOCK_FILE_FOR_PARTIAL_UPDATE: i32 = 3; + +/// Exit code: lock file is invalid or corrupt. +pub const LOCK_FILE_INVALID: i32 = 4; + +/// Exit code: audit found a security advisory. +pub const AUDIT_FAILED: i32 = 5; + +/// Exit code: HTTP / network transport error. +pub const TRANSPORT_ERROR: i32 = 100; + +// --------------------------------------------------------------------------- +// MozartError — carries a specific exit code through anyhow's error chain +// --------------------------------------------------------------------------- + +/// An error type that carries a specific exit code for Mozart to use on exit. +/// +/// Use [`bail`] or [`bail_silent`] to construct one wrapped in `anyhow::Error`. +#[derive(Debug)] +pub struct MozartError { + pub message: String, + pub exit_code: i32, +} + +impl std::fmt::Display for MozartError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{}", self.message) + } +} + +impl std::error::Error for MozartError {} + +/// Return an `anyhow::Error` that carries `exit_code` and prints `message`. +pub fn bail(exit_code: i32, message: impl Into<String>) -> anyhow::Error { + MozartError { + message: message.into(), + exit_code, + } + .into() +} + +/// Return an `anyhow::Error` that carries `exit_code` but suppresses the +/// message (caller has already printed it). +pub fn bail_silent(exit_code: i32) -> anyhow::Error { + MozartError { + message: String::new(), + exit_code, + } + .into() +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_constants_have_expected_values() { + assert_eq!(OK, 0); + assert_eq!(GENERAL_ERROR, 1); + assert_eq!(DEPENDENCY_RESOLUTION_FAILED, 2); + assert_eq!(NO_LOCK_FILE_FOR_PARTIAL_UPDATE, 3); + assert_eq!(LOCK_FILE_INVALID, 4); + assert_eq!(AUDIT_FAILED, 5); + assert_eq!(TRANSPORT_ERROR, 100); + } + + #[test] + fn test_mozart_error_display() { + let err = MozartError { + message: "something went wrong".to_string(), + exit_code: GENERAL_ERROR, + }; + assert_eq!(format!("{err}"), "something went wrong"); + } + + #[test] + fn test_bail_can_be_downcast() { + let err = bail(DEPENDENCY_RESOLUTION_FAILED, "cannot resolve"); + let me = err.downcast_ref::<MozartError>().expect("should downcast"); + assert_eq!(me.exit_code, DEPENDENCY_RESOLUTION_FAILED); + assert_eq!(me.message, "cannot resolve"); + } + + #[test] + fn test_bail_silent_has_empty_message() { + let err = bail_silent(GENERAL_ERROR); + let me = err.downcast_ref::<MozartError>().expect("should downcast"); + assert_eq!(me.exit_code, GENERAL_ERROR); + assert!(me.message.is_empty()); + } + + #[test] + fn test_mozart_error_is_std_error() { + let err: Box<dyn std::error::Error> = Box::new(MozartError { + message: "test".to_string(), + exit_code: 1, + }); + assert_eq!(err.to_string(), "test"); + } +} diff --git a/crates/mozart/src/lib.rs b/crates/mozart/src/lib.rs index d28ff9c..4275833 100644 --- a/crates/mozart/src/lib.rs +++ b/crates/mozart/src/lib.rs @@ -5,6 +5,7 @@ pub mod commands; pub mod console; pub mod constraint; pub mod downloader; +pub mod exit_code; pub mod installed; pub mod lockfile; pub mod package; @@ -12,6 +13,7 @@ pub mod packagist; pub mod php_scanner; pub mod platform; pub mod resolver; +pub mod suggest; pub mod validation; pub mod version; pub mod version_bumper; diff --git a/crates/mozart/src/main.rs b/crates/mozart/src/main.rs index cd85137..dd85279 100644 --- a/crates/mozart/src/main.rs +++ b/crates/mozart/src/main.rs @@ -1,7 +1,24 @@ use clap::Parser; use mozart::commands; +use mozart::exit_code; -fn main() -> anyhow::Result<()> { +fn main() { let cli = commands::Cli::parse(); - commands::execute(&cli) + match commands::execute(&cli) { + Ok(()) => {} + Err(e) => { + // Check if this is a structured MozartError with a specific exit code. + if let Some(mozart_err) = e.downcast_ref::<exit_code::MozartError>() { + // Only print a message when there is one (bail_silent produces empty message). + if !mozart_err.message.is_empty() { + eprintln!("{}", mozart::console::error(&mozart_err.message)); + } + std::process::exit(mozart_err.exit_code); + } + + // Generic anyhow error — print and exit with GENERAL_ERROR. + eprintln!("{}", mozart::console::error(&format!("{e:#}"))); + std::process::exit(exit_code::GENERAL_ERROR); + } + } } diff --git a/crates/mozart/src/suggest.rs b/crates/mozart/src/suggest.rs new file mode 100644 index 0000000..d80ff3c --- /dev/null +++ b/crates/mozart/src/suggest.rs @@ -0,0 +1,220 @@ +//! Fuzzy package name suggestions using Levenshtein distance. +//! +//! Used to provide "Did you mean ...?" hints when a user types a package name +//! that does not exist in the installed packages or in the require/require-dev +//! sections of composer.json. + +/// Compute the Levenshtein edit distance between two strings. +/// +/// This is a standard dynamic-programming implementation that runs in O(m*n) +/// time and O(min(m,n)) space. +pub fn levenshtein(a: &str, b: &str) -> usize { + let a: Vec<char> = a.chars().collect(); + let b: Vec<char> = b.chars().collect(); + + let m = a.len(); + let n = b.len(); + + if m == 0 { + return n; + } + if n == 0 { + return m; + } + + // Use two alternating rows to save memory. + let mut prev: Vec<usize> = (0..=n).collect(); + let mut curr: Vec<usize> = vec![0; n + 1]; + + for i in 1..=m { + curr[0] = i; + for j in 1..=n { + let cost = if a[i - 1] == b[j - 1] { 0 } else { 1 }; + curr[j] = (prev[j] + 1) // deletion + .min(curr[j - 1] + 1) // insertion + .min(prev[j - 1] + cost); // substitution + } + std::mem::swap(&mut prev, &mut curr); + } + + prev[n] +} + +/// Maximum edit distance for a suggestion to be considered "similar". +/// +/// Packages with Levenshtein distance greater than this threshold are not +/// returned as suggestions. +const MAX_DISTANCE: usize = 5; + +/// Find package names from `candidates` that are similar to `query`. +/// +/// Returns a list of `(distance, name)` pairs sorted by ascending distance, +/// then ascending name for stability. Only candidates with a Levenshtein +/// distance <= [`MAX_DISTANCE`] are returned. +pub fn find_similar<'a>( + query: &str, + candidates: impl Iterator<Item = &'a str>, +) -> Vec<(usize, &'a str)> { + let query_lower = query.to_lowercase(); + let mut results: Vec<(usize, &'a str)> = candidates + .filter_map(|name| { + let dist = levenshtein(&query_lower, &name.to_lowercase()); + if dist <= MAX_DISTANCE && dist > 0 { + Some((dist, name)) + } else { + None + } + }) + .collect(); + + results.sort_by(|a, b| a.0.cmp(&b.0).then_with(|| a.1.cmp(b.1))); + results +} + +/// Format a "Did you mean ...?" message from a list of suggestions. +/// +/// Returns `None` when `suggestions` is empty. +/// +/// # Examples +/// +/// ``` +/// use mozart::suggest::format_did_you_mean; +/// let msg = format_did_you_mean(&["psr/log", "psr/cache"]); +/// assert!(msg.unwrap().contains("Did you mean")); +/// ``` +pub fn format_did_you_mean(suggestions: &[&str]) -> Option<String> { + if suggestions.is_empty() { + return None; + } + + let formatted = suggestions + .iter() + .map(|s| format!("\"{}\"", s)) + .collect::<Vec<_>>() + .join(" or "); + + Some(format!("Did you mean {}?", formatted)) +} + +// ─── Tests ─────────────────────────────────────────────────────────────────── + +#[cfg(test)] +mod tests { + use super::*; + + // ── levenshtein ─────────────────────────────────────────────────────────── + + #[test] + fn test_levenshtein_identical() { + assert_eq!(levenshtein("psr/log", "psr/log"), 0); + } + + #[test] + fn test_levenshtein_empty_left() { + assert_eq!(levenshtein("", "abc"), 3); + } + + #[test] + fn test_levenshtein_empty_right() { + assert_eq!(levenshtein("abc", ""), 3); + } + + #[test] + fn test_levenshtein_both_empty() { + assert_eq!(levenshtein("", ""), 0); + } + + #[test] + fn test_levenshtein_single_insertion() { + assert_eq!(levenshtein("psr/log", "psr/logs"), 1); + } + + #[test] + fn test_levenshtein_single_deletion() { + assert_eq!(levenshtein("psr/logs", "psr/log"), 1); + } + + #[test] + fn test_levenshtein_single_substitution() { + assert_eq!(levenshtein("psr/log", "psr/lag"), 1); + } + + #[test] + fn test_levenshtein_completely_different() { + assert_eq!(levenshtein("abc", "xyz"), 3); + } + + #[test] + fn test_levenshtein_package_names() { + // "monolog/monolog" vs "monolong/monolog" — 1 insertion + assert_eq!(levenshtein("monolog/monolog", "monolong/monolog"), 1); + } + + // ── find_similar ────────────────────────────────────────────────────────── + + #[test] + fn test_find_similar_returns_close_matches() { + let candidates = ["psr/log", "psr/cache", "monolog/monolog", "symfony/console"]; + let results = find_similar("psr/lod", candidates.iter().copied()); + assert!(!results.is_empty()); + // "psr/log" has distance 1 from "psr/lod" + assert_eq!(results[0].1, "psr/log"); + assert_eq!(results[0].0, 1); + } + + #[test] + fn test_find_similar_excludes_exact_match() { + let candidates = ["psr/log", "psr/cache"]; + // Exact match should not appear (distance == 0) + let results = find_similar("psr/log", candidates.iter().copied()); + assert!(!results.iter().any(|(_, name)| *name == "psr/log")); + } + + #[test] + fn test_find_similar_excludes_too_distant() { + let candidates = ["completely/different", "another/package"]; + let results = find_similar("psr/log", candidates.iter().copied()); + // All candidates are more than MAX_DISTANCE away + assert!(results.is_empty()); + } + + #[test] + fn test_find_similar_sorted_by_distance() { + let candidates = ["psr/log", "psr/logs", "psr/logsx"]; + // "psr/lod" -> "psr/log" distance 1, "psr/logs" distance 2, "psr/logsx" distance 3 + let results = find_similar("psr/lod", candidates.iter().copied()); + if results.len() >= 2 { + assert!(results[0].0 <= results[1].0); + } + } + + #[test] + fn test_find_similar_case_insensitive() { + let candidates = ["PSR/Log"]; + let results = find_similar("psr/log", candidates.iter().copied()); + // "psr/log" vs "psr/log" (both lowercased) = distance 0, so excluded + assert!(results.is_empty()); + } + + // ── format_did_you_mean ─────────────────────────────────────────────────── + + #[test] + fn test_format_did_you_mean_empty() { + assert!(format_did_you_mean(&[]).is_none()); + } + + #[test] + fn test_format_did_you_mean_single() { + let msg = format_did_you_mean(&["psr/log"]).unwrap(); + assert_eq!(msg, "Did you mean \"psr/log\"?"); + } + + #[test] + fn test_format_did_you_mean_multiple() { + let msg = format_did_you_mean(&["psr/log", "psr/cache"]).unwrap(); + assert!(msg.contains("Did you mean")); + assert!(msg.contains("\"psr/log\"")); + assert!(msg.contains("\"psr/cache\"")); + assert!(msg.contains(" or ")); + } +} |
