Skip to main content

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>;