aboutsummaryrefslogtreecommitdiffhomepage
path: root/crates
diff options
context:
space:
mode:
authornsfisis <nsfisis@gmail.com>2026-02-21 23:38:32 +0900
committernsfisis <nsfisis@gmail.com>2026-02-21 23:38:32 +0900
commit52310761f67220c9c075cd847205825a720035ee (patch)
tree0528fc94aea7853e41313e19964d74a958dae9c9 /crates
parent92da9e37c68beb180e45e550fba5acd7d28dca27 (diff)
downloadphp-mozart-52310761f67220c9c075cd847205825a720035ee.tar.gz
php-mozart-52310761f67220c9c075cd847205825a720035ee.tar.zst
php-mozart-52310761f67220c9c075cd847205825a720035ee.zip
feat(console): add structured error handling, verbosity, and suggestions
Implement Phase 7.2 error handling & UX infrastructure: - Add exit_code module with MozartError, bail()/bail_silent() helpers, and Composer-compatible exit code constants (0-5, 100) - Redesign Console struct with Verbosity enum (Quiet/Normal/Verbose/ VeryVerbose/Debug), ANSI auto-detection via IsTerminal, and verbosity-gated output methods (info/verbose/debug/error) - Thread Console through all 33 command execute() signatures - Replace all std::process::exit() calls with structured MozartError returns handled in main() - Migrate eprintln\! status messages to console.info() for quiet-mode suppression - Add suggest module with Levenshtein distance and "Did you mean?" formatting for future package name suggestions Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Diffstat (limited to 'crates')
-rw-r--r--crates/mozart/src/archiver.rs56
-rw-r--r--crates/mozart/src/commands.rs65
-rw-r--r--crates/mozart/src/commands/about.rs6
-rw-r--r--crates/mozart/src/commands/archive.rs11
-rw-r--r--crates/mozart/src/commands/audit.rs6
-rw-r--r--crates/mozart/src/commands/browse.rs15
-rw-r--r--crates/mozart/src/commands/bump.rs76
-rw-r--r--crates/mozart/src/commands/check_platform_reqs.rs6
-rw-r--r--crates/mozart/src/commands/clear_cache.rs14
-rw-r--r--crates/mozart/src/commands/config.rs6
-rw-r--r--crates/mozart/src/commands/create_project.rs71
-rw-r--r--crates/mozart/src/commands/depends.rs6
-rw-r--r--crates/mozart/src/commands/diagnose.rs6
-rw-r--r--crates/mozart/src/commands/dump_autoload.rs10
-rw-r--r--crates/mozart/src/commands/exec.rs6
-rw-r--r--crates/mozart/src/commands/fund.rs6
-rw-r--r--crates/mozart/src/commands/global.rs10
-rw-r--r--crates/mozart/src/commands/init.rs10
-rw-r--r--crates/mozart/src/commands/install.rs73
-rw-r--r--crates/mozart/src/commands/licenses.rs6
-rw-r--r--crates/mozart/src/commands/outdated.rs6
-rw-r--r--crates/mozart/src/commands/prohibits.rs6
-rw-r--r--crates/mozart/src/commands/reinstall.rs24
-rw-r--r--crates/mozart/src/commands/remove.rs83
-rw-r--r--crates/mozart/src/commands/repository.rs6
-rw-r--r--crates/mozart/src/commands/require.rs110
-rw-r--r--crates/mozart/src/commands/run_script.rs6
-rw-r--r--crates/mozart/src/commands/search.rs6
-rw-r--r--crates/mozart/src/commands/self_update.rs6
-rw-r--r--crates/mozart/src/commands/show.rs6
-rw-r--r--crates/mozart/src/commands/status.rs6
-rw-r--r--crates/mozart/src/commands/suggests.rs6
-rw-r--r--crates/mozart/src/commands/update.rs213
-rw-r--r--crates/mozart/src/commands/validate.rs44
-rw-r--r--crates/mozart/src/console.rs259
-rw-r--r--crates/mozart/src/exit_code.rs114
-rw-r--r--crates/mozart/src/lib.rs2
-rw-r--r--crates/mozart/src/main.rs21
-rw-r--r--crates/mozart/src/suggest.rs220
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 "));
+ }
+}