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}