Skip to content

Commit 4fea628

Browse files
authored
feat: deterministic exit codes per error category (#91)
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
1 parent f21fd09 commit 4fea628

File tree

3 files changed

+333
-1
lines changed

3 files changed

+333
-1
lines changed

src/error.rs

Lines changed: 110 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -131,6 +131,52 @@ impl MorphError {
131131
}
132132
}
133133

134+
// ---------------------------------------------------------------------------
135+
// Exit codes — deterministic, user-facing error categories
136+
// ---------------------------------------------------------------------------
137+
138+
/// Deterministic exit codes for each error category.
139+
///
140+
/// These provide stable, scriptable exit semantics so callers can
141+
/// programmatically distinguish failure modes.
142+
///
143+
/// | Code | Category | Description |
144+
/// |------|-----------------------|-------------------------------------------|
145+
/// | 0 | Success | No error |
146+
/// | 1 | General / unknown | Catch-all (should not normally occur) |
147+
/// | 2 | CLI / usage | Invalid arguments, unknown flags |
148+
/// | 3 | I/O | File not found, permission denied, etc. |
149+
/// | 4 | Format / parse | Malformed input data |
150+
/// | 5 | Mapping | Error in mapping evaluation |
151+
/// | 6 | Value | Type mismatch, overflow, invalid cast |
152+
pub mod exit_code {
153+
/// Catch-all for unexpected errors.
154+
pub const GENERAL: i32 = 1;
155+
/// Invalid CLI arguments or usage.
156+
pub const CLI: i32 = 2;
157+
/// I/O error (file not found, permission denied, broken pipe, etc.).
158+
pub const IO: i32 = 3;
159+
/// Format/parse error (malformed input data).
160+
pub const FORMAT: i32 = 4;
161+
/// Mapping evaluation error.
162+
pub const MAPPING: i32 = 5;
163+
/// Value error (type mismatch, overflow, invalid cast).
164+
pub const VALUE: i32 = 6;
165+
}
166+
167+
impl MorphError {
168+
/// Return the deterministic exit code for this error category.
169+
pub fn exit_code(&self) -> i32 {
170+
match self {
171+
MorphError::Cli(_) => exit_code::CLI,
172+
MorphError::Io(_) => exit_code::IO,
173+
MorphError::Format { .. } => exit_code::FORMAT,
174+
MorphError::Mapping { .. } => exit_code::MAPPING,
175+
MorphError::Value(_) => exit_code::VALUE,
176+
}
177+
}
178+
}
179+
134180
// ---------------------------------------------------------------------------
135181
// Pretty error formatting
136182
// ---------------------------------------------------------------------------
@@ -683,4 +729,68 @@ mod tests {
683729
assert_eq!(edit_distance("", "abc"), 3);
684730
assert_eq!(edit_distance("abc", ""), 3);
685731
}
732+
733+
// -- Exit code tests --
734+
735+
#[test]
736+
fn exit_code_cli() {
737+
let err = MorphError::cli("bad flag");
738+
assert_eq!(err.exit_code(), exit_code::CLI);
739+
assert_eq!(err.exit_code(), 2);
740+
}
741+
742+
#[test]
743+
fn exit_code_io() {
744+
let err: MorphError = std::io::Error::new(std::io::ErrorKind::NotFound, "gone").into();
745+
assert_eq!(err.exit_code(), exit_code::IO);
746+
assert_eq!(err.exit_code(), 3);
747+
}
748+
749+
#[test]
750+
fn exit_code_format() {
751+
let err = MorphError::format("bad json");
752+
assert_eq!(err.exit_code(), exit_code::FORMAT);
753+
assert_eq!(err.exit_code(), 4);
754+
}
755+
756+
#[test]
757+
fn exit_code_mapping() {
758+
let err = MorphError::mapping("unknown op");
759+
assert_eq!(err.exit_code(), exit_code::MAPPING);
760+
assert_eq!(err.exit_code(), 5);
761+
}
762+
763+
#[test]
764+
fn exit_code_value() {
765+
let err = MorphError::value("overflow");
766+
assert_eq!(err.exit_code(), exit_code::VALUE);
767+
assert_eq!(err.exit_code(), 6);
768+
}
769+
770+
#[test]
771+
fn exit_codes_are_distinct() {
772+
let codes = [
773+
exit_code::GENERAL,
774+
exit_code::CLI,
775+
exit_code::IO,
776+
exit_code::FORMAT,
777+
exit_code::MAPPING,
778+
exit_code::VALUE,
779+
];
780+
// All codes should be unique
781+
let mut seen = std::collections::HashSet::new();
782+
for code in &codes {
783+
assert!(seen.insert(code), "duplicate exit code: {code}");
784+
}
785+
}
786+
787+
#[test]
788+
fn exit_codes_are_nonzero() {
789+
assert_ne!(exit_code::GENERAL, 0);
790+
assert_ne!(exit_code::CLI, 0);
791+
assert_ne!(exit_code::IO, 0);
792+
assert_ne!(exit_code::FORMAT, 0);
793+
assert_ne!(exit_code::MAPPING, 0);
794+
assert_ne!(exit_code::VALUE, 0);
795+
}
686796
}

