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