Skip to main content

ignition_core/
error.rs

1//! Typed error taxonomy — THE exit-code contract for `ign`.
2//!
3//! The mapping error-class → exit code is LOCKED (Phase-1 API freeze). The
4//! table exists in exactly two places: [`CoreError::exit_code`] here and the
5//! README — kept in sync by the `exit_code_mapping_enumerated` unit test and
6//! the golden-file tests in `crates/ignition-cli/tests/`.
7//!
8//! | exit | class          | slugs
9//! |------|----------------|-----------------------------------------------
10//! | 1    | internal       | `internal`
11//! | 2    | usage          | `confirmation_required`, `invalid_import_file`, `invalid_input`, `gateway_client_error` (09-01) (clap renders its own usage errors — never hook clap)
12//! | 3    | config         | `profile_not_found`, `no_active_profile`, `secret_unavailable`, `config_invalid`, `poll_interval_too_small` (08-01), `module_not_registered` (16-01)
13//! | 4    | network        | `network_error`, `module_feed_unreachable` (15-01)
14//! | 5    | auth           | `auth_rejected`
15//! | 6    | target_state   | `gateway_too_old`, `gateway_not_commissioned`, `gateway_restarting`, `not_found`, `project_exists`, `resource_binary`, `trial_not_expired` (04-03), `provider_not_found` (05-04), `routes_not_deployed`, `webdev_unlicensed`, `route_version_mismatch`, `webdev_route_error` (05-03), `tag_collision` (05-05), `alarm_journal_missing` (05-06), `import_denied` (05-07), `session_not_prunable` (06-07), `eam_not_controller` (07-02), `eam_task_type_refused` (07-02), `eam_task_in_flight` (07-06), `script_exec_not_configured` (07-03), `lint_tool_absent` (07-04), `provider_root_unsupported` (07-06), `bundle_not_available` (09-07), `module_release_not_found`, `module_digest_mismatch`, `module_feed_unusable` (15-01), `module_digest_changed` (15-02), `module_uninstall_denied` (19-01)
16//! | 7    | rig            | `rig_error` (reserved — first used in Phase 4)
17//!
18//! Slugs are public contract: never respell them. Exit codes are public
19//! contract: never renumber them (the enumerated test guards both).
20
21use serde::Serialize;
22
23/// The `ign tui` TTY-refusal reason (06-07). The InvalidInput hint is
24/// content-addressed off this exact string: the frozen taxonomy keeps
25/// ONE usage-input variant (no hint field, no new variant), but the
26/// TTY refusal's fix is terminal-related, not `--file`/stdin —
27/// [`CoreError::hint`] special-cases this one reason while every other
28/// raise site keeps the resource-put default. Construct via
29/// [`CoreError::tui_tty_refusal`] so the reason/hint pair cannot drift.
30pub const TUI_TTY_REFUSAL_REASON: &str = "ign tui requires a terminal (stdout is not a TTY)";
31
32/// The TAGS-12 loss-gate refusal reason prefix (11-07 gap closure). The
33/// InvalidInput hint is content-addressed off this literal: the loss
34/// gate's reason is DYNAMIC prose (the CLI's `render_loss_prose` header
35/// `loss report ({label}): …` plus per-fact lines), so unlike
36/// [`TUI_TTY_REFUSAL_REASON`] the sentinel cannot be the whole reason —
37/// it is the stable header prefix instead. Same slug (`invalid_input`),
38/// same exit 2 (frozen taxonomy; only the hint differs) — the 06-07
39/// TTY-refusal pattern at one removal. The contract_tags loss-gate
40/// pins are the drift guard: if the prose header ever changes, the
41/// hint silently regresses to the generic default and those pins fail.
42pub const LOSS_GATE_REFUSAL_REASON_PREFIX: &str = "loss report (";
43
44/// The TESTING bundle's route prefix (QUICK-p0g). The
45/// [`CoreError::RoutesNotDeployed`] hint is content-addressed off it
46/// — same third instance of the 06-07 pattern: the frozen taxonomy
47/// keeps ONE absent-routes variant and one slug
48/// (`routes_not_deployed`, exit 6), but the testing bundle installs
49/// through a DIFFERENT command (`ign webdev deploy --with-testing` /
50/// `ign adopt --project NAME --testing`), so the generic
51/// `ign webdev deploy` hint would name a command that does not fix
52/// it. Raise sites pass `route: "testing/run"`.
53pub const TESTING_ROUTE_PREFIX: &str = "testing/";
54
55/// The api-call catch-all's body cap (09-01): a gateway 4xx body rides
56/// [`CoreError::GatewayClientError`] VERBATIM up to this many bytes; a
57/// larger body is truncated at [`truncate_api_body`] with the explicit
58/// [`GATEWAY_CLIENT_BODY_TRUNCATION_MARKER`]. 4 KiB is the plan-locked
59/// cap — an unbounded passthrough would let a pathological gateway
60/// page flood the agent's envelope.
61pub const GATEWAY_CLIENT_BODY_CAP_BYTES: usize = 4096;
62
63/// The truncation marker [`truncate_api_body`] appends when the api-call
64/// body exceeds [`GATEWAY_CLIENT_BODY_CAP_BYTES`]. ASCII-pinned (no
65/// multi-byte characters) so golden-file consumers never see an encoding
66/// surprise at the cut.
67pub const GATEWAY_CLIENT_BODY_TRUNCATION_MARKER: &str = "... [truncated]";
68
69/// The ONE construction site for a capped api-call body: returns `body`
70/// verbatim when it fits [`GATEWAY_CLIENT_BODY_CAP_BYTES`], otherwise its
71/// first cap bytes (on a UTF-8 char boundary) plus
72/// [`GATEWAY_CLIENT_BODY_TRUNCATION_MARKER`]. The variant always carries
73/// its final form — construction cannot forget the cap.
74pub fn truncate_api_body(body: &str) -> String {
75    if body.len() <= GATEWAY_CLIENT_BODY_CAP_BYTES {
76        return body.to_string();
77    }
78    let mut end = GATEWAY_CLIENT_BODY_CAP_BYTES;
79    while !body.is_char_boundary(end) {
80        end -= 1;
81    }
82    let mut truncated = body[..end].to_string();
83    truncated.push_str(GATEWAY_CLIENT_BODY_TRUNCATION_MARKER);
84    truncated
85}
86
87/// Every failure `ign` can report. One variant per contract class; `code()`,
88/// `exit_code()`, `hint()` are total functions over it.
89#[derive(Debug, thiserror::Error)]
90pub enum CoreError {
91    /// Unexpected runtime failure — the catch-all; report as a bug. Exit 1.
92    #[error("internal error: {0}")]
93    Internal(String),
94
95    /// Destructive operation invoked without `--yes`. Exit 2 (same class as
96    /// usage: it names a flag the caller must add; clap renders its own
97    /// usage errors with its exit 2 — never hook clap).
98    #[error("{operation} is destructive; rerun with --yes to confirm")]
99    ConfirmationRequired { operation: String },
100
101    /// An import byte source the caller must fix (wrong file, too big,
102    /// unreadable). Exit 2 — usage class: it names what the CALLER must
103    /// change, like [`Self::ConfirmationRequired`] (03-02).
104    #[error("invalid import file: {reason}")]
105    InvalidImportFile { reason: String },
106
107    /// The gateway ANSWERED the import POST with HTTP 200 but the
108    /// body says `{"success": false, "problem": "…"}` — the
109    /// denial-rides-200 class the WebDev family handles (05-01)
110    /// applied to the project-import family (05-07, UAT Gap 1):
111    /// without this check the import caller reports ok while nothing
112    /// landed. Exit 6 — target state: the gateway refused the
113    /// import (the problem text names why, verbatim).
114    #[error("gateway rejected the project import for {project:?}: {problem}")]
115    ImportDenied {
116        /// The project the import was headed for.
117        project: String,
118        /// The gateway's own `problem` text (verbatim when present).
119        problem: String,
120        /// URL of the import request, when known.
121        endpoint: Option<String>,
122    },
123
124    /// A command input the caller must fix (unreadable `--file`, failed
125    /// stdin read). Exit 2 — usage class, the generic sibling of
126    /// [`Self::InvalidImportFile`] (03-03: `resource put`'s byte
127    /// source).
128    #[error("invalid input: {reason}")]
129    InvalidInput { reason: String },
130
131    /// Named profile absent from config. Exit 3.
132    #[error("profile {name:?} not found (known profiles: {known:?})")]
133    ProfileNotFound { name: String, known: Vec<String> },
134
135    /// No `--profile`, no `IGNITION_PROFILE`, no active profile in config.
136    /// Exit 3. Constructible from the CLI once config resolution lands
137    /// (01-03); the taxonomy is complete on day one.
138    #[error("no active profile configured")]
139    NoActiveProfile,
140
141    /// No credential resolvable for the profile (env, token_env, keyring all
142    /// missed or failed). Exit 3.
143    #[error("secret unavailable for profile {profile:?}")]
144    SecretUnavailable { profile: String },
145
146    /// The adopt bootstrap's login rung needs `IGNITION_PASSWORD` and it
147    /// was absent/empty (ADOPT-05 review round: the generic
148    /// [`Self::SecretUnavailable`] hint names the TOKEN paths, which is
149    /// exactly wrong for the PASSWORD gate — same exit class, own slug,
150    /// never a new exit code; the PollIntervalTooSmall precedent).
151    #[error("login password unavailable for profile {profile:?}")]
152    PasswordUnavailable { profile: String },
153
154    /// Config file unreadable or wrong shape. Exit 3.
155    #[error("invalid configuration: {reason}")]
156    ConfigInvalid { reason: String },
157
158    /// A profile's `poll_interval_secs` is below the 1-second floor
159    /// (08-01, TUIX-05 clamp): sub-second gateway polling is refused at
160    /// load time — the TUI's background refresh cadence may not hammer
161    /// the gateway. Exit 3 — the CONFIG class (the Phase-7 additive-slug
162    /// precedent, e.g. `eam_not_controller`: same exit class, own slug,
163    /// never a new exit code — the 1–7 taxonomy is frozen).
164    #[error("profile {profile:?}: poll_interval_secs must be >= 1 (sub-second polling refused)")]
165    PollIntervalTooSmall {
166        /// The profile carrying the refused value.
167        profile: String,
168    },
169
170    /// Gateway unreachable / timeout / TLS failure. Exit 4.
171    ///
172    /// `source: None` marks a POLL deadline expiry (02-04 `poll.rs`):
173    /// same class, same slug (`network_error`), no new variant — the
174    /// transport-error `source` a real failure carries is simply
175    /// absent, and `url` describes what was being waited on instead
176    /// (the poll's subject). The deadline's last observation rides
177    /// `observation` (09-07): when it is `Some` the gateway ANSWERED
178    /// with a concrete state, and the message leads with "no terminal
179    /// state" — it NEVER claims unreachability for an observed
180    /// answer; `None` keeps the plain unreachability wording.
181    #[error(
182        "{lead} at {url}{source_note}{observation_note}",
183        lead = if observation.is_some() {
184            "no terminal state"
185        } else {
186            "gateway unreachable"
187        },
188        source_note = source.as_ref().map(|source| format!(": {source}")).unwrap_or_default(),
189        observation_note = observation
190            .as_deref()
191            .map(|obs| format!("; last observation: {obs}"))
192            .unwrap_or_default()
193    )]
194    Network {
195        url: String,
196        #[source]
197        source: Option<reqwest::Error>,
198        /// The poll deadline's last concrete observation (e.g. the
199        /// gateway's reported state) — `None` for transport failures
200        /// and observation-less deadlines. `Some` ⇒ the gateway
201        /// answered; the Display never says "unreachable" then.
202        observation: Option<String>,
203    },
204
205    /// Gateway reachable but rejected credentials (401/403). Exit 5.
206    #[error("gateway rejected credentials (HTTP {status})")]
207    Auth {
208        status: u16,
209        /// URL/path of the request that was rejected, when known.
210        endpoint: Option<String>,
211    },
212
213    /// Gateway reachable but the command is invalid for its current state —
214    /// version below minimum, uncommissioned, mid-restart, or a missing
215    /// resource. Exit 6.
216    #[error("gateway version {found} is below minimum {minimum}")]
217    GatewayTooOld {
218        found: String,
219        minimum: String,
220        /// URL/path of the request that answered, when known.
221        endpoint: Option<String>,
222    },
223
224    /// Gateway reachable but uncommissioned — every `/data` route 302s to
225    /// `/welcome` (verified on a fresh 8.3.6 container; 02-RESEARCH
226    /// §Error-Body Sniffing). Exit 6.
227    #[error("gateway at {} is not commissioned", endpoint.as_deref().unwrap_or("unknown address"))]
228    GatewayNotCommissioned {
229        /// URL that was redirected to the commissioning wizard.
230        endpoint: Option<String>,
231    },
232
233    /// Gateway restarting — webserver answers (503) but services are down
234    /// (verified restart lifecycle: webserver never drops the connection).
235    /// Exit 6.
236    #[error("gateway is restarting (webserver up, services down)")]
237    GatewayRestarting {
238        /// URL that answered 503.
239        endpoint: Option<String>,
240    },
241
242    /// Named resource absent (404) — terminating a nonexistent session id,
243    /// an unknown path, or a pre-8.3 gateway's JSON
244    /// `{"message": "No route match for path: …"}`. Exit 6.
245    #[error("resource not found on the gateway")]
246    NotFound {
247        /// URL that answered 404.
248        endpoint: Option<String>,
249    },
250
251    /// A project of this name already exists and the import's collision
252    /// policy is abort — the CLI-side pre-check refused BEFORE any
253    /// upload (the server's own answer remains the backstop). Exit 6 —
254    /// target state: the command is invalid for the gateway's current
255    /// state (03-02, the GatewayTooOld action-built-variant precedent:
256    /// constructed by the actions layer, not classify).
257    #[error("project {name} already exists on the gateway")]
258    ProjectExists {
259        /// The colliding project name.
260        name: String,
261        /// URL of the pre-check request, when known.
262        endpoint: Option<String>,
263    },
264
265    /// A binary (data.bin-class) resource met the surgical JSON/text
266    /// loop — REFUSED rather than corrupted through it. Exit 6 —
267    /// target state: the command is invalid for that resource's
268    /// nature; the export/import family owns binary resources
269    /// (Pitfall 7).
270    #[error("resource {path:?} has binary content — not editable via the resource loop")]
271    ResourceBinary {
272        /// The resource path that was refused.
273        path: String,
274        /// URL of the request involved, when known.
275        endpoint: Option<String>,
276    },
277
278    /// The gateway refuses trial resets while the trial is still
279    /// active — live-discovered on 8.3.3 during 04-03's spike: the
280    /// reset POST answers 403 on a NON-expired trial (verified from
281    /// the browser page itself with the exact UI headers), and 200 +
282    /// the flip on an expired one. The action layer's expiry pre-check
283    /// turns that misleading auth-shaped 403 into the honest
284    /// target-state refusal. Exit 6 (the ProjectExists precedent:
285    /// action-constructed, not classify).
286    #[error(
287        "trial is not expired ({remaining_s}s left) — the gateway only honors resets once the trial expires"
288    )]
289    TrialNotExpired {
290        /// Seconds left on the active trial.
291        remaining_s: i64,
292        /// URL of the rig's trial endpoint, when known.
293        endpoint: Option<String>,
294    },
295
296    /// The WebDev route family a command depends on is not deployed —
297    /// the presence probe answered 405, the live-proven 8.3 absent
298    /// marker (missing routes AND missing projects both answer 405,
299    /// NOT 404; 05-RESEARCH Pitfall 1). Exit 6 — target state: the
300    /// command is invalid until `ign webdev deploy` installs the
301    /// routes (the TrialNotExpired precedent: action-constructed, not
302    /// classify).
303    #[error(
304        "webdev routes are not deployed (probe of {route:?} in project {project:?} answered 405)"
305    )]
306    RoutesNotDeployed {
307        /// The deploy project the probe targeted.
308        project: String,
309        /// The route folder the probe named.
310        route: String,
311        /// Path of the probe request, when known.
312        endpoint: Option<String>,
313    },
314
315    /// The WebDev module answered 402 — installed but unlicensed (a
316    /// trial-expired gateway; live-verified cross-version on 8.3.6,
317    /// 05-RESEARCH §Servlet). Exit 6 — no `/system/webdev` route can
318    /// answer until the gateway is licensed.
319    #[error(
320        "the WebDev module is unlicensed on this gateway (HTTP 402 — trial-expired rigs cannot serve /system/webdev routes)"
321    )]
322    WebdevUnlicensed {
323        /// Path of the probe request, when known.
324        endpoint: Option<String>,
325    },
326
327    /// A deployed route's handshake version differs from the embedded
328    /// bundle's — the CLI refuses rather than auto-upgrading either
329    /// side (roadmap-locked: actionable error, no auto-upgrade
330    /// magic). Exit 6.
331    #[error("route {route:?} version mismatch: deployed {deployed}, this CLI expects {expected}")]
332    RouteVersionMismatch {
333        /// The route folder that answered.
334        route: String,
335        /// The route's deployed `routeVersion`.
336        deployed: String,
337        /// The embedded bundle's version
338        /// ([`crate::webdev::ROUTE_BUNDLE_VERSION`]).
339        expected: String,
340        /// Path of the probe request, when known.
341        endpoint: Option<String>,
342    },
343
344    /// A tag provider of this name does not exist — the
345    /// find→signature→delete chain's find half missed (05-04,
346    /// TAGS-01). Exit 6 — target state: the named thing is absent
347    /// (the ProjectExists precedent family: action-constructed, not
348    /// classify — the honest, family-specific refusal over a bare
349    /// 404).
350    #[error("tag provider {name:?} not found on the gateway")]
351    ProviderNotFound {
352        /// The provider name that missed.
353        name: String,
354        /// URL of the find request, when known.
355        endpoint: Option<String>,
356    },
357
358    /// A WebDev route answered HTTP 200 with a body denial
359    /// (`{ok:false, error{code,message}}`) whose machine code this CLI
360    /// does not specifically map — code + message ride verbatim so
361    /// agents can branch on the stable route contract (05-01). Exit 6
362    /// — target state: the deployed route refused the action.
363    #[error("webdev route denied the call ({code}): {message}")]
364    WebdevRouteError {
365        /// The route's machine error code (stable contract).
366        code: String,
367        /// The route's human message.
368        message: String,
369        /// Path of the request, when known.
370        endpoint: Option<String>,
371    },
372
373    /// A tag import under abort policy found EXISTING tags at the
374    /// target provider (05-05, TAGS-09) — the browse pre-check
375    /// refuses BEFORE any route write (the LOCKED Phase-3 collision
376    /// matrix mapped onto configure's 'a'/'o'). Exit 6 — target
377    /// state: the named tags exist; overwrite is the explicit,
378    /// guarded opt-in.
379    #[error(
380        "tag collision importing into provider {provider:?}: {} already exist(s)",
381        names.join(", ")
382    )]
383    TagCollision {
384        /// The target provider the import was headed for.
385        provider: String,
386        /// The colliding top-level tag names the pre-check found.
387        names: Vec<String>,
388        /// URL of the pre-check browse request, when known.
389        endpoint: Option<String>,
390    },
391
392    /// The gateway has no alarm-journal profile configured — alarm
393    /// history has nowhere to read from. The alarms route's
394    /// structured `no_alarm_journal` denial maps here (the
395    /// denial_to_error seam, 05-06 TAGS-07): DEFAULT rigs hit this
396    /// ALWAYS, because the journal is a config-resource chain —
397    /// database connection + `ignition/alarm-journal` profile + the
398    /// `general-alarm-settings` singleton pointing at it. Exit 6 —
399    /// target state: the command is invalid for the gateway's
400    /// current state until that chain is provisioned (the honest,
401    /// actionable refusal over a bare route error).
402    #[error(
403        "no alarm journal profile is configured on this gateway — alarm history has nothing to read"
404    )]
405    AlarmJournalMissing {
406        /// URL of the alarms route request, when known.
407        endpoint: Option<String>,
408    },
409
410    /// Pruning a LIVE Designer session entry — the gateway's prune
411    /// route answers 409 (empty body, wire-verified on 8.3.3,
412    /// 06-UAT test 6): prune removes STALE entries only, so the
413    /// command is invalid while the Designer is still open. Exit 6 —
414    /// target state (additive slug in the frozen taxonomy's
415    /// established growth pattern; classify()'s ROUTE-SCOPED 409 arm
416    /// constructs this — the first classify-constructed refusal added
417    /// since the 02-01 set).
418    #[error("designer session {id} is live — the gateway refused the prune")]
419    SessionNotPrunable {
420        /// The Designer session id that was refused.
421        id: String,
422        /// URL of the refused DELETE, when known.
423        endpoint: Option<String>,
424    },
425
426    /// Docker/compose rig failure. Exit 7. Reserved — first used in Phase 4;
427    /// trivially constructible so the taxonomy enumerates completely today.
428    #[error("rig error: {0}")]
429    Rig(String),
430
431    /// The gateway's EAM module is not configured as a controller —
432    /// every `/data/eam/api/v1/*` runtime endpoint answers 403 with
433    /// "This operation can only be performed when EAM is configured
434    /// as a controller" on a stock gateway (live-proven 8.3.3,
435    /// 07-RESEARCH): a STATE refusal, not auth — the token is fine,
436    /// the module's role is not. Message-classified at the classify
437    /// seam, path-scoped to `/data/eam/` so generic 403s elsewhere
438    /// cannot shift (the trial_not_expired pattern, classify
439    /// edition). Exit 6 — target state.
440    #[error(
441        "EAM is not configured as a controller on this gateway — every EAM runtime \
442         operation refuses until the module's installMode is flipped"
443    )]
444    EamNotController {
445        /// URL of the refused request, when known.
446        endpoint: Option<String>,
447    },
448
449    /// A provider-ROOT tag path (`[default]` alone, or a bare first
450    /// segment that resolves to a provider) met the tagConfig route
451    /// — `system.tag.getConfiguration`/`exportTags` need an RPC
452    /// context WebDev threads don't carry (live-proven 8.3.3
453    /// b2026012009, both gateways; 07-UAT test 12). The route
454    /// refuses honestly (pre-call bracket detection + RpcContext
455    /// translation for the bare form) instead of surfacing the
456    /// IllegalStateException as a generic route error; subtree
457    /// paths (`[default]folder`) are the supported form. Exit 6 —
458    /// target state: a platform limitation, not a bug.
459    #[error(
460        "provider-root tag paths are not supported by the deployed route (the gateway needs an \
461         RPC context WebDev threads don't carry) — target a subtree like [provider]folder"
462    )]
463    ProviderRootUnsupported {
464        /// URL of the tagConfig route request, when known.
465        endpoint: Option<String>,
466    },
467
468    /// An `eam task new` whose type is in the REFUSED set —
469    /// `eam_restoreBackup`, `eam_installModules`, `eam_remoteUpgrade`
470    /// are fleet-destructive (they push backups/modules/upgrades to
471    /// every AGENT target), and the CLI refuses them outright over
472    /// guard-everything: honest refusal with the v2 scope pointer
473    /// (the planner-locked create ladder's top rung). Exit 6 —
474    /// target state (additive slug alongside Task 2's).
475    #[error(
476        "EAM task type {task_type} is fleet-destructive — refused (restore/install/upgrade \
477         are EXT-03 (v2) scope; run them from the EAM console)"
478    )]
479    EamTaskTypeRefused {
480        /// The refused `profile.type` token.
481        task_type: String,
482    },
483
484    /// An `eam task force` whose slot a leftover run occupies — the
485    /// force route answers 409 with the gateway's own Jetty page
486    /// ("Task 'X (forced)' already exists! It must be completed or
487    /// deleted before another task of this type can be force
488    /// executed."; live-captured 8.3.3, 07-UAT test 7). Exit 6 —
489    /// target state (the `session_not_prunable` precedent,
490    /// force-route edition): the gateway's state refused the command,
491    /// not a bug; classify()'s path-scoped 409 arm constructs this.
492    #[error("EAM task {task} has a run in flight — the gateway refused the force: {detail}")]
493    EamTaskInFlight {
494        /// The forced task's name (the force URL's last segment).
495        task: String,
496        /// The gateway's Jetty page message verbatim when sniffed;
497        /// the '(forced)' fallback text otherwise.
498        detail: String,
499        /// URL of the refused force POST, when known.
500        endpoint: Option<String>,
501    },
502
503    /// The scriptExec route is not configured for this profile — no
504    /// webdev secret is persisted, which can only mean `ign webdev
505    /// deploy --with-script-exec` has never run (deploy persists the
506    /// secret 0600 BEFORE upload, so a deployed route without a
507    /// stored secret is not a reachable state). `ign script run`'s
508    /// opt-in is STRUCTURAL — the deploy flag — so the verb carries
509    /// no `--yes` guard and refuses here instead (07-03, SCRPT-01:
510    /// the TrialNotExpired precedent — action-constructed, not
511    /// classify). Exit 6 — target state.
512    #[error(
513        "scriptExec is not configured for profile {profile:?} — the secret-gated route deploys \
514         only via the explicit opt-in"
515    )]
516    ScriptExecNotConfigured {
517        /// The profile whose secret store is empty.
518        profile: String,
519    },
520
521    /// `ign lint` found no `ignition-lint` executable on PATH — the
522    /// delegation has nothing to delegate to (07-04, INTR-02). The
523    /// hint carries the install command + repo. Exit 6 — target
524    /// state (additive slug; the environment lacks the tool, the
525    /// command is fine).
526    #[error("ignition-lint is not installed (no executable found on PATH)")]
527    LintToolAbsent,
528
529    /// `ign e2e init` found no `node` executable on PATH — the scaffold
530    /// it would write is unusable without one (QUICK-tg4).
531    ///
532    /// Exit 6, mirroring [`Self::LintToolAbsent`] line for line: this
533    /// is the SAME shape — a local external tool a verb delegates to is
534    /// absent, with an install hint. It deliberately does NOT join
535    /// exit 7, whose sole slug is `rig_error` for docker/compose rig
536    /// failures; putting a missing Node there would force a relabel of
537    /// a locked exit class.
538    #[error("{tool} is not installed (no executable found on PATH)")]
539    NodeToolAbsent {
540        /// The tool that was looked for (`node`).
541        tool: String,
542    },
543
544    /// The gateway answered the api call with a 4xx this CLI does not
545    /// curate — the caller's request is the problem, and the body is
546    /// theirs to read. Exit 2 (usage class; additive slug — the
547    /// poll_interval_too_small precedent: same class, own slug, no new
548    /// exit code). Constructed ONLY by the api-call-scoped classify arm
549    /// (09-01: the `api_call` parameter) — a curated command's 4xx keeps
550    /// its existing classification (the catch-all cannot fire without
551    /// the parameter). The body is the gateway's answer VERBATIM,
552    /// truncated at [`GATEWAY_CLIENT_BODY_CAP_BYTES`] with the explicit
553    /// [`GATEWAY_CLIENT_BODY_TRUNCATION_MARKER`] via [`truncate_api_body`]
554    /// at construction.
555    #[error("gateway rejected the api call (HTTP {status} from {endpoint}): {body}")]
556    GatewayClientError {
557        /// HTTP status the gateway answered with.
558        status: u16,
559        /// Full URL of the rejected request.
560        endpoint: String,
561        /// The response body VERBATIM, truncated at
562        /// [`GATEWAY_CLIENT_BODY_CAP_BYTES`] with an explicit marker.
563        body: String,
564    },
565
566    /// No diagnostics bundle is available — the status poll ANSWERED
567    /// with a captured TERMINAL steady state meaning "no current
568    /// bundle" (`Invalid`: live-proven on 8.3.6 rig ign-p9-836,
569    /// 2026-09-07 UAT / 09-UAT.md Gap 3 — a `Valid` bundle decays to
570    /// `Invalid` within ~2 minutes UNPROMPTED and stays `Invalid`;
571    /// only a fresh generate changes it, polling cannot). Exit 6 —
572    /// target state (the `ImportDenied` precedent: a gateway-answered
573    /// refusal riding its own class, action-constructed by `bundle
574    /// wait`'s probe, not classify).
575    #[error(
576        "no diagnostics bundle available (gateway reports state {state:?}) — run \
577         `ign diagnostics bundle generate` first; polling cannot change this state"
578    )]
579    BundleNotAvailable {
580        /// The observed steady state (the captured unavailable
581        /// vocabulary, e.g. `Invalid`).
582        state: String,
583    },
584
585    /// Transport failure reaching a third-party module release feed or
586    /// its asset CDN — DNS/connect/timeout/TLS (Phase 15, D-06). Exit 4:
587    /// same network class as [`Self::Network`], but a SEPARATE slug and
588    /// wording — `Network`'s Display hard-codes "gateway unreachable",
589    /// which is actively wrong here (`api.github.com` is not a gateway,
590    /// and the user's own gateway may be perfectly reachable). This is
591    /// the offline signal `fetch_and_verify` returns when the cache is
592    /// empty (SC-4's negative half).
593    #[error(
594        "module release feed at {url} is unreachable{source_note}",
595        source_note = source.as_ref().map(|source| format!(": {source}")).unwrap_or_default()
596    )]
597    ModuleFeedUnreachable {
598        /// The feed or CDN URL that could not be reached.
599        url: String,
600        #[source]
601        source: Option<reqwest::Error>,
602    },
603
604    /// No release exists for the pinned tag, or the release carries no
605    /// asset of the expected name (Phase 15, SC-1's "unknown version"
606    /// refusal). Exit 6 — target state: names BOTH the requested version
607    /// and the resolved URL, and (for a missing asset) both the expected
608    /// asset name and the names actually present, so the caller never
609    /// has to guess which lookup failed or falls back to "latest".
610    #[error("module {module:?} version {version:?} not found at {url}: {detail}")]
611    ModuleReleaseNotFound {
612        /// The [`crate::module::ModuleSpec::id`] being fetched.
613        module: String,
614        /// The requested version string.
615        version: String,
616        /// The tag-pinned releases URL that was queried.
617        url: String,
618        /// What specifically was missing (no release for the tag, or no
619        /// matching asset — names the asset actually present).
620        detail: String,
621    },
622
623    /// Downloaded bytes hash to something other than the release's
624    /// published digest — SC-2's hard refusal (Phase 15; this variant is
625    /// defined in 15-01, the branch is exercised starting 15-02). Exit 6
626    /// — target state: the feed answered and served bytes, but what it
627    /// served cannot be trusted. The bytes are discarded (never
628    /// persisted to the cache) and BOTH digests are named.
629    ///
630    /// Payload is BOXED: five inline `String`s put `CoreError` at
631    /// exactly clippy's 128-byte `result_large_err` threshold, which
632    /// fails `-D warnings` in every crate returning `Result<_,
633    /// CoreError>` — `ignition-tui` first. Error paths are cold, so one
634    /// allocation on construction is the cheap side of that trade.
635    #[error("{0}")]
636    ModuleDigestMismatch(Box<ModuleDigestMismatchDetails>),
637
638    /// The module release feed ANSWERED but not usably — rate-limited,
639    /// unauthenticated 401/403, 5xx, an unparseable body, an asset with
640    /// no published digest, or an insecure downgrade of the asset URL
641    /// (Phase 15). Exit 6 — target state: distinct from
642    /// [`Self::ModuleFeedUnreachable`] (exit 4 — transport never reached
643    /// the feed at all); here the feed DID answer, just not with
644    /// something `ign` can safely act on.
645    #[error("module release feed at {url} is unusable: {detail}")]
646    ModuleFeedUnusable {
647        /// The feed or asset URL that answered unusably.
648        url: String,
649        /// What specifically made the answer unusable.
650        detail: String,
651    },
652
653    /// A pinned version's upstream digest differs from what's already
654    /// cached — D-02's default refusal on `FetchPolicy::Refresh` /
655    /// `AcceptUpstreamChange` (Phase 15, 15-02): the feed answered
656    /// fine, but what it currently publishes for this version no
657    /// longer matches what was verified and cached before. Exit 6 —
658    /// target state: names BOTH full digests and the path of the
659    /// cached artifact, which is left on disk UNTOUCHED (no path in
660    /// `module/` deletes, truncates, or overwrites a cache entry).
661    /// `FetchPolicy::AcceptUpstreamChange` is the explicit, documented
662    /// override.
663    ///
664    /// Payload is BOXED for the same reason as
665    /// [`Self::ModuleDigestMismatch`] — see that variant.
666    #[error("{0}")]
667    ModuleDigestChanged(Box<ModuleDigestChangedDetails>),
668
669    /// A declared module id has no matching registry entry (Phase 16,
670    /// D-13). A well-formed id that simply isn't registered is a CONFIG
671    /// problem, not malformed input (exit 2 belongs to
672    /// `validate_module_id`/`validate_version`) and not a Docker
673    /// failure (exit 7 is [`Self::Rig`]) — every OTHER module
674    /// provisioning refusal (missing artifact, non-file artifact,
675    /// relative path, underivable service) stays `CoreError::Rig`.
676    /// Exit 3, structurally the [`Self::ProfileNotFound`] shape: names
677    /// both the bad id and every id that IS registered.
678    #[error("module {id:?} is not registered (known modules: {known:?})")]
679    ModuleNotRegistered {
680        /// The declared id with no matching [`crate::module::ModuleSpec`].
681        id: String,
682        /// Every currently registered [`crate::module::ModuleSpec::id`].
683        known: Vec<String>,
684    },
685
686    /// The gateway ANSWERED `DELETE /data/api/v1/modules/uninstall` with
687    /// HTTP 200 but the body says `{"success": false, "failedUninstalls":
688    /// {"uninstall": […]}}` — the denial-rides-200 class
689    /// ([`Self::ImportDenied`]'s precedent) applied to module uninstall
690    /// (Phase 19, D-19-03): live-verified that a stock module, a bogus
691    /// id, and a still-mounted module ALL answer 200 with
692    /// `success:false`. An unparseable body, a missing `success`, or an
693    /// absent `failedUninstalls` all map here too — absence of detail
694    /// must never degrade into a reported success. Exit 6 — target
695    /// state: names both the registry id and the gateway id that was
696    /// sent, plus every failed id the gateway reported (or the sent id
697    /// itself when the gateway gave no detail).
698    #[error(
699        "gateway refused to uninstall module {module_id:?} ({gateway_module_id}): {}",
700        failed.join(", ")
701    )]
702    ModuleUninstallDenied {
703        /// The registry slug (`git`) the caller named.
704        module_id: String,
705        /// The gateway's own id ([`crate::module::ModuleSpec::gateway_module_id`])
706        /// sent in the request.
707        gateway_module_id: String,
708        /// `failedUninstalls.uninstall` verbatim, or `[gateway_module_id]`
709        /// when the gateway's body carried no usable detail.
710        failed: Vec<String>,
711        /// URL of the refused DELETE, when known.
712        endpoint: Option<String>,
713    },
714}
715
716/// Payload of [`CoreError::ModuleDigestMismatch`], boxed to keep
717/// `CoreError` under clippy's `result_large_err` threshold.
718#[derive(Debug, Clone, PartialEq, Eq)]
719pub struct ModuleDigestMismatchDetails {
720    /// The [`crate::module::ModuleSpec::id`] being fetched.
721    pub module: String,
722    /// The requested version string.
723    pub version: String,
724    /// The asset URL the mismatched bytes were downloaded from.
725    pub url: String,
726    /// The release's published sha256 digest.
727    pub expected: String,
728    /// The sha256 digest of the bytes actually downloaded.
729    pub actual: String,
730}
731
732/// Payload of [`CoreError::ModuleDigestChanged`], boxed to keep
733/// `CoreError` under clippy's `result_large_err` threshold.
734#[derive(Debug, Clone, PartialEq, Eq)]
735pub struct ModuleDigestChangedDetails {
736    /// The [`crate::module::ModuleSpec::id`] being fetched.
737    pub module: String,
738    /// The requested version string.
739    pub version: String,
740    /// The digest of the artifact already cached for this version.
741    pub cached_digest: String,
742    /// The digest the feed currently publishes for this version.
743    pub upstream_digest: String,
744    /// Path of the untouched, still-usable cached artifact.
745    pub cached_path: String,
746}
747
748impl std::fmt::Display for ModuleDigestMismatchDetails {
749    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
750        write!(
751            f,
752            "module {:?} version {:?} digest mismatch at {}: expected {}, got {}",
753            self.module, self.version, self.url, self.expected, self.actual
754        )
755    }
756}
757
758impl std::fmt::Display for ModuleDigestChangedDetails {
759    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
760        write!(
761            f,
762            "module {:?} version {:?} digest changed upstream: cached {}, \
763             upstream now reports {} (cached artifact kept at {})",
764            self.module, self.version, self.cached_digest, self.upstream_digest, self.cached_path
765        )
766    }
767}
768
769impl CoreError {
770    /// Stable machine slug — public contract, never respell.
771    pub fn code(&self) -> &'static str {
772        match self {
773            Self::Internal(_) => "internal",
774            Self::ConfirmationRequired { .. } => "confirmation_required",
775            Self::InvalidImportFile { .. } => "invalid_import_file",
776            Self::ImportDenied { .. } => "import_denied",
777            Self::InvalidInput { .. } => "invalid_input",
778            Self::ProfileNotFound { .. } => "profile_not_found",
779            Self::NoActiveProfile => "no_active_profile",
780            Self::SecretUnavailable { .. } => "secret_unavailable",
781            Self::PasswordUnavailable { .. } => "password_unavailable",
782            Self::ConfigInvalid { .. } => "config_invalid",
783            Self::PollIntervalTooSmall { .. } => "poll_interval_too_small",
784            Self::Network { .. } => "network_error",
785            Self::Auth { .. } => "auth_rejected",
786            Self::GatewayTooOld { .. } => "gateway_too_old",
787            Self::GatewayNotCommissioned { .. } => "gateway_not_commissioned",
788            Self::GatewayRestarting { .. } => "gateway_restarting",
789            Self::NotFound { .. } => "not_found",
790            Self::ProjectExists { .. } => "project_exists",
791            Self::ResourceBinary { .. } => "resource_binary",
792            Self::TrialNotExpired { .. } => "trial_not_expired",
793            Self::ProviderNotFound { .. } => "provider_not_found",
794            Self::RoutesNotDeployed { .. } => "routes_not_deployed",
795            Self::WebdevUnlicensed { .. } => "webdev_unlicensed",
796            Self::RouteVersionMismatch { .. } => "route_version_mismatch",
797            Self::WebdevRouteError { .. } => "webdev_route_error",
798            Self::TagCollision { .. } => "tag_collision",
799            Self::AlarmJournalMissing { .. } => "alarm_journal_missing",
800            Self::SessionNotPrunable { .. } => "session_not_prunable",
801            Self::Rig(_) => "rig_error",
802            Self::EamNotController { .. } => "eam_not_controller",
803            Self::ProviderRootUnsupported { .. } => "provider_root_unsupported",
804            Self::EamTaskTypeRefused { .. } => "eam_task_type_refused",
805            Self::EamTaskInFlight { .. } => "eam_task_in_flight",
806            Self::ScriptExecNotConfigured { .. } => "script_exec_not_configured",
807            Self::LintToolAbsent => "lint_tool_absent",
808            Self::NodeToolAbsent { .. } => "node_tool_absent",
809            Self::GatewayClientError { .. } => "gateway_client_error",
810            Self::BundleNotAvailable { .. } => "bundle_not_available",
811            Self::ModuleFeedUnreachable { .. } => "module_feed_unreachable",
812            Self::ModuleReleaseNotFound { .. } => "module_release_not_found",
813            Self::ModuleDigestMismatch(..) => "module_digest_mismatch",
814            Self::ModuleFeedUnusable { .. } => "module_feed_unusable",
815            Self::ModuleDigestChanged(..) => "module_digest_changed",
816            Self::ModuleNotRegistered { .. } => "module_not_registered",
817            Self::ModuleUninstallDenied { .. } => "module_uninstall_denied",
818        }
819    }
820
821    /// The LOCKED exit-code mapping — the only place exit codes are decided.
822    pub fn exit_code(&self) -> u8 {
823        match self {
824            Self::Internal(_) => 1,
825            Self::ConfirmationRequired { .. }
826            | Self::InvalidImportFile { .. }
827            | Self::InvalidInput { .. }
828            | Self::GatewayClientError { .. } => 2,
829            Self::ProfileNotFound { .. }
830            | Self::NoActiveProfile
831            | Self::SecretUnavailable { .. }
832            | Self::PasswordUnavailable { .. }
833            | Self::ConfigInvalid { .. }
834            | Self::PollIntervalTooSmall { .. }
835            | Self::ModuleNotRegistered { .. } => 3,
836            Self::Network { .. } | Self::ModuleFeedUnreachable { .. } => 4,
837            Self::Auth { .. } => 5,
838            Self::GatewayTooOld { .. }
839            | Self::GatewayNotCommissioned { .. }
840            | Self::GatewayRestarting { .. }
841            | Self::NotFound { .. }
842            | Self::ProjectExists { .. }
843            | Self::ResourceBinary { .. }
844            | Self::TrialNotExpired { .. }
845            | Self::ProviderNotFound { .. }
846            | Self::RoutesNotDeployed { .. }
847            | Self::WebdevUnlicensed { .. }
848            | Self::RouteVersionMismatch { .. }
849            | Self::WebdevRouteError { .. }
850            | Self::TagCollision { .. }
851            | Self::AlarmJournalMissing { .. }
852            | Self::SessionNotPrunable { .. }
853            | Self::ImportDenied { .. }
854            | Self::EamNotController { .. }
855            | Self::ProviderRootUnsupported { .. }
856            | Self::EamTaskTypeRefused { .. }
857            | Self::EamTaskInFlight { .. }
858            | Self::ScriptExecNotConfigured { .. }
859            | Self::LintToolAbsent
860            | Self::NodeToolAbsent { .. }
861            | Self::BundleNotAvailable { .. }
862            | Self::ModuleReleaseNotFound { .. }
863            | Self::ModuleDigestMismatch(..)
864            | Self::ModuleFeedUnusable { .. }
865            | Self::ModuleDigestChanged(..)
866            | Self::ModuleUninstallDenied { .. } => 6,
867            Self::Rig(_) => 7,
868        }
869    }
870
871    /// Actionable next step (CORE-05). Every class carries one.
872    pub fn hint(&self) -> Option<String> {
873        match self {
874            Self::Internal(_) => Some(
875                "internal errors are bugs; re-run with -vv and report the \
876                 diagnostics output"
877                    .to_string(),
878            ),
879            Self::ConfirmationRequired { .. } => Some(
880                "this operation is destructive; re-run with --yes or set \
881                   IGNITION_YES=1"
882                    .to_string(),
883            ),
884            Self::InvalidImportFile { .. } => Some(
885                "import expects a project-export ZIP (PK\\x03\\x04 magic) of at \
886                   most 512 MB — pass a file produced by `ign project export` \
887                   via --file (or `-` to pipe one on stdin)"
888                    .to_string(),
889            ),
890            Self::ImportDenied { problem, .. } => Some(format!(
891                "the gateway refused the import over a 200 answer — the problem \
892                   text above is the gateway's own; `ign project export` of the \
893                   current state is the honest baseline for hand-editing ({problem})"
894            )),
895            Self::InvalidInput { reason } => Some(
896                if reason == TUI_TTY_REFUSAL_REASON {
897                    // The ONE contextual InvalidInput hint (06-07): the
898                    // TTY refusal's fix is terminal-related — the
899                    // --file/stdin default is meaningless for a pipe.
900                    "run `ign tui` in an interactive terminal (the cockpit \
901                     needs a TTY on stdout — not a pipe or redirect)"
902                } else if reason.starts_with(LOSS_GATE_REFUSAL_REASON_PREFIX) {
903                    // The loss-gate refusal (11-07): the message above already names
904                    // every finding and ends with the actionable guidance — the hint
905                    // restates it for envelope readers instead of the file-read
906                    // default, which is meaningless here (the file WAS readable).
907                    "the loss report above names what this import would drop or \
908                     coerce — re-run with --yes to import anyway"
909                } else {
910                    "fix the input source — a readable file path via --file, or `-` \
911                     to pipe the content on stdin"
912                }
913                .to_string(),
914            ),
915            Self::ProfileNotFound { known, .. } => Some(if known.is_empty() {
916                "no profiles configured yet; run `ign profile add` to create \
917                     one"
918                .to_string()
919            } else {
920                format!(
921                    "known profiles: {}; run `ign profile add` to add another",
922                    known.join(", ")
923                )
924            }),
925            Self::NoActiveProfile => Some(
926                "pass --profile NAME, set IGNITION_PROFILE, or mark a profile \
927                 active with `ign profile use`"
928                    .to_string(),
929            ),
930            Self::SecretUnavailable { profile } => Some(format!(
931                "set IGNITION_TOKEN (or token_env in the profile), or store a \
932                 keyring entry: service 'ignition-cli', user 'profile:{profile}'"
933            )),
934            Self::PasswordUnavailable { .. } => Some(
935                "ign adopt logs in with the gateway admin password — export \
936                 IGNITION_PASSWORD (env-only, never a flag)"
937                    .to_string(),
938            ),
939            Self::ConfigInvalid { .. } => Some(
940                "verify the config file is valid TOML with [profiles.NAME] \
941                 tables; `ign profile add` writes a known-good one"
942                    .to_string(),
943            ),
944            Self::PollIntervalTooSmall { profile } => Some(format!(
945                "set poll_interval_secs to 1 or higher in [profiles.{profile}], \
946                 or remove the key to use the default cadence"
947            )),
948            Self::Network {
949                url,
950                observation,
951                ..
952            } => Some(match observation {
953                Some(observation) => format!(
954                    "the gateway answered but no terminal state arrived before the deadline — \
955                     the last observation was: {observation}; address that state, not the \
956                     connection ({url})"
957                ),
958                None => format!("check the gateway is reachable at {url} (host, port, VPN, TLS)"),
959            }),
960            Self::Auth { status, .. } => Some(match status {
961                401 => {
962                    // 401 = token not recognized — the #1 setup failure is
963                    // a key-only header (verified: key-only → 401, full
964                    // `name:key` → 200; Basic is dead on 8.3 /data).
965                    "token not recognized — the X-Ignition-API-Token header must be the FULL `name:key` string from the gateway UI (Platform→Security→API Keys); Basic auth does not work on 8.3 /data routes — create an API token"
966                }
967                403 => {
968                    // 403 = recognized but under-permitted (verified
969                    // semantics; see 02-RESEARCH Auth §4/§5).
970                    "token recognized but under-permitted — Ignition token setup is three parts: (1) token holds an adequate security level, (2) gateway read/write permissions include that level, (3) 'Require secure connections' is unchecked for http gateways; run `ign doctor` for a diagnosis"
971                }
972                _ => "check the credential; Ignition token setup is three parts: security level, write permissions, token assignment",
973            }
974            .to_string()),
975            Self::GatewayTooOld { minimum, .. } => {
976                Some(format!("upgrade the gateway to at least {minimum}"))
977            }
978            Self::GatewayNotCommissioned { .. } => Some(
979                "open http://<host>:<port>/welcome in a browser and complete the \
980                 commissioning wizard"
981                    .to_string(),
982            ),
983            Self::GatewayRestarting { .. } => Some(
984                "wait for readiness with `ign wait restart` or retry in ~1 minute".to_string(),
985            ),
986            Self::NotFound { .. } => Some(
987                "check the id/path; a 404 JSON 'No route match' body can also mean \
988                  a pre-8.3 gateway"
989                    .to_string(),
990            ),
991            Self::ProjectExists { .. } => Some(
992                "the default collision policy refuses to overwrite; re-run with \
993                  --collision-policy overwrite to replace it — overwrite \
994                  REPLACES the ENTIRE project (resources absent from the ZIP \
995                  are deleted; merge is Designer-only)"
996                    .to_string(),
997            ),
998            Self::ResourceBinary { .. } => Some(
999                "resource content is binary — use `ign project export`/`import` \
1000                  for data.bin-class resources"
1001                    .to_string(),
1002            ),
1003            Self::TrialNotExpired { .. } => Some(
1004                "wait for the trial to expire (watch `ign rig trial status`), or \
1005                  run `ign rig reset --yes` for a completely fresh trial volume"
1006                    .to_string(),
1007            ),
1008            Self::RoutesNotDeployed { route, .. } => Some(
1009                // CONTENT-ADDRESSED off the route (the
1010                // TUI_TTY_REFUSAL_REASON / loss-gate prose-prefix
1011                // pattern, same file): the testing bundle is a
1012                // SEPARATE deploy with its own flag, so the generic
1013                // `ign webdev deploy` hint would send the caller to a
1014                // command that does not install it. No new variant,
1015                // no new slug — the exit tables are untouched.
1016                if route.starts_with(TESTING_ROUTE_PREFIX) {
1017                    "run `ign webdev deploy --with-testing` to install the gateway \
1018                      testing bundle into the project (or bootstrap it in one shot \
1019                      with `ign adopt --project NAME --testing`), then retry"
1020                } else {
1021                    "run `ign webdev deploy` to install the CLI's WebDev routes into \
1022                      the gateway, then retry"
1023                }
1024                .to_string(),
1025            ),
1026            Self::WebdevUnlicensed { .. } => Some(
1027                "license the gateway — the WebDev module answers 402 while \
1028                  unlicensed (on a rig, `ign rig trial reset --yes` restarts an \
1029                  expired trial)"
1030                    .to_string(),
1031            ),
1032            Self::RouteVersionMismatch { deployed, expected, .. } => {
1033                // Direction decides the fix (roadmap criterion): an older
1034                // deployed route → redeploy from THIS binary; a NEWER
1035                // deployed route → this CLI is behind (the route bundle
1036                // travels with the binary). Same slug either way.
1037                let newer = semver::Version::parse(deployed)
1038                    .ok()
1039                    .zip(semver::Version::parse(expected).ok())
1040                    .is_some_and(|(deployed, expected)| deployed > expected);
1041                Some(if newer {
1042                    "the deployed routes are NEWER than this CLI — update ign \
1043                      (the route bundle travels with the binary)"
1044                        .to_string()
1045                } else {
1046                    "run `ign webdev deploy` to redeploy the route version \
1047                      this CLI expects"
1048                        .to_string()
1049                })
1050            }
1051            Self::ProviderNotFound { .. } => Some(
1052                "check the provider name; `ign tags provider list` shows the \
1053                  gateway's tag providers"
1054                    .to_string(),
1055            ),
1056            Self::TagCollision { .. } => Some(
1057                "re-run with --collision-policy overwrite to replace the \
1058                  existing tags (destructive: requires --yes)"
1059                    .to_string(),
1060            ),
1061            Self::AlarmJournalMissing { .. } => Some(
1062                "alarm history needs a journal profile — provision a database \
1063                  connection + alarm-journal profile on the gateway (and point \
1064                  the general-alarm-settings singleton at it), then retry; see \
1065                  the README 'Alarm history' section"
1066                    .to_string(),
1067            ),
1068            Self::SessionNotPrunable { .. } => Some(
1069                "close the Designer first — prune removes stale entries only".to_string(),
1070            ),
1071            Self::WebdevRouteError { code, .. } => Some(if code == "secret_required" || code == "secret_mismatch" {
1072                "the scriptExec route is secret-gated — deploy it with `ign \
1073                  webdev deploy --with-script-exec` (the secret is generated \
1074                  and stored in the profile config at 0600); a mismatch means \
1075                  the route was deployed with a different secret: redeploy or \
1076                  pass --rotate-secret"
1077                    .to_string()
1078            } else {
1079                "the deployed route refused the action — the code and message \
1080                  are the route's stable contract; `ign webdev status` \
1081                  diagnoses the deployment"
1082                    .to_string()
1083            }),
1084            Self::EamNotController { .. } => Some(
1085                "flip the gateway's EAM role: config-resource PUT on \
1086                   com.inductiveautomation.eam/module-settings with \
1087                   installMode \"Controller\" (array body carrying the current \
1088                   signature) — a manual gateway-role decision this CLI \
1089                   deliberately does not automate; see the README 'EAM tasks' \
1090                   section"
1091                    .to_string(),
1092            ),
1093            Self::ProviderRootUnsupported { .. } => Some(
1094                "target a subtree path like [provider]folder — provider-ROOT \
1095                   forms ([default] alone, or a bare provider name) need an \
1096                   RPC context WebDev threads don't carry (8.3.3); subtree \
1097                   paths are the supported form"
1098                    .to_string(),
1099            ),
1100            Self::EamTaskTypeRefused { .. } => Some(
1101                "restore/install/upgrade tasks dispatch fleet-wide (every \
1102                   agent target); run them from the Ignition EAM console — \
1103                   EXT-03 (v2) will scope them into the CLI"
1104                    .to_string(),
1105            ),
1106            Self::EamTaskInFlight { .. } => Some(
1107                "complete or delete the leftover '(forced)' run from the EAM \
1108                   console — no ign verb deletes runs; the slot frees once the \
1109                   run is resolved"
1110                    .to_string(),
1111            ),
1112            Self::ScriptExecNotConfigured { .. } => Some(
1113                "run `ign webdev deploy --with-script-exec` to deploy the route and \
1114                  generate + persist its secret (the deploy flag IS the opt-in — \
1115                  `ign script run` has no --yes by design)"
1116                    .to_string(),
1117            ),
1118            Self::LintToolAbsent => Some(
1119                "install the linter: `uv tool install ignition-lint-toolkit` \
1120                 (or `pip install ignition-lint-toolkit`) — \
1121                 github.com/TheThoughtagen/ignition-lint; then re-run with \
1122                 ignition-lint on PATH"
1123                    .to_string(),
1124            ),
1125            // The SAME string `ign e2e doctor`'s node row hands out —
1126            // a user who hits this refusal and then runs the doctor
1127            // must not be given two wordings of one instruction.
1128            Self::NodeToolAbsent { .. } => {
1129                Some(crate::actions::e2e::NODE_INSTALL_HINT.to_string())
1130            }
1131            Self::GatewayClientError { .. } => Some(
1132                "the gateway rejected this request — the body above is the \
1133                 gateway's own answer; fix the path/method/body, or use a \
1134                 curated `ign` command when one exists"
1135                    .to_string(),
1136            ),
1137            Self::BundleNotAvailable { .. } => Some(
1138                "generate a fresh bundle with `ign diagnostics bundle generate`, \
1139                 then wait again — the gateway reports no current bundle and \
1140                 polling cannot produce one"
1141                    .to_string(),
1142            ),
1143            Self::ModuleFeedUnreachable { .. } => Some(
1144                "check network reachability to the module release feed — an \
1145                 already-cached version works fully offline, so this only \
1146                 blocks a first-time fetch of a new version"
1147                    .to_string(),
1148            ),
1149            Self::ModuleReleaseNotFound {
1150                module, version, ..
1151            } => Some(format!(
1152                "check that {module} version {version} exists as a release upstream \
1153                 (the tag and asset name must match exactly) — this CLI never falls \
1154                 back to \"latest\""
1155            )),
1156            Self::ModuleDigestMismatch(d) => Some(format!(
1157                "the downloaded bytes for {} version {} do not match \
1158                 the feed's published digest — do not trust this artifact; nothing \
1159                 was cached, so re-running the fetch tries again from scratch",
1160                d.module,
1161                d.version
1162            )),
1163            Self::ModuleFeedUnusable { .. } => Some(
1164                "the module release feed answered but was not usable — if the \
1165                 detail names a rate limit, wait and retry; otherwise the feed's \
1166                 response itself needs investigation"
1167                    .to_string(),
1168            ),
1169            Self::ModuleDigestChanged(d) => Some(format!(
1170                "upstream re-released this version with different bytes — the cached \
1171                 artifact at {} is still there and usable offline; re-run with \
1172                 FetchPolicy::AcceptUpstreamChange to deliberately accept and verify the \
1173                 new bytes",
1174                d.cached_path
1175            )),
1176            Self::Rig(_) => Some(
1177                "check Docker is running and inspect the rig containers \
1178                 (docker ps)"
1179                    .to_string(),
1180            ),
1181            Self::ModuleNotRegistered { known, .. } => Some(if known.is_empty() {
1182                "no modules are registered in this build of ign".to_string()
1183            } else {
1184                format!(
1185                    "known module ids: {} — check the id passed to --with-module \
1186                     or declared under [rigs.NAME.modules.*]",
1187                    known.join(", ")
1188                )
1189            }),
1190            Self::ModuleUninstallDenied { .. } => Some(
1191                "the gateway refuses to uninstall a module whose .modl is still \
1192                 mounted — remove it from [rigs.NAME.modules], run `ign rig up` \
1193                 so the mount goes, then retry; a successful uninstall is NOT \
1194                 undone by re-declaring the module — recovery today means \
1195                 `ign rig reset --yes`, which destroys the rig's data volume"
1196                    .to_string(),
1197            ),
1198        }
1199    }
1200
1201    /// URL/path of the request involved, when one was — populated for the
1202    /// network, auth, and target-state classes (CORE-05).
1203    pub fn endpoint(&self) -> Option<String> {
1204        match self {
1205            Self::Network { url, .. } => Some(url.clone()),
1206            Self::Auth { endpoint, .. } => endpoint.clone(),
1207            Self::GatewayTooOld { endpoint, .. }
1208            | Self::GatewayNotCommissioned { endpoint }
1209            | Self::GatewayRestarting { endpoint }
1210            | Self::NotFound { endpoint }
1211            | Self::ProjectExists { endpoint, .. }
1212            | Self::ResourceBinary { endpoint, .. }
1213            | Self::TrialNotExpired { endpoint, .. }
1214            | Self::ProviderNotFound { endpoint, .. }
1215            | Self::RoutesNotDeployed { endpoint, .. }
1216            | Self::WebdevUnlicensed { endpoint }
1217            | Self::RouteVersionMismatch { endpoint, .. }
1218            | Self::WebdevRouteError { endpoint, .. }
1219            | Self::TagCollision { endpoint, .. }
1220            | Self::AlarmJournalMissing { endpoint }
1221            | Self::SessionNotPrunable { endpoint, .. }
1222            | Self::ImportDenied { endpoint, .. }
1223            | Self::EamNotController { endpoint }
1224            | Self::ProviderRootUnsupported { endpoint }
1225            | Self::EamTaskInFlight { endpoint, .. }
1226            | Self::ModuleUninstallDenied { endpoint, .. } => endpoint.clone(),
1227            Self::ModuleFeedUnreachable { url, .. }
1228            | Self::ModuleReleaseNotFound { url, .. }
1229            | Self::ModuleFeedUnusable { url, .. } => Some(url.clone()),
1230            // Boxed payload, so it cannot share the or-pattern above.
1231            Self::ModuleDigestMismatch(d) => Some(d.url.clone()),
1232            _ => None,
1233        }
1234    }
1235
1236    /// The `ign tui` TTY refusal (06-07): InvalidInput carrying the
1237    /// ONE reason whose hint is terminal-contextual — see
1238    /// [`TUI_TTY_REFUSAL_REASON`]. Same slug, same exit 2 (frozen
1239    /// taxonomy; only the hint differs).
1240    pub fn tui_tty_refusal() -> Self {
1241        Self::InvalidInput {
1242            reason: TUI_TTY_REFUSAL_REASON.to_string(),
1243        }
1244    }
1245
1246    /// Build the LOCKED failure envelope for this error (field order is part
1247    /// of the golden contract: `ok`, `profile`, `error` then `code`,
1248    /// `message`, `endpoint`, `hint`).
1249    pub fn envelope<'a>(&self, profile: Option<&'a str>) -> ErrorEnvelope<'a> {
1250        ErrorEnvelope {
1251            ok: false,
1252            profile,
1253            error: ErrorBody {
1254                code: self.code(),
1255                message: self.to_string(),
1256                endpoint: self.endpoint(),
1257                hint: self.hint(),
1258            },
1259        }
1260    }
1261}
1262
1263/// LOCKED failure envelope shape: exactly the top-level fields `ok`,
1264/// `profile`, `error` — changing the set is a breaking change for agents.
1265#[derive(Debug, Serialize)]
1266pub struct ErrorEnvelope<'a> {
1267    /// Always `false` in this envelope.
1268    pub ok: bool,
1269    /// Active profile echoed in every output (CORE-01); `None` until config
1270    /// resolution lands.
1271    pub profile: Option<&'a str>,
1272    /// The typed error body.
1273    pub error: ErrorBody,
1274}
1275
1276/// LOCKED error body: `code` (stable slug), `message` (human-readable),
1277/// `endpoint` (when a request was involved), `hint` (actionable next step).
1278#[derive(Debug, Serialize)]
1279pub struct ErrorBody {
1280    /// Stable slug from [`CoreError::code`] — never respelled.
1281    pub code: &'static str,
1282    /// Human-readable description.
1283    pub message: String,
1284    /// URL/path when a request was involved.
1285    pub endpoint: Option<String>,
1286    /// Actionable next step.
1287    pub hint: Option<String>,
1288}
1289
1290#[cfg(test)]
1291mod tests {
1292    use super::{
1293        CoreError, ErrorBody, ErrorEnvelope, GATEWAY_CLIENT_BODY_CAP_BYTES,
1294        GATEWAY_CLIENT_BODY_TRUNCATION_MARKER, LOSS_GATE_REFUSAL_REASON_PREFIX,
1295        ModuleDigestChangedDetails, ModuleDigestMismatchDetails, TESTING_ROUTE_PREFIX,
1296        truncate_api_body,
1297    };
1298
1299    /// Build a real `reqwest::Error` for the Network variant: a request to
1300    /// an unroutable loopback port fails at connect time (instant refusal —
1301    /// `reqwest::Error` has no public constructor).
1302    fn network_error() -> CoreError {
1303        // reqwest is built with `rustls-no-provider`, so a raw
1304        // `reqwest::get` panics unless the process-wide provider is
1305        // installed. In a full lib-test run some other test happens to
1306        // install it first; running the error tests ALONE (which the
1307        // exit-table verify command does) hit the panic. The install is
1308        // idempotent and documented for exactly this use.
1309        crate::client::install_crypto_provider();
1310        let rt = tokio::runtime::Builder::new_current_thread()
1311            .enable_all()
1312            .build()
1313            .expect("test runtime");
1314        let url = "http://127.0.0.1:1";
1315        let source = rt
1316            .block_on(reqwest::get(url))
1317            .expect_err("request to an unroutable port must fail");
1318        CoreError::Network {
1319            url: url.to_string(),
1320            source: Some(source),
1321            observation: None,
1322        }
1323    }
1324
1325    /// The [`CoreError::ModuleFeedUnreachable`] twin of [`network_error`]
1326    /// — a real `reqwest::Error` from an unroutable loopback port, for
1327    /// the offline-module-feed exit-4 case.
1328    fn module_feed_unreachable_error() -> CoreError {
1329        crate::client::install_crypto_provider();
1330        let rt = tokio::runtime::Builder::new_current_thread()
1331            .enable_all()
1332            .build()
1333            .expect("test runtime");
1334        let url = "http://127.0.0.1:1";
1335        let source = rt
1336            .block_on(reqwest::get(url))
1337            .expect_err("request to an unroutable port must fail");
1338        CoreError::ModuleFeedUnreachable {
1339            url: url.to_string(),
1340            source: Some(source),
1341        }
1342    }
1343
1344    /// Pitfall-5 guard: the FULL 1–7 taxonomy enumerated on day one so no
1345    /// later phase can silently renumber it or respell a slug. The slugs are
1346    /// asserted against literals — that IS the stability contract.
1347    #[test]
1348    fn exit_code_mapping_enumerated() {
1349        let cases: Vec<(CoreError, u8, &'static str)> = vec![
1350            (CoreError::Internal("boom".into()), 1, "internal"),
1351            (
1352                CoreError::ConfirmationRequired {
1353                    operation: "project download".into(),
1354                },
1355                2,
1356                "confirmation_required",
1357            ),
1358            (
1359                CoreError::InvalidImportFile {
1360                    reason: "missing ZIP magic".into(),
1361                },
1362                2,
1363                "invalid_import_file",
1364            ),
1365            (
1366                CoreError::InvalidInput {
1367                    reason: "cannot read put.json".into(),
1368                },
1369                2,
1370                "invalid_input",
1371            ),
1372            (
1373                CoreError::ProfileNotFound {
1374                    name: "nope".into(),
1375                    known: vec!["dev".into()],
1376                },
1377                3,
1378                "profile_not_found",
1379            ),
1380            (CoreError::NoActiveProfile, 3, "no_active_profile"),
1381            (
1382                CoreError::SecretUnavailable {
1383                    profile: "dev".into(),
1384                },
1385                3,
1386                "secret_unavailable",
1387            ),
1388            (
1389                CoreError::PasswordUnavailable {
1390                    profile: "dev".into(),
1391                },
1392                3,
1393                "password_unavailable",
1394            ),
1395            (
1396                CoreError::ConfigInvalid {
1397                    reason: "bad toml".into(),
1398                },
1399                3,
1400                "config_invalid",
1401            ),
1402            (
1403                CoreError::PollIntervalTooSmall {
1404                    profile: "dev".into(),
1405                },
1406                3,
1407                "poll_interval_too_small",
1408            ),
1409            (network_error(), 4, "network_error"),
1410            (
1411                CoreError::Auth {
1412                    status: 401,
1413                    endpoint: None,
1414                },
1415                5,
1416                "auth_rejected",
1417            ),
1418            (
1419                CoreError::GatewayTooOld {
1420                    found: "8.1.0".into(),
1421                    minimum: "8.3.1".into(),
1422                    endpoint: None,
1423                },
1424                6,
1425                "gateway_too_old",
1426            ),
1427            (
1428                CoreError::GatewayNotCommissioned {
1429                    endpoint: Some("http://gw:8088/data/api/v1/gateway-info".into()),
1430                },
1431                6,
1432                "gateway_not_commissioned",
1433            ),
1434            (
1435                CoreError::GatewayRestarting {
1436                    endpoint: Some("http://gw:8088/data/api/v1/gateway-info".into()),
1437                },
1438                6,
1439                "gateway_restarting",
1440            ),
1441            (
1442                CoreError::NotFound {
1443                    endpoint: Some("http://gw:8088/data/api/v1/designer/42".into()),
1444                },
1445                6,
1446                "not_found",
1447            ),
1448            (
1449                CoreError::ProjectExists {
1450                    name: "PlantFloor".into(),
1451                    endpoint: None,
1452                },
1453                6,
1454                "project_exists",
1455            ),
1456            (
1457                CoreError::ResourceBinary {
1458                    path: "com.x/perspective/session-permissions".into(),
1459                    endpoint: None,
1460                },
1461                6,
1462                "resource_binary",
1463            ),
1464            (
1465                CoreError::TrialNotExpired {
1466                    remaining_s: 6590,
1467                    endpoint: Some("http://localhost:9088/data/api/v1/trial".into()),
1468                },
1469                6,
1470                "trial_not_expired",
1471            ),
1472            (
1473                CoreError::ProviderNotFound {
1474                    name: "nope".into(),
1475                    endpoint: Some("/data/api/v1/resources/find/ignition/tag-provider/nope".into()),
1476                },
1477                6,
1478                "provider_not_found",
1479            ),
1480            (
1481                CoreError::RoutesNotDeployed {
1482                    project: "ign-cli".into(),
1483                    route: "tags".into(),
1484                    endpoint: Some("/system/webdev/ign-cli/cli/tags".into()),
1485                },
1486                6,
1487                "routes_not_deployed",
1488            ),
1489            (
1490                CoreError::WebdevUnlicensed {
1491                    endpoint: Some("/system/webdev/ign-cli/cli/tags".into()),
1492                },
1493                6,
1494                "webdev_unlicensed",
1495            ),
1496            (
1497                CoreError::RouteVersionMismatch {
1498                    route: "tags".into(),
1499                    deployed: "0.9.0".into(),
1500                    expected: "1.0.0".into(),
1501                    endpoint: Some("/system/webdev/ign-cli/cli/tags".into()),
1502                },
1503                6,
1504                "route_version_mismatch",
1505            ),
1506            (
1507                CoreError::WebdevRouteError {
1508                    code: "route_error".into(),
1509                    message: "boom".into(),
1510                    endpoint: Some("/system/webdev/ign-cli/cli/tags".into()),
1511                },
1512                6,
1513                "webdev_route_error",
1514            ),
1515            (
1516                CoreError::TagCollision {
1517                    provider: "p5import".into(),
1518                    names: vec!["T1".into(), "P5".into()],
1519                    endpoint: Some("/system/webdev/ign-cli/cli/tags".into()),
1520                },
1521                6,
1522                "tag_collision",
1523            ),
1524            (
1525                CoreError::AlarmJournalMissing {
1526                    endpoint: Some("/system/webdev/ign-cli/cli/alarms".into()),
1527                },
1528                6,
1529                "alarm_journal_missing",
1530            ),
1531            (
1532                CoreError::ImportDenied {
1533                    project: "PlantFloor".into(),
1534                    problem: "resource already exists: ResourceId{resourcePath=com.example, collectionName=views}".into(),
1535                    endpoint: Some("http://gw:8088/data/api/v1/projects/import/PlantFloor?overwrite=true".into()),
1536                },
1537                6,
1538                "import_denied",
1539            ),
1540            (
1541                CoreError::SessionNotPrunable {
1542                    id: "d-live-1".into(),
1543                    endpoint: Some("http://gw:8088/data/api/v1/designer/d-live-1".into()),
1544                },
1545                6,
1546                "session_not_prunable",
1547            ),
1548            (
1549                CoreError::EamNotController {
1550                    endpoint: Some("http://gw:8088/data/eam/api/v1/eam-tasks/history".into()),
1551                },
1552                6,
1553                "eam_not_controller",
1554            ),
1555            (
1556                CoreError::ProviderRootUnsupported {
1557                    endpoint: Some("/system/webdev/ign-cli/cli/tagConfig".into()),
1558                },
1559                6,
1560                "provider_root_unsupported",
1561            ),
1562            (
1563                CoreError::EamTaskTypeRefused {
1564                    task_type: "eam_remoteUpgrade".into(),
1565                },
1566                6,
1567                "eam_task_type_refused",
1568            ),
1569            (
1570                CoreError::EamTaskInFlight {
1571                    task: "cli-research-backup".into(),
1572                    detail: "Task 'cli-research-backup (forced)' already exists! It must be completed or deleted before another task of this type can be force executed.".into(),
1573                    endpoint: Some(
1574                        "http://gw:8088/data/eam/api/v1/eam-tasks/force/eam/cli-research-backup"
1575                            .into(),
1576                    ),
1577                },
1578                6,
1579                "eam_task_in_flight",
1580            ),
1581            (
1582                CoreError::ScriptExecNotConfigured {
1583                    profile: "dev".into(),
1584                },
1585                6,
1586                "script_exec_not_configured",
1587            ),
1588            (CoreError::LintToolAbsent, 6, "lint_tool_absent"),
1589            (
1590                CoreError::NodeToolAbsent {
1591                    tool: "node".into(),
1592                },
1593                6,
1594                "node_tool_absent",
1595            ),
1596            (
1597                CoreError::BundleNotAvailable {
1598                    state: "Invalid".into(),
1599                },
1600                6,
1601                "bundle_not_available",
1602            ),
1603            (
1604                CoreError::GatewayClientError {
1605                    status: 400,
1606                    endpoint: "http://gw:8088/data/api/v1/nonexistent".into(),
1607                    body: r#"{"error":{"code":"NOT_FOUND"}}"#.into(),
1608                },
1609                2,
1610                "gateway_client_error",
1611            ),
1612            (CoreError::Rig("compose up failed".into()), 7, "rig_error"),
1613            (
1614                module_feed_unreachable_error(),
1615                4,
1616                "module_feed_unreachable",
1617            ),
1618            (
1619                CoreError::ModuleReleaseNotFound {
1620                    module: "git".into(),
1621                    version: "99.99.99".into(),
1622                    url: "https://api.github.com/repos/WhiskeyHouse/ignition-git-module/releases/tags/v99.99.99".into(),
1623                    detail: "no release exists for tag \"v99.99.99\"".into(),
1624                },
1625                6,
1626                "module_release_not_found",
1627            ),
1628            (
1629                CoreError::ModuleDigestMismatch(Box::new(ModuleDigestMismatchDetails {
1630                    module: "git".into(),
1631                    version: "2.3.4".into(),
1632                    url: "https://release-assets.githubusercontent.com/Git-2.3.4-signed.modl".into(),
1633                    expected: "b".repeat(64),
1634                    actual: "c".repeat(64),
1635                })),
1636                6,
1637                "module_digest_mismatch",
1638            ),
1639            (
1640                CoreError::ModuleFeedUnusable {
1641                    url: "https://api.github.com/repos/WhiskeyHouse/ignition-git-module/releases/tags/v2.3.4".into(),
1642                    detail: "rate limit exceeded".into(),
1643                },
1644                6,
1645                "module_feed_unusable",
1646            ),
1647            (
1648                CoreError::ModuleDigestChanged(Box::new(ModuleDigestChangedDetails {
1649                    module: "git".into(),
1650                    version: "2.3.4".into(),
1651                    cached_digest: "a".repeat(64),
1652                    upstream_digest: "b".repeat(64),
1653                    cached_path: "/cache/modules/git/2.3.4-aaaa.modl".into(),
1654                })),
1655                6,
1656                "module_digest_changed",
1657            ),
1658            (
1659                CoreError::ModuleNotRegistered {
1660                    id: "bogus".into(),
1661                    known: vec!["git".into()],
1662                },
1663                3,
1664                "module_not_registered",
1665            ),
1666            (
1667                CoreError::ModuleUninstallDenied {
1668                    module_id: "git".into(),
1669                    gateway_module_id: "com.axone_io.ignition.git".into(),
1670                    failed: vec!["com.axone_io.ignition.git".into()],
1671                    endpoint: Some(
1672                        "http://gw:8088/data/api/v1/modules/uninstall".into(),
1673                    ),
1674                },
1675                6,
1676                "module_uninstall_denied",
1677            ),
1678        ];
1679        for (err, code, slug) in cases {
1680            assert_eq!(err.exit_code(), code, "wrong exit code for: {err}");
1681            assert_eq!(err.code(), slug, "unstable slug for: {err}");
1682        }
1683    }
1684
1685    /// The (exit_code, slug) literal table the Three-Place rule syncs.
1686    /// DUPLICATED from [`exit_code_mapping_enumerated`]'s triple list by
1687    /// design: that test is the literal source of truth (enum ↔ literals);
1688    /// this flat table is what the README cross-check runs against, so a
1689    /// drift inside error.rs surfaces as a disagreement instead of silently
1690    /// re-shuffling both sides. Keep the two lists in lockstep — the
1691    /// enumerated test fails if the enum respells/renumbers, and a stale
1692    /// copy here fails the README check below until updated.
1693    const EXIT_SLUG_LITERALS: &[(u8, &str)] = &[
1694        (1, "internal"),
1695        (2, "confirmation_required"),
1696        (2, "invalid_import_file"),
1697        (2, "invalid_input"),
1698        (2, "gateway_client_error"),
1699        (3, "profile_not_found"),
1700        (3, "no_active_profile"),
1701        (3, "secret_unavailable"),
1702        (3, "password_unavailable"),
1703        (3, "config_invalid"),
1704        (3, "poll_interval_too_small"),
1705        (3, "module_not_registered"),
1706        (4, "network_error"),
1707        (5, "auth_rejected"),
1708        (6, "gateway_too_old"),
1709        (6, "gateway_not_commissioned"),
1710        (6, "gateway_restarting"),
1711        (6, "not_found"),
1712        (6, "project_exists"),
1713        (6, "resource_binary"),
1714        (6, "trial_not_expired"),
1715        (6, "provider_not_found"),
1716        (6, "routes_not_deployed"),
1717        (6, "webdev_unlicensed"),
1718        (6, "route_version_mismatch"),
1719        (6, "webdev_route_error"),
1720        (6, "tag_collision"),
1721        (6, "alarm_journal_missing"),
1722        (6, "import_denied"),
1723        (6, "session_not_prunable"),
1724        (6, "eam_not_controller"),
1725        (6, "eam_task_type_refused"),
1726        (6, "eam_task_in_flight"),
1727        (6, "script_exec_not_configured"),
1728        (6, "lint_tool_absent"),
1729        (6, "node_tool_absent"),
1730        (6, "provider_root_unsupported"),
1731        (6, "bundle_not_available"),
1732        (4, "module_feed_unreachable"),
1733        (6, "module_release_not_found"),
1734        (6, "module_digest_mismatch"),
1735        (6, "module_feed_unusable"),
1736        (6, "module_digest_changed"),
1737        (6, "module_uninstall_denied"),
1738        (7, "rig_error"),
1739    ];
1740
1741    /// Parse the README's exit-code table: rows `| <exit> | class | meaning |
1742    /// \`slug\`, ... |` where `<exit>` is 1–7 (row 0 is the success row, no
1743    /// slugs). Parsing is SCOPED to the `## Exit codes` section — the README
1744    /// contains unrelated tables whose rows coincidentally begin `| 5 |`,
1745    /// `| 3 |`, etc. Std-only string ops: split on `|`, trim, take the LAST
1746    /// non-empty cell as the slug column (the meaning column can carry
1747    /// backticked non-slugs like `--yes`), and keep the backtick-delimited
1748    /// tokens that start with an ASCII letter and contain no spaces.
1749    fn parse_readme_exit_table(readme: &str) -> Vec<(u8, Vec<String>)> {
1750        let section = readme.split("## Exit codes").nth(1).unwrap_or_default();
1751        let mut rows = Vec::new();
1752        let table_lines = section
1753            .lines()
1754            .skip_while(|line| !line.trim_start().starts_with('|'));
1755        for line in table_lines {
1756            if !line.trim_start().starts_with('|') {
1757                break; // table ended
1758            }
1759            let cells: Vec<&str> = line.split('|').map(str::trim).collect();
1760            let Some(exit) = cells.get(1).and_then(|cell| cell.parse::<u8>().ok()) else {
1761                continue;
1762            };
1763            if !(1..=7).contains(&exit) {
1764                continue;
1765            }
1766            let slug_cell = cells
1767                .iter()
1768                .rev()
1769                .find(|cell| !cell.is_empty())
1770                .copied()
1771                .unwrap_or_default();
1772            let slugs: Vec<String> = slug_cell
1773                .split('`')
1774                .enumerate()
1775                .filter(|(idx, _)| idx % 2 == 1)
1776                .map(|(_, token)| token.trim().to_string())
1777                .filter(|token| {
1778                    token.starts_with(|c: char| c.is_ascii_alphabetic()) && !token.contains(' ')
1779                })
1780                .collect();
1781            rows.push((exit, slugs));
1782        }
1783        rows
1784    }
1785
1786    /// CORE-11, the Three-Place slug rule made executable: the exit-code
1787    /// table exists in the enum ([`CoreError::exit_code`]), this file's
1788    /// literal triples, and the README — and the README side is now
1789    /// machine-checked. The README is parsed verbatim via `include_str!`
1790    /// and cross-checked against the literal table in BOTH directions:
1791    /// (a) every literal slug appears under its exit code (a README row
1792    /// that lost or misspelled a slug fails), (b) every README slug token
1793    /// exists in the literal table (a stale/deleted row fails). Exit 6
1794    /// carries 23 slugs, so the full cross-check is the value — not the
1795    /// happy-path smoke.
1796    #[test]
1797    fn readme_exit_table_agreement() {
1798        let readme = include_str!("../../../README.md");
1799        let rows = parse_readme_exit_table(readme);
1800        assert!(
1801            rows.len() >= 7,
1802            "README exit-code table not found — the parser must see all 7 \
1803             failure-class rows (found {})",
1804            rows.len()
1805        );
1806
1807        // Direction (a): every literal (exit, slug) is present in the
1808        // README row matching its exit code.
1809        for (exit, slug) in EXIT_SLUG_LITERALS {
1810            let row = rows
1811                .iter()
1812                .find(|(readme_exit, _)| readme_exit == exit)
1813                .unwrap_or_else(|| panic!("README table has no row for exit {exit}"));
1814            assert!(
1815                row.1.iter().any(|s| s == slug),
1816                "README exit-{exit} row is missing slug {slug:?} (row: {:?})",
1817                row.1
1818            );
1819        }
1820
1821        // Direction (b): every README slug token exists in the literal
1822        // table under the same exit code — catches stale/deleted rows.
1823        for (exit, slugs) in &rows {
1824            for slug in slugs {
1825                assert!(
1826                    EXIT_SLUG_LITERALS
1827                        .iter()
1828                        .any(|(lit_exit, lit_slug)| lit_exit == exit && lit_slug == slug),
1829                    "README exit-{exit} row carries slug {slug:?} that no \
1830                     CoreError variant emits — stale table row or slug/exit \
1831                     drift between README and error.rs"
1832                );
1833            }
1834        }
1835    }
1836
1837    /// CORE-05: config, auth, and target-state classes carry actionable
1838    /// hints (and every other class does too).
1839    #[test]
1840    fn hints_are_actionable_for_config_auth_target_state() {
1841        let profile_not_found = CoreError::ProfileNotFound {
1842            name: "x".into(),
1843            known: vec!["dev".into(), "prod".into()],
1844        };
1845        let hint = profile_not_found.hint().expect("hint required");
1846        assert!(
1847            hint.contains("dev"),
1848            "hint must list known profiles: {hint}"
1849        );
1850        assert!(
1851            hint.contains("ign profile add"),
1852            "hint must name the fix: {hint}"
1853        );
1854
1855        let auth = CoreError::Auth {
1856            status: 403,
1857            endpoint: None,
1858        };
1859        let hint = auth.hint().expect("hint required");
1860        assert!(
1861            hint.contains("three parts"),
1862            "auth hint must name the three-part token setup: {hint}"
1863        );
1864
1865        // Status-aware auth hints (02-RESEARCH Auth §4): 401 = not
1866        // recognized (name:key format), 403 = under-permitted.
1867        let unauthorized = CoreError::Auth {
1868            status: 401,
1869            endpoint: None,
1870        };
1871        let hint = unauthorized.hint().expect("hint required");
1872        assert!(
1873            hint.contains("name:key"),
1874            "401 hint must name the full name:key token format: {hint}"
1875        );
1876        assert!(
1877            hint.contains("API token"),
1878            "401 hint must say Basic cannot work: {hint}"
1879        );
1880        let hint403 = auth.hint().expect("hint required");
1881        assert!(
1882            hint403.contains("secure connections"),
1883            "403 hint must name the secure-channel part: {hint403}"
1884        );
1885
1886        let too_old = CoreError::GatewayTooOld {
1887            found: "8.1.0".into(),
1888            minimum: "8.3.1".into(),
1889            endpoint: None,
1890        };
1891        let hint = too_old.hint().expect("hint required");
1892        assert!(
1893            hint.contains("8.3.1"),
1894            "target-state hint must name the minimum: {hint}"
1895        );
1896
1897        // The WebDev refusal matrix (05-03): every hint names the fix
1898        // — `ign webdev deploy` for absent/older routes, `update ign`
1899        // for newer ones (the roadmap's actionable-error criterion).
1900        let undeployed = CoreError::RoutesNotDeployed {
1901            project: "ign-cli".into(),
1902            route: "tags".into(),
1903            endpoint: None,
1904        };
1905        let hint = undeployed.hint().expect("hint required");
1906        assert!(
1907            hint.contains("ign webdev deploy"),
1908            "absent-routes hint must name the fix: {hint}"
1909        );
1910
1911        let older = CoreError::RouteVersionMismatch {
1912            route: "tags".into(),
1913            deployed: "0.9.0".into(),
1914            expected: "1.0.0".into(),
1915            endpoint: None,
1916        };
1917        let hint = older.hint().expect("hint required");
1918        assert!(
1919            hint.contains("ign webdev deploy") && !hint.contains("update ign"),
1920            "older-route hint says redeploy: {hint}"
1921        );
1922
1923        let newer = CoreError::RouteVersionMismatch {
1924            route: "tags".into(),
1925            deployed: "1.1.0".into(),
1926            expected: "1.0.0".into(),
1927            endpoint: None,
1928        };
1929        let hint = newer.hint().expect("hint required");
1930        assert!(
1931            hint.contains("update ign") && !hint.contains("ign webdev deploy"),
1932            "newer-route hint says update ign: {hint}"
1933        );
1934
1935        let secret_gate = CoreError::WebdevRouteError {
1936            code: "secret_required".into(),
1937            message: "missing x-ignition-cli-secret header".into(),
1938            endpoint: None,
1939        };
1940        let hint = secret_gate.hint().expect("hint required");
1941        assert!(
1942            hint.contains("--with-script-exec"),
1943            "secret-gate hint names the deploy flag: {hint}"
1944        );
1945
1946        // The alarm-journal refusal (05-06): the hint names the missing
1947        // provisioning chain AND the README section.
1948        let journal = CoreError::AlarmJournalMissing { endpoint: None };
1949        let hint = journal.hint().expect("hint required");
1950        assert!(
1951            hint.contains("journal profile") && hint.contains("database connection"),
1952            "journal hint names the chain: {hint}"
1953        );
1954        assert!(
1955            hint.contains("README"),
1956            "journal hint points at the README section: {hint}"
1957        );
1958
1959        // The live-designer prune refusal (06-07): the hint names the
1960        // action — close the Designer — and the stale-only semantics.
1961        let prunable = CoreError::SessionNotPrunable {
1962            id: "d-live-1".into(),
1963            endpoint: None,
1964        };
1965        let hint = prunable.hint().expect("hint required");
1966        assert!(
1967            hint.contains("close the Designer"),
1968            "prune hint must name the action: {hint}"
1969        );
1970        assert!(
1971            hint.contains("stale entries"),
1972            "prune hint must name stale-only semantics: {hint}"
1973        );
1974
1975        // The TTY refusal's hint is contextual (06-07): the ONE
1976        // InvalidInput reason whose fix is terminal-related. Every
1977        // other reason keeps the --file/stdin resource-put hint (pinned
1978        // here so the special case can never leak or regress).
1979        let tty = CoreError::tui_tty_refusal();
1980        assert_eq!(tty.code(), "invalid_input", "slug unchanged");
1981        assert_eq!(tty.exit_code(), 2, "usage class unchanged");
1982        let hint = tty.hint().expect("hint required");
1983        assert!(
1984            hint.contains("interactive terminal"),
1985            "TTY hint must name the fix: {hint}"
1986        );
1987        assert!(
1988            !hint.contains("--file"),
1989            "TTY hint must not carry the resource-put hint: {hint}"
1990        );
1991        let put = CoreError::InvalidInput {
1992            reason: "cannot read put.json".into(),
1993        };
1994        let hint = put.hint().expect("hint required");
1995        assert!(
1996            hint.contains("--file") && hint.contains("stdin"),
1997            "resource-put hint unchanged: {hint}"
1998        );
1999
2000        // The EAM controller state gate (07-02): the hint names the
2001        // manual flip recipe + the README section (role decisions
2002        // stay one config-PUT away from the CLI, never one flag).
2003        let controller = CoreError::EamNotController { endpoint: None };
2004        let hint = controller.hint().expect("hint required");
2005        assert!(
2006            hint.contains("installMode"),
2007            "controller hint names the flip: {hint}"
2008        );
2009        assert!(
2010            hint.contains("Controller"),
2011            "controller hint names the target role: {hint}"
2012        );
2013        assert!(
2014            hint.contains("README"),
2015            "controller hint points at the README section: {hint}"
2016        );
2017
2018        // The refused task-type ladder top (07-02 Task 3): the hint
2019        // names the EAM console + the v2 scope pointer.
2020        let refused = CoreError::EamTaskTypeRefused {
2021            task_type: "eam_restoreBackup".into(),
2022        };
2023        assert_eq!(refused.exit_code(), 6, "target state");
2024        let hint = refused.hint().expect("hint required");
2025        assert!(
2026            hint.contains("EAM console"),
2027            "refused hint names where to run it: {hint}"
2028        );
2029        assert!(
2030            hint.contains("fleet-wide") || hint.contains("EXT-03"),
2031            "refused hint names the fleet consequence / scope: {hint}"
2032        );
2033
2034        // The force-route 409 (07-06 gap 4): the hint names the
2035        // resolution — complete or delete the leftover '(forced)'
2036        // run via the EAM console (no ign verb deletes runs).
2037        let in_flight = CoreError::EamTaskInFlight {
2038            task: "cli-research-backup".into(),
2039            detail: "the previous '(forced)' run must be completed or deleted first".into(),
2040            endpoint: None,
2041        };
2042        assert_eq!(in_flight.exit_code(), 6, "target state");
2043        let hint = in_flight.hint().expect("hint required");
2044        assert!(
2045            hint.contains("EAM console"),
2046            "in-flight hint names where to resolve it: {hint}"
2047        );
2048        assert!(
2049            hint.contains("complete or delete"),
2050            "in-flight hint names the resolution: {hint}"
2051        );
2052
2053        // The scriptExec structural gate (07-03): the hint names the
2054        // deploy flag VERBATIM — the flag IS the opt-in (no --yes
2055        // exists on script run).
2056        let unconfigured = CoreError::ScriptExecNotConfigured {
2057            profile: "dev".into(),
2058        };
2059        assert_eq!(unconfigured.exit_code(), 6, "target state");
2060        let hint = unconfigured.hint().expect("hint required");
2061        assert!(
2062            hint.contains("ign webdev deploy --with-script-exec"),
2063            "the hint names the deploy flag verbatim: {hint}"
2064        );
2065
2066        // Totality: no class silently loses its hint later.
2067        let no_active = CoreError::NoActiveProfile;
2068        assert!(no_active.hint().is_some());
2069    }
2070
2071    /// The failure envelope's serialized field order and endpoint population
2072    /// are contract: `ok`, `profile`, `error` / `code`, `message`,
2073    /// `endpoint`, `hint` (string-level comparison because `serde_json::Value`
2074    /// maps are key-sorted and would hide ordering).
2075    #[test]
2076    fn error_envelope_locked_shape_and_endpoint() {
2077        let auth = CoreError::Auth {
2078            status: 401,
2079            endpoint: Some("https://gw.example.com/data/api/v1/gateway-info".into()),
2080        };
2081        let envelope: ErrorEnvelope<'_> = auth.envelope(Some("dev"));
2082        let json = serde_json::to_string(&envelope).expect("serialize envelope");
2083
2084        assert_eq!(
2085            json,
2086            concat!(
2087                r#"{"ok":false,"profile":"dev","error":{"code":"auth_rejected","#,
2088                r#""message":"gateway rejected credentials (HTTP 401)","#,
2089                r#""endpoint":"https://gw.example.com/data/api/v1/gateway-info","#,
2090                r#""hint":"token not recognized — the X-Ignition-API-Token header must be the FULL `name:key` string from the gateway UI (Platform→Security→API Keys); Basic auth does not work on 8.3 /data routes — create an API token"}}"#
2091            )
2092        );
2093
2094        // Classes without a request involved carry no endpoint.
2095        let no_request = CoreError::NoActiveProfile;
2096        let body: &ErrorBody = &no_request.envelope(None).error;
2097        assert_eq!(body.endpoint, None);
2098    }
2099
2100    /// The api-call body cap (09-01): a body at or under
2101    /// [`GATEWAY_CLIENT_BODY_CAP_BYTES`] rides verbatim (no marker); a
2102    /// larger body is truncated to the cap on a UTF-8 char boundary with
2103    /// the exact ASCII marker appended — and the cap is enforced at
2104    /// CONSTRUCTION, so the variant always carries its final form.
2105    #[test]
2106    fn truncates_at_cap_with_marker() {
2107        // Under the cap: byte-identical passthrough.
2108        let short = r#"{"error":{"code":"NOT_FOUND"}}"#;
2109        assert_eq!(truncate_api_body(short), short);
2110        assert!(!short.contains(GATEWAY_CLIENT_BODY_TRUNCATION_MARKER));
2111
2112        // Exactly at the cap: still verbatim (the marker only joins an
2113        // OVER-cap body — the boundary is <=, not <).
2114        let exact = "x".repeat(GATEWAY_CLIENT_BODY_CAP_BYTES);
2115        assert_eq!(truncate_api_body(&exact), exact);
2116
2117        // One byte over: truncated, marker appended, total size bounded.
2118        let over = "x".repeat(GATEWAY_CLIENT_BODY_CAP_BYTES + 1);
2119        let truncated = truncate_api_body(&over);
2120        assert!(
2121            truncated.ends_with(GATEWAY_CLIENT_BODY_TRUNCATION_MARKER),
2122            "truncated body must end with the explicit marker"
2123        );
2124        assert!(
2125            truncated.len()
2126                <= GATEWAY_CLIENT_BODY_CAP_BYTES + GATEWAY_CLIENT_BODY_TRUNCATION_MARKER.len(),
2127            "truncated body must stay within cap + marker: {}",
2128            truncated.len()
2129        );
2130
2131        // A multi-byte character straddling the cap boundary does not
2132        // panic (the cut backs up to a char boundary) and still carries
2133        // the marker.
2134        let multibyte = "é".repeat(GATEWAY_CLIENT_BODY_CAP_BYTES); // 2 bytes each
2135        let truncated = truncate_api_body(&multibyte);
2136        assert!(truncated.ends_with(GATEWAY_CLIENT_BODY_TRUNCATION_MARKER));
2137        assert!(
2138            truncated
2139                .is_char_boundary(truncated.len() - GATEWAY_CLIENT_BODY_TRUNCATION_MARKER.len())
2140        );
2141    }
2142
2143    /// The loss-gate hint override (11-07 gap closure): a reason with the
2144    /// [`LOSS_GATE_REFUSAL_REASON_PREFIX`] sentinel carries the --yes
2145    /// hint, while every OTHER InvalidInput reason (generic file-read)
2146    /// and the TTY-refusal precedent keep their hints byte-identically.
2147    /// The loss-gate reason is built from the CONST via format! — a
2148    /// string literal of the sentinel text here would be a third
2149    /// production-literal hit and break the prefix-uniqueness gate the
2150    /// plan's verify pins.
2151    #[test]
2152    fn loss_gate_hint_override_and_neighbors_unchanged() {
2153        // Sentinel-prefixed reason (dynamic prose after the header):
2154        // the --yes hint, never the file-read default.
2155        let loss_gate = CoreError::InvalidInput {
2156            reason: format!(
2157                "{}xml): the scan reports 2 finding(s) before the import:\nre-run \
2158                 with --yes to import anyway",
2159                LOSS_GATE_REFUSAL_REASON_PREFIX
2160            ),
2161        };
2162        assert_eq!(loss_gate.code(), "invalid_input", "slug unchanged");
2163        assert_eq!(loss_gate.exit_code(), 2, "usage class unchanged");
2164        let hint = loss_gate.hint().expect("hint required");
2165        assert!(
2166            hint.contains("--yes"),
2167            "loss-gate hint must name the --yes re-run: {hint}"
2168        );
2169        assert!(
2170            hint.contains("loss report above"),
2171            "loss-gate hint must point back at the report: {hint}"
2172        );
2173        assert!(
2174            !hint.contains("fix the input source"),
2175            "the file-read default must never leak onto the loss-gate path: {hint}"
2176        );
2177
2178        // Generic InvalidInput: the file-read default UNCHANGED (the
2179        // regression pin for every other raise site).
2180        let generic = CoreError::InvalidInput {
2181            reason: "x is not valid JSON: expected value at line 1".into(),
2182        };
2183        let hint = generic.hint().expect("hint required");
2184        assert!(
2185            hint.contains("--file") && hint.contains("stdin"),
2186            "resource-put hint unchanged: {hint}"
2187        );
2188        assert!(
2189            !hint.contains("--yes"),
2190            "the --yes hint must not leak onto generic reasons: {hint}"
2191        );
2192
2193        // The TTY-refusal precedent intact (06-07).
2194        let tty = CoreError::tui_tty_refusal();
2195        let hint = tty.hint().expect("hint required");
2196        assert!(
2197            hint.contains("interactive terminal"),
2198            "TTY hint unchanged: {hint}"
2199        );
2200        assert!(
2201            !hint.contains("--yes"),
2202            "the --yes hint must not leak onto the TTY path: {hint}"
2203        );
2204    }
2205
2206    /// The testing-scoped `routes_not_deployed` hint (QUICK-p0g): a
2207    /// `testing/`-prefixed route names the TESTING install commands,
2208    /// while every other route keeps the generic `ign webdev deploy`
2209    /// text BYTE-identically. Both branches are pinned so a future
2210    /// edit cannot silently merge them — sending a caller to
2211    /// `ign webdev deploy` for an absent testing bundle is a hint
2212    /// that does not fix the error.
2213    #[test]
2214    fn routes_not_deployed_hint_is_testing_scoped() {
2215        let testing = CoreError::RoutesNotDeployed {
2216            project: "Flux".into(),
2217            route: format!("{TESTING_ROUTE_PREFIX}run"),
2218            endpoint: None,
2219        };
2220        assert_eq!(testing.code(), "routes_not_deployed", "slug unchanged");
2221        assert_eq!(testing.exit_code(), 6, "target-state class unchanged");
2222        let hint = testing.hint().expect("hint required");
2223        assert!(
2224            hint.contains("ign webdev deploy --with-testing"),
2225            "testing hint must name the deploy flag: {hint}"
2226        );
2227        assert!(
2228            hint.contains("ign adopt") && hint.contains("--testing"),
2229            "testing hint must name the adopt path: {hint}"
2230        );
2231
2232        // Every non-testing route: the generic hint, unchanged.
2233        let generic = CoreError::RoutesNotDeployed {
2234            project: "ign-cli".into(),
2235            route: "tags".into(),
2236            endpoint: None,
2237        };
2238        let hint = generic.hint().expect("hint required");
2239        assert_eq!(
2240            hint,
2241            "run `ign webdev deploy` to install the CLI's WebDev routes into \
2242             the gateway, then retry",
2243            "the generic branch is byte-identical to its pre-QUICK-p0g text"
2244        );
2245    }
2246}