From 2562e3edd49f81fc9aba5c10dc3a70b4964da098 Mon Sep 17 00:00:00 2001 From: Alvin Real Date: Tue, 24 Feb 2026 01:50:41 +0100 Subject: [PATCH] feat: deterministic exit codes per error category Adds stable, scriptable exit codes so callers can programmatically distinguish failure modes: 0 - Success 1 - General / unknown (catch-all) 2 - CLI / usage error (invalid args, unknown format) 3 - I/O error (file not found, permission denied) 4 - Format / parse error (malformed input data) 5 - Mapping error (evaluation failure) 6 - Value error (type mismatch, overflow) Changes: - Add exit_code module with named constants to error.rs - Add MorphError::exit_code() method mapping each variant to its code - Update main.rs to use e.exit_code() instead of hardcoded 1 - Add unit tests for exit code correctness and uniqueness - Add integration tests (tests/exit_codes.rs) verifying each code via the CLI binary Fixes #85 --- src/error.rs | 110 ++++++++++++++++++++++ src/main.rs | 2 +- tests/exit_codes.rs | 222 ++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 333 insertions(+), 1 deletion(-) create mode 100644 tests/exit_codes.rs diff --git a/src/error.rs b/src/error.rs index 83a6557..4584da9 100644 --- a/src/error.rs +++ b/src/error.rs @@ -131,6 +131,52 @@ impl MorphError { } } +// --------------------------------------------------------------------------- +// Exit codes — deterministic, user-facing error categories +// --------------------------------------------------------------------------- + +/// Deterministic exit codes for each error category. +/// +/// These provide stable, scriptable exit semantics so callers can +/// programmatically distinguish failure modes. +/// +/// | Code | Category | Description | +/// |------|-----------------------|-------------------------------------------| +/// | 0 | Success | No error | +/// | 1 | General / unknown | Catch-all (should not normally occur) | +/// | 2 | CLI / usage | Invalid arguments, unknown flags | +/// | 3 | I/O | File not found, permission denied, etc. | +/// | 4 | Format / parse | Malformed input data | +/// | 5 | Mapping | Error in mapping evaluation | +/// | 6 | Value | Type mismatch, overflow, invalid cast | +pub mod exit_code { + /// Catch-all for unexpected errors. + pub const GENERAL: i32 = 1; + /// Invalid CLI arguments or usage. + pub const CLI: i32 = 2; + /// I/O error (file not found, permission denied, broken pipe, etc.). + pub const IO: i32 = 3; + /// Format/parse error (malformed input data). + pub const FORMAT: i32 = 4; + /// Mapping evaluation error. + pub const MAPPING: i32 = 5; + /// Value error (type mismatch, overflow, invalid cast). + pub const VALUE: i32 = 6; +} + +impl MorphError { + /// Return the deterministic exit code for this error category. + pub fn exit_code(&self) -> i32 { + match self { + MorphError::Cli(_) => exit_code::CLI, + MorphError::Io(_) => exit_code::IO, + MorphError::Format { .. } => exit_code::FORMAT, + MorphError::Mapping { .. } => exit_code::MAPPING, + MorphError::Value(_) => exit_code::VALUE, + } + } +} + // --------------------------------------------------------------------------- // Pretty error formatting // --------------------------------------------------------------------------- @@ -683,4 +729,68 @@ mod tests { assert_eq!(edit_distance("", "abc"), 3); assert_eq!(edit_distance("abc", ""), 3); } + + // -- Exit code tests -- + + #[test] + fn exit_code_cli() { + let err = MorphError::cli("bad flag"); + assert_eq!(err.exit_code(), exit_code::CLI); + assert_eq!(err.exit_code(), 2); + } + + #[test] + fn exit_code_io() { + let err: MorphError = std::io::Error::new(std::io::ErrorKind::NotFound, "gone").into(); + assert_eq!(err.exit_code(), exit_code::IO); + assert_eq!(err.exit_code(), 3); + } + + #[test] + fn exit_code_format() { + let err = MorphError::format("bad json"); + assert_eq!(err.exit_code(), exit_code::FORMAT); + assert_eq!(err.exit_code(), 4); + } + + #[test] + fn exit_code_mapping() { + let err = MorphError::mapping("unknown op"); + assert_eq!(err.exit_code(), exit_code::MAPPING); + assert_eq!(err.exit_code(), 5); + } + + #[test] + fn exit_code_value() { + let err = MorphError::value("overflow"); + assert_eq!(err.exit_code(), exit_code::VALUE); + assert_eq!(err.exit_code(), 6); + } + + #[test] + fn exit_codes_are_distinct() { + let codes = [ + exit_code::GENERAL, + exit_code::CLI, + exit_code::IO, + exit_code::FORMAT, + exit_code::MAPPING, + exit_code::VALUE, + ]; + // All codes should be unique + let mut seen = std::collections::HashSet::new(); + for code in &codes { + assert!(seen.insert(code), "duplicate exit code: {code}"); + } + } + + #[test] + fn exit_codes_are_nonzero() { + assert_ne!(exit_code::GENERAL, 0); + assert_ne!(exit_code::CLI, 0); + assert_ne!(exit_code::IO, 0); + assert_ne!(exit_code::FORMAT, 0); + assert_ne!(exit_code::MAPPING, 0); + assert_ne!(exit_code::VALUE, 0); + } } diff --git a/src/main.rs b/src/main.rs index 135d712..5e805b6 100644 --- a/src/main.rs +++ b/src/main.rs @@ -4,6 +4,6 @@ fn main() { let cli = morph::cli::Cli::parse_args(); if let Err(e) = morph::cli::run(&cli) { eprintln!("{}", e.pretty_print(None)); - process::exit(1); + process::exit(e.exit_code()); } } diff --git a/tests/exit_codes.rs b/tests/exit_codes.rs new file mode 100644 index 0000000..39b0bfb --- /dev/null +++ b/tests/exit_codes.rs @@ -0,0 +1,222 @@ +#![allow(deprecated)] +//! Integration tests for issue #85: deterministic exit codes per error category. +//! +//! Exit code semantics: +//! 0 — Success +//! 1 — General / unknown +//! 2 — CLI / usage error +//! 3 — I/O error (file not found, permission denied) +//! 4 — Format / parse error (malformed input) +//! 5 — Mapping evaluation error +//! 6 — Value error + +use assert_cmd::Command; +use std::io::Write; +use tempfile::NamedTempFile; + +fn morph() -> Command { + Command::cargo_bin("morph").unwrap() +} + +/// Create a temp file with the given content and extension. +fn temp_file(content: &str, suffix: &str) -> NamedTempFile { + let mut f = tempfile::Builder::new().suffix(suffix).tempfile().unwrap(); + f.write_all(content.as_bytes()).unwrap(); + f.flush().unwrap(); + f +} + +// --------------------------------------------------------------------------- +// Exit code 0 — Success +// --------------------------------------------------------------------------- + +#[test] +fn exit_0_on_success() { + let input = temp_file(r#"{"key": "value"}"#, ".json"); + morph() + .args(["-i", input.path().to_str().unwrap(), "-t", "yaml"]) + .assert() + .success() + .code(0); +} + +#[test] +fn exit_0_on_formats_flag() { + morph().args(["--formats"]).assert().success().code(0); +} + +#[test] +fn exit_0_on_functions_flag() { + morph().args(["--functions"]).assert().success().code(0); +} + +#[test] +fn exit_0_on_dry_run_valid_mapping() { + morph() + .args([ + "--dry-run", + "-e", + "rename .a -> .b", + "-f", + "json", + "-t", + "json", + ]) + .assert() + .success() + .code(0); +} + +// --------------------------------------------------------------------------- +// Exit code 2 — CLI / usage error +// --------------------------------------------------------------------------- + +#[test] +fn exit_2_on_unknown_input_format() { + let input = temp_file(r#"{"key": "value"}"#, ".json"); + morph() + .args([ + "-i", + input.path().to_str().unwrap(), + "-f", + "xyzzy", + "-t", + "json", + ]) + .assert() + .failure() + .code(2); +} + +#[test] +fn exit_2_on_unknown_output_format() { + let input = temp_file(r#"{"key": "value"}"#, ".json"); + morph() + .args(["-i", input.path().to_str().unwrap(), "-t", "xyzzy"]) + .assert() + .failure() + .code(2); +} + +#[test] +fn exit_2_on_missing_format_for_stdin() { + // Reading from stdin without -f should fail with exit code 2 + morph() + .args(["-t", "json"]) + .write_stdin("{}") + .assert() + .failure() + .code(2); +} + +#[test] +fn exit_2_on_cannot_detect_extension() { + let input = temp_file(r#"{"key": "value"}"#, ".unknown"); + morph() + .args(["-i", input.path().to_str().unwrap(), "-t", "json"]) + .assert() + .failure() + .code(2); +} + +// --------------------------------------------------------------------------- +// Exit code 3 — I/O error +// --------------------------------------------------------------------------- + +#[test] +fn exit_3_on_file_not_found() { + morph() + .args(["-i", "/tmp/nonexistent_morph_test_file.json", "-t", "yaml"]) + .assert() + .failure() + .code(3); +} + +// --------------------------------------------------------------------------- +// Exit code 4 — Format / parse error +// --------------------------------------------------------------------------- + +#[test] +fn exit_4_on_malformed_json() { + let input = temp_file("{ not valid json !!!", ".json"); + morph() + .args(["-i", input.path().to_str().unwrap(), "-t", "yaml"]) + .assert() + .failure() + .code(4); +} + +#[test] +fn exit_4_on_malformed_toml() { + let input = temp_file("[broken\nkey =", ".toml"); + morph() + .args(["-i", input.path().to_str().unwrap(), "-t", "json"]) + .assert() + .failure() + .code(4); +} + +#[test] +fn exit_4_on_malformed_yaml() { + let input = temp_file(":\n - :\n - :\n :", ".yaml"); + morph() + .args(["-i", input.path().to_str().unwrap(), "-t", "json"]) + .assert() + .failure() + .code(4); +} + +// --------------------------------------------------------------------------- +// Exit code 5 — Mapping evaluation error +// --------------------------------------------------------------------------- + +#[test] +fn exit_5_on_mapping_parse_error() { + let input = temp_file(r#"{"key": "value"}"#, ".json"); + morph() + .args([ + "-i", + input.path().to_str().unwrap(), + "-t", + "json", + "-e", + "invalid!!!syntax@@@", + ]) + .assert() + .failure() + .code(5); +} + +#[test] +fn exit_5_on_mapping_eval_error() { + // `each .nonexistent` should fail during mapping evaluation + let input = temp_file(r#"{"key": "value"}"#, ".json"); + morph() + .args([ + "-i", + input.path().to_str().unwrap(), + "-t", + "json", + "-e", + "each .nonexistent { drop .key }", + ]) + .assert() + .failure() + .code(5); +} + +// --------------------------------------------------------------------------- +// Determinism: same error → same code every time +// --------------------------------------------------------------------------- + +#[test] +fn exit_codes_are_deterministic() { + // Run the same failing command 3 times and verify same exit code + for _ in 0..3 { + morph() + .args(["-i", "/tmp/nonexistent_morph_test_file.json", "-t", "yaml"]) + .assert() + .failure() + .code(3); + } +}