Skip to main content

cairn_mod/cli/
error.rs

1//! Unified error taxonomy for the CLI handlers + the exit-code
2//! contract (criterion G / §F9's "specific exit codes per error
3//! class").
4//!
5//! Exit codes are a durable part of the CLI contract — scripts that
6//! branch on them will depend on the numbers below. Add new classes
7//! by appending new codes; renumbering is breaking.
8
9use std::net::SocketAddr;
10
11use thiserror::Error;
12
13use super::pds::PdsError;
14use super::session::SessionError;
15use crate::signing_key::KeyLoadError;
16
17/// Exit code taxonomy. Matches criterion G from the #16 plan.
18pub mod code {
19    /// Clean exit.
20    pub const SUCCESS: i32 = 0;
21    /// Usage / argument parsing. clap exits with 2 directly — this
22    /// constant exists so hand-written handlers agree.
23    pub const USAGE: i32 = 2;
24    /// Session file absent, permissions wrong, version mismatch.
25    pub const SESSION: i32 = 3;
26    /// Transport-level failure: unreachable host, TLS error, timeout.
27    pub const NETWORK: i32 = 4;
28    /// PDS rejected credentials OR Cairn returned 401.
29    pub const AUTH: i32 = 5;
30    /// Cairn returned 403.
31    pub const FORBIDDEN: i32 = 6;
32    /// Cairn returned some other 4xx.
33    pub const SERVER_4XX: i32 = 7;
34    /// Cairn returned 5xx.
35    pub const SERVER_5XX: i32 = 8;
36    /// Internal error (malformed response body, unexpected state).
37    pub const INTERNAL: i32 = 10;
38    /// `cairn serve` could not acquire the single-instance lease —
39    /// another Cairn process holds it with a fresh heartbeat (§F5).
40    /// Distinct from INTERNAL so systemd-restart-loop logic can
41    /// branch ("another instance is running, don't restart") vs.
42    /// genuine internal failure.
43    pub const LEASE_CONFLICT: i32 = 11;
44    /// Service record verify (§F1, #8): local `[labeler]` config
45    /// differs from the published `app.bsky.labeler.service` record
46    /// on the PDS. Operator action required: reconcile via
47    /// `cairn publish-service-record`.
48    pub const SERVICE_RECORD_DRIFT: i32 = 12;
49    /// Service record verify (§F1, #8): no record published yet.
50    /// Operator action required: run
51    /// `cairn publish-service-record` for the first time.
52    pub const SERVICE_RECORD_ABSENT: i32 = 13;
53    /// Service record verify (§F1, #8): PDS unreachable during
54    /// the verify fetch. Transient infra issue; orchestrators
55    /// should retry rather than treating as a config drift.
56    pub const SERVICE_RECORD_UNREACHABLE: i32 = 14;
57}
58
59/// Error sources the CLI dispatcher knows about. The human-readable
60/// `Display` output is what lands on stderr; [`CliError::exit_code`]
61/// maps variants onto the [`code`] table.
62#[derive(Debug, Error)]
63pub enum CliError {
64    /// Session-file failure (absent, permissions, ownership,
65    /// version mismatch, malformed JSON).
66    #[error("{0}")]
67    Session(#[from] SessionError),
68    /// PDS interaction failure (network, unauthorized, swap-race,
69    /// unexpected status, malformed response).
70    #[error("{0}")]
71    Pds(#[from] PdsError),
72    /// Authed command ran with no session file present.
73    #[error("not logged in; run `cairn login --cairn-server ... --pds ... --handle ...`")]
74    NotLoggedIn,
75    /// Transport-level failure contacting a Cairn server URL
76    /// (timeout, DNS, TLS, connection refused).
77    #[error("network error contacting {url}: {source}")]
78    Http {
79        /// URL that failed.
80        url: String,
81        /// Underlying reqwest error.
82        #[source]
83        source: reqwest::Error,
84    },
85    /// Cairn returned a non-2xx HTTP status.
86    #[error("Cairn at {url} returned {status}: {body}")]
87    CairnStatus {
88        /// URL that returned the error.
89        url: String,
90        /// HTTP status code.
91        status: u16,
92        /// Response body for operator-side diagnostics.
93        body: String,
94    },
95    /// Cairn returned a 2xx response but the body didn't parse as
96    /// the expected shape.
97    #[error("could not parse Cairn response from {url}: {source}")]
98    MalformedResponse {
99        /// URL whose response failed to parse.
100        url: String,
101        /// Underlying serde_json error.
102        #[source]
103        source: serde_json::Error,
104    },
105    /// Argument-shape failure — malformed CLI inputs, config
106    /// validation failures.
107    #[error("{0}")]
108    Config(String),
109    /// Signing key file couldn't be loaded (§5.1 invariant failure,
110    /// bad hex, wrong length, env-override attempt). Full cause lives
111    /// on the nested `KeyLoadError`; `Display` never includes the key
112    /// material because the key bytes are never decoded when an error
113    /// path runs, and `KeyLoadError::Display` only formats paths +
114    /// metadata.
115    #[error("signing key load failed: {0}")]
116    KeyLoad(#[from] KeyLoadError),
117    /// SQLite migrations failed at startup. String-only rather than
118    /// wrapping sqlx's error type to avoid leaking schema internals
119    /// into the error taxonomy.
120    #[error("migrations failed: {0}")]
121    MigrationFailed(String),
122    /// Another Cairn instance holds the single-instance lease (§F5).
123    /// Distinct variant so operators + systemd can branch on the
124    /// dedicated [`code::LEASE_CONFLICT`] exit code.
125    #[error("another Cairn instance holds the lease (instance_id={instance_id}, age={age_secs}s)")]
126    LeaseConflict {
127        /// Instance identifier held by the rival process.
128        instance_id: String,
129        /// Seconds since the rival last heartbeated.
130        age_secs: u64,
131    },
132    /// `TcpListener::bind` failed — port in use, permission denied,
133    /// malformed addr, etc.
134    #[error("could not bind {addr}: {source}")]
135    BindFailed {
136        /// Address we attempted to bind.
137        addr: SocketAddr,
138        /// Underlying I/O error.
139        #[source]
140        source: std::io::Error,
141    },
142    /// Anything in the startup sequence that doesn't fit the buckets
143    /// above (writer spawn, auth-context init). Rare at runtime;
144    /// surfaces as INTERNAL.
145    #[error("startup failure: {0}")]
146    Startup(String),
147    /// `cairn serve` startup verify (§F1, #8): the published
148    /// `app.bsky.labeler.service` record on the operator's PDS
149    /// differs from what the local `[labeler]` config block would
150    /// render. Reconcile via `cairn publish-service-record`.
151    #[error(
152        "service record verification failed: local [labeler] config differs from the published record at {pds_url}/{service_did}.\n\nDifferences:\n{summary}\n\nThe local config and the published record must agree before `cairn serve` will start. To reconcile, review the differences above, then on the operator's host run:\n\n    cairn publish-service-record --config <path>\n\nto update the PDS record."
153    )]
154    ServiceRecordDrift {
155        /// PDS URL the verify check fetched from.
156        pds_url: String,
157        /// Labeler service DID whose record was fetched.
158        service_did: String,
159        /// Per-field drift summary built by `serve::verify`. Not
160        /// raw JSON — a human-readable enumeration of the fields
161        /// that differ.
162        summary: String,
163    },
164    /// `cairn serve` startup verify (§F1, #8): no
165    /// `app.bsky.labeler.service` record exists on the operator's
166    /// PDS for the labeler's DID. The labeler hasn't published
167    /// its declaration yet.
168    #[error(
169        "service record verification failed: app.bsky.labeler.service record not found at {pds_url} for repo {service_did}.\n\nThis labeler has not yet published its declaration to the PDS. On the operator's host (where operator credentials are configured), run:\n\n    cairn publish-service-record --config <path>\n\nthen restart `cairn serve`."
170    )]
171    ServiceRecordAbsent {
172        /// PDS URL the verify check fetched from.
173        pds_url: String,
174        /// Labeler service DID whose record was searched.
175        service_did: String,
176    },
177    /// `cairn serve` startup verify (§F1, #8): could not reach
178    /// the PDS to perform the verify fetch. Transient infra
179    /// issue, distinct from drift / absent — orchestrators should
180    /// retry.
181    #[error(
182        "service record verification failed: could not reach PDS at {pds_url} to fetch app.bsky.labeler.service.\n\nUnderlying error: {cause}\n\nThis is likely a transient infrastructure issue (PDS down, network flaky, rate limit). Verify the PDS is reachable then retry. If the issue persists, check `operator.pds_url` in your config and confirm the labeler DID's home PDS."
183    )]
184    ServiceRecordUnreachable {
185        /// PDS URL the verify check tried to fetch from.
186        pds_url: String,
187        /// Underlying error message (transport, status, etc.).
188        cause: String,
189    },
190}
191
192impl CliError {
193    /// Exit code per criterion G. Kept close to the Display output
194    /// so script authors can correlate message prefix with code.
195    pub fn exit_code(&self) -> i32 {
196        match self {
197            CliError::Session(SessionError::Io(_)) => code::SESSION,
198            CliError::Session(_) => code::SESSION,
199            CliError::NotLoggedIn => code::SESSION,
200            CliError::Pds(PdsError::Network { .. }) => code::NETWORK,
201            CliError::Pds(PdsError::Unauthorized { .. }) => code::AUTH,
202            CliError::Pds(PdsError::InvalidUrl { .. }) => code::USAGE,
203            CliError::Pds(PdsError::MalformedResponse { .. }) => code::INTERNAL,
204            CliError::Pds(PdsError::SwapRace { .. }) => code::INTERNAL,
205            CliError::Pds(PdsError::UnexpectedStatus { status, .. }) => match *status {
206                401 => code::AUTH,
207                403 => code::FORBIDDEN,
208                400..=499 => code::SERVER_4XX,
209                500..=599 => code::SERVER_5XX,
210                _ => code::INTERNAL,
211            },
212            CliError::Http { .. } => code::NETWORK,
213            CliError::CairnStatus { status, .. } => match *status {
214                401 => code::AUTH,
215                403 => code::FORBIDDEN,
216                400..=499 => code::SERVER_4XX,
217                500..=599 => code::SERVER_5XX,
218                _ => code::INTERNAL,
219            },
220            CliError::MalformedResponse { .. } => code::INTERNAL,
221            CliError::Config(_) => code::USAGE,
222            CliError::KeyLoad(_) => code::INTERNAL,
223            CliError::MigrationFailed(_) => code::INTERNAL,
224            CliError::LeaseConflict { .. } => code::LEASE_CONFLICT,
225            CliError::BindFailed { .. } => code::NETWORK,
226            CliError::Startup(_) => code::INTERNAL,
227            CliError::ServiceRecordDrift { .. } => code::SERVICE_RECORD_DRIFT,
228            CliError::ServiceRecordAbsent { .. } => code::SERVICE_RECORD_ABSENT,
229            CliError::ServiceRecordUnreachable { .. } => code::SERVICE_RECORD_UNREACHABLE,
230        }
231    }
232}