src/main.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,6 @@ fn main() {
44
let cli = morph::cli::Cli::parse_args();
55
if let Err(e) = morph::cli::run(&cli) {
66
eprintln!("{}", e.pretty_print(None));
7-
process::exit(1);
7+
process::exit(e.exit_code());
88
}
99
}

tests/exit_codes.rs

Lines changed: 222 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,222 @@
1+
#![allow(deprecated)]
2+
//! Integration tests for issue #85: deterministic exit codes per error category.
3+
//!
4+
//! Exit code semantics:
5+
//! 0 — Success
6+
//! 1 — General / unknown
7+
//! 2 — CLI / usage error
8+
//! 3 — I/O error (file not found, permission denied)
9+
//! 4 — Format / parse error (malformed input)
10+
//! 5 — Mapping evaluation error
11+
//! 6 — Value error
12+
13+
use assert_cmd::Command;
14+
use std::io::Write;
15+
use tempfile::NamedTempFile;
16+
17+
fn morph() -> Command {
18+
Command::cargo_bin("morph").unwrap()
19+
}
20+
21+
/// Create a temp file with the given content and extension.
22+
fn temp_file(content: &str, suffix: &str) -> NamedTempFile {
23+
let mut f = tempfile::Builder::new().suffix(suffix).tempfile().unwrap();
24+
f.write_all(content.as_bytes()).unwrap();
25+
f.flush().unwrap();
26+
f
27+
}
28+
29+
// ---------------------------------------------------------------------------
30+
// Exit code 0 — Success
31+
// ---------------------------------------------------------------------------
32+
33+
#[test]
34+
fn exit_0_on_success() {
35+
let input = temp_file(r#"{"key": "value"}"#, ".json");
36+
morph()
37+
.args(["-i", input.path().to_str().unwrap(), "-t", "yaml"])
38+
.assert()
39+
.success()
40+
.code(0);
41+
}
42+
43+
#[test]
44+
fn exit_0_on_formats_flag() {
45+
morph().args(["--formats"]).assert().success().code(0);
46+
}
47+
48+
#[test]
49+
fn exit_0_on_functions_flag() {
50+
morph().args(["--functions"]).assert().success().code(0);
51+
}
52+
53+
#[test]
54+
fn exit_0_on_dry_run_valid_mapping() {
55+
morph()
56+
.args([
57+
"--dry-run",
58+
"-e",
59+
"rename .a -> .b",
60+
"-f",
61+
"json",
62+
"-t",
63+
"json",
64+
])
65+
.assert()
66+
.success()
67+
.code(0);
68+
}
69+
70+
// ---------------------------------------------------------------------------
71+
// Exit code 2 — CLI / usage error
72+
// ---------------------------------------------------------------------------
73+
74+
#[test]
75+
fn exit_2_on_unknown_input_format() {
76+
let input = temp_file(r#"{"key": "value"}"#, ".json");
77+
morph()
78+
.args([
79+
"-i",
80+
input.path().to_str().unwrap(),
81+
"-f",
82+
"xyzzy",
83+
"-t",
84+
"json",
85+
])
86+
.assert()
87+
.failure()
88+
.code(2);
89+
}
90+
91+
#[test]
92+
fn exit_2_on_unknown_output_format() {
93+
let input = temp_file(r#"{"key": "value"}"#, ".json");
94+
morph()
95+
.args(["-i", input.path().to_str().unwrap(), "-t", "xyzzy"])
96+
.assert()
97+
.failure()
98+
.code(2);
99+
}
100+
101+
#[test]
102+
fn exit_2_on_missing_format_for_stdin() {
103+
// Reading from stdin without -f should fail with exit code 2
104+
morph()
105+
.args(["-t", "json"])
106+
.write_stdin("{}")
107+
.assert()
108+
.failure()
109+
.code(2);
110+
}
111+
112+
#[test]
113+
fn exit_2_on_cannot_detect_extension() {
114+
let input = temp_file(r#"{"key": "value"}"#, ".unknown");
115+
morph()
116+
.args(["-i", input.path().to_str().unwrap(), "-t", "json"])
117+
.assert()
118+
.failure()
119+
.code(2);
120+
}
121+
122+
// ---------------------------------------------------------------------------
123+
// Exit code 3 — I/O error
124+
// ---------------------------------------------------------------------------
125+
126+
#[test]
127+
fn exit_3_on_file_not_found() {
128+
morph()
129+
.args(["-i", "/tmp/nonexistent_morph_test_file.json", "-t", "yaml"])
130+
.assert()
131+
.failure()
132+
.code(3);
133+
}
134+
135+
// ---------------------------------------------------------------------------
136+
// Exit code 4 — Format / parse error
137+
// ---------------------------------------------------------------------------
138+
139+
#[test]
140+
fn exit_4_on_malformed_json() {
141+
let input = temp_file("{ not valid json !!!", ".json");
142+
morph()
143+
.args(["-i", input.path().to_str().unwrap(), "-t", "yaml"])
144+
.assert()
145+
.failure()
146+
.code(4);
147+
}
148+
149+
#[test]
150+
fn exit_4_on_malformed_toml() {
151+
let input = temp_file("[broken\nkey =", ".toml");
152+
morph()
153+
.args(["-i", input.path().to_str().unwrap(), "-t", "json"])
154+
.assert()
155+
.failure()
156+
.code(4);
157+
}
158+
159+
#[test]
160+
fn exit_4_on_malformed_yaml() {
161+
let input = temp_file(":\n - :\n - :\n :", ".yaml");
162+
morph()
163+
.args(["-i", input.path().to_str().unwrap(), "-t", "json"])
164+
.assert()
165+
.failure()
166+
.code(4);
167+
}
168+
169+
// ---------------------------------------------------------------------------
170+
// Exit code 5 — Mapping evaluation error
171+
// ---------------------------------------------------------------------------
172+
173+
#[test]
174+
fn exit_5_on_mapping_parse_error() {
175+
let input = temp_file(r#"{"key": "value"}"#, ".json");
176+
morph()
177+
.args([
178+
"-i",
179+
input.path().to_str().unwrap(),
180+
"-t",
181+
"json",
182+
"-e",
183+
"invalid!!!syntax@@@",
184+
])
185+
.assert()
186+
.failure()
187+
.code(5);
188+
}
189+
190+
#[test]
191+
fn exit_5_on_mapping_eval_error() {
192+
// `each .nonexistent` should fail during mapping evaluation
193+
let input = temp_file(r#"{"key": "value"}"#, ".json");
194+
morph()
195+
.args([
196+
"-i",
197+
input.path().to_str().unwrap(),
198+
"-t",
199+
"json",
200+
"-e",
201+
"each .nonexistent { drop .key }",
202+
])
203+
.assert()
204+
.failure()
205+
.code(5);
206+
}
207+
208+
// ---------------------------------------------------------------------------
209+
// Determinism: same error → same code every time
210+
// ---------------------------------------------------------------------------
211+
212+
#[test]
213+
fn exit_codes_are_deterministic() {
214+
// Run the same failing command 3 times and verify same exit code
215+
for _ in 0..3 {
216+
morph()
217+
.args(["-i", "/tmp/nonexistent_morph_test_file.json", "-t", "yaml"])
218+
.assert()
219+
.failure()
220+
.code(3);
221+
}
222+
}

0 commit comments

Comments
 (0)