cellos_ctl/exit.rs
1//! Exit-code discipline — pattern-11 (`axiom_exit::Exit`) aligned with the
2//! Algol/AXIOM cohort (tdep / tboundary / tflip).
3//!
4//! All non-success exits in `cellctl` MUST go through `CtlError::exit()`, which
5//! routes the process exit byte through [`axiom_exit::Exit`] so the contract
6//! between the CLI and shell scripts/CI is the same taxonomy every cohort tool
7//! ships:
8//!
9//! | code | `Exit` variant | meaning |
10//! |------|----------------------|-----------------------------------------------|
11//! | 0 | `Ok` | success |
12//! | 1 | `AssertionFailed` | a verify / chain assertion failed (mismatch) |
13//! | 2 | `Usage` | usage / config error (bad flag, missing file) |
14//! | 3 | `Preflight` | local validation / parse / startup gate failed|
15//! | 4 | `Degraded` | completed in a reduced / advisory mode |
16//! | >=64 | `ToolSpecific(n)` | tool-defined terminal outcome |
17//!
18//! `cellctl`'s "API error" (the server returned 4xx/5xx, or the network call
19//! failed) is a tool-specific terminal outcome, so it maps to
20//! [`CTL_API_FAILED`] (`Exit::ToolSpecific(64)`) — it is NOT an
21//! `AssertionFailed` (cellctl runs no verify verb) and NOT a `Preflight`
22//! failure (the local preflight passed; the remote call is what failed).
23//!
24//! Errors are always written to **stderr**. Machine-readable output (JSON, names)
25//! always goes to **stdout** so it can be piped through `jq`, `xargs`, etc.
26
27use std::fmt;
28use std::process;
29
30use axiom_exit::Exit;
31
32/// `cellctl`'s tool-specific terminal outcome: the server returned an error
33/// status, or the network call to cellos-server failed. axiom-exit owns the
34/// byte (`64`); cellctl owns the meaning.
35pub const CTL_API_FAILED: Exit = Exit::ToolSpecific(64);
36
37/// Structured CLI error carrying both a message and the doctrine-mandated exit code.
38///
39/// `status` carries the HTTP status code when the error originated from a real
40/// HTTP response with a non-success status. Transport / network errors leave
41/// `status` as `None` so callers can distinguish "the server said no" from
42/// "we never reached the server". See SMOKE-TEST report Finding #1:
43/// `cellctl diff` must only treat HTTP 404 as "would create", not every
44/// `ErrorKind::Api`.
45#[derive(Debug)]
46pub struct CtlError {
47 pub kind: ErrorKind,
48 pub message: String,
49 pub status: Option<u16>,
50}
51
52#[derive(Debug, Clone, Copy, PartialEq, Eq)]
53pub enum ErrorKind {
54 /// Usage / config error — bad CLI invocation, missing config, malformed YAML on read.
55 /// Maps to [`Exit::Usage`] (code 2).
56 Usage,
57 /// API error — cellos-server returned an error status, or the network call failed.
58 /// Maps to [`CTL_API_FAILED`] (`Exit::ToolSpecific(64)`).
59 Api,
60 /// Validation error — local schema check failed before the API call.
61 /// Maps to [`Exit::Preflight`] (code 3).
62 Validation,
63}
64
65impl ErrorKind {
66 /// The pattern-11 [`Exit`] this error maps to. `axiom_exit::Exit` is the
67 /// single source of truth for the numeric process-exit byte.
68 pub fn exit(self) -> Exit {
69 match self {
70 Self::Usage => Exit::Usage,
71 Self::Api => CTL_API_FAILED,
72 Self::Validation => Exit::Preflight,
73 }
74 }
75
76 pub fn code(self) -> i32 {
77 self.exit().as_i32()
78 }
79}
80
81impl fmt::Display for ErrorKind {
82 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
83 match self {
84 Self::Usage => f.write_str("usage"),
85 Self::Api => f.write_str("api"),
86 Self::Validation => f.write_str("validation"),
87 }
88 }
89}
90
91impl CtlError {
92 pub fn usage(msg: impl Into<String>) -> Self {
93 Self {
94 kind: ErrorKind::Usage,
95 message: msg.into(),
96 status: None,
97 }
98 }
99 pub fn api(msg: impl Into<String>) -> Self {
100 Self {
101 kind: ErrorKind::Api,
102 message: msg.into(),
103 status: None,
104 }
105 }
106 pub fn validation(msg: impl Into<String>) -> Self {
107 Self {
108 kind: ErrorKind::Validation,
109 message: msg.into(),
110 status: None,
111 }
112 }
113
114 /// Attach an HTTP status code. Use this in client error paths that map
115 /// a real HTTP response into a `CtlError` so downstream callers
116 /// (e.g. `cellctl diff`'s 404→"would create" branch) can distinguish
117 /// "server returned 404" from "transport failure".
118 pub fn with_status(mut self, status: u16) -> Self {
119 self.status = Some(status);
120 self
121 }
122
123 /// Emit the error to stderr and exit with the pattern-11 code derived from
124 /// [`axiom_exit::Exit`]. Never returns.
125 pub fn exit(&self) -> ! {
126 eprintln!("cellctl: {}: {}", self.kind, self.message);
127 process::exit(self.kind.exit().code().into())
128 }
129}
130
131impl fmt::Display for CtlError {
132 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
133 write!(f, "{}: {}", self.kind, self.message)
134 }
135}
136
137impl std::error::Error for CtlError {}
138
139/// Convenience: convert anyhow errors into a generic API error.
140impl From<anyhow::Error> for CtlError {
141 fn from(e: anyhow::Error) -> Self {
142 Self::api(e.to_string())
143 }
144}
145
146impl From<reqwest::Error> for CtlError {
147 fn from(e: reqwest::Error) -> Self {
148 Self::api(format!("http: {e}"))
149 }
150}
151
152impl From<std::io::Error> for CtlError {
153 fn from(e: std::io::Error) -> Self {
154 Self::usage(format!("io: {e}"))
155 }
156}
157
158impl From<serde_yaml::Error> for CtlError {
159 fn from(e: serde_yaml::Error) -> Self {
160 Self::validation(format!("yaml: {e}"))
161 }
162}
163
164impl From<serde_json::Error> for CtlError {
165 fn from(e: serde_json::Error) -> Self {
166 Self::api(format!("json: {e}"))
167 }
168}
169
170pub type CtlResult<T> = Result<T, CtlError>;