Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
110 changes: 110 additions & 0 deletions src/error.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
// ---------------------------------------------------------------------------
Expand Down Expand Up @@ -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);
}
}
2 changes: 1 addition & 1 deletion src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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());
}
}
222 changes: 222 additions & 0 deletions tests/exit_codes.rs
Original file line number Diff line number Diff line change
@@ -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);
}
}