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), `deploy_tree_invalid`, `embedded_secret` (21-03), `destructive_change_refused` (23-02) (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), `transport_failed` (22-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), `scan_lock_held` (20-02), `resource_signature_rejected` (20-02), `resource_exists` (20-REVIEW WR-01), `mode_exists` (20-REVIEW WR-01), `deploy_ledger_invalid` (22-03), `plan_stale`, `deploy_blocked`, `deploy_preflight_failed`, `deploy_journal_present`, `deploy_live_changed`, `deploy_run_invalid`, `rollback_failed` (23-02), `project_git_managed` (25-07), `deploy_unreadable` (quick-261029-ms3), `deploy_pair_tags_refused` (debug deploy-tag-redundancy-sync)
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    /// JVM profiling invoked without `--i-accept-the-risk`. Exit 2
110    /// (usage class — it names a flag the caller must add, like
111    /// [`Self::ConfirmationRequired`]) but DELIBERATELY NOT satisfiable
112    /// by the global `--yes`/`IGNITION_YES`: active JVM instrumentation
113    /// is a stronger opt-in than the destructive-verb guard — the
114    /// caller must acknowledge THIS specific production risk, not
115    /// confirm generically.
116    #[error(
117        "{operation} actively instruments the gateway JVM; rerun with --i-accept-the-risk to acknowledge the production risk"
118    )]
119    ProfilingRiskNotAccepted { operation: String },
120
121    /// An import byte source the caller must fix (wrong file, too big,
122    /// unreadable). Exit 2 — usage class: it names what the CALLER must
123    /// change, like [`Self::ConfirmationRequired`] (03-02).
124    #[error("invalid import file: {reason}")]
125    InvalidImportFile { reason: String },
126
127    /// The gateway ANSWERED the import POST with HTTP 200 but the
128    /// body says `{"success": false, "problem": "…"}` — the
129    /// denial-rides-200 class the WebDev family handles (05-01)
130    /// applied to the project-import family (05-07, UAT Gap 1):
131    /// without this check the import caller reports ok while nothing
132    /// landed. Exit 6 — target state: the gateway refused the
133    /// import (the problem text names why, verbatim).
134    #[error("gateway rejected the project import for {project:?}: {problem}")]
135    ImportDenied {
136        /// The project the import was headed for.
137        project: String,
138        /// The gateway's own `problem` text (verbatim when present).
139        problem: String,
140        /// URL of the import request, when known.
141        endpoint: Option<String>,
142    },
143
144    /// A command input the caller must fix (unreadable `--file`, failed
145    /// stdin read). Exit 2 — usage class, the generic sibling of
146    /// [`Self::InvalidImportFile`] (03-03: `resource put`'s byte
147    /// source).
148    #[error("invalid input: {reason}")]
149    InvalidInput { reason: String },
150
151    /// Named profile absent from config. Exit 3.
152    #[error("profile {name:?} not found (known profiles: {known:?})")]
153    ProfileNotFound { name: String, known: Vec<String> },
154
155    /// No `--profile`, no `IGNITION_PROFILE`, no active profile in config.
156    /// Exit 3. Constructible from the CLI once config resolution lands
157    /// (01-03); the taxonomy is complete on day one.
158    #[error("no active profile configured")]
159    NoActiveProfile,
160
161    /// No credential resolvable for the profile (env, token_env, keyring all
162    /// missed or failed). Exit 3.
163    #[error("secret unavailable for profile {profile:?}")]
164    SecretUnavailable { profile: String },
165
166    /// The adopt bootstrap's login rung needs `IGNITION_PASSWORD` and it
167    /// was absent/empty (ADOPT-05 review round: the generic
168    /// [`Self::SecretUnavailable`] hint names the TOKEN paths, which is
169    /// exactly wrong for the PASSWORD gate — same exit class, own slug,
170    /// never a new exit code; the PollIntervalTooSmall precedent).
171    #[error("login password unavailable for profile {profile:?}")]
172    PasswordUnavailable { profile: String },
173
174    /// Config file unreadable or wrong shape. Exit 3.
175    #[error("invalid configuration: {reason}")]
176    ConfigInvalid { reason: String },
177
178    /// A profile's `poll_interval_secs` is below the 1-second floor
179    /// (08-01, TUIX-05 clamp): sub-second gateway polling is refused at
180    /// load time — the TUI's background refresh cadence may not hammer
181    /// the gateway. Exit 3 — the CONFIG class (the Phase-7 additive-slug
182    /// precedent, e.g. `eam_not_controller`: same exit class, own slug,
183    /// never a new exit code — the 1–7 taxonomy is frozen).
184    #[error("profile {profile:?}: poll_interval_secs must be >= 1 (sub-second polling refused)")]
185    PollIntervalTooSmall {
186        /// The profile carrying the refused value.
187        profile: String,
188    },
189
190    /// Gateway unreachable / timeout / TLS failure. Exit 4.
191    ///
192    /// `source: None` marks a POLL deadline expiry (02-04 `poll.rs`):
193    /// same class, same slug (`network_error`), no new variant — the
194    /// transport-error `source` a real failure carries is simply
195    /// absent, and `url` describes what was being waited on instead
196    /// (the poll's subject). The deadline's last observation rides
197    /// `observation` (09-07): when it is `Some` the gateway ANSWERED
198    /// with a concrete state, and the message leads with "no terminal
199    /// state" — it NEVER claims unreachability for an observed
200    /// answer; `None` keeps the plain unreachability wording.
201    #[error(
202        "{lead} at {url}{source_note}{observation_note}",
203        lead = if observation.is_some() {
204            "no terminal state"
205        } else {
206            "gateway unreachable"
207        },
208        source_note = source.as_ref().map(|source| format!(": {source}")).unwrap_or_default(),
209        observation_note = observation
210            .as_deref()
211            .map(|obs| format!("; last observation: {obs}"))
212            .unwrap_or_default()
213    )]
214    Network {
215        url: String,
216        #[source]
217        source: Option<reqwest::Error>,
218        /// The poll deadline's last concrete observation (e.g. the
219        /// gateway's reported state) — `None` for transport failures
220        /// and observation-less deadlines. `Some` ⇒ the gateway
221        /// answered; the Display never says "unreachable" then.
222        observation: Option<String>,
223    },
224
225    /// Gateway reachable but rejected credentials (401/403). Exit 5.
226    #[error("gateway rejected credentials (HTTP {status})")]
227    Auth {
228        status: u16,
229        /// URL/path of the request that was rejected, when known.
230        endpoint: Option<String>,
231    },
232
233    /// Gateway reachable but the command is invalid for its current state —
234    /// version below minimum, uncommissioned, mid-restart, or a missing
235    /// resource. Exit 6.
236    #[error("gateway version {found} is below minimum {minimum}")]
237    GatewayTooOld {
238        found: String,
239        minimum: String,
240        /// URL/path of the request that answered, when known.
241        endpoint: Option<String>,
242    },
243
244    /// Gateway reachable but uncommissioned — every `/data` route 302s to
245    /// `/welcome` (verified on a fresh 8.3.6 container; 02-RESEARCH
246    /// §Error-Body Sniffing). Exit 6.
247    #[error("gateway at {} is not commissioned", endpoint.as_deref().unwrap_or("unknown address"))]
248    GatewayNotCommissioned {
249        /// URL that was redirected to the commissioning wizard.
250        endpoint: Option<String>,
251    },
252
253    /// Gateway restarting — webserver answers (503) but services are down
254    /// (verified restart lifecycle: webserver never drops the connection).
255    /// Exit 6.
256    #[error("gateway is restarting (webserver up, services down)")]
257    GatewayRestarting {
258        /// URL that answered 503.
259        endpoint: Option<String>,
260    },
261
262    /// Named resource absent (404) — terminating a nonexistent session id,
263    /// an unknown path, or a pre-8.3 gateway's JSON
264    /// `{"message": "No route match for path: …"}`. Exit 6.
265    #[error("resource not found on the gateway")]
266    NotFound {
267        /// URL that answered 404.
268        endpoint: Option<String>,
269    },
270
271    /// A project of this name already exists and the import's collision
272    /// policy is abort — the CLI-side pre-check refused BEFORE any
273    /// upload (the server's own answer remains the backstop). Exit 6 —
274    /// target state: the command is invalid for the gateway's current
275    /// state (03-02, the GatewayTooOld action-built-variant precedent:
276    /// constructed by the actions layer, not classify).
277    #[error("project {name} already exists on the gateway")]
278    ProjectExists {
279        /// The colliding project name.
280        name: String,
281        /// URL of the pre-check request, when known.
282        endpoint: Option<String>,
283    },
284
285    /// A binary (data.bin-class) resource met the surgical JSON/text
286    /// loop — REFUSED rather than corrupted through it. Exit 6 —
287    /// target state: the command is invalid for that resource's
288    /// nature; the export/import family owns binary resources
289    /// (Pitfall 7).
290    #[error("resource {path:?} has binary content — not editable via the resource loop")]
291    ResourceBinary {
292        /// The resource path that was refused.
293        path: String,
294        /// URL of the request involved, when known.
295        endpoint: Option<String>,
296    },
297
298    /// The gateway refuses trial resets while the trial is still
299    /// active — live-discovered on 8.3.3 during 04-03's spike: the
300    /// reset POST answers 403 on a NON-expired trial (verified from
301    /// the browser page itself with the exact UI headers), and 200 +
302    /// the flip on an expired one. The action layer's expiry pre-check
303    /// turns that misleading auth-shaped 403 into the honest
304    /// target-state refusal. Exit 6 (the ProjectExists precedent:
305    /// action-constructed, not classify).
306    #[error(
307        "trial is not expired ({remaining_s}s left) — the gateway only honors resets once the trial expires"
308    )]
309    TrialNotExpired {
310        /// Seconds left on the active trial.
311        remaining_s: i64,
312        /// URL of the rig's trial endpoint, when known.
313        endpoint: Option<String>,
314    },
315
316    /// The WebDev route family a command depends on is not deployed —
317    /// the presence probe answered 405, the live-proven 8.3 absent
318    /// marker (missing routes AND missing projects both answer 405,
319    /// NOT 404; 05-RESEARCH Pitfall 1). Exit 6 — target state: the
320    /// command is invalid until `ign webdev deploy` installs the
321    /// routes (the TrialNotExpired precedent: action-constructed, not
322    /// classify).
323    #[error(
324        "webdev routes are not deployed (probe of {route:?} in project {project:?} answered 405)"
325    )]
326    RoutesNotDeployed {
327        /// The deploy project the probe targeted.
328        project: String,
329        /// The route folder the probe named.
330        route: String,
331        /// Path of the probe request, when known.
332        endpoint: Option<String>,
333    },
334
335    /// The WebDev module answered 402 — installed but unlicensed (a
336    /// trial-expired gateway; live-verified cross-version on 8.3.6,
337    /// 05-RESEARCH §Servlet). Exit 6 — no `/system/webdev` route can
338    /// answer until the gateway is licensed.
339    #[error(
340        "the WebDev module is unlicensed on this gateway (HTTP 402 — trial-expired rigs cannot serve /system/webdev routes)"
341    )]
342    WebdevUnlicensed {
343        /// Path of the probe request, when known.
344        endpoint: Option<String>,
345    },
346
347    /// A deployed route's handshake version differs from the embedded
348    /// bundle's — the CLI refuses rather than auto-upgrading either
349    /// side (roadmap-locked: actionable error, no auto-upgrade
350    /// magic). Exit 6.
351    #[error("route {route:?} version mismatch: deployed {deployed}, this CLI expects {expected}")]
352    RouteVersionMismatch {
353        /// The route folder that answered.
354        route: String,
355        /// The route's deployed `routeVersion`.
356        deployed: String,
357        /// The embedded bundle's version
358        /// ([`crate::webdev::ROUTE_BUNDLE_VERSION`]).
359        expected: String,
360        /// Path of the probe request, when known.
361        endpoint: Option<String>,
362    },
363
364    /// A tag provider of this name does not exist — the
365    /// find→signature→delete chain's find half missed (05-04,
366    /// TAGS-01). Exit 6 — target state: the named thing is absent
367    /// (the ProjectExists precedent family: action-constructed, not
368    /// classify — the honest, family-specific refusal over a bare
369    /// 404).
370    #[error("tag provider {name:?} not found on the gateway")]
371    ProviderNotFound {
372        /// The provider name that missed.
373        name: String,
374        /// URL of the find request, when known.
375        endpoint: Option<String>,
376    },
377
378    /// A WebDev route answered HTTP 200 with a body denial
379    /// (`{ok:false, error{code,message}}`) whose machine code this CLI
380    /// does not specifically map — code + message ride verbatim so
381    /// agents can branch on the stable route contract (05-01). Exit 6
382    /// — target state: the deployed route refused the action.
383    #[error("webdev route denied the call ({code}): {message}")]
384    WebdevRouteError {
385        /// The route's machine error code (stable contract).
386        code: String,
387        /// The route's human message.
388        message: String,
389        /// Path of the request, when known.
390        endpoint: Option<String>,
391    },
392
393    /// A tag import under abort policy found EXISTING tags at the
394    /// target provider (05-05, TAGS-09) — the browse pre-check
395    /// refuses BEFORE any route write (the LOCKED Phase-3 collision
396    /// matrix mapped onto configure's 'a'/'o'). Exit 6 — target
397    /// state: the named tags exist; overwrite is the explicit,
398    /// guarded opt-in.
399    #[error(
400        "tag collision importing into provider {provider:?}: {} already exist(s)",
401        names.join(", ")
402    )]
403    TagCollision {
404        /// The target provider the import was headed for.
405        provider: String,
406        /// The colliding top-level tag names the pre-check found.
407        names: Vec<String>,
408        /// URL of the pre-check browse request, when known.
409        endpoint: Option<String>,
410    },
411
412    /// The gateway has no alarm-journal profile configured — alarm
413    /// history has nowhere to read from. The alarms route's
414    /// structured `no_alarm_journal` denial maps here (the
415    /// denial_to_error seam, 05-06 TAGS-07): DEFAULT rigs hit this
416    /// ALWAYS, because the journal is a config-resource chain —
417    /// database connection + `ignition/alarm-journal` profile + the
418    /// `general-alarm-settings` singleton pointing at it. Exit 6 —
419    /// target state: the command is invalid for the gateway's
420    /// current state until that chain is provisioned (the honest,
421    /// actionable refusal over a bare route error).
422    #[error(
423        "no alarm journal profile is configured on this gateway — alarm history has nothing to read"
424    )]
425    AlarmJournalMissing {
426        /// URL of the alarms route request, when known.
427        endpoint: Option<String>,
428    },
429
430    /// Pruning a LIVE Designer session entry — the gateway's prune
431    /// route answers 409 (empty body, wire-verified on 8.3.3,
432    /// 06-UAT test 6): prune removes STALE entries only, so the
433    /// command is invalid while the Designer is still open. Exit 6 —
434    /// target state (additive slug in the frozen taxonomy's
435    /// established growth pattern; classify()'s ROUTE-SCOPED 409 arm
436    /// constructs this — the first classify-constructed refusal added
437    /// since the 02-01 set).
438    #[error("designer session {id} is live — the gateway refused the prune")]
439    SessionNotPrunable {
440        /// The Designer session id that was refused.
441        id: String,
442        /// URL of the refused DELETE, when known.
443        endpoint: Option<String>,
444    },
445
446    /// Docker/compose rig failure. Exit 7. Reserved — first used in Phase 4;
447    /// trivially constructible so the taxonomy enumerates completely today.
448    #[error("rig error: {0}")]
449    Rig(String),
450
451    /// The gateway's EAM module is not configured as a controller —
452    /// every `/data/eam/api/v1/*` runtime endpoint answers 403 with
453    /// "This operation can only be performed when EAM is configured
454    /// as a controller" on a stock gateway (live-proven 8.3.3,
455    /// 07-RESEARCH): a STATE refusal, not auth — the token is fine,
456    /// the module's role is not. Message-classified at the classify
457    /// seam, path-scoped to `/data/eam/` so generic 403s elsewhere
458    /// cannot shift (the trial_not_expired pattern, classify
459    /// edition). Exit 6 — target state.
460    #[error(
461        "EAM is not configured as a controller on this gateway — every EAM runtime \
462         operation refuses until the module's installMode is flipped"
463    )]
464    EamNotController {
465        /// URL of the refused request, when known.
466        endpoint: Option<String>,
467    },
468
469    /// A provider-ROOT tag path (`[default]` alone, or a bare first
470    /// segment that resolves to a provider) met the tagConfig route
471    /// — `system.tag.getConfiguration`/`exportTags` need an RPC
472    /// context WebDev threads don't carry (live-proven 8.3.3
473    /// b2026012009, both gateways; 07-UAT test 12). The route
474    /// refuses honestly (pre-call bracket detection + RpcContext
475    /// translation for the bare form) instead of surfacing the
476    /// IllegalStateException as a generic route error; subtree
477    /// paths (`[default]folder`) are the supported form. Exit 6 —
478    /// target state: a platform limitation, not a bug.
479    #[error(
480        "provider-root tag paths are not supported by the deployed route (the gateway needs an \
481         RPC context WebDev threads don't carry) — target a subtree like [provider]folder"
482    )]
483    ProviderRootUnsupported {
484        /// URL of the tagConfig route request, when known.
485        endpoint: Option<String>,
486    },
487
488    /// An `eam task new` whose type is in the REFUSED set —
489    /// `eam_restoreBackup`, `eam_installModules`, `eam_remoteUpgrade`
490    /// are fleet-destructive (they push backups/modules/upgrades to
491    /// every AGENT target), and the CLI refuses them outright over
492    /// guard-everything: honest refusal with the v2 scope pointer
493    /// (the planner-locked create ladder's top rung). Exit 6 —
494    /// target state (additive slug alongside Task 2's).
495    #[error(
496        "EAM task type {task_type} is fleet-destructive — refused (restore/install/upgrade \
497         are EXT-03 (v2) scope; run them from the EAM console)"
498    )]
499    EamTaskTypeRefused {
500        /// The refused `profile.type` token.
501        task_type: String,
502    },
503
504    /// An `eam task force` whose slot a leftover run occupies — the
505    /// force route answers 409 with the gateway's own Jetty page
506    /// ("Task 'X (forced)' already exists! It must be completed or
507    /// deleted before another task of this type can be force
508    /// executed."; live-captured 8.3.3, 07-UAT test 7). Exit 6 —
509    /// target state (the `session_not_prunable` precedent,
510    /// force-route edition): the gateway's state refused the command,
511    /// not a bug; classify()'s path-scoped 409 arm constructs this.
512    #[error("EAM task {task} has a run in flight — the gateway refused the force: {detail}")]
513    EamTaskInFlight {
514        /// The forced task's name (the force URL's last segment).
515        task: String,
516        /// The gateway's Jetty page message verbatim when sniffed;
517        /// the '(forced)' fallback text otherwise.
518        detail: String,
519        /// URL of the refused force POST, when known.
520        endpoint: Option<String>,
521    },
522
523    /// The scriptExec route is not configured for this profile — no
524    /// webdev secret is persisted, which can only mean `ign webdev
525    /// deploy --with-script-exec` has never run (deploy persists the
526    /// secret 0600 BEFORE upload, so a deployed route without a
527    /// stored secret is not a reachable state). `ign script run`'s
528    /// opt-in is STRUCTURAL — the deploy flag — so the verb carries
529    /// no `--yes` guard and refuses here instead (07-03, SCRPT-01:
530    /// the TrialNotExpired precedent — action-constructed, not
531    /// classify). Exit 6 — target state.
532    #[error(
533        "scriptExec is not configured for profile {profile:?} — the secret-gated route deploys \
534         only via the explicit opt-in"
535    )]
536    ScriptExecNotConfigured {
537        /// The profile whose secret store is empty.
538        profile: String,
539    },
540
541    /// `ign lint` found no `ignition-lint` executable on PATH — the
542    /// delegation has nothing to delegate to (07-04, INTR-02). The
543    /// hint carries the install command + repo. Exit 6 — target
544    /// state (additive slug; the environment lacks the tool, the
545    /// command is fine).
546    #[error("ignition-lint is not installed (no executable found on PATH)")]
547    LintToolAbsent,
548
549    /// `ign e2e init` found no `node` executable on PATH — the scaffold
550    /// it would write is unusable without one (QUICK-tg4).
551    ///
552    /// Exit 6, mirroring [`Self::LintToolAbsent`] line for line: this
553    /// is the SAME shape — a local external tool a verb delegates to is
554    /// absent, with an install hint. It deliberately does NOT join
555    /// exit 7, whose sole slug is `rig_error` for docker/compose rig
556    /// failures; putting a missing Node there would force a relabel of
557    /// a locked exit class.
558    #[error("{tool} is not installed (no executable found on PATH)")]
559    NodeToolAbsent {
560        /// The tool that was looked for (`node`).
561        tool: String,
562    },
563
564    /// The gateway answered the api call with a 4xx this CLI does not
565    /// curate — the caller's request is the problem, and the body is
566    /// theirs to read. Exit 2 (usage class; additive slug — the
567    /// poll_interval_too_small precedent: same class, own slug, no new
568    /// exit code). Constructed ONLY by the api-call-scoped classify arm
569    /// (09-01: the `api_call` parameter) — a curated command's 4xx keeps
570    /// its existing classification (the catch-all cannot fire without
571    /// the parameter). The body is the gateway's answer VERBATIM,
572    /// truncated at [`GATEWAY_CLIENT_BODY_CAP_BYTES`] with the explicit
573    /// [`GATEWAY_CLIENT_BODY_TRUNCATION_MARKER`] via [`truncate_api_body`]
574    /// at construction.
575    #[error("gateway rejected the api call (HTTP {status} from {endpoint}): {body}")]
576    GatewayClientError {
577        /// HTTP status the gateway answered with.
578        status: u16,
579        /// Full URL of the rejected request.
580        endpoint: String,
581        /// The response body VERBATIM, truncated at
582        /// [`GATEWAY_CLIENT_BODY_CAP_BYTES`] with an explicit marker.
583        body: String,
584    },
585
586    /// No diagnostics bundle is available — the status poll ANSWERED
587    /// with a captured TERMINAL steady state meaning "no current
588    /// bundle" (`Invalid`: live-proven on 8.3.6 rig ign-p9-836,
589    /// 2026-09-07 UAT / 09-UAT.md Gap 3 — a `Valid` bundle decays to
590    /// `Invalid` within ~2 minutes UNPROMPTED and stays `Invalid`;
591    /// only a fresh generate changes it, polling cannot). Exit 6 —
592    /// target state (the `ImportDenied` precedent: a gateway-answered
593    /// refusal riding its own class, action-constructed by `bundle
594    /// wait`'s probe, not classify).
595    #[error(
596        "no diagnostics bundle available (gateway reports state {state:?}) — run \
597         `ign diagnostics bundle generate` first; polling cannot change this state"
598    )]
599    BundleNotAvailable {
600        /// The observed steady state (the captured unavailable
601        /// vocabulary, e.g. `Invalid`).
602        state: String,
603    },
604
605    /// Transport failure reaching a third-party module release feed or
606    /// its asset CDN — DNS/connect/timeout/TLS (Phase 15, D-06). Exit 4:
607    /// same network class as [`Self::Network`], but a SEPARATE slug and
608    /// wording — `Network`'s Display hard-codes "gateway unreachable",
609    /// which is actively wrong here (`api.github.com` is not a gateway,
610    /// and the user's own gateway may be perfectly reachable). This is
611    /// the offline signal `fetch_and_verify` returns when the cache is
612    /// empty (SC-4's negative half).
613    #[error(
614        "module release feed at {url} is unreachable{source_note}",
615        source_note = source.as_ref().map(|source| format!(": {source}")).unwrap_or_default()
616    )]
617    ModuleFeedUnreachable {
618        /// The feed or CDN URL that could not be reached.
619        url: String,
620        #[source]
621        source: Option<reqwest::Error>,
622    },
623
624    /// No release exists for the pinned tag, or the release carries no
625    /// asset of the expected name (Phase 15, SC-1's "unknown version"
626    /// refusal). Exit 6 — target state: names BOTH the requested version
627    /// and the resolved URL, and (for a missing asset) both the expected
628    /// asset name and the names actually present, so the caller never
629    /// has to guess which lookup failed or falls back to "latest".
630    #[error("module {module:?} version {version:?} not found at {url}: {detail}")]
631    ModuleReleaseNotFound {
632        /// The [`crate::module::ModuleSpec::id`] being fetched.
633        module: String,
634        /// The requested version string.
635        version: String,
636        /// The tag-pinned releases URL that was queried.
637        url: String,
638        /// What specifically was missing (no release for the tag, or no
639        /// matching asset — names the asset actually present).
640        detail: String,
641    },
642
643    /// Downloaded bytes hash to something other than the release's
644    /// published digest — SC-2's hard refusal (Phase 15; this variant is
645    /// defined in 15-01, the branch is exercised starting 15-02). Exit 6
646    /// — target state: the feed answered and served bytes, but what it
647    /// served cannot be trusted. The bytes are discarded (never
648    /// persisted to the cache) and BOTH digests are named.
649    ///
650    /// Payload is BOXED: five inline `String`s put `CoreError` at
651    /// exactly clippy's 128-byte `result_large_err` threshold, which
652    /// fails `-D warnings` in every crate returning `Result<_,
653    /// CoreError>` — `ignition-tui` first. Error paths are cold, so one
654    /// allocation on construction is the cheap side of that trade.
655    #[error("{0}")]
656    ModuleDigestMismatch(Box<ModuleDigestMismatchDetails>),
657
658    /// The module release feed ANSWERED but not usably — rate-limited,
659    /// unauthenticated 401/403, 5xx, an unparseable body, an asset with
660    /// no published digest, or an insecure downgrade of the asset URL
661    /// (Phase 15). Exit 6 — target state: distinct from
662    /// [`Self::ModuleFeedUnreachable`] (exit 4 — transport never reached
663    /// the feed at all); here the feed DID answer, just not with
664    /// something `ign` can safely act on.
665    #[error("module release feed at {url} is unusable: {detail}")]
666    ModuleFeedUnusable {
667        /// The feed or asset URL that answered unusably.
668        url: String,
669        /// What specifically made the answer unusable.
670        detail: String,
671    },
672
673    /// A pinned version's upstream digest differs from what's already
674    /// cached — D-02's default refusal on `FetchPolicy::Refresh` /
675    /// `AcceptUpstreamChange` (Phase 15, 15-02): the feed answered
676    /// fine, but what it currently publishes for this version no
677    /// longer matches what was verified and cached before. Exit 6 —
678    /// target state: names BOTH full digests and the path of the
679    /// cached artifact, which is left on disk UNTOUCHED (no path in
680    /// `module/` deletes, truncates, or overwrites a cache entry).
681    /// `FetchPolicy::AcceptUpstreamChange` is the explicit, documented
682    /// override.
683    ///
684    /// Payload is BOXED for the same reason as
685    /// [`Self::ModuleDigestMismatch`] — see that variant.
686    #[error("{0}")]
687    ModuleDigestChanged(Box<ModuleDigestChangedDetails>),
688
689    /// A declared module id has no matching registry entry (Phase 16,
690    /// D-13). A well-formed id that simply isn't registered is a CONFIG
691    /// problem, not malformed input (exit 2 belongs to
692    /// `validate_module_id`/`validate_version`) and not a Docker
693    /// failure (exit 7 is [`Self::Rig`]) — every OTHER module
694    /// provisioning refusal (missing artifact, non-file artifact,
695    /// relative path, underivable service) stays `CoreError::Rig`.
696    /// Exit 3, structurally the [`Self::ProfileNotFound`] shape: names
697    /// both the bad id and every id that IS registered.
698    #[error("module {id:?} is not registered (known modules: {known:?})")]
699    ModuleNotRegistered {
700        /// The declared id with no matching [`crate::module::ModuleSpec`].
701        id: String,
702        /// Every currently registered [`crate::module::ModuleSpec::id`].
703        known: Vec<String>,
704    },
705
706    /// The gateway ANSWERED `DELETE /data/api/v1/modules/uninstall` with
707    /// HTTP 200 but the body says `{"success": false, "failedUninstalls":
708    /// {"uninstall": […]}}` — the denial-rides-200 class
709    /// ([`Self::ImportDenied`]'s precedent) applied to module uninstall
710    /// (Phase 19, D-19-03): live-verified that a stock module, a bogus
711    /// id, and a still-mounted module ALL answer 200 with
712    /// `success:false`. An unparseable body, a missing `success`, or an
713    /// absent `failedUninstalls` all map here too — absence of detail
714    /// must never degrade into a reported success. Exit 6 — target
715    /// state: names both the registry id and the gateway id that was
716    /// sent, plus every failed id the gateway reported (or the sent id
717    /// itself when the gateway gave no detail).
718    #[error(
719        "gateway refused to uninstall module {module_id:?} ({gateway_module_id}): {}",
720        failed.join(", ")
721    )]
722    ModuleUninstallDenied {
723        /// The registry slug (`git`) the caller named.
724        module_id: String,
725        /// The gateway's own id ([`crate::module::ModuleSpec::gateway_module_id`])
726        /// sent in the request.
727        gateway_module_id: String,
728        /// `failedUninstalls.uninstall` verbatim, or `[gateway_module_id]`
729        /// when the gateway's body carried no usable detail.
730        failed: Vec<String>,
731        /// URL of the refused DELETE, when known.
732        endpoint: Option<String>,
733    },
734
735    /// Another actor holds the gateway's scan-lock. Exit 6 — target
736    /// state. Constructed by the route-scoped scan-lock arm in
737    /// classify.rs (20-03); `kind` is `config` or `projects`. `actor`
738    /// is the holder's opaque identity string when the gateway named
739    /// it (`api-token:<name>:<ip>`), echoed and never parsed; `None`
740    /// when the refusal did not name one.
741    #[error(
742        "the {kind} scan-lock is held by {}",
743        actor.as_deref().unwrap_or("another actor")
744    )]
745    ScanLockHeld {
746        /// The holder's identity string, when the gateway named it.
747        actor: Option<String>,
748        /// Which scan-lock: `config` or `projects`.
749        kind: String,
750        /// URL of the refused request, when known.
751        endpoint: Option<String>,
752    },
753
754    /// The gateway refused a resource write over its signature. Exit 6 —
755    /// target state. Constructed ONLY by the deploy-scoped write helper
756    /// in client/deploy_api.rs (20-04), never by classify.rs, so the
757    /// v1.1 EAM 500 pin stays exit 1 `internal`. The payload
758    /// deliberately carries no signature and no gateway problem text:
759    /// 8.3.3 problem messages leak the live signature.
760    #[error("gateway refused the write to resource {name:?}: {reason} signature")]
761    ResourceSignatureRejected {
762        /// The resource name that was being written.
763        name: String,
764        /// Why the signature was refused.
765        reason: SignatureReason,
766        /// URL of the refused request, when known.
767        endpoint: Option<String>,
768    },
769
770    /// A resource create named a resource that already exists. Exit 6 —
771    /// target state. Constructed ONLY by the deploy-scoped create helper
772    /// in client/deploy_api.rs from the live 409 JSON `problem` body
773    /// (20-LIVE-CAPTURES D10), never by classify.rs. The payload carries
774    /// no gateway problem text or stacktrace.
775    #[error("resource {name:?} already exists")]
776    ResourceExists {
777        /// The resource name that was being created.
778        name: String,
779        /// URL of the refused request, when known.
780        endpoint: Option<String>,
781    },
782
783    /// A deployment-mode create named a mode that is already defined.
784    /// Exit 6 — target state. Constructed ONLY by the deploy-scoped mode
785    /// create in client/deploy_api.rs from the live 409 (D6).
786    #[error("deployment mode {name:?} is already defined")]
787    ModeExists {
788        /// The mode name that was being created.
789        name: String,
790        /// URL of the refused request, when known.
791        endpoint: Option<String>,
792    },
793
794    /// A declared gateway tree failed render validation (21-03): one or
795    /// more findings, each naming its file and, for YAML syntax and
796    /// schema defects, a line and column. Exit 2 — usage class: the
797    /// CALLER authored the tree and must fix it. Render is offline, so
798    /// there is no endpoint. Boxed to keep `CoreError` under clippy's
799    /// `result_large_err` threshold.
800    #[error("deploy tree invalid: {0}")]
801    DeployTreeInvalid(Box<DeployTreeInvalidDetails>),
802
803    /// A declared tree carries an Embedded or plaintext secret (21-03).
804    /// Exit 2 — usage class. The payload holds JSON paths and other
805    /// findings only: no field exists that could carry a secret value.
806    #[error("refusing to render an embedded or plaintext secret: {0}")]
807    EmbeddedSecret(Box<EmbeddedSecretDetails>),
808
809    /// A deploy host transport could not read (or later write) a node's
810    /// gateway data directory (22-01): a missing or unreadable directory, a
811    /// hostile live tree (symlink, special file, non-UTF-8 name), a size cap
812    /// breach or an ssh failure. Exit 4 — network class: the host could not
813    /// be reached or read. A failed read is NEVER an empty result, so a plan
814    /// cannot mistake an unreadable tree for deletions. `detail` names paths
815    /// and causes only, never file content.
816    #[error("transport failed ({operation}) on {node}: {detail}")]
817    TransportFailed {
818        /// What was being done, e.g. `read`.
819        operation: String,
820        /// The target node label, e.g. `nodes.master`.
821        node: String,
822        /// What went wrong; names paths and causes, never file content.
823        detail: String,
824    },
825
826    /// The on-box declared-names ledger exists but cannot be trusted (22-03,
827    /// D-07): unreadable, empty, over its size cap, not strict UTF-8 JSON, of
828    /// another schema version, naming another gateway or mode, unsorted or
829    /// duplicated, or listing a path plan must never delete. Exit 6 -
830    /// target_state class: the on-box state is wrong, not the caller's input.
831    /// Plan never falls back to an empty delete set. `problem` names fields
832    /// and listed paths only, never other file content.
833    #[error("declared-names ledger {location} is unusable: {problem}")]
834    DeployLedgerInvalid {
835        /// The ledger location relative to the node's data directory.
836        location: String,
837        /// What is wrong; names fields and listed paths, never file content.
838        problem: String,
839    },
840
841    /// The plan the caller reviewed is no longer the plan (23-02, DAPL-02):
842    /// `--plan-sha256` differs from the recomputed `plan_sha256`, or the
843    /// on-box pre-image changed under the scan lock. Exit 6 - target_state
844    /// class: the gateway moved since the review. Carries hashes only.
845    #[error("plan changed since review: expected plan_sha256 {expected}, now {actual} ({detail})")]
846    PlanStale {
847        /// The `plan_sha256` the caller reviewed.
848        expected: String,
849        /// The `plan_sha256` of the plan as it is now.
850        actual: String,
851        /// Where the mismatch was found, e.g. `review hash`; no file content.
852        detail: String,
853    },
854
855    /// A destructive or drifted plan was applied without its flag (23-02,
856    /// DAPL-02). Exit 2 - usage class: the caller must pass
857    /// `--allow-destructive` or `--allow-drift <reason>`. Carries unit keys
858    /// and counts only.
859    #[error("{}", destructive_change_refused_text(.gate, .keys, *.count))]
860    DestructiveChangeRefused {
861        /// Which gate refused: `destructive` or `drift`.
862        gate: String,
863        /// The first [`APPLY_LIST_SHOWN`] offending unit keys, byte-sorted.
864        keys: Vec<String>,
865        /// How many entries the gate refused in all.
866        count: usize,
867    },
868
869    /// A removal that cannot take effect (23-02, DAPL-01): the unit's `core`
870    /// copy shadows the mode collection, so deleting from the mode collection
871    /// changes nothing. No flag permits it. Exit 6.
872    #[error("{}", deploy_blocked_text(.keys))]
873    DeployBlocked {
874        /// The `shadows_removal` unit keys, byte-sorted.
875        keys: Vec<String>,
876    },
877
878    /// An apply would write, check or delete a unit whose live copy the
879    /// host's sftp server cannot read (quick-261029-ms3): a host path of 240
880    /// characters or more, so the live copy is unknown and must never be
881    /// taken as absent. Raised when an unreadable unit is declared or listed
882    /// in the declared-names ledger, on the master by the plan's gate, and on
883    /// the backup by the `backup_read` preflight (a unit the apply writes or
884    /// deletes that the backup cannot read, WR-02). No flag permits it. Exit
885    /// 6.
886    #[error("{}", deploy_unreadable_text(.node, .keys))]
887    DeployUnreadable {
888        /// The node whose host cannot read the units: `master` or `backup`.
889        node: String,
890        /// The unreadable unit keys that are declared or in the ledger,
891        /// byte-sorted.
892        keys: Vec<String>,
893    },
894
895    /// A deploy to a redundant pair (a target with `nodes.backup`) would
896    /// leave or restore files of a `tag-definition` or `tag-type-definition`
897    /// unit in the mode collection (debug deploy-tag-redundancy-sync).
898    /// Ignition 8.3.6 counts those resources of a mode collection in its
899    /// `config-resource` sync snapshot but leaves them out of every diff, and
900    /// syncs tags only from `core`, so the backup can never match the master
901    /// and stops syncing configuration. A pair's tag units are deployed to
902    /// `core` (quick 261102-cef, A2), so this is only the residue: mode files
903    /// of such a unit the apply cannot delete, a manual rollback that
904    /// would put mode files back, or a file or folder in such a type folder of
905    /// the mode collection that the deploy does not read, an empty folder
906    /// included, even beside files the deploy deletes (quick 261103-ell, F3;
907    /// quick 261104-7cy, C3). It is removed by hand on the master (README
908    /// runbook T6e). No flag permits it. Exit 6.
909    #[error("{}", deploy_pair_tags_refused_text(.mode, .keys))]
910    DeployPairTagsRefused {
911        /// The target's mode (the collection the units would sit in).
912        mode: String,
913        /// The tag unit keys, byte-sorted and unique.
914        keys: Vec<String>,
915    },
916
917    /// One or more apply preflight checks failed (23-02, DAPL-04). Exit 6.
918    /// Nothing was written to the gateway. The payload is BOXED so the large
919    /// list stays off every `Result<_, CoreError>`.
920    #[error("{}", deploy_preflight_failed_text(.failures))]
921    DeployPreflightFailed {
922        /// Every failed check, in the order the checks ran.
923        failures: Box<[PreflightFailure]>,
924    },
925
926    /// An interrupted deploy left its journal on the box (23-02, D-09). Exit
927    /// 6. The message, not the hint, carries the recovery command.
928    #[error(
929        "an interrupted deploy left the journal {location} for run {run_id}; \
930         recover it with: ign deploy rollback --run {run_id} --env {env}"
931    )]
932    DeployJournalPresent {
933        /// The interrupted run.
934        run_id: String,
935        /// The `--env` value the recovery command needs.
936        env: String,
937        /// The on-box journal location.
938        location: String,
939    },
940
941    /// Live files changed after the run wrote them, so a rollback would
942    /// overwrite someone's edit (23-02, DAPL-07). Exit 6. Carries paths and
943    /// counts only.
944    #[error("{}", deploy_live_changed_text(.run_id, .paths, *.count))]
945    DeployLiveChanged {
946        /// The run being rolled back.
947        run_id: String,
948        /// The first [`APPLY_LIST_SHOWN`] changed paths, byte-sorted.
949        paths: Vec<String>,
950        /// How many touched paths changed in all.
951        count: usize,
952    },
953
954    /// A run's record or snapshot cannot be trusted (23-02, D-04). Exit 6.
955    /// `problem` names fields and paths only, never file content.
956    #[error("deploy run {run_id} is unusable: {problem}")]
957    DeployRunInvalid {
958        /// The run id asked for.
959        run_id: String,
960        /// What is wrong.
961        problem: String,
962    },
963
964    /// A rollback could not restore the pre-apply files (23-02, D-12).
965    /// Exit 6. Boxed to keep `CoreError` under clippy's `result_large_err`.
966    #[error("{0}")]
967    RollbackFailed(Box<RollbackFailure>),
968
969    /// `deploy projects` refused a project the git module still manages
970    /// (25-07, DPRJ-04, D-11): the project's `.git` entry exists on the
971    /// gateway host. Exit 6 - target_state class: the gateway is not in a
972    /// state this deploy may touch. The tool never deletes a `.git`; an
973    /// administrator removes it per project. Carries the project name, the
974    /// location below the data directory and the kind (`directory`, `file`,
975    /// `symlink`, `other`) only.
976    #[error(
977        "project {project} is still managed by the git module: {location} is present ({kind}); an administrator removes it before this project is deployed"
978    )]
979    ProjectGitManaged {
980        /// The gateway project name.
981        project: String,
982        /// The `.git` path below the node's data directory.
983        location: String,
984        /// What the entry is.
985        kind: String,
986    },
987
988    /// `deploy projects` refused a project whose parent cannot be used
989    /// (25-10, DPRJ-02, D-05, K6): the parent named in the repo's
990    /// `project.json` is missing on the gateway, closes a cycle, or is not
991    /// inheritable. Exit 6 - target_state class: the gateway is not in a
992    /// state this deploy may build on. Carries names and the reason word
993    /// (`missing`, `cycle`, `not_inheritable`) only. The parent is only read.
994    #[error("project {project} cannot be deployed under parent {parent}: {reason}")]
995    ProjectParentInvalid {
996        /// The gateway project being deployed.
997        project: String,
998        /// The parent named in the repo's `project.json`.
999        parent: String,
1000        /// `missing`, `cycle` or `not_inheritable`.
1001        reason: String,
1002    },
1003
1004    /// `deploy projects` refused an artifact that is not the one that was
1005    /// reviewed (25-11, SC-1, D-01, V1): `--artifact-sha256` differs from the
1006    /// canonical digest of the artifact the checkout builds. Exit 6 -
1007    /// target_state class: the checkout is not in the state the reviewer saw.
1008    /// The comparison runs after the local build and before any host or
1009    /// gateway contact, so nothing was uploaded. Carries the project name and
1010    /// the two hashes only.
1011    #[error(
1012        "artifact hash mismatch for project {project}: expected {expected}, the checkout builds {actual}; nothing was uploaded"
1013    )]
1014    ProjectHashMismatch {
1015        /// The gateway project name.
1016        project: String,
1017        /// The `--artifact-sha256` the caller reviewed.
1018        expected: String,
1019        /// The canonical digest of the artifact this checkout builds.
1020        actual: String,
1021    },
1022}
1023
1024/// Items listed inline by the Phase 23 refusal Displays; the rest collapse
1025/// into `and {m} more`.
1026pub const APPLY_LIST_SHOWN: usize = 10;
1027
1028/// One failed apply preflight check (23-02). `detail` carries no value from
1029/// the gateway, the host or the repo that could be a secret.
1030#[derive(Debug, Clone, PartialEq, Eq)]
1031pub struct PreflightFailure {
1032    /// The check id, e.g. `mode_master` or `run_dir_not_ignored`.
1033    pub check: String,
1034    /// What failed, value-free.
1035    pub detail: String,
1036}
1037
1038/// Payload of [`CoreError::RollbackFailed`] (23-02).
1039#[derive(Debug, Clone, PartialEq, Eq)]
1040pub struct RollbackFailure {
1041    /// The run being restored.
1042    pub run_id: String,
1043    /// The `--env` value the retry command needs.
1044    pub env: String,
1045    /// The local run directory holding the snapshot, when one was made.
1046    pub run_dir: Option<String>,
1047    /// The on-box journal path, left in place for a manual recovery.
1048    pub journal: String,
1049    /// Why the restore failed; names paths and causes, never file content.
1050    pub cause: String,
1051}
1052
1053impl RollbackFailure {
1054    /// The on-box directory holding the run's pre-images
1055    /// (`snapshots/<run>/`), next to the journal.
1056    pub fn snapshot_dir(&self) -> String {
1057        match self.journal.rsplit_once('/') {
1058            Some((dir, _)) => format!("{dir}/snapshots/{}/", self.run_id),
1059            None => format!("snapshots/{}/", self.run_id),
1060        }
1061    }
1062}
1063
1064impl std::fmt::Display for RollbackFailure {
1065    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1066        // The run directory's `snapshot/` is made only after a restore has
1067        // succeeded, so it is never offered here: the intact pre-images are
1068        // the on-box `snapshots/<run>/` directory and, when one was taken, the
1069        // `.gwbk`.
1070        write!(
1071            f,
1072            "rollback of run {run} failed ({cause}); the gateway files may be half restored. \
1073             Retry with: ign deploy rollback --run {run} --env {env} (the journal at {journal} \
1074             and the snapshot are still on the host). If the retry reports a lost or damaged \
1075             snapshot, restore the files from the .gwbk backup (when this run took one) or from \
1076             the on-box snapshot {snapshot}, then delete the journal by hand. \
1077             run directory: {run_dir}",
1078            run = self.run_id,
1079            cause = self.cause,
1080            env = self.env,
1081            journal = self.journal,
1082            snapshot = self.snapshot_dir(),
1083            run_dir = self.run_dir.as_deref().unwrap_or("none was created"),
1084        )
1085    }
1086}
1087
1088/// `a, b, c and {m} more`: `shown` capped at [`APPLY_LIST_SHOWN`], then the
1089/// remainder of `total`.
1090fn listed(shown: &[String], total: usize) -> String {
1091    let take = shown.len().min(APPLY_LIST_SHOWN);
1092    let mut out = shown[..take].join(", ");
1093    if total > take {
1094        out.push_str(&format!(" and {} more", total - take));
1095    }
1096    out
1097}
1098
1099fn destructive_change_refused_text(gate: &str, keys: &[String], count: usize) -> String {
1100    let (flag, verb) = match gate {
1101        "drift" => ("--allow-drift <reason>", "apply"),
1102        "downgrade" => ("--allow-downgrade", "deploy projects"),
1103        "first_deploy" => ("--allow-first-change <reason>", "deploy projects"),
1104        _ => ("--allow-destructive", "apply"),
1105    };
1106    format!(
1107        "{gate} change refused for {count} entr{}: {}; {verb} needs {flag}",
1108        if count == 1 { "y" } else { "ies" },
1109        listed(keys, count)
1110    )
1111}
1112
1113fn deploy_blocked_text(keys: &[String]) -> String {
1114    format!(
1115        "deploy blocked: {} unit(s) are removed from the declared tree but a core copy \
1116         shadows them, so deleting them from the mode collection cannot take effect: {}",
1117        keys.len(),
1118        listed(keys, keys.len())
1119    )
1120}
1121
1122fn deploy_unreadable_text(node: &str, keys: &[String]) -> String {
1123    format!(
1124        "deploy refused: {} unit(s) the deploy would write or delete have a live path the \
1125         {node} host's sftp server cannot read, so their live copy is unknown: {}; nothing was \
1126         written",
1127        keys.len(),
1128        listed(keys, keys.len())
1129    )
1130}
1131
1132fn deploy_pair_tags_refused_text(mode: &str, keys: &[String]) -> String {
1133    format!(
1134        "deploy refused: {} tag unit(s) or folder(s) would leave files or folders in, or restore \
1135         files to, the {mode} collection of a redundant pair, which Ignition's redundancy sync \
1136         cannot carry (the backup stops syncing configuration): {}; \
1137         nothing was written to the {mode} or core collection",
1138        keys.len(),
1139        listed(keys, keys.len())
1140    )
1141}
1142
1143fn deploy_preflight_failed_text(failures: &[PreflightFailure]) -> String {
1144    let mut out = format!("deploy preflight failed ({} check(s)):", failures.len());
1145    for failure in failures {
1146        out.push_str(&format!("\n  {}: {}", failure.check, failure.detail));
1147    }
1148    out
1149}
1150
1151fn deploy_live_changed_text(run_id: &str, paths: &[String], count: usize) -> String {
1152    format!(
1153        "live files changed since run {run_id} wrote them ({count}): {}; restoring the \
1154         snapshot would overwrite them",
1155        listed(paths, count)
1156    )
1157}
1158
1159/// Findings shown inline by the deploy error Displays; the rest collapse
1160/// into `and {m} more`.
1161pub const DEPLOY_FINDINGS_SHOWN: usize = 20;
1162
1163/// One defect in a declared deploy tree (21-03).
1164#[derive(Debug, Clone, PartialEq, Eq)]
1165pub struct DeployFinding {
1166    /// Collection-relative file the finding is about.
1167    pub file: String,
1168    /// 1-based line, when the defect has a source position.
1169    pub line: Option<usize>,
1170    /// 1-based column, when the defect has a source position.
1171    pub column: Option<usize>,
1172    /// What is wrong. Never contains a value read from the tree.
1173    pub message: String,
1174}
1175
1176impl std::fmt::Display for DeployFinding {
1177    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1178        match (self.line, self.column) {
1179            (Some(line), Some(column)) => {
1180                write!(f, "{}:{line}:{column}: {}", self.file, self.message)
1181            }
1182            _ => write!(f, "{}: {}", self.file, self.message),
1183        }
1184    }
1185}
1186
1187/// Payload of [`CoreError::DeployTreeInvalid`], boxed to keep
1188/// `CoreError` under clippy's `result_large_err` threshold.
1189#[derive(Debug, Clone, PartialEq, Eq)]
1190pub struct DeployTreeInvalidDetails {
1191    /// Every finding, in discovery order.
1192    pub findings: Vec<DeployFinding>,
1193}
1194
1195impl std::fmt::Display for DeployTreeInvalidDetails {
1196    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1197        if let [only] = self.findings.as_slice() {
1198            return write!(f, "{only}");
1199        }
1200        write!(f, "{} findings: ", self.findings.len())?;
1201        write_findings(f, &self.findings, DEPLOY_FINDINGS_SHOWN)
1202    }
1203}
1204
1205/// Write `findings` joined by `; `, at most `shown` of them, then
1206/// `; and {m} more` when truncated.
1207fn write_findings(
1208    f: &mut std::fmt::Formatter<'_>,
1209    findings: &[DeployFinding],
1210    shown: usize,
1211) -> std::fmt::Result {
1212    for (i, finding) in findings.iter().take(shown).enumerate() {
1213        if i > 0 {
1214            f.write_str("; ")?;
1215        }
1216        write!(f, "{finding}")?;
1217    }
1218    if findings.len() > shown {
1219        write!(f, "; and {} more", findings.len() - shown)?;
1220    }
1221    Ok(())
1222}
1223
1224/// Payload of [`CoreError::EmbeddedSecret`], boxed to keep `CoreError`
1225/// under clippy's `result_large_err` threshold. Carries locations only:
1226/// there is deliberately no field that can hold a secret value.
1227#[derive(Debug, Clone, PartialEq, Eq)]
1228pub struct EmbeddedSecretDetails {
1229    /// Each entry is `<collection-relative file>: <dotted json path>`.
1230    pub paths: Vec<String>,
1231    /// Non-secret tree findings discovered in the same render.
1232    pub other_findings: Vec<DeployFinding>,
1233}
1234
1235impl std::fmt::Display for EmbeddedSecretDetails {
1236    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1237        write!(f, "embedded secret at {} path(s): ", self.paths.len())?;
1238        let shown_paths = self.paths.len().min(DEPLOY_FINDINGS_SHOWN);
1239        f.write_str(&self.paths[..shown_paths].join("; "))?;
1240        if self.paths.len() > shown_paths {
1241            write!(f, "; and {} more", self.paths.len() - shown_paths)?;
1242        }
1243        if !self.other_findings.is_empty() {
1244            write!(
1245                f,
1246                "; also {} other tree finding(s): ",
1247                self.other_findings.len()
1248            )?;
1249            // The combined list stays within DEPLOY_FINDINGS_SHOWN entries.
1250            let room = DEPLOY_FINDINGS_SHOWN.saturating_sub(shown_paths);
1251            write_findings(f, &self.other_findings, room)?;
1252        }
1253        Ok(())
1254    }
1255}
1256
1257/// Why a resource write's signature was refused
1258/// ([`CoreError::ResourceSignatureRejected`]).
1259#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1260pub enum SignatureReason {
1261    /// The gateway refused a signature that no longer matches: the
1262    /// resource changed since it was read.
1263    Stale,
1264    /// No usable signature was supplied: either refused before any
1265    /// request because it was empty, or a gateway refusal that the live
1266    /// capture shows is distinguishable.
1267    Missing,
1268}
1269
1270impl std::fmt::Display for SignatureReason {
1271    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1272        f.write_str(match self {
1273            Self::Stale => "stale",
1274            Self::Missing => "missing",
1275        })
1276    }
1277}
1278
1279/// Payload of [`CoreError::ModuleDigestMismatch`], boxed to keep
1280/// `CoreError` under clippy's `result_large_err` threshold.
1281#[derive(Debug, Clone, PartialEq, Eq)]
1282pub struct ModuleDigestMismatchDetails {
1283    /// The [`crate::module::ModuleSpec::id`] being fetched.
1284    pub module: String,
1285    /// The requested version string.
1286    pub version: String,
1287    /// The asset URL the mismatched bytes were downloaded from.
1288    pub url: String,
1289    /// The release's published sha256 digest.
1290    pub expected: String,
1291    /// The sha256 digest of the bytes actually downloaded.
1292    pub actual: String,
1293}
1294
1295/// Payload of [`CoreError::ModuleDigestChanged`], boxed to keep
1296/// `CoreError` under clippy's `result_large_err` threshold.
1297#[derive(Debug, Clone, PartialEq, Eq)]
1298pub struct ModuleDigestChangedDetails {
1299    /// The [`crate::module::ModuleSpec::id`] being fetched.
1300    pub module: String,
1301    /// The requested version string.
1302    pub version: String,
1303    /// The digest of the artifact already cached for this version.
1304    pub cached_digest: String,
1305    /// The digest the feed currently publishes for this version.
1306    pub upstream_digest: String,
1307    /// Path of the untouched, still-usable cached artifact.
1308    pub cached_path: String,
1309}
1310
1311impl std::fmt::Display for ModuleDigestMismatchDetails {
1312    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1313        write!(
1314            f,
1315            "module {:?} version {:?} digest mismatch at {}: expected {}, got {}",
1316            self.module, self.version, self.url, self.expected, self.actual
1317        )
1318    }
1319}
1320
1321impl std::fmt::Display for ModuleDigestChangedDetails {
1322    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1323        write!(
1324            f,
1325            "module {:?} version {:?} digest changed upstream: cached {}, \
1326             upstream now reports {} (cached artifact kept at {})",
1327            self.module, self.version, self.cached_digest, self.upstream_digest, self.cached_path
1328        )
1329    }
1330}
1331
1332impl CoreError {
1333    /// Stable machine slug — public contract, never respell.
1334    pub fn code(&self) -> &'static str {
1335        match self {
1336            Self::Internal(_) => "internal",
1337            Self::ConfirmationRequired { .. } => "confirmation_required",
1338            Self::ProfilingRiskNotAccepted { .. } => "profiling_risk_not_accepted",
1339            Self::InvalidImportFile { .. } => "invalid_import_file",
1340            Self::ImportDenied { .. } => "import_denied",
1341            Self::InvalidInput { .. } => "invalid_input",
1342            Self::ProfileNotFound { .. } => "profile_not_found",
1343            Self::NoActiveProfile => "no_active_profile",
1344            Self::SecretUnavailable { .. } => "secret_unavailable",
1345            Self::PasswordUnavailable { .. } => "password_unavailable",
1346            Self::ConfigInvalid { .. } => "config_invalid",
1347            Self::PollIntervalTooSmall { .. } => "poll_interval_too_small",
1348            Self::Network { .. } => "network_error",
1349            Self::Auth { .. } => "auth_rejected",
1350            Self::GatewayTooOld { .. } => "gateway_too_old",
1351            Self::GatewayNotCommissioned { .. } => "gateway_not_commissioned",
1352            Self::GatewayRestarting { .. } => "gateway_restarting",
1353            Self::NotFound { .. } => "not_found",
1354            Self::ProjectExists { .. } => "project_exists",
1355            Self::ResourceBinary { .. } => "resource_binary",
1356            Self::TrialNotExpired { .. } => "trial_not_expired",
1357            Self::ProviderNotFound { .. } => "provider_not_found",
1358            Self::RoutesNotDeployed { .. } => "routes_not_deployed",
1359            Self::WebdevUnlicensed { .. } => "webdev_unlicensed",
1360            Self::RouteVersionMismatch { .. } => "route_version_mismatch",
1361            Self::WebdevRouteError { .. } => "webdev_route_error",
1362            Self::TagCollision { .. } => "tag_collision",
1363            Self::AlarmJournalMissing { .. } => "alarm_journal_missing",
1364            Self::SessionNotPrunable { .. } => "session_not_prunable",
1365            Self::Rig(_) => "rig_error",
1366            Self::EamNotController { .. } => "eam_not_controller",
1367            Self::ProviderRootUnsupported { .. } => "provider_root_unsupported",
1368            Self::EamTaskTypeRefused { .. } => "eam_task_type_refused",
1369            Self::EamTaskInFlight { .. } => "eam_task_in_flight",
1370            Self::ScriptExecNotConfigured { .. } => "script_exec_not_configured",
1371            Self::LintToolAbsent => "lint_tool_absent",
1372            Self::NodeToolAbsent { .. } => "node_tool_absent",
1373            Self::GatewayClientError { .. } => "gateway_client_error",
1374            Self::BundleNotAvailable { .. } => "bundle_not_available",
1375            Self::ModuleFeedUnreachable { .. } => "module_feed_unreachable",
1376            Self::ModuleReleaseNotFound { .. } => "module_release_not_found",
1377            Self::ModuleDigestMismatch(..) => "module_digest_mismatch",
1378            Self::ModuleFeedUnusable { .. } => "module_feed_unusable",
1379            Self::ModuleDigestChanged(..) => "module_digest_changed",
1380            Self::ModuleNotRegistered { .. } => "module_not_registered",
1381            Self::ModuleUninstallDenied { .. } => "module_uninstall_denied",
1382            Self::ScanLockHeld { .. } => "scan_lock_held",
1383            Self::ResourceSignatureRejected { .. } => "resource_signature_rejected",
1384            Self::ResourceExists { .. } => "resource_exists",
1385            Self::ModeExists { .. } => "mode_exists",
1386            Self::DeployTreeInvalid(..) => "deploy_tree_invalid",
1387            Self::EmbeddedSecret(..) => "embedded_secret",
1388            Self::TransportFailed { .. } => "transport_failed",
1389            Self::DeployLedgerInvalid { .. } => "deploy_ledger_invalid",
1390            Self::PlanStale { .. } => "plan_stale",
1391            Self::DestructiveChangeRefused { .. } => "destructive_change_refused",
1392            Self::DeployBlocked { .. } => "deploy_blocked",
1393            Self::DeployUnreadable { .. } => "deploy_unreadable",
1394            Self::DeployPairTagsRefused { .. } => "deploy_pair_tags_refused",
1395            Self::DeployPreflightFailed { .. } => "deploy_preflight_failed",
1396            Self::DeployJournalPresent { .. } => "deploy_journal_present",
1397            Self::DeployLiveChanged { .. } => "deploy_live_changed",
1398            Self::DeployRunInvalid { .. } => "deploy_run_invalid",
1399            Self::RollbackFailed(..) => "rollback_failed",
1400            Self::ProjectGitManaged { .. } => "project_git_managed",
1401            Self::ProjectParentInvalid { .. } => "project_parent_invalid",
1402            Self::ProjectHashMismatch { .. } => "project_hash_mismatch",
1403        }
1404    }
1405
1406    /// The LOCKED exit-code mapping — the only place exit codes are decided.
1407    pub fn exit_code(&self) -> u8 {
1408        match self {
1409            Self::Internal(_) => 1,
1410            Self::ConfirmationRequired { .. }
1411            | Self::ProfilingRiskNotAccepted { .. }
1412            | Self::InvalidImportFile { .. }
1413            | Self::InvalidInput { .. }
1414            | Self::GatewayClientError { .. }
1415            | Self::DeployTreeInvalid(..)
1416            | Self::EmbeddedSecret(..)
1417            | Self::DestructiveChangeRefused { .. } => 2,
1418            Self::ProfileNotFound { .. }
1419            | Self::NoActiveProfile
1420            | Self::SecretUnavailable { .. }
1421            | Self::PasswordUnavailable { .. }
1422            | Self::ConfigInvalid { .. }
1423            | Self::PollIntervalTooSmall { .. }
1424            | Self::ModuleNotRegistered { .. } => 3,
1425            Self::Network { .. }
1426            | Self::ModuleFeedUnreachable { .. }
1427            | Self::TransportFailed { .. } => 4,
1428            Self::Auth { .. } => 5,
1429            Self::GatewayTooOld { .. }
1430            | Self::GatewayNotCommissioned { .. }
1431            | Self::GatewayRestarting { .. }
1432            | Self::NotFound { .. }
1433            | Self::ProjectExists { .. }
1434            | Self::ResourceBinary { .. }
1435            | Self::TrialNotExpired { .. }
1436            | Self::ProviderNotFound { .. }
1437            | Self::RoutesNotDeployed { .. }
1438            | Self::WebdevUnlicensed { .. }
1439            | Self::RouteVersionMismatch { .. }
1440            | Self::WebdevRouteError { .. }
1441            | Self::TagCollision { .. }
1442            | Self::AlarmJournalMissing { .. }
1443            | Self::SessionNotPrunable { .. }
1444            | Self::ImportDenied { .. }
1445            | Self::EamNotController { .. }
1446            | Self::ProviderRootUnsupported { .. }
1447            | Self::EamTaskTypeRefused { .. }
1448            | Self::EamTaskInFlight { .. }
1449            | Self::ScriptExecNotConfigured { .. }
1450            | Self::LintToolAbsent
1451            | Self::NodeToolAbsent { .. }
1452            | Self::BundleNotAvailable { .. }
1453            | Self::ModuleReleaseNotFound { .. }
1454            | Self::ModuleDigestMismatch(..)
1455            | Self::ModuleFeedUnusable { .. }
1456            | Self::ModuleDigestChanged(..)
1457            | Self::ModuleUninstallDenied { .. }
1458            | Self::ScanLockHeld { .. }
1459            | Self::ResourceSignatureRejected { .. }
1460            | Self::ResourceExists { .. }
1461            | Self::ModeExists { .. }
1462            | Self::DeployLedgerInvalid { .. }
1463            | Self::PlanStale { .. }
1464            | Self::DeployBlocked { .. }
1465            | Self::DeployUnreadable { .. }
1466            | Self::DeployPairTagsRefused { .. }
1467            | Self::DeployPreflightFailed { .. }
1468            | Self::DeployJournalPresent { .. }
1469            | Self::DeployLiveChanged { .. }
1470            | Self::DeployRunInvalid { .. }
1471            | Self::RollbackFailed(..)
1472            | Self::ProjectGitManaged { .. }
1473            | Self::ProjectParentInvalid { .. }
1474            | Self::ProjectHashMismatch { .. } => 6,
1475            Self::Rig(_) => 7,
1476        }
1477    }
1478
1479    /// Actionable next step (CORE-05). Every class carries one.
1480    pub fn hint(&self) -> Option<String> {
1481        match self {
1482            Self::Internal(_) => Some(
1483                "internal errors are bugs; re-run with -vv and report the \
1484                 diagnostics output"
1485                    .to_string(),
1486            ),
1487            Self::ConfirmationRequired { .. } => Some(
1488                "this operation is destructive; re-run with --yes or set \
1489                   IGNITION_YES=1"
1490                    .to_string(),
1491            ),
1492            Self::ProfilingRiskNotAccepted { .. } => Some(
1493                "JVM profiling ACTIVELY instruments the gateway: stack sampling \
1494                   adds CPU overhead proportional to thread count x frequency, \
1495                   and flight recordings buffer in JVM memory and write to \
1496                   gateway disk. On a PRODUCTION server this belongs inside an \
1497                   approved change window with a rollback plan — never as a \
1498                   casual probe. --yes deliberately does NOT satisfy this gate; \
1499                   re-run with --i-accept-the-risk after confirming that"
1500                    .to_string(),
1501            ),
1502            Self::InvalidImportFile { .. } => Some(
1503                "import expects a project-export ZIP (PK\\x03\\x04 magic) of at \
1504                   most 512 MB — pass a file produced by `ign project export` \
1505                   via --file (or `-` to pipe one on stdin)"
1506                    .to_string(),
1507            ),
1508            Self::ImportDenied { problem, .. } => Some(format!(
1509                "the gateway refused the import over a 200 answer — the problem \
1510                   text above is the gateway's own; `ign project export` of the \
1511                   current state is the honest baseline for hand-editing ({problem})"
1512            )),
1513            Self::InvalidInput { reason } => Some(
1514                if reason == TUI_TTY_REFUSAL_REASON {
1515                    // The ONE contextual InvalidInput hint (06-07): the
1516                    // TTY refusal's fix is terminal-related — the
1517                    // --file/stdin default is meaningless for a pipe.
1518                    "run `ign tui` in an interactive terminal (the cockpit \
1519                     needs a TTY on stdout — not a pipe or redirect)"
1520                } else if reason == TOP_TTY_REFUSAL_REASON {
1521                    // itop's sibling refusal: same terminal-contextual
1522                    // fix, naming the MONITOR's command (a hint naming
1523                    // `ign tui` would not fix an `ign top` run).
1524                    "run `ign top` in an interactive terminal (the monitor \
1525                     needs a TTY on stdout — not a pipe or redirect)"
1526                } else if reason.starts_with(LOSS_GATE_REFUSAL_REASON_PREFIX) {
1527                    // The loss-gate refusal (11-07): the message above already names
1528                    // every finding and ends with the actionable guidance — the hint
1529                    // restates it for envelope readers instead of the file-read
1530                    // default, which is meaningless here (the file WAS readable).
1531                    "the loss report above names what this import would drop or \
1532                     coerce — re-run with --yes to import anyway"
1533                } else if reason.starts_with("--plan-sha256 ")
1534                    || reason.starts_with("--allow-drift ")
1535                    || reason.starts_with("--artifact-sha256 ")
1536                    || reason.starts_with("--release ")
1537                    || reason.starts_with("--allow-first-change ")
1538                    || reason.starts_with("deploy projects needs --artifact-sha256")
1539                {
1540                    // The deploy apply and deploy projects gate flags (23-02,
1541                    // 25-11): the --file/stdin default is meaningless for a
1542                    // flag value.
1543                    "correct the flag value named above and rerun; no host or gateway \
1544                     was contacted"
1545                } else {
1546                    "fix the input source — a readable file path via --file, or `-` \
1547                     to pipe the content on stdin"
1548                }
1549                .to_string(),
1550            ),
1551            Self::ProfileNotFound { known, .. } => Some(if known.is_empty() {
1552                "no profiles configured yet; run `ign profile add` to create \
1553                     one"
1554                .to_string()
1555            } else {
1556                format!(
1557                    "known profiles: {}; run `ign profile add` to add another",
1558                    known.join(", ")
1559                )
1560            }),
1561            Self::NoActiveProfile => Some(
1562                "pass --profile NAME, set IGNITION_PROFILE, or mark a profile \
1563                 active with `ign profile use`"
1564                    .to_string(),
1565            ),
1566            Self::SecretUnavailable { profile } => Some(format!(
1567                "set IGNITION_TOKEN (or token_env in the profile), or store a \
1568                 keyring entry: service 'ignition-cli', user 'profile:{profile}'"
1569            )),
1570            Self::PasswordUnavailable { .. } => Some(
1571                "ign adopt logs in with the gateway admin password — export \
1572                 IGNITION_PASSWORD (env-only, never a flag)"
1573                    .to_string(),
1574            ),
1575            Self::ConfigInvalid { .. } => Some(
1576                "verify the config file is valid TOML with [profiles.NAME] \
1577                 tables; `ign profile add` writes a known-good one"
1578                    .to_string(),
1579            ),
1580            Self::PollIntervalTooSmall { profile } => Some(format!(
1581                "set poll_interval_secs to 1 or higher in [profiles.{profile}], \
1582                 or remove the key to use the default cadence"
1583            )),
1584            Self::Network {
1585                url,
1586                observation,
1587                ..
1588            } => Some(match observation {
1589                Some(observation) => format!(
1590                    "the gateway answered but no terminal state arrived before the deadline — \
1591                     the last observation was: {observation}; address that state, not the \
1592                     connection ({url})"
1593                ),
1594                None => format!("check the gateway is reachable at {url} (host, port, VPN, TLS)"),
1595            }),
1596            Self::Auth { status, .. } => Some(match status {
1597                401 => {
1598                    // 401 = token not recognized — the #1 setup failure is
1599                    // a key-only header (verified: key-only → 401, full
1600                    // `name:key` → 200; Basic is dead on 8.3 /data).
1601                    "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"
1602                }
1603                403 => {
1604                    // 403 = recognized but under-permitted (verified
1605                    // semantics; see 02-RESEARCH Auth §4/§5).
1606                    "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"
1607                }
1608                _ => "check the credential; Ignition token setup is three parts: security level, write permissions, token assignment",
1609            }
1610            .to_string()),
1611            Self::GatewayTooOld { minimum, .. } => {
1612                Some(format!("upgrade the gateway to at least {minimum}"))
1613            }
1614            Self::GatewayNotCommissioned { .. } => Some(
1615                "open http://<host>:<port>/welcome in a browser and complete the \
1616                 commissioning wizard"
1617                    .to_string(),
1618            ),
1619            Self::GatewayRestarting { .. } => Some(
1620                "wait for readiness with `ign wait restart` or retry in ~1 minute".to_string(),
1621            ),
1622            Self::NotFound { .. } => Some(
1623                "check the id/path; a 404 JSON 'No route match' body can also mean \
1624                  a pre-8.3 gateway"
1625                    .to_string(),
1626            ),
1627            Self::ProjectExists { .. } => Some(
1628                "the default collision policy refuses to overwrite; re-run with \
1629                  --collision-policy overwrite to replace it — overwrite \
1630                  REPLACES the ENTIRE project (resources absent from the ZIP \
1631                  are deleted; merge is Designer-only)"
1632                    .to_string(),
1633            ),
1634            Self::ResourceBinary { .. } => Some(
1635                "resource content is binary — use `ign project export`/`import` \
1636                  for data.bin-class resources"
1637                    .to_string(),
1638            ),
1639            Self::TrialNotExpired { .. } => Some(
1640                "wait for the trial to expire (watch `ign rig trial status`), or \
1641                  run `ign rig reset --yes` for a completely fresh trial volume"
1642                    .to_string(),
1643            ),
1644            Self::RoutesNotDeployed { route, .. } => Some(
1645                // CONTENT-ADDRESSED off the route (the
1646                // TUI_TTY_REFUSAL_REASON / loss-gate prose-prefix
1647                // pattern, same file): the testing bundle is a
1648                // SEPARATE deploy with its own flag, so the generic
1649                // `ign webdev deploy` hint would send the caller to a
1650                // command that does not install it. No new variant,
1651                // no new slug — the exit tables are untouched.
1652                if route.starts_with(TESTING_ROUTE_PREFIX) {
1653                    "run `ign webdev deploy --with-testing` to install the gateway \
1654                      testing bundle into the project (or bootstrap it in one shot \
1655                      with `ign adopt --project NAME --testing`), then retry"
1656                } else {
1657                    "run `ign webdev deploy` to install the CLI's WebDev routes into \
1658                      the gateway, then retry"
1659                }
1660                .to_string(),
1661            ),
1662            Self::WebdevUnlicensed { .. } => Some(
1663                "license the gateway — the WebDev module answers 402 while \
1664                  unlicensed (on a rig, `ign rig trial reset --yes` restarts an \
1665                  expired trial)"
1666                    .to_string(),
1667            ),
1668            Self::RouteVersionMismatch { deployed, expected, .. } => {
1669                // Direction decides the fix (roadmap criterion): an older
1670                // deployed route → redeploy from THIS binary; a NEWER
1671                // deployed route → this CLI is behind (the route bundle
1672                // travels with the binary). Same slug either way.
1673                let newer = semver::Version::parse(deployed)
1674                    .ok()
1675                    .zip(semver::Version::parse(expected).ok())
1676                    .is_some_and(|(deployed, expected)| deployed > expected);
1677                Some(if newer {
1678                    "the deployed routes are NEWER than this CLI — update ign \
1679                      (the route bundle travels with the binary)"
1680                        .to_string()
1681                } else {
1682                    "run `ign webdev deploy` to redeploy the route version \
1683                      this CLI expects"
1684                        .to_string()
1685                })
1686            }
1687            Self::ProviderNotFound { .. } => Some(
1688                "check the provider name; `ign tags provider list` shows the \
1689                  gateway's tag providers"
1690                    .to_string(),
1691            ),
1692            Self::TagCollision { .. } => Some(
1693                "re-run with --collision-policy overwrite to replace the \
1694                  existing tags (destructive: requires --yes)"
1695                    .to_string(),
1696            ),
1697            Self::AlarmJournalMissing { .. } => Some(
1698                "alarm history needs a journal profile — provision a database \
1699                  connection + alarm-journal profile on the gateway (and point \
1700                  the general-alarm-settings singleton at it), then retry; see \
1701                  the README 'Alarm history' section"
1702                    .to_string(),
1703            ),
1704            Self::SessionNotPrunable { .. } => Some(
1705                "close the Designer first — prune removes stale entries only".to_string(),
1706            ),
1707            Self::WebdevRouteError { code, .. } => Some(if code == "secret_required" || code == "secret_mismatch" {
1708                "the scriptExec route is secret-gated — deploy it with `ign \
1709                  webdev deploy --with-script-exec` (the secret is generated \
1710                  and stored in the profile config at 0600); a mismatch means \
1711                  the route was deployed with a different secret: redeploy or \
1712                  pass --rotate-secret"
1713                    .to_string()
1714            } else {
1715                "the deployed route refused the action — the code and message \
1716                  are the route's stable contract; `ign webdev status` \
1717                  diagnoses the deployment"
1718                    .to_string()
1719            }),
1720            Self::EamNotController { .. } => Some(
1721                "flip the gateway's EAM role: config-resource PUT on \
1722                   com.inductiveautomation.eam/module-settings with \
1723                   installMode \"Controller\" (array body carrying the current \
1724                   signature) — a manual gateway-role decision this CLI \
1725                   deliberately does not automate; see the README 'EAM tasks' \
1726                   section"
1727                    .to_string(),
1728            ),
1729            Self::ProviderRootUnsupported { .. } => Some(
1730                "target a subtree path like [provider]folder — provider-ROOT \
1731                   forms ([default] alone, or a bare provider name) need an \
1732                   RPC context WebDev threads don't carry (8.3.3); subtree \
1733                   paths are the supported form"
1734                    .to_string(),
1735            ),
1736            Self::EamTaskTypeRefused { .. } => Some(
1737                "restore/install/upgrade tasks dispatch fleet-wide (every \
1738                   agent target); run them from the Ignition EAM console — \
1739                   EXT-03 (v2) will scope them into the CLI"
1740                    .to_string(),
1741            ),
1742            Self::EamTaskInFlight { .. } => Some(
1743                "complete or delete the leftover '(forced)' run from the EAM \
1744                   console — no ign verb deletes runs; the slot frees once the \
1745                   run is resolved"
1746                    .to_string(),
1747            ),
1748            Self::ScriptExecNotConfigured { .. } => Some(
1749                "run `ign webdev deploy --with-script-exec` to deploy the route and \
1750                  generate + persist its secret (the deploy flag IS the opt-in — \
1751                  `ign script run` has no --yes by design)"
1752                    .to_string(),
1753            ),
1754            Self::LintToolAbsent => Some(
1755                "install the linter: `uv tool install ignition-lint-toolkit` \
1756                 (or `pip install ignition-lint-toolkit`) — \
1757                 github.com/TheThoughtagen/ignition-lint; then re-run with \
1758                 ignition-lint on PATH"
1759                    .to_string(),
1760            ),
1761            // The SAME string `ign e2e doctor`'s node row hands out —
1762            // a user who hits this refusal and then runs the doctor
1763            // must not be given two wordings of one instruction.
1764            Self::NodeToolAbsent { .. } => {
1765                Some(crate::actions::e2e::NODE_INSTALL_HINT.to_string())
1766            }
1767            Self::GatewayClientError { .. } => Some(
1768                "the gateway rejected this request — the body above is the \
1769                 gateway's own answer; fix the path/method/body, or use a \
1770                 curated `ign` command when one exists"
1771                    .to_string(),
1772            ),
1773            Self::BundleNotAvailable { .. } => Some(
1774                "generate a fresh bundle with `ign diagnostics bundle generate`, \
1775                 then wait again — the gateway reports no current bundle and \
1776                 polling cannot produce one"
1777                    .to_string(),
1778            ),
1779            Self::ModuleFeedUnreachable { .. } => Some(
1780                "check network reachability to the module release feed — an \
1781                 already-cached version works fully offline, so this only \
1782                 blocks a first-time fetch of a new version"
1783                    .to_string(),
1784            ),
1785            Self::ModuleReleaseNotFound {
1786                module, version, ..
1787            } => Some(format!(
1788                "check that {module} version {version} exists as a release upstream \
1789                 (the tag and asset name must match exactly) — this CLI never falls \
1790                 back to \"latest\""
1791            )),
1792            Self::ModuleDigestMismatch(d) => Some(format!(
1793                "the downloaded bytes for {} version {} do not match \
1794                 the feed's published digest — do not trust this artifact; nothing \
1795                 was cached, so re-running the fetch tries again from scratch",
1796                d.module,
1797                d.version
1798            )),
1799            Self::ModuleFeedUnusable { .. } => Some(
1800                "the module release feed answered but was not usable — if the \
1801                 detail names a rate limit, wait and retry; otherwise the feed's \
1802                 response itself needs investigation"
1803                    .to_string(),
1804            ),
1805            Self::ModuleDigestChanged(d) => Some(format!(
1806                "upstream re-released this version with different bytes — the cached \
1807                 artifact at {} is still there and usable offline; re-run with \
1808                 FetchPolicy::AcceptUpstreamChange to deliberately accept and verify the \
1809                 new bytes",
1810                d.cached_path
1811            )),
1812            Self::Rig(_) => Some(
1813                "check Docker is running and inspect the rig containers \
1814                 (docker ps)"
1815                    .to_string(),
1816            ),
1817            Self::ModuleNotRegistered { known, .. } => Some(if known.is_empty() {
1818                "no modules are registered in this build of ign".to_string()
1819            } else {
1820                format!(
1821                    "known module ids: {} — check the id passed to --with-module \
1822                     or declared under [rigs.NAME.modules.*]",
1823                    known.join(", ")
1824                )
1825            }),
1826            Self::ModuleUninstallDenied { .. } => Some(
1827                "the gateway refuses to uninstall a module whose .modl is still \
1828                 mounted — remove it from [rigs.NAME.modules], run `ign rig up` \
1829                 so the mount goes, then retry; a successful uninstall is NOT \
1830                 undone by re-declaring the module — recovery today means \
1831                 `ign rig reset --yes`, which destroys the rig's data volume"
1832                    .to_string(),
1833            ),
1834            Self::ScanLockHeld { .. } => Some(
1835                "the scan-lock is released when the holder's scan runs or its \
1836                 holdTimeout expires — wait for that, then retry"
1837                    .to_string(),
1838            ),
1839            Self::ResourceSignatureRejected { .. } => Some(
1840                "re-read the resource for its current signature and retry — \
1841                 signatures change on every write"
1842                    .to_string(),
1843            ),
1844            Self::ResourceExists { .. } => Some(
1845                "a resource of that name is already present — read it for its \
1846                 current signature and update it, or choose another name"
1847                    .to_string(),
1848            ),
1849            Self::ModeExists { .. } => Some(
1850                "that deployment mode is already defined — list the modes and \
1851                 skip the create"
1852                    .to_string(),
1853            ),
1854            Self::DeployTreeInvalid(..) => Some(
1855                "fix each file named above in the declared gateway tree; render \
1856                 refuses until every finding is resolved"
1857                    .to_string(),
1858            ),
1859            Self::EmbeddedSecret(..) => Some(
1860                "store the value in the gateway's secret provider and reference it \
1861                 as {\"type\":\"Referenced\",\"data\":{\"providerName\":\"…\",\
1862                 \"secretName\":\"…\"}}; never commit an Embedded or plaintext secret"
1863                    .to_string(),
1864            ),
1865            Self::TransportFailed { .. } => Some(
1866                "check the node's host access in targets/<env>.yaml: for `local`, that \
1867                 data_dir exists and is readable; for `ssh`, the pinned host key, the \
1868                 key and reachability. Nothing was read as absent, so no plan was made"
1869                    .to_string(),
1870            ),
1871            Self::DeployLedgerInvalid { .. } => Some(
1872                "the delete set is never assumed empty, so no plan was made; inspect or \
1873                 restore the ledger file named above (only an apply writes it) and rerun"
1874                    .to_string(),
1875            ),
1876            Self::PlanStale { detail, .. } if detail.starts_with("live project") => Some(
1877                "the live project changed after the plan was drawn; run the check again, \
1878                 review what it reports and rerun the deploy, nothing was imported"
1879                    .to_string(),
1880            ),
1881            Self::PlanStale { .. } => Some(
1882                "the plan changed after it was reviewed; review the new plan and rerun \
1883                 with its plan_sha256, nothing was written"
1884                    .to_string(),
1885            ),
1886            Self::DestructiveChangeRefused { gate, .. } => Some(match gate.as_str() {
1887                "drift" => "live files differ from both the base render and the declared tree; \
1888                     review the drift, then rerun with --allow-drift and a reason of at most \
1889                     500 characters"
1890                    .to_string(),
1891                "downgrade" => "the gateway last received a newer release of this project; \
1892                     deploy the newer release, or rerun with --allow-downgrade when the older \
1893                     release must replace it"
1894                    .to_string(),
1895                "first_deploy" => "this is the first deploy of the project by this tool and it \
1896                     would change what the gateway runs; release what the gateway runs first, \
1897                     or rerun with --allow-first-change and a reason"
1898                    .to_string(),
1899                _ => "review the destructive entries named above, then rerun with \
1900                     --allow-destructive and the plan_sha256 you reviewed"
1901                    .to_string(),
1902            }),
1903            Self::DeployBlocked { .. } => Some(
1904                "a core copy still shadows each unit named above, so no flag permits the \
1905                 removal; keep the unit in the declared tree, or remove the core copy on \
1906                 the gateway first"
1907                    .to_string(),
1908            ),
1909            Self::DeployUnreadable { .. } => Some(
1910                "the live path of each unit named above is too long for the host's sftp \
1911                 server; shorten it on the gateway (rename the folder), or, when the gateway \
1912                 owns the provider, list the provider in exclude_tag_providers and stop \
1913                 declaring it; nothing was written"
1914                    .to_string(),
1915            ),
1916            Self::DeployPairTagsRefused { .. } => Some(
1917                "on a redundant pair, tag units are deployed to the gateway's core collection, \
1918                 which syncs to the backup, and a deploy moves the files its ledger lists out \
1919                 of the mode collection; the units named above have mode-collection files the \
1920                 ledger does not list (or the delete set skips), or a rollback would put them \
1921                 back; a file or folder in an `ignition/tag-definition` or \
1922                 `ignition/tag-type-definition` folder of the mode collection that the deploy \
1923                 does not read (an empty folder included), even beside files the deploy \
1924                 deletes, counts too; remove those files and their emptied folders by hand \
1925                 from the master's mode collection (README runbook T6e), then plan again; \
1926                 instead of rolling back a run that moved tags, apply again; \
1927                 nothing was written to the mode or core collection"
1928                    .to_string(),
1929            ),
1930            Self::DeployPreflightFailed { .. } => Some(
1931                "fix each failed check named above and rerun; no gateway file was written"
1932                    .to_string(),
1933            ),
1934            Self::DeployJournalPresent { .. } => Some(
1935                "an earlier deploy was interrupted and its journal is still on the box; run \
1936                 the recovery command in the message, then rerun; plan and apply refuse \
1937                 until the journal is cleared"
1938                    .to_string(),
1939            ),
1940            Self::DeployLiveChanged { .. } => Some(
1941                "someone changed the files named above after the run wrote them; review \
1942                 those changes, then rerun the rollback with --discard-live-changes to \
1943                 restore the snapshot over them"
1944                    .to_string(),
1945            ),
1946            Self::DeployRunInvalid { .. } => Some(
1947                "the run directory or the on-box record is missing or damaged; restore the \
1948                 files from the .gwbk backup or the run-directory snapshot instead"
1949                    .to_string(),
1950            ),
1951            Self::RollbackFailed(..) => Some(
1952                "do not apply again until the files are restored: run the retry command in \
1953                 the message first, because the journal and the snapshot are usually intact; \
1954                 only if it reports a lost or damaged snapshot restore from the .gwbk backup \
1955                 or the on-box snapshot named there, and then delete the journal at the path \
1956                 in the message by hand"
1957                    .to_string(),
1958            ),
1959            Self::ProjectGitManaged { .. } => Some(
1960                "follow the README cutover runbook: remove this project's .git on the \
1961                 gateway (the deploy tool never deletes it), then run the check again"
1962                    .to_string(),
1963            ),
1964            Self::ProjectParentInvalid { reason, .. } => Some(
1965                match reason.as_str() {
1966                    "missing" => "deploy the parent project from its own repo first, then run \
1967                                  the check again",
1968                    "cycle" => "fix the parent in project.json so the inheritance has no cycle",
1969                    _ => "the parent is not inheritable; fix the parent in project.json or \
1970                          make the parent inheritable",
1971                }
1972                .to_string(),
1973            ),
1974            Self::ProjectHashMismatch { .. } => Some(
1975                "the checkout is not the reviewed one; rerun the check on this commit and \
1976                 pass its artifact_sha256"
1977                    .to_string(),
1978            ),
1979        }
1980    }
1981
1982    /// URL/path of the request involved, when one was — populated for the
1983    /// network, auth, and target-state classes (CORE-05).
1984    pub fn endpoint(&self) -> Option<String> {
1985        match self {
1986            Self::Network { url, .. } => Some(url.clone()),
1987            Self::Auth { endpoint, .. } => endpoint.clone(),
1988            Self::GatewayTooOld { endpoint, .. }
1989            | Self::GatewayNotCommissioned { endpoint }
1990            | Self::GatewayRestarting { endpoint }
1991            | Self::NotFound { endpoint }
1992            | Self::ProjectExists { endpoint, .. }
1993            | Self::ResourceBinary { endpoint, .. }
1994            | Self::TrialNotExpired { endpoint, .. }
1995            | Self::ProviderNotFound { endpoint, .. }
1996            | Self::RoutesNotDeployed { endpoint, .. }
1997            | Self::WebdevUnlicensed { endpoint }
1998            | Self::RouteVersionMismatch { endpoint, .. }
1999            | Self::WebdevRouteError { endpoint, .. }
2000            | Self::TagCollision { endpoint, .. }
2001            | Self::AlarmJournalMissing { endpoint }
2002            | Self::SessionNotPrunable { endpoint, .. }
2003            | Self::ImportDenied { endpoint, .. }
2004            | Self::EamNotController { endpoint }
2005            | Self::ProviderRootUnsupported { endpoint }
2006            | Self::EamTaskInFlight { endpoint, .. }
2007            | Self::ModuleUninstallDenied { endpoint, .. }
2008            | Self::ScanLockHeld { endpoint, .. }
2009            | Self::ResourceSignatureRejected { endpoint, .. }
2010            | Self::ResourceExists { endpoint, .. }
2011            | Self::ModeExists { endpoint, .. } => endpoint.clone(),
2012            Self::ModuleFeedUnreachable { url, .. }
2013            | Self::ModuleReleaseNotFound { url, .. }
2014            | Self::ModuleFeedUnusable { url, .. } => Some(url.clone()),
2015            // Boxed payload, so it cannot share the or-pattern above.
2016            Self::ModuleDigestMismatch(d) => Some(d.url.clone()),
2017            _ => None,
2018        }
2019    }
2020
2021    /// The deploy slugs (21-03), in the order the README row lists them.
2022    pub const DEPLOY_SLUGS: &'static [&'static str] = &[
2023        "deploy_tree_invalid",
2024        "embedded_secret",
2025        "transport_failed",
2026        "deploy_ledger_invalid",
2027        "plan_stale",
2028        "destructive_change_refused",
2029        "deploy_blocked",
2030        "deploy_preflight_failed",
2031        "deploy_journal_present",
2032        "deploy_live_changed",
2033        "deploy_run_invalid",
2034        "rollback_failed",
2035        "project_git_managed",
2036        "project_parent_invalid",
2037        "project_hash_mismatch",
2038        "deploy_unreadable",
2039        "deploy_pair_tags_refused",
2040    ];
2041
2042    /// A single positioned (or file-only, when `line`/`column` are
2043    /// `None`) tree finding as a [`Self::DeployTreeInvalid`].
2044    pub fn deploy_tree_invalid(
2045        file: impl Into<String>,
2046        line: Option<usize>,
2047        column: Option<usize>,
2048        message: impl Into<String>,
2049    ) -> Self {
2050        Self::deploy_findings(vec![DeployFinding {
2051            file: file.into(),
2052            line,
2053            column,
2054            message: message.into(),
2055        }])
2056    }
2057
2058    /// Several tree findings as one [`Self::DeployTreeInvalid`]. An empty
2059    /// vector is a programming error: debug builds assert, release builds
2060    /// still return one generic finding so the error is never empty.
2061    pub fn deploy_findings(findings: Vec<DeployFinding>) -> Self {
2062        debug_assert!(!findings.is_empty(), "deploy_findings needs a finding");
2063        let findings = if findings.is_empty() {
2064            vec![DeployFinding {
2065                file: String::new(),
2066                line: None,
2067                column: None,
2068                message: "the declared tree is invalid".to_string(),
2069            }]
2070        } else {
2071            findings
2072        };
2073        Self::DeployTreeInvalid(Box::new(DeployTreeInvalidDetails { findings }))
2074    }
2075
2076    /// An embedded or plaintext secret at each of `paths` (locations
2077    /// only, never values), plus any non-secret findings from the same
2078    /// render.
2079    pub fn embedded_secret(paths: Vec<String>, other_findings: Vec<DeployFinding>) -> Self {
2080        Self::EmbeddedSecret(Box::new(EmbeddedSecretDetails {
2081            paths,
2082            other_findings,
2083        }))
2084    }
2085
2086    /// The `ign tui` TTY refusal (06-07): InvalidInput carrying the
2087    /// ONE reason whose hint is terminal-contextual — see
2088    /// [`TUI_TTY_REFUSAL_REASON`]. Same slug, same exit 2 (frozen
2089    /// taxonomy; only the hint differs).
2090    pub fn tui_tty_refusal() -> Self {
2091        Self::InvalidInput {
2092            reason: TUI_TTY_REFUSAL_REASON.to_string(),
2093        }
2094    }
2095
2096    /// The `ign top` TTY refusal (itop): the tui refusal's sibling at
2097    /// one command over — same slug, same exit 2, the hint naming the
2098    /// monitor's command. See [`TOP_TTY_REFUSAL_REASON`].
2099    pub fn top_tty_refusal() -> Self {
2100        Self::InvalidInput {
2101            reason: TOP_TTY_REFUSAL_REASON.to_string(),
2102        }
2103    }
2104
2105    /// Build the LOCKED failure envelope for this error (field order is part
2106    /// of the golden contract: `ok`, `profile`, `error` then `code`,
2107    /// `message`, `endpoint`, `hint`).
2108    pub fn envelope<'a>(&self, profile: Option<&'a str>) -> ErrorEnvelope<'a> {
2109        ErrorEnvelope {
2110            ok: false,
2111            profile,
2112            error: ErrorBody {
2113                code: self.code(),
2114                message: self.to_string(),
2115                endpoint: self.endpoint(),
2116                hint: self.hint(),
2117            },
2118        }
2119    }
2120}
2121
2122/// LOCKED failure envelope shape: exactly the top-level fields `ok`,
2123/// `profile`, `error` — changing the set is a breaking change for agents.
2124#[derive(Debug, Serialize)]
2125pub struct ErrorEnvelope<'a> {
2126    /// Always `false` in this envelope.
2127    pub ok: bool,
2128    /// Active profile echoed in every output (CORE-01); `None` until config
2129    /// resolution lands.
2130    pub profile: Option<&'a str>,
2131    /// The typed error body.
2132    pub error: ErrorBody,
2133}
2134
2135/// LOCKED error body: `code` (stable slug), `message` (human-readable),
2136/// `endpoint` (when a request was involved), `hint` (actionable next step).
2137#[derive(Debug, Serialize)]
2138pub struct ErrorBody {
2139    /// Stable slug from [`CoreError::code`] — never respelled.
2140    pub code: &'static str,
2141    /// Human-readable description.
2142    pub message: String,
2143    /// URL/path when a request was involved.
2144    pub endpoint: Option<String>,
2145    /// Actionable next step.
2146    pub hint: Option<String>,
2147}
2148
2149#[cfg(test)]
2150mod tests {
2151    use super::{
2152        CoreError, DeployFinding, ErrorBody, ErrorEnvelope, GATEWAY_CLIENT_BODY_CAP_BYTES,
2153        GATEWAY_CLIENT_BODY_TRUNCATION_MARKER, LOSS_GATE_REFUSAL_REASON_PREFIX,
2154        ModuleDigestChangedDetails, ModuleDigestMismatchDetails, PreflightFailure, RollbackFailure,
2155        SignatureReason, TESTING_ROUTE_PREFIX, truncate_api_body,
2156    };
2157
2158    /// Build a real `reqwest::Error` for the Network variant: a request to
2159    /// an unroutable loopback port fails at connect time (instant refusal —
2160    /// `reqwest::Error` has no public constructor).
2161    fn network_error() -> CoreError {
2162        // reqwest is built with `rustls-no-provider`, so a raw
2163        // `reqwest::get` panics unless the process-wide provider is
2164        // installed. In a full lib-test run some other test happens to
2165        // install it first; running the error tests ALONE (which the
2166        // exit-table verify command does) hit the panic. The install is
2167        // idempotent and documented for exactly this use.
2168        crate::client::install_crypto_provider();
2169        let rt = tokio::runtime::Builder::new_current_thread()
2170            .enable_all()
2171            .build()
2172            .expect("test runtime");
2173        let url = "http://127.0.0.1:1";
2174        let source = rt
2175            .block_on(reqwest::get(url))
2176            .expect_err("request to an unroutable port must fail");
2177        CoreError::Network {
2178            url: url.to_string(),
2179            source: Some(source),
2180            observation: None,
2181        }
2182    }
2183
2184    /// The [`CoreError::ModuleFeedUnreachable`] twin of [`network_error`]
2185    /// — a real `reqwest::Error` from an unroutable loopback port, for
2186    /// the offline-module-feed exit-4 case.
2187    fn module_feed_unreachable_error() -> CoreError {
2188        crate::client::install_crypto_provider();
2189        let rt = tokio::runtime::Builder::new_current_thread()
2190            .enable_all()
2191            .build()
2192            .expect("test runtime");
2193        let url = "http://127.0.0.1:1";
2194        let source = rt
2195            .block_on(reqwest::get(url))
2196            .expect_err("request to an unroutable port must fail");
2197        CoreError::ModuleFeedUnreachable {
2198            url: url.to_string(),
2199            source: Some(source),
2200        }
2201    }
2202
2203    /// Pitfall-5 guard: the FULL 1–7 taxonomy enumerated on day one so no
2204    /// later phase can silently renumber it or respell a slug. The slugs are
2205    /// asserted against literals — that IS the stability contract.
2206    #[test]
2207    fn exit_code_mapping_enumerated() {
2208        let cases: Vec<(CoreError, u8, &'static str)> = vec![
2209            (CoreError::Internal("boom".into()), 1, "internal"),
2210            (
2211                CoreError::ConfirmationRequired {
2212                    operation: "project download".into(),
2213                },
2214                2,
2215                "confirmation_required",
2216            ),
2217            (
2218                CoreError::ProfilingRiskNotAccepted {
2219                    operation: "jvm sample".into(),
2220                },
2221                2,
2222                "profiling_risk_not_accepted",
2223            ),
2224            (
2225                CoreError::InvalidImportFile {
2226                    reason: "missing ZIP magic".into(),
2227                },
2228                2,
2229                "invalid_import_file",
2230            ),
2231            (
2232                CoreError::InvalidInput {
2233                    reason: "cannot read put.json".into(),
2234                },
2235                2,
2236                "invalid_input",
2237            ),
2238            (
2239                CoreError::ProfileNotFound {
2240                    name: "nope".into(),
2241                    known: vec!["dev".into()],
2242                },
2243                3,
2244                "profile_not_found",
2245            ),
2246            (CoreError::NoActiveProfile, 3, "no_active_profile"),
2247            (
2248                CoreError::SecretUnavailable {
2249                    profile: "dev".into(),
2250                },
2251                3,
2252                "secret_unavailable",
2253            ),
2254            (
2255                CoreError::PasswordUnavailable {
2256                    profile: "dev".into(),
2257                },
2258                3,
2259                "password_unavailable",
2260            ),
2261            (
2262                CoreError::ConfigInvalid {
2263                    reason: "bad toml".into(),
2264                },
2265                3,
2266                "config_invalid",
2267            ),
2268            (
2269                CoreError::PollIntervalTooSmall {
2270                    profile: "dev".into(),
2271                },
2272                3,
2273                "poll_interval_too_small",
2274            ),
2275            (network_error(), 4, "network_error"),
2276            (
2277                CoreError::Auth {
2278                    status: 401,
2279                    endpoint: None,
2280                },
2281                5,
2282                "auth_rejected",
2283            ),
2284            (
2285                CoreError::GatewayTooOld {
2286                    found: "8.1.0".into(),
2287                    minimum: "8.3.1".into(),
2288                    endpoint: None,
2289                },
2290                6,
2291                "gateway_too_old",
2292            ),
2293            (
2294                CoreError::GatewayNotCommissioned {
2295                    endpoint: Some("http://gw:8088/data/api/v1/gateway-info".into()),
2296                },
2297                6,
2298                "gateway_not_commissioned",
2299            ),
2300            (
2301                CoreError::GatewayRestarting {
2302                    endpoint: Some("http://gw:8088/data/api/v1/gateway-info".into()),
2303                },
2304                6,
2305                "gateway_restarting",
2306            ),
2307            (
2308                CoreError::NotFound {
2309                    endpoint: Some("http://gw:8088/data/api/v1/designer/42".into()),
2310                },
2311                6,
2312                "not_found",
2313            ),
2314            (
2315                CoreError::ProjectExists {
2316                    name: "PlantFloor".into(),
2317                    endpoint: None,
2318                },
2319                6,
2320                "project_exists",
2321            ),
2322            (
2323                CoreError::ResourceBinary {
2324                    path: "com.x/perspective/session-permissions".into(),
2325                    endpoint: None,
2326                },
2327                6,
2328                "resource_binary",
2329            ),
2330            (
2331                CoreError::TrialNotExpired {
2332                    remaining_s: 6590,
2333                    endpoint: Some("http://localhost:9088/data/api/v1/trial".into()),
2334                },
2335                6,
2336                "trial_not_expired",
2337            ),
2338            (
2339                CoreError::ProviderNotFound {
2340                    name: "nope".into(),
2341                    endpoint: Some("/data/api/v1/resources/find/ignition/tag-provider/nope".into()),
2342                },
2343                6,
2344                "provider_not_found",
2345            ),
2346            (
2347                CoreError::RoutesNotDeployed {
2348                    project: "ign-cli".into(),
2349                    route: "tags".into(),
2350                    endpoint: Some("/system/webdev/ign-cli/cli/tags".into()),
2351                },
2352                6,
2353                "routes_not_deployed",
2354            ),
2355            (
2356                CoreError::WebdevUnlicensed {
2357                    endpoint: Some("/system/webdev/ign-cli/cli/tags".into()),
2358                },
2359                6,
2360                "webdev_unlicensed",
2361            ),
2362            (
2363                CoreError::RouteVersionMismatch {
2364                    route: "tags".into(),
2365                    deployed: "0.9.0".into(),
2366                    expected: "1.0.0".into(),
2367                    endpoint: Some("/system/webdev/ign-cli/cli/tags".into()),
2368                },
2369                6,
2370                "route_version_mismatch",
2371            ),
2372            (
2373                CoreError::WebdevRouteError {
2374                    code: "route_error".into(),
2375                    message: "boom".into(),
2376                    endpoint: Some("/system/webdev/ign-cli/cli/tags".into()),
2377                },
2378                6,
2379                "webdev_route_error",
2380            ),
2381            (
2382                CoreError::TagCollision {
2383                    provider: "p5import".into(),
2384                    names: vec!["T1".into(), "P5".into()],
2385                    endpoint: Some("/system/webdev/ign-cli/cli/tags".into()),
2386                },
2387                6,
2388                "tag_collision",
2389            ),
2390            (
2391                CoreError::AlarmJournalMissing {
2392                    endpoint: Some("/system/webdev/ign-cli/cli/alarms".into()),
2393                },
2394                6,
2395                "alarm_journal_missing",
2396            ),
2397            (
2398                CoreError::ImportDenied {
2399                    project: "PlantFloor".into(),
2400                    problem: "resource already exists: ResourceId{resourcePath=com.example, collectionName=views}".into(),
2401                    endpoint: Some("http://gw:8088/data/api/v1/projects/import/PlantFloor?overwrite=true".into()),
2402                },
2403                6,
2404                "import_denied",
2405            ),
2406            (
2407                CoreError::SessionNotPrunable {
2408                    id: "d-live-1".into(),
2409                    endpoint: Some("http://gw:8088/data/api/v1/designer/d-live-1".into()),
2410                },
2411                6,
2412                "session_not_prunable",
2413            ),
2414            (
2415                CoreError::EamNotController {
2416                    endpoint: Some("http://gw:8088/data/eam/api/v1/eam-tasks/history".into()),
2417                },
2418                6,
2419                "eam_not_controller",
2420            ),
2421            (
2422                CoreError::ProviderRootUnsupported {
2423                    endpoint: Some("/system/webdev/ign-cli/cli/tagConfig".into()),
2424                },
2425                6,
2426                "provider_root_unsupported",
2427            ),
2428            (
2429                CoreError::EamTaskTypeRefused {
2430                    task_type: "eam_remoteUpgrade".into(),
2431                },
2432                6,
2433                "eam_task_type_refused",
2434            ),
2435            (
2436                CoreError::EamTaskInFlight {
2437                    task: "cli-research-backup".into(),
2438                    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(),
2439                    endpoint: Some(
2440                        "http://gw:8088/data/eam/api/v1/eam-tasks/force/eam/cli-research-backup"
2441                            .into(),
2442                    ),
2443                },
2444                6,
2445                "eam_task_in_flight",
2446            ),
2447            (
2448                CoreError::ScriptExecNotConfigured {
2449                    profile: "dev".into(),
2450                },
2451                6,
2452                "script_exec_not_configured",
2453            ),
2454            (CoreError::LintToolAbsent, 6, "lint_tool_absent"),
2455            (
2456                CoreError::NodeToolAbsent {
2457                    tool: "node".into(),
2458                },
2459                6,
2460                "node_tool_absent",
2461            ),
2462            (
2463                CoreError::BundleNotAvailable {
2464                    state: "Invalid".into(),
2465                },
2466                6,
2467                "bundle_not_available",
2468            ),
2469            (
2470                CoreError::GatewayClientError {
2471                    status: 400,
2472                    endpoint: "http://gw:8088/data/api/v1/nonexistent".into(),
2473                    body: r#"{"error":{"code":"NOT_FOUND"}}"#.into(),
2474                },
2475                2,
2476                "gateway_client_error",
2477            ),
2478            (CoreError::Rig("compose up failed".into()), 7, "rig_error"),
2479            (
2480                module_feed_unreachable_error(),
2481                4,
2482                "module_feed_unreachable",
2483            ),
2484            (
2485                CoreError::ModuleReleaseNotFound {
2486                    module: "git".into(),
2487                    version: "99.99.99".into(),
2488                    url: "https://api.github.com/repos/WhiskeyHouse/ignition-git-module/releases/tags/v99.99.99".into(),
2489                    detail: "no release exists for tag \"v99.99.99\"".into(),
2490                },
2491                6,
2492                "module_release_not_found",
2493            ),
2494            (
2495                CoreError::ModuleDigestMismatch(Box::new(ModuleDigestMismatchDetails {
2496                    module: "git".into(),
2497                    version: "2.3.4".into(),
2498                    url: "https://release-assets.githubusercontent.com/Git-2.3.4-signed.modl".into(),
2499                    expected: "b".repeat(64),
2500                    actual: "c".repeat(64),
2501                })),
2502                6,
2503                "module_digest_mismatch",
2504            ),
2505            (
2506                CoreError::ModuleFeedUnusable {
2507                    url: "https://api.github.com/repos/WhiskeyHouse/ignition-git-module/releases/tags/v2.3.4".into(),
2508                    detail: "rate limit exceeded".into(),
2509                },
2510                6,
2511                "module_feed_unusable",
2512            ),
2513            (
2514                CoreError::ModuleDigestChanged(Box::new(ModuleDigestChangedDetails {
2515                    module: "git".into(),
2516                    version: "2.3.4".into(),
2517                    cached_digest: "a".repeat(64),
2518                    upstream_digest: "b".repeat(64),
2519                    cached_path: "/cache/modules/git/2.3.4-aaaa.modl".into(),
2520                })),
2521                6,
2522                "module_digest_changed",
2523            ),
2524            (
2525                CoreError::ModuleNotRegistered {
2526                    id: "bogus".into(),
2527                    known: vec!["git".into()],
2528                },
2529                3,
2530                "module_not_registered",
2531            ),
2532            (
2533                CoreError::ModuleUninstallDenied {
2534                    module_id: "git".into(),
2535                    gateway_module_id: "com.axone_io.ignition.git".into(),
2536                    failed: vec!["com.axone_io.ignition.git".into()],
2537                    endpoint: Some(
2538                        "http://gw:8088/data/api/v1/modules/uninstall".into(),
2539                    ),
2540                },
2541                6,
2542                "module_uninstall_denied",
2543            ),
2544            (
2545                CoreError::ScanLockHeld {
2546                    actor: Some("api-token:cap-a:198.51.100.1".into()),
2547                    kind: "config".into(),
2548                    endpoint: Some("http://gw:8088/data/api/v1/scan-lock/config".into()),
2549                },
2550                6,
2551                "scan_lock_held",
2552            ),
2553            (
2554                CoreError::ResourceSignatureRejected {
2555                    name: "DB_A".into(),
2556                    reason: SignatureReason::Stale,
2557                    endpoint: Some(
2558                        "http://gw:8088/data/api/v1/resources/find/ignition/database-connection/DB_A"
2559                            .into(),
2560                    ),
2561                },
2562                6,
2563                "resource_signature_rejected",
2564            ),
2565            (
2566                CoreError::ResourceExists {
2567                    name: "DB_A".into(),
2568                    endpoint: Some("http://gw:8088/data/api/v1/resources/ignition/database-connection".into()),
2569                },
2570                6,
2571                "resource_exists",
2572            ),
2573            (
2574                CoreError::ModeExists {
2575                    name: "production".into(),
2576                    endpoint: Some("http://gw:8088/data/api/v1/mode".into()),
2577                },
2578                6,
2579                "mode_exists",
2580            ),
2581            (
2582                CoreError::deploy_tree_invalid("manifest.yaml", Some(2), Some(1), "unknown field"),
2583                2,
2584                "deploy_tree_invalid",
2585            ),
2586            (
2587                CoreError::embedded_secret(vec!["a.json: settings.password".into()], vec![]),
2588                2,
2589                "embedded_secret",
2590            ),
2591            (
2592                CoreError::TransportFailed {
2593                    operation: "read".into(),
2594                    node: "nodes.master".into(),
2595                    detail: "cannot read directory config/resources (NotFound)".into(),
2596                },
2597                4,
2598                "transport_failed",
2599            ),
2600            (
2601                CoreError::DeployLedgerInvalid {
2602                    location: "ign-deploy/gw/dev/ledger.json".into(),
2603                    problem: "files is not byte-sorted".into(),
2604                },
2605                6,
2606                "deploy_ledger_invalid",
2607            ),
2608            (
2609                CoreError::PlanStale {
2610                    expected: "a".repeat(64),
2611                    actual: "b".repeat(64),
2612                    detail: "review hash".into(),
2613                },
2614                6,
2615                "plan_stale",
2616            ),
2617            (
2618                CoreError::DestructiveChangeRefused {
2619                    gate: "destructive".into(),
2620                    keys: vec!["ignition/a/x".into()],
2621                    count: 1,
2622                },
2623                2,
2624                "destructive_change_refused",
2625            ),
2626            (
2627                CoreError::DeployBlocked {
2628                    keys: vec!["ignition/a/x".into()],
2629                },
2630                6,
2631                "deploy_blocked",
2632            ),
2633            (
2634                CoreError::DeployPreflightFailed {
2635                    failures: vec![PreflightFailure {
2636                        check: "mode_master".into(),
2637                        detail: "the gateway is not in the declared mode".into(),
2638                    }]
2639                    .into(),
2640                },
2641                6,
2642                "deploy_preflight_failed",
2643            ),
2644            (
2645                CoreError::DeployJournalPresent {
2646                    run_id: "r1".into(),
2647                    env: "prod".into(),
2648                    location: "ign-deploy/gw/m/journal.json".into(),
2649                },
2650                6,
2651                "deploy_journal_present",
2652            ),
2653            (
2654                CoreError::DeployLiveChanged {
2655                    run_id: "r1".into(),
2656                    paths: vec!["a/b.json".into()],
2657                    count: 1,
2658                },
2659                6,
2660                "deploy_live_changed",
2661            ),
2662            (
2663                CoreError::DeployRunInvalid {
2664                    run_id: "r1".into(),
2665                    problem: "record.json is missing".into(),
2666                },
2667                6,
2668                "deploy_run_invalid",
2669            ),
2670            (
2671                CoreError::RollbackFailed(Box::new(RollbackFailure {
2672                    run_id: "r1".into(),
2673                    env: "dev".into(),
2674                    run_dir: Some(".ign/deploy/r1".into()),
2675                    journal: "ign-deploy/gw/m/journal.json".into(),
2676                    cause: "write_failed".into(),
2677                })),
2678                6,
2679                "rollback_failed",
2680            ),
2681            (
2682                CoreError::ProjectGitManaged {
2683                    project: "CapApp".into(),
2684                    location: "projects/CapApp/.git".into(),
2685                    kind: "directory".into(),
2686                },
2687                6,
2688                "project_git_managed",
2689            ),
2690            (
2691                CoreError::ProjectParentInvalid {
2692                    project: "CapApp".into(),
2693                    parent: "ParentProject".into(),
2694                    reason: "missing".into(),
2695                },
2696                6,
2697                "project_parent_invalid",
2698            ),
2699            (
2700                CoreError::ProjectHashMismatch {
2701                    project: "CapApp".into(),
2702                    expected: "0".repeat(64),
2703                    actual: "1".repeat(64),
2704                },
2705                6,
2706                "project_hash_mismatch",
2707            ),
2708            (
2709                CoreError::DeployUnreadable {
2710                    node: "master".into(),
2711                    keys: vec!["ignition/tag-definition/default/Deep".into()],
2712                },
2713                6,
2714                "deploy_unreadable",
2715            ),
2716            (
2717                CoreError::DeployPairTagsRefused {
2718                    mode: "production".into(),
2719                    keys: vec!["ignition/tag-definition/default/Deploy".into()],
2720                },
2721                6,
2722                "deploy_pair_tags_refused",
2723            ),
2724        ];
2725        for (err, code, slug) in cases {
2726            assert_eq!(err.exit_code(), code, "wrong exit code for: {err}");
2727            assert_eq!(err.code(), slug, "unstable slug for: {err}");
2728        }
2729    }
2730
2731    /// The (exit_code, slug) literal table the Three-Place rule syncs.
2732    /// DUPLICATED from [`exit_code_mapping_enumerated`]'s triple list by
2733    /// design: that test is the literal source of truth (enum ↔ literals);
2734    /// this flat table is what the README cross-check runs against, so a
2735    /// drift inside error.rs surfaces as a disagreement instead of silently
2736    /// re-shuffling both sides. Keep the two lists in lockstep — the
2737    /// enumerated test fails if the enum respells/renumbers, and a stale
2738    /// copy here fails the README check below until updated.
2739    const EXIT_SLUG_LITERALS: &[(u8, &str)] = &[
2740        (1, "internal"),
2741        (2, "confirmation_required"),
2742        (2, "profiling_risk_not_accepted"),
2743        (2, "invalid_import_file"),
2744        (2, "invalid_input"),
2745        (2, "gateway_client_error"),
2746        (2, "deploy_tree_invalid"),
2747        (2, "embedded_secret"),
2748        (2, "destructive_change_refused"),
2749        (3, "profile_not_found"),
2750        (3, "no_active_profile"),
2751        (3, "secret_unavailable"),
2752        (3, "password_unavailable"),
2753        (3, "config_invalid"),
2754        (3, "poll_interval_too_small"),
2755        (3, "module_not_registered"),
2756        (4, "network_error"),
2757        (5, "auth_rejected"),
2758        (6, "gateway_too_old"),
2759        (6, "gateway_not_commissioned"),
2760        (6, "gateway_restarting"),
2761        (6, "not_found"),
2762        (6, "project_exists"),
2763        (6, "resource_binary"),
2764        (6, "trial_not_expired"),
2765        (6, "provider_not_found"),
2766        (6, "routes_not_deployed"),
2767        (6, "webdev_unlicensed"),
2768        (6, "route_version_mismatch"),
2769        (6, "webdev_route_error"),
2770        (6, "tag_collision"),
2771        (6, "alarm_journal_missing"),
2772        (6, "import_denied"),
2773        (6, "session_not_prunable"),
2774        (6, "eam_not_controller"),
2775        (6, "eam_task_type_refused"),
2776        (6, "eam_task_in_flight"),
2777        (6, "script_exec_not_configured"),
2778        (6, "lint_tool_absent"),
2779        (6, "node_tool_absent"),
2780        (6, "provider_root_unsupported"),
2781        (6, "bundle_not_available"),
2782        (4, "module_feed_unreachable"),
2783        (4, "transport_failed"),
2784        (6, "module_release_not_found"),
2785        (6, "module_digest_mismatch"),
2786        (6, "module_feed_unusable"),
2787        (6, "module_digest_changed"),
2788        (6, "module_uninstall_denied"),
2789        (6, "scan_lock_held"),
2790        (6, "resource_signature_rejected"),
2791        (6, "resource_exists"),
2792        (6, "mode_exists"),
2793        (6, "deploy_ledger_invalid"),
2794        (6, "plan_stale"),
2795        (6, "deploy_blocked"),
2796        (6, "deploy_preflight_failed"),
2797        (6, "deploy_journal_present"),
2798        (6, "deploy_live_changed"),
2799        (6, "deploy_run_invalid"),
2800        (6, "rollback_failed"),
2801        (6, "project_git_managed"),
2802        (6, "project_parent_invalid"),
2803        (6, "project_hash_mismatch"),
2804        (6, "deploy_unreadable"),
2805        (6, "deploy_pair_tags_refused"),
2806        (7, "rig_error"),
2807    ];
2808
2809    /// The two deploy slugs (20-02) never carry a signature value or
2810    /// gateway problem text, and render their payloads sensibly.
2811    #[test]
2812    fn deploy_slugs_never_carry_signature_or_problem_text() {
2813        const SAMPLE_SIG: &str = "d80c81d167362252bf389893ea0f3a81479521ba0bb68ee3bfc46e94bc2540b7";
2814
2815        let held = CoreError::ScanLockHeld {
2816            actor: Some("api-token:cap-a:198.51.100.1".into()),
2817            kind: "config".into(),
2818            endpoint: Some("http://gw:8088/data/api/v1/scan-lock/config".into()),
2819        };
2820        assert_eq!(held.code(), "scan_lock_held");
2821        assert_eq!(held.exit_code(), 6);
2822        let shown = held.to_string();
2823        assert!(shown.contains("api-token:cap-a:198.51.100.1"), "{shown}");
2824        assert!(shown.contains("config"), "{shown}");
2825        assert_eq!(
2826            held.endpoint().as_deref(),
2827            Some("http://gw:8088/data/api/v1/scan-lock/config")
2828        );
2829
2830        let anonymous = CoreError::ScanLockHeld {
2831            actor: None,
2832            kind: "config".into(),
2833            endpoint: None,
2834        };
2835        let shown = anonymous.to_string();
2836        assert!(shown.contains("another actor"), "{shown}");
2837        assert!(!shown.contains("None"), "{shown}");
2838
2839        for reason in [SignatureReason::Stale, SignatureReason::Missing] {
2840            let err = CoreError::ResourceSignatureRejected {
2841                name: "DB_A".into(),
2842                reason,
2843                endpoint: Some("http://gw:8088/data/api/v1/resources/x".into()),
2844            };
2845            assert_eq!(err.code(), "resource_signature_rejected");
2846            assert_eq!(err.exit_code(), 6);
2847            let shown = err.to_string();
2848            assert!(shown.contains("DB_A"), "{shown}");
2849            assert!(shown.contains(&reason.to_string()), "{shown}");
2850            assert!(!shown.contains(SAMPLE_SIG), "{shown}");
2851            let hint = err.hint().expect("hint required");
2852            assert!(!hint.contains(SAMPLE_SIG), "{hint}");
2853            let endpoint = err.endpoint().expect("endpoint");
2854            assert!(!endpoint.contains(SAMPLE_SIG), "{endpoint}");
2855        }
2856        assert_eq!(SignatureReason::Stale.to_string(), "stale");
2857        assert_eq!(SignatureReason::Missing.to_string(), "missing");
2858    }
2859
2860    /// Parse the README's exit-code table: rows `| <exit> | class | meaning |
2861    /// \`slug\`, ... |` where `<exit>` is 1–7 (row 0 is the success row, no
2862    /// slugs). Parsing is SCOPED to the `## Exit codes` section — the README
2863    /// contains unrelated tables whose rows coincidentally begin `| 5 |`,
2864    /// `| 3 |`, etc. Std-only string ops: split on `|`, trim, take the LAST
2865    /// non-empty cell as the slug column (the meaning column can carry
2866    /// backticked non-slugs like `--yes`), and keep the backtick-delimited
2867    /// tokens that start with an ASCII letter and contain no spaces.
2868    fn parse_readme_exit_table(readme: &str) -> Vec<(u8, Vec<String>)> {
2869        let section = readme.split("## Exit codes").nth(1).unwrap_or_default();
2870        let mut rows = Vec::new();
2871        let table_lines = section
2872            .lines()
2873            .skip_while(|line| !line.trim_start().starts_with('|'));
2874        for line in table_lines {
2875            if !line.trim_start().starts_with('|') {
2876                break; // table ended
2877            }
2878            let cells: Vec<&str> = line.split('|').map(str::trim).collect();
2879            let Some(exit) = cells.get(1).and_then(|cell| cell.parse::<u8>().ok()) else {
2880                continue;
2881            };
2882            if !(1..=7).contains(&exit) {
2883                continue;
2884            }
2885            let slug_cell = cells
2886                .iter()
2887                .rev()
2888                .find(|cell| !cell.is_empty())
2889                .copied()
2890                .unwrap_or_default();
2891            let slugs: Vec<String> = slug_cell
2892                .split('`')
2893                .enumerate()
2894                .filter(|(idx, _)| idx % 2 == 1)
2895                .map(|(_, token)| token.trim().to_string())
2896                .filter(|token| {
2897                    token.starts_with(|c: char| c.is_ascii_alphabetic()) && !token.contains(' ')
2898                })
2899                .collect();
2900            rows.push((exit, slugs));
2901        }
2902        rows
2903    }
2904
2905    /// CORE-11, the Three-Place slug rule made executable: the exit-code
2906    /// table exists in the enum ([`CoreError::exit_code`]), this file's
2907    /// literal triples, and the README — and the README side is now
2908    /// machine-checked. The README is parsed verbatim via `include_str!`
2909    /// and cross-checked against the literal table in BOTH directions:
2910    /// (a) every literal slug appears under its exit code (a README row
2911    /// that lost or misspelled a slug fails), (b) every README slug token
2912    /// exists in the literal table (a stale/deleted row fails). Exit 6
2913    /// carries 23 slugs, so the full cross-check is the value — not the
2914    /// happy-path smoke.
2915    #[test]
2916    fn readme_exit_table_agreement() {
2917        let readme = include_str!("../../../README.md");
2918        let rows = parse_readme_exit_table(readme);
2919        assert!(
2920            rows.len() >= 7,
2921            "README exit-code table not found — the parser must see all 7 \
2922             failure-class rows (found {})",
2923            rows.len()
2924        );
2925
2926        // Direction (a): every literal (exit, slug) is present in the
2927        // README row matching its exit code.
2928        for (exit, slug) in EXIT_SLUG_LITERALS {
2929            let row = rows
2930                .iter()
2931                .find(|(readme_exit, _)| readme_exit == exit)
2932                .unwrap_or_else(|| panic!("README table has no row for exit {exit}"));
2933            assert!(
2934                row.1.iter().any(|s| s == slug),
2935                "README exit-{exit} row is missing slug {slug:?} (row: {:?})",
2936                row.1
2937            );
2938        }
2939
2940        // Direction (b): every README slug token exists in the literal
2941        // table under the same exit code — catches stale/deleted rows.
2942        for (exit, slugs) in &rows {
2943            for slug in slugs {
2944                assert!(
2945                    EXIT_SLUG_LITERALS
2946                        .iter()
2947                        .any(|(lit_exit, lit_slug)| lit_exit == exit && lit_slug == slug),
2948                    "README exit-{exit} row carries slug {slug:?} that no \
2949                     CoreError variant emits — stale table row or slug/exit \
2950                     drift between README and error.rs"
2951                );
2952            }
2953        }
2954    }
2955
2956    /// CORE-05: config, auth, and target-state classes carry actionable
2957    /// hints (and every other class does too).
2958    #[test]
2959    fn hints_are_actionable_for_config_auth_target_state() {
2960        let profile_not_found = CoreError::ProfileNotFound {
2961            name: "x".into(),
2962            known: vec!["dev".into(), "prod".into()],
2963        };
2964        let hint = profile_not_found.hint().expect("hint required");
2965        assert!(
2966            hint.contains("dev"),
2967            "hint must list known profiles: {hint}"
2968        );
2969        assert!(
2970            hint.contains("ign profile add"),
2971            "hint must name the fix: {hint}"
2972        );
2973
2974        let auth = CoreError::Auth {
2975            status: 403,
2976            endpoint: None,
2977        };
2978        let hint = auth.hint().expect("hint required");
2979        assert!(
2980            hint.contains("three parts"),
2981            "auth hint must name the three-part token setup: {hint}"
2982        );
2983
2984        // Status-aware auth hints (02-RESEARCH Auth §4): 401 = not
2985        // recognized (name:key format), 403 = under-permitted.
2986        let unauthorized = CoreError::Auth {
2987            status: 401,
2988            endpoint: None,
2989        };
2990        let hint = unauthorized.hint().expect("hint required");
2991        assert!(
2992            hint.contains("name:key"),
2993            "401 hint must name the full name:key token format: {hint}"
2994        );
2995        assert!(
2996            hint.contains("API token"),
2997            "401 hint must say Basic cannot work: {hint}"
2998        );
2999        let hint403 = auth.hint().expect("hint required");
3000        assert!(
3001            hint403.contains("secure connections"),
3002            "403 hint must name the secure-channel part: {hint403}"
3003        );
3004
3005        let too_old = CoreError::GatewayTooOld {
3006            found: "8.1.0".into(),
3007            minimum: "8.3.1".into(),
3008            endpoint: None,
3009        };
3010        let hint = too_old.hint().expect("hint required");
3011        assert!(
3012            hint.contains("8.3.1"),
3013            "target-state hint must name the minimum: {hint}"
3014        );
3015
3016        // The WebDev refusal matrix (05-03): every hint names the fix
3017        // — `ign webdev deploy` for absent/older routes, `update ign`
3018        // for newer ones (the roadmap's actionable-error criterion).
3019        let undeployed = CoreError::RoutesNotDeployed {
3020            project: "ign-cli".into(),
3021            route: "tags".into(),
3022            endpoint: None,
3023        };
3024        let hint = undeployed.hint().expect("hint required");
3025        assert!(
3026            hint.contains("ign webdev deploy"),
3027            "absent-routes hint must name the fix: {hint}"
3028        );
3029
3030        let older = CoreError::RouteVersionMismatch {
3031            route: "tags".into(),
3032            deployed: "0.9.0".into(),
3033            expected: "1.0.0".into(),
3034            endpoint: None,
3035        };
3036        let hint = older.hint().expect("hint required");
3037        assert!(
3038            hint.contains("ign webdev deploy") && !hint.contains("update ign"),
3039            "older-route hint says redeploy: {hint}"
3040        );
3041
3042        let newer = CoreError::RouteVersionMismatch {
3043            route: "tags".into(),
3044            deployed: "1.1.0".into(),
3045            expected: "1.0.0".into(),
3046            endpoint: None,
3047        };
3048        let hint = newer.hint().expect("hint required");
3049        assert!(
3050            hint.contains("update ign") && !hint.contains("ign webdev deploy"),
3051            "newer-route hint says update ign: {hint}"
3052        );
3053
3054        let secret_gate = CoreError::WebdevRouteError {
3055            code: "secret_required".into(),
3056            message: "missing x-ignition-cli-secret header".into(),
3057            endpoint: None,
3058        };
3059        let hint = secret_gate.hint().expect("hint required");
3060        assert!(
3061            hint.contains("--with-script-exec"),
3062            "secret-gate hint names the deploy flag: {hint}"
3063        );
3064
3065        // The alarm-journal refusal (05-06): the hint names the missing
3066        // provisioning chain AND the README section.
3067        let journal = CoreError::AlarmJournalMissing { endpoint: None };
3068        let hint = journal.hint().expect("hint required");
3069        assert!(
3070            hint.contains("journal profile") && hint.contains("database connection"),
3071            "journal hint names the chain: {hint}"
3072        );
3073        assert!(
3074            hint.contains("README"),
3075            "journal hint points at the README section: {hint}"
3076        );
3077
3078        // The live-designer prune refusal (06-07): the hint names the
3079        // action — close the Designer — and the stale-only semantics.
3080        let prunable = CoreError::SessionNotPrunable {
3081            id: "d-live-1".into(),
3082            endpoint: None,
3083        };
3084        let hint = prunable.hint().expect("hint required");
3085        assert!(
3086            hint.contains("close the Designer"),
3087            "prune hint must name the action: {hint}"
3088        );
3089        assert!(
3090            hint.contains("stale entries"),
3091            "prune hint must name stale-only semantics: {hint}"
3092        );
3093
3094        // The scan-lock refusal (20-02): the hint names the release
3095        // conditions and no ign command.
3096        let held = CoreError::ScanLockHeld {
3097            actor: None,
3098            kind: "config".into(),
3099            endpoint: None,
3100        };
3101        let hint = held.hint().expect("hint required");
3102        assert!(
3103            hint.contains("holdTimeout") && hint.contains("scan runs"),
3104            "scan-lock hint names the release conditions: {hint}"
3105        );
3106        assert!(
3107            !hint.contains("ign "),
3108            "scan-lock hint names no ign command: {hint}"
3109        );
3110
3111        // The signature refusal (20-02): re-read and retry.
3112        let rejected = CoreError::ResourceSignatureRejected {
3113            name: "DB_A".into(),
3114            reason: SignatureReason::Stale,
3115            endpoint: None,
3116        };
3117        let hint = rejected.hint().expect("hint required");
3118        assert!(
3119            hint.contains("re-read") && hint.contains("signatures change on every write"),
3120            "signature hint names the action: {hint}"
3121        );
3122
3123        // The duplicate-create refusals (20-REVIEW WR-01): each names the
3124        // next step and carries no gateway problem text.
3125        let exists = CoreError::ResourceExists {
3126            name: "DB_A".into(),
3127            endpoint: None,
3128        };
3129        let hint = exists.hint().expect("hint required");
3130        assert!(
3131            hint.contains("update it"),
3132            "resource_exists hint names the action: {hint}"
3133        );
3134        assert_eq!(exists.to_string(), "resource \"DB_A\" already exists");
3135        let mode_exists = CoreError::ModeExists {
3136            name: "production".into(),
3137            endpoint: None,
3138        };
3139        let hint = mode_exists.hint().expect("hint required");
3140        assert!(
3141            hint.contains("skip the create"),
3142            "mode_exists hint names the action: {hint}"
3143        );
3144
3145        // The TTY refusal's hint is contextual (06-07): the ONE
3146        // InvalidInput reason whose fix is terminal-related. Every
3147        // other reason keeps the --file/stdin resource-put hint (pinned
3148        // here so the special case can never leak or regress).
3149        let tty = CoreError::tui_tty_refusal();
3150        assert_eq!(tty.code(), "invalid_input", "slug unchanged");
3151        assert_eq!(tty.exit_code(), 2, "usage class unchanged");
3152        let hint = tty.hint().expect("hint required");
3153        assert!(
3154            hint.contains("interactive terminal"),
3155            "TTY hint must name the fix: {hint}"
3156        );
3157        assert!(
3158            !hint.contains("--file"),
3159            "TTY hint must not carry the resource-put hint: {hint}"
3160        );
3161
3162        // The itop sibling (ign top): the SAME usage class and slug,
3163        // but the refusal/hint name the MONITOR's command — a hint
3164        // naming `ign tui` would not fix an `ign top` run (the
3165        // RoutesNotDeployed rule at the TTY pair).
3166        let top = CoreError::top_tty_refusal();
3167        assert_eq!(top.code(), "invalid_input", "slug unchanged");
3168        assert_eq!(top.exit_code(), 2, "usage class unchanged");
3169        let top_hint = top.hint().expect("hint required");
3170        assert!(
3171            top_hint.contains("`ign top`"),
3172            "the top refusal's hint names ign top: {top_hint}"
3173        );
3174        assert!(
3175            !top_hint.contains("ign tui"),
3176            "the top refusal never names the cockpit command: {top_hint}"
3177        );
3178        assert!(
3179            top_hint.contains("interactive terminal"),
3180            "the top refusal keeps the terminal-contextual fix: {top_hint}"
3181        );
3182        let put = CoreError::InvalidInput {
3183            reason: "cannot read put.json".into(),
3184        };
3185        let hint = put.hint().expect("hint required");
3186        assert!(
3187            hint.contains("--file") && hint.contains("stdin"),
3188            "resource-put hint unchanged: {hint}"
3189        );
3190
3191        // The EAM controller state gate (07-02): the hint names the
3192        // manual flip recipe + the README section (role decisions
3193        // stay one config-PUT away from the CLI, never one flag).
3194        let controller = CoreError::EamNotController { endpoint: None };
3195        let hint = controller.hint().expect("hint required");
3196        assert!(
3197            hint.contains("installMode"),
3198            "controller hint names the flip: {hint}"
3199        );
3200        assert!(
3201            hint.contains("Controller"),
3202            "controller hint names the target role: {hint}"
3203        );
3204        assert!(
3205            hint.contains("README"),
3206            "controller hint points at the README section: {hint}"
3207        );
3208
3209        // The refused task-type ladder top (07-02 Task 3): the hint
3210        // names the EAM console + the v2 scope pointer.
3211        let refused = CoreError::EamTaskTypeRefused {
3212            task_type: "eam_restoreBackup".into(),
3213        };
3214        assert_eq!(refused.exit_code(), 6, "target state");
3215        let hint = refused.hint().expect("hint required");
3216        assert!(
3217            hint.contains("EAM console"),
3218            "refused hint names where to run it: {hint}"
3219        );
3220        assert!(
3221            hint.contains("fleet-wide") || hint.contains("EXT-03"),
3222            "refused hint names the fleet consequence / scope: {hint}"
3223        );
3224
3225        // The force-route 409 (07-06 gap 4): the hint names the
3226        // resolution — complete or delete the leftover '(forced)'
3227        // run via the EAM console (no ign verb deletes runs).
3228        let in_flight = CoreError::EamTaskInFlight {
3229            task: "cli-research-backup".into(),
3230            detail: "the previous '(forced)' run must be completed or deleted first".into(),
3231            endpoint: None,
3232        };
3233        assert_eq!(in_flight.exit_code(), 6, "target state");
3234        let hint = in_flight.hint().expect("hint required");
3235        assert!(
3236            hint.contains("EAM console"),
3237            "in-flight hint names where to resolve it: {hint}"
3238        );
3239        assert!(
3240            hint.contains("complete or delete"),
3241            "in-flight hint names the resolution: {hint}"
3242        );
3243
3244        // The scriptExec structural gate (07-03): the hint names the
3245        // deploy flag VERBATIM — the flag IS the opt-in (no --yes
3246        // exists on script run).
3247        let unconfigured = CoreError::ScriptExecNotConfigured {
3248            profile: "dev".into(),
3249        };
3250        assert_eq!(unconfigured.exit_code(), 6, "target state");
3251        let hint = unconfigured.hint().expect("hint required");
3252        assert!(
3253            hint.contains("ign webdev deploy --with-script-exec"),
3254            "the hint names the deploy flag verbatim: {hint}"
3255        );
3256
3257        // Totality: no class silently loses its hint later.
3258        let no_active = CoreError::NoActiveProfile;
3259        assert!(no_active.hint().is_some());
3260    }
3261
3262    /// The failure envelope's serialized field order and endpoint population
3263    /// are contract: `ok`, `profile`, `error` / `code`, `message`,
3264    /// `endpoint`, `hint` (string-level comparison because `serde_json::Value`
3265    /// maps are key-sorted and would hide ordering).
3266    #[test]
3267    fn error_envelope_locked_shape_and_endpoint() {
3268        let auth = CoreError::Auth {
3269            status: 401,
3270            endpoint: Some("https://gw.example.com/data/api/v1/gateway-info".into()),
3271        };
3272        let envelope: ErrorEnvelope<'_> = auth.envelope(Some("dev"));
3273        let json = serde_json::to_string(&envelope).expect("serialize envelope");
3274
3275        assert_eq!(
3276            json,
3277            concat!(
3278                r#"{"ok":false,"profile":"dev","error":{"code":"auth_rejected","#,
3279                r#""message":"gateway rejected credentials (HTTP 401)","#,
3280                r#""endpoint":"https://gw.example.com/data/api/v1/gateway-info","#,
3281                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"}}"#
3282            )
3283        );
3284
3285        // Classes without a request involved carry no endpoint.
3286        let no_request = CoreError::NoActiveProfile;
3287        let body: &ErrorBody = &no_request.envelope(None).error;
3288        assert_eq!(body.endpoint, None);
3289    }
3290
3291    /// The api-call body cap (09-01): a body at or under
3292    /// [`GATEWAY_CLIENT_BODY_CAP_BYTES`] rides verbatim (no marker); a
3293    /// larger body is truncated to the cap on a UTF-8 char boundary with
3294    /// the exact ASCII marker appended — and the cap is enforced at
3295    /// CONSTRUCTION, so the variant always carries its final form.
3296    #[test]
3297    fn truncates_at_cap_with_marker() {
3298        // Under the cap: byte-identical passthrough.
3299        let short = r#"{"error":{"code":"NOT_FOUND"}}"#;
3300        assert_eq!(truncate_api_body(short), short);
3301        assert!(!short.contains(GATEWAY_CLIENT_BODY_TRUNCATION_MARKER));
3302
3303        // Exactly at the cap: still verbatim (the marker only joins an
3304        // OVER-cap body — the boundary is <=, not <).
3305        let exact = "x".repeat(GATEWAY_CLIENT_BODY_CAP_BYTES);
3306        assert_eq!(truncate_api_body(&exact), exact);
3307
3308        // One byte over: truncated, marker appended, total size bounded.
3309        let over = "x".repeat(GATEWAY_CLIENT_BODY_CAP_BYTES + 1);
3310        let truncated = truncate_api_body(&over);
3311        assert!(
3312            truncated.ends_with(GATEWAY_CLIENT_BODY_TRUNCATION_MARKER),
3313            "truncated body must end with the explicit marker"
3314        );
3315        assert!(
3316            truncated.len()
3317                <= GATEWAY_CLIENT_BODY_CAP_BYTES + GATEWAY_CLIENT_BODY_TRUNCATION_MARKER.len(),
3318            "truncated body must stay within cap + marker: {}",
3319            truncated.len()
3320        );
3321
3322        // A multi-byte character straddling the cap boundary does not
3323        // panic (the cut backs up to a char boundary) and still carries
3324        // the marker.
3325        let multibyte = "é".repeat(GATEWAY_CLIENT_BODY_CAP_BYTES); // 2 bytes each
3326        let truncated = truncate_api_body(&multibyte);
3327        assert!(truncated.ends_with(GATEWAY_CLIENT_BODY_TRUNCATION_MARKER));
3328        assert!(
3329            truncated
3330                .is_char_boundary(truncated.len() - GATEWAY_CLIENT_BODY_TRUNCATION_MARKER.len())
3331        );
3332    }
3333
3334    /// The loss-gate hint override (11-07 gap closure): a reason with the
3335    /// [`LOSS_GATE_REFUSAL_REASON_PREFIX`] sentinel carries the --yes
3336    /// hint, while every OTHER InvalidInput reason (generic file-read)
3337    /// and the TTY-refusal precedent keep their hints byte-identically.
3338    /// The loss-gate reason is built from the CONST via format! — a
3339    /// string literal of the sentinel text here would be a third
3340    /// production-literal hit and break the prefix-uniqueness gate the
3341    /// plan's verify pins.
3342    #[test]
3343    fn loss_gate_hint_override_and_neighbors_unchanged() {
3344        // Sentinel-prefixed reason (dynamic prose after the header):
3345        // the --yes hint, never the file-read default.
3346        let loss_gate = CoreError::InvalidInput {
3347            reason: format!(
3348                "{}xml): the scan reports 2 finding(s) before the import:\nre-run \
3349                 with --yes to import anyway",
3350                LOSS_GATE_REFUSAL_REASON_PREFIX
3351            ),
3352        };
3353        assert_eq!(loss_gate.code(), "invalid_input", "slug unchanged");
3354        assert_eq!(loss_gate.exit_code(), 2, "usage class unchanged");
3355        let hint = loss_gate.hint().expect("hint required");
3356        assert!(
3357            hint.contains("--yes"),
3358            "loss-gate hint must name the --yes re-run: {hint}"
3359        );
3360        assert!(
3361            hint.contains("loss report above"),
3362            "loss-gate hint must point back at the report: {hint}"
3363        );
3364        assert!(
3365            !hint.contains("fix the input source"),
3366            "the file-read default must never leak onto the loss-gate path: {hint}"
3367        );
3368
3369        // Generic InvalidInput: the file-read default UNCHANGED (the
3370        // regression pin for every other raise site).
3371        let generic = CoreError::InvalidInput {
3372            reason: "x is not valid JSON: expected value at line 1".into(),
3373        };
3374        let hint = generic.hint().expect("hint required");
3375        assert!(
3376            hint.contains("--file") && hint.contains("stdin"),
3377            "resource-put hint unchanged: {hint}"
3378        );
3379        assert!(
3380            !hint.contains("--yes"),
3381            "the --yes hint must not leak onto generic reasons: {hint}"
3382        );
3383
3384        // The TTY-refusal precedent intact (06-07).
3385        let tty = CoreError::tui_tty_refusal();
3386        let hint = tty.hint().expect("hint required");
3387        assert!(
3388            hint.contains("interactive terminal"),
3389            "TTY hint unchanged: {hint}"
3390        );
3391        assert!(
3392            !hint.contains("--yes"),
3393            "the --yes hint must not leak onto the TTY path: {hint}"
3394        );
3395    }
3396
3397    /// The testing-scoped `routes_not_deployed` hint (QUICK-p0g): a
3398    /// `testing/`-prefixed route names the TESTING install commands,
3399    /// while every other route keeps the generic `ign webdev deploy`
3400    /// text BYTE-identically. Both branches are pinned so a future
3401    /// edit cannot silently merge them — sending a caller to
3402    /// `ign webdev deploy` for an absent testing bundle is a hint
3403    /// that does not fix the error.
3404    #[test]
3405    fn routes_not_deployed_hint_is_testing_scoped() {
3406        let testing = CoreError::RoutesNotDeployed {
3407            project: "Flux".into(),
3408            route: format!("{TESTING_ROUTE_PREFIX}run"),
3409            endpoint: None,
3410        };
3411        assert_eq!(testing.code(), "routes_not_deployed", "slug unchanged");
3412        assert_eq!(testing.exit_code(), 6, "target-state class unchanged");
3413        let hint = testing.hint().expect("hint required");
3414        assert!(
3415            hint.contains("ign webdev deploy --with-testing"),
3416            "testing hint must name the deploy flag: {hint}"
3417        );
3418        assert!(
3419            hint.contains("ign adopt") && hint.contains("--testing"),
3420            "testing hint must name the adopt path: {hint}"
3421        );
3422
3423        // Every non-testing route: the generic hint, unchanged.
3424        let generic = CoreError::RoutesNotDeployed {
3425            project: "ign-cli".into(),
3426            route: "tags".into(),
3427            endpoint: None,
3428        };
3429        let hint = generic.hint().expect("hint required");
3430        assert_eq!(
3431            hint,
3432            "run `ign webdev deploy` to install the CLI's WebDev routes into \
3433             the gateway, then retry",
3434            "the generic branch is byte-identical to its pre-QUICK-p0g text"
3435        );
3436    }
3437
3438    fn finding(file: &str, line: Option<usize>, column: Option<usize>, msg: &str) -> DeployFinding {
3439        DeployFinding {
3440            file: file.into(),
3441            line,
3442            column,
3443            message: msg.into(),
3444        }
3445    }
3446
3447    #[test]
3448    fn deploy_finding_display_names_file_line_column() {
3449        let positioned = finding("targets/prod.yaml", Some(3), Some(1), "unknown field `x`");
3450        assert_eq!(
3451            positioned.to_string(),
3452            "targets/prod.yaml:3:1: unknown field `x`"
3453        );
3454        let bare = finding("targets/prod.yaml", None, None, "missing field `gateway`");
3455        assert_eq!(
3456            bare.to_string(),
3457            "targets/prod.yaml: missing field `gateway`"
3458        );
3459        // A half-positioned finding never claims a position.
3460        let half = finding("m.yaml", Some(3), None, "m");
3461        assert_eq!(half.to_string(), "m.yaml: m");
3462    }
3463
3464    #[test]
3465    fn deploy_findings_cap_at_twenty() {
3466        let many: Vec<DeployFinding> = (0..25)
3467            .map(|i| finding("manifest.yaml", None, None, &format!("problem {i}")))
3468            .collect();
3469        let shown = CoreError::deploy_findings(many).to_string();
3470        assert!(shown.contains("25 findings: "), "{shown}");
3471        assert!(shown.contains("problem 19"), "{shown}");
3472        assert!(!shown.contains("problem 20"), "{shown}");
3473        assert!(shown.ends_with("; and 5 more"), "{shown}");
3474
3475        let single = CoreError::deploy_tree_invalid("m.yaml", Some(2), Some(1), "bad");
3476        assert_eq!(single.to_string(), "deploy tree invalid: m.yaml:2:1: bad");
3477    }
3478
3479    #[test]
3480    fn embedded_secret_display_lists_paths_only() {
3481        let err = CoreError::embedded_secret(
3482            vec![
3483                "ignition/database-connection/A/config.json: settings.password".into(),
3484                "ignition/database-connection/B/config.json: settings.password".into(),
3485            ],
3486            vec![finding("manifest.yaml", None, None, "duplicate type")],
3487        );
3488        let shown = err.to_string();
3489        assert!(shown.contains("2 path(s)"), "{shown}");
3490        assert!(
3491            shown.contains("A/config.json: settings.password"),
3492            "{shown}"
3493        );
3494        assert!(
3495            shown.contains("B/config.json: settings.password"),
3496            "{shown}"
3497        );
3498        assert!(shown.contains("also 1 other tree finding(s)"), "{shown}");
3499        assert!(shown.contains("manifest.yaml: duplicate type"), "{shown}");
3500
3501        // The combined list stays within the cap.
3502        let paths: Vec<String> = (0..25).map(|i| format!("f{i}.json: a.b")).collect();
3503        let shown =
3504            CoreError::embedded_secret(paths, vec![finding("m.yaml", None, None, "x")]).to_string();
3505        assert!(shown.contains("25 path(s)"), "{shown}");
3506        assert!(shown.contains("and 5 more"), "{shown}");
3507        assert!(shown.contains("f19.json: a.b"), "{shown}");
3508        assert!(!shown.contains("f20.json: a.b"), "{shown}");
3509    }
3510
3511    #[test]
3512    fn deploy_slugs_have_their_exit_codes() {
3513        let errs = [
3514            (
3515                CoreError::deploy_tree_invalid("m.yaml", None, None, "bad"),
3516                2,
3517            ),
3518            (
3519                CoreError::embedded_secret(vec!["a.json: p".into()], vec![]),
3520                2,
3521            ),
3522            (
3523                CoreError::TransportFailed {
3524                    operation: "read".into(),
3525                    node: "nodes.master".into(),
3526                    detail: "data_dir is missing".into(),
3527                },
3528                4,
3529            ),
3530            (
3531                CoreError::DeployLedgerInvalid {
3532                    location: "ign-deploy/gw/dev/ledger.json".into(),
3533                    problem: "the file is empty".into(),
3534                },
3535                6,
3536            ),
3537            (
3538                CoreError::PlanStale {
3539                    expected: "a".repeat(64),
3540                    actual: "b".repeat(64),
3541                    detail: "review hash".into(),
3542                },
3543                6,
3544            ),
3545            (
3546                CoreError::DestructiveChangeRefused {
3547                    gate: "destructive".into(),
3548                    keys: vec!["ignition/a/x".into()],
3549                    count: 1,
3550                },
3551                2,
3552            ),
3553            (
3554                CoreError::DeployBlocked {
3555                    keys: vec!["ignition/a/x".into()],
3556                },
3557                6,
3558            ),
3559            (
3560                CoreError::DeployPreflightFailed {
3561                    failures: vec![PreflightFailure {
3562                        check: "mode_master".into(),
3563                        detail: "the gateway is not in the declared mode".into(),
3564                    }]
3565                    .into(),
3566                },
3567                6,
3568            ),
3569            (
3570                CoreError::DeployJournalPresent {
3571                    run_id: "20261015T142233Z-a1b2c3".into(),
3572                    env: "prod".into(),
3573                    location: "ign-deploy/gw/m/journal.json".into(),
3574                },
3575                6,
3576            ),
3577            (
3578                CoreError::DeployLiveChanged {
3579                    run_id: "r1".into(),
3580                    paths: vec!["a/b.json".into()],
3581                    count: 1,
3582                },
3583                6,
3584            ),
3585            (
3586                CoreError::DeployRunInvalid {
3587                    run_id: "r1".into(),
3588                    problem: "record.json is missing".into(),
3589                },
3590                6,
3591            ),
3592            (
3593                CoreError::RollbackFailed(Box::new(RollbackFailure {
3594                    run_id: "r1".into(),
3595                    env: "dev".into(),
3596                    run_dir: Some(".ign/deploy/r1".into()),
3597                    journal: "ign-deploy/gw/m/journal.json".into(),
3598                    cause: "write_failed".into(),
3599                })),
3600                6,
3601            ),
3602            (
3603                CoreError::ProjectGitManaged {
3604                    project: "CapApp".into(),
3605                    location: "projects/CapApp/.git".into(),
3606                    kind: "directory".into(),
3607                },
3608                6,
3609            ),
3610            (
3611                CoreError::ProjectParentInvalid {
3612                    project: "CapApp".into(),
3613                    parent: "ParentProject".into(),
3614                    reason: "cycle".into(),
3615                },
3616                6,
3617            ),
3618            (
3619                CoreError::ProjectHashMismatch {
3620                    project: "CapApp".into(),
3621                    expected: "0".repeat(64),
3622                    actual: "1".repeat(64),
3623                },
3624                6,
3625            ),
3626            (
3627                CoreError::DeployUnreadable {
3628                    node: "master".into(),
3629                    keys: vec!["ignition/tag-definition/default/Deep".into()],
3630                },
3631                6,
3632            ),
3633            (
3634                CoreError::DeployPairTagsRefused {
3635                    mode: "production".into(),
3636                    keys: vec!["ignition/tag-definition/default/Deploy".into()],
3637                },
3638                6,
3639            ),
3640        ];
3641        let codes: Vec<&str> = errs.iter().map(|(e, _)| e.code()).collect();
3642        assert_eq!(codes, CoreError::DEPLOY_SLUGS);
3643        for (err, exit) in &errs {
3644            assert_eq!(err.exit_code(), *exit, "{err}");
3645            assert_eq!(err.endpoint(), None, "deploy errors name no URL: {err}");
3646            let hint = err.hint().expect("hint required");
3647            assert!(
3648                !hint.contains("`ign ") && !hint.contains("ign "),
3649                "hint must not name an ign subcommand: {hint}"
3650            );
3651        }
3652    }
3653
3654    #[test]
3655    fn apply_gate_flag_refusals_do_not_get_the_file_hint() {
3656        for reason in [
3657            "--plan-sha256 must be 64 lowercase hex characters",
3658            "--allow-drift reason must not be empty",
3659            "--artifact-sha256 must be 64 lowercase hex characters",
3660            "--release <tag>: not a semver tag (for example v1.4.0)",
3661            "--allow-first-change <reason>: the reason is 1 to 500 characters",
3662            "deploy projects needs --artifact-sha256 when --json is given",
3663        ] {
3664            let hint = CoreError::InvalidInput {
3665                reason: reason.into(),
3666            }
3667            .hint()
3668            .expect("hint");
3669            assert!(!hint.contains("--file"), "{hint}");
3670            assert!(hint.contains("flag value"), "{hint}");
3671        }
3672    }
3673
3674    /// The exact ordered deploy slug list. Verdicts (`deploy_rolled_back`,
3675    /// `deploy_backup_unsynced`, `deploy_project_unverified`) are payload
3676    /// slugs on a success envelope and never `CoreError` codes. Later plans
3677    /// extend this list in README row order (25-09, 25-10, 25-11).
3678    #[test]
3679    fn deploy_slugs_are_pinned_and_never_a_verdict() {
3680        assert_eq!(
3681            CoreError::DEPLOY_SLUGS,
3682            &[
3683                "deploy_tree_invalid",
3684                "embedded_secret",
3685                "transport_failed",
3686                "deploy_ledger_invalid",
3687                "plan_stale",
3688                "destructive_change_refused",
3689                "deploy_blocked",
3690                "deploy_preflight_failed",
3691                "deploy_journal_present",
3692                "deploy_live_changed",
3693                "deploy_run_invalid",
3694                "rollback_failed",
3695                "project_git_managed",
3696                "project_parent_invalid",
3697                "project_hash_mismatch",
3698                "deploy_unreadable",
3699                "deploy_pair_tags_refused",
3700            ]
3701        );
3702        assert_eq!(CoreError::DEPLOY_SLUGS.len(), 17);
3703        for verdict in [
3704            "deploy_rolled_back",
3705            "deploy_backup_unsynced",
3706            "deploy_project_unverified",
3707        ] {
3708            assert!(!CoreError::DEPLOY_SLUGS.contains(&verdict), "{verdict}");
3709        }
3710    }
3711
3712    /// 25-07: the four-place rule for `project_git_managed` (variant, code,
3713    /// exit class 6, hint) and the message names what the operator needs.
3714    #[test]
3715    fn project_git_managed_four_places() {
3716        let err = CoreError::ProjectGitManaged {
3717            project: "CapApp".into(),
3718            location: "projects/CapApp/.git".into(),
3719            kind: "directory".into(),
3720        };
3721        assert_eq!(err.code(), "project_git_managed");
3722        assert_eq!(err.exit_code(), 6);
3723        let message = err.to_string();
3724        for needle in [
3725            "CapApp",
3726            "projects/CapApp/.git",
3727            "directory",
3728            "administrator",
3729        ] {
3730            assert!(message.contains(needle), "{needle}: {message}");
3731        }
3732        let hint = err.hint().expect("hint required");
3733        assert!(!hint.contains("ign "), "{hint}");
3734        assert!(hint.contains(".git"), "{hint}");
3735        assert!(CoreError::DEPLOY_SLUGS.contains(&"project_git_managed"));
3736    }
3737
3738    /// 25-10: the four-place rule for `project_parent_invalid` (variant,
3739    /// code, exit class 6, hint per reason) and the message names the
3740    /// project, the parent and the reason.
3741    #[test]
3742    fn project_parent_invalid_four_places() {
3743        for (reason, needle) in [
3744            ("missing", "its own repo"),
3745            ("cycle", "no cycle"),
3746            ("not_inheritable", "inheritable"),
3747        ] {
3748            let err = CoreError::ProjectParentInvalid {
3749                project: "CapApp".into(),
3750                parent: "ParentProject".into(),
3751                reason: reason.into(),
3752            };
3753            assert_eq!(err.code(), "project_parent_invalid");
3754            assert_eq!(err.exit_code(), 6);
3755            let message = err.to_string();
3756            for part in ["CapApp", "ParentProject", reason] {
3757                assert!(message.contains(part), "{part}: {message}");
3758            }
3759            let hint = err.hint().expect("hint required");
3760            assert!(!hint.contains("ign "), "{hint}");
3761            assert!(hint.contains(needle), "{reason}: {hint}");
3762            assert_eq!(err.endpoint(), None);
3763        }
3764        assert!(CoreError::DEPLOY_SLUGS.contains(&"project_parent_invalid"));
3765    }
3766
3767    /// 25-11: the four-place rule for `project_hash_mismatch` (variant, code,
3768    /// exit class 6, hint). The message names the project and both hashes and
3769    /// says nothing was uploaded; nothing else rides it (SC-1, D-01).
3770    #[test]
3771    fn project_hash_mismatch_four_places() {
3772        let err = CoreError::ProjectHashMismatch {
3773            project: "CapApp".into(),
3774            expected: "0".repeat(64),
3775            actual: "1".repeat(64),
3776        };
3777        assert_eq!(err.code(), "project_hash_mismatch");
3778        assert_eq!(err.exit_code(), 6);
3779        let message = err.to_string();
3780        for part in [
3781            "CapApp",
3782            "0000000000000000000000000000000000000000000000000000000000000000",
3783            "1111111111111111111111111111111111111111111111111111111111111111",
3784            "nothing was uploaded",
3785        ] {
3786            assert!(message.contains(part), "{part}: {message}");
3787        }
3788        let hint = err.hint().expect("hint required");
3789        assert!(!hint.contains("ign "), "{hint}");
3790        assert!(hint.contains("artifact_sha256"), "{hint}");
3791        assert_eq!(err.endpoint(), None);
3792        assert!(CoreError::DEPLOY_SLUGS.contains(&"project_hash_mismatch"));
3793    }
3794
3795    /// quick-261029-ms3: the four-place rule for `deploy_unreadable`
3796    /// (variant, code, exit class 6, hint). The message names every unit,
3797    /// says the host's sftp server cannot read its live path and that nothing
3798    /// was written; the hint names the two fixes and no `ign` subcommand.
3799    #[test]
3800    fn deploy_unreadable_four_places() {
3801        let keys = vec![
3802            "ignition/database-translator/OLD".to_string(),
3803            "ignition/tag-definition/default/Deep".to_string(),
3804        ];
3805        let err = CoreError::DeployUnreadable {
3806            node: "backup".to_string(),
3807            keys: keys.clone(),
3808        };
3809        assert_eq!(err.code(), "deploy_unreadable");
3810        assert_eq!(err.exit_code(), 6);
3811        assert_eq!(err.endpoint(), None);
3812        let message = err.to_string();
3813        for part in keys.iter().map(String::as_str).chain([
3814            "backup host's sftp server cannot read",
3815            "nothing was written",
3816            "2 unit(s)",
3817        ]) {
3818            assert!(message.contains(part), "{part}: {message}");
3819        }
3820        let hint = err.hint().expect("hint required");
3821        assert!(!hint.contains("ign "), "{hint}");
3822        assert!(hint.contains("exclude_tag_providers"), "{hint}");
3823        assert!(hint.contains("shorten"), "{hint}");
3824        assert!(CoreError::DEPLOY_SLUGS.contains(&"deploy_unreadable"));
3825    }
3826
3827    /// debug deploy-tag-redundancy-sync: the four-place rule for
3828    /// `deploy_pair_tags_refused` (variant, code, exit class 6, hint). The
3829    /// message names the mode, every unit, the pair and that nothing was
3830    /// written; the hint names the fixes and no `ign` subcommand.
3831    #[test]
3832    fn deploy_pair_tags_refused_four_places() {
3833        let keys = vec![
3834            "ignition/tag-definition/default/Deploy".to_string(),
3835            "ignition/tag-type-definition/default/DeployTypes".to_string(),
3836        ];
3837        let err = CoreError::DeployPairTagsRefused {
3838            mode: "production".to_string(),
3839            keys: keys.clone(),
3840        };
3841        assert_eq!(err.code(), "deploy_pair_tags_refused");
3842        assert_eq!(err.exit_code(), 6);
3843        assert_eq!(err.endpoint(), None);
3844        let message = err.to_string();
3845        for part in keys.iter().map(String::as_str).chain([
3846            "2 tag unit(s)",
3847            "production collection of a redundant pair",
3848            "nothing was written to the production or core collection",
3849        ]) {
3850            assert!(message.contains(part), "{part}: {message}");
3851        }
3852        let hint = err.hint().expect("hint required");
3853        assert!(!hint.contains("ign "), "{hint}");
3854        assert!(!hint.contains("exclude_tag_providers"), "{hint}");
3855        assert!(hint.contains("ledger does not list"), "{hint}");
3856        assert!(hint.contains("core collection"), "{hint}");
3857        assert!(
3858            hint.contains("nothing was written to the mode or core collection"),
3859            "{hint}"
3860        );
3861        assert!(CoreError::DEPLOY_SLUGS.contains(&"deploy_pair_tags_refused"));
3862    }
3863
3864    /// 25-11 (K15): a stale live project is told apart from a stale plan
3865    /// hash, and neither hint names an `ign` subcommand.
3866    #[test]
3867    fn plan_stale_hint_names_the_live_project_case() {
3868        let live = CoreError::PlanStale {
3869            expected: "a".repeat(64),
3870            actual: "b".repeat(64),
3871            detail: "live project changed since the plan".into(),
3872        };
3873        let hint = live.hint().expect("hint");
3874        assert!(hint.contains("live project"), "{hint}");
3875        assert!(hint.contains("nothing was imported"), "{hint}");
3876        assert!(!hint.contains("ign "), "{hint}");
3877        let review = CoreError::PlanStale {
3878            expected: "a".repeat(64),
3879            actual: "b".repeat(64),
3880            detail: "review hash".into(),
3881        };
3882        assert!(
3883            review.hint().expect("hint").contains("plan_sha256"),
3884            "the plan-hash case keeps its hint"
3885        );
3886    }
3887
3888    #[test]
3889    fn journal_present_message_carries_the_recovery_command() {
3890        let err = CoreError::DeployJournalPresent {
3891            run_id: "20261015T142233Z-a1b2c3".into(),
3892            env: "prod".into(),
3893            location: "ign-deploy/gw/m/journal.json".into(),
3894        };
3895        let shown = err.to_string();
3896        assert!(
3897            shown.contains("ign deploy rollback --run 20261015T142233Z-a1b2c3 --env prod"),
3898            "{shown}"
3899        );
3900        assert!(shown.contains("ign-deploy/gw/m/journal.json"), "{shown}");
3901        let hint = err.hint().expect("hint");
3902        assert!(
3903            !hint.contains("ign deploy"),
3904            "D-09: the command lives in Display: {hint}"
3905        );
3906    }
3907
3908    #[test]
3909    fn rollback_failed_message_names_run_dir_journal_and_the_guidance() {
3910        let err = CoreError::RollbackFailed(Box::new(RollbackFailure {
3911            run_id: "r-7".into(),
3912            env: "prod".into(),
3913            run_dir: Some(".ign/deploy/r-7".into()),
3914            journal: "ign-deploy/gw/m/journal.json".into(),
3915            cause: "write_failed".into(),
3916        }));
3917        let shown = err.to_string();
3918        for needle in [
3919            "r-7",
3920            ".ign/deploy/r-7",
3921            "ign-deploy/gw/m/journal.json",
3922            ".gwbk",
3923            "snapshot",
3924            "by hand",
3925            // The retry comes first, with the real env, and the intact
3926            // pre-images are the on-box directory (review WR-02).
3927            "ign deploy rollback --run r-7 --env prod",
3928            "ign-deploy/gw/m/snapshots/r-7/",
3929        ] {
3930            assert!(shown.contains(needle), "{needle} missing from: {shown}");
3931        }
3932        assert!(
3933            shown.find("Retry with").expect("retry") < shown.find("by hand").expect("by hand"),
3934            "the retry precedes the hand deletion: {shown}"
3935        );
3936        assert!(
3937            !shown.contains("run-directory snapshot"),
3938            "the run directory has no snapshot to offer: {shown}"
3939        );
3940        let hint = err.hint().expect("hint");
3941        assert!(
3942            hint.contains("retry") && !hint.contains("run-directory snapshot"),
3943            "{hint}"
3944        );
3945        let none = CoreError::RollbackFailed(Box::new(RollbackFailure {
3946            run_id: "r-7".into(),
3947            env: "prod".into(),
3948            run_dir: None,
3949            journal: "j".into(),
3950            cause: "c".into(),
3951        }));
3952        assert!(!none.to_string().contains("None"), "{none}");
3953    }
3954
3955    #[test]
3956    fn preflight_failed_lists_every_failure_in_order() {
3957        let err = CoreError::DeployPreflightFailed {
3958            failures: vec![
3959                PreflightFailure {
3960                    check: "mode_master".into(),
3961                    detail: "first".into(),
3962                },
3963                PreflightFailure {
3964                    check: "gwbk".into(),
3965                    detail: "second".into(),
3966                },
3967            ]
3968            .into(),
3969        };
3970        let shown = err.to_string();
3971        let first = shown.find("mode_master: first").expect("first");
3972        let second = shown.find("gwbk: second").expect("second");
3973        assert!(first < second, "{shown}");
3974        assert_eq!(shown.lines().count(), 3, "{shown}");
3975    }
3976
3977    #[test]
3978    fn apply_refusal_lists_cap_at_ten() {
3979        let keys: Vec<String> = (0..10).map(|i| format!("u{i}")).collect();
3980        let shown = CoreError::DestructiveChangeRefused {
3981            gate: "destructive".into(),
3982            keys: keys.clone(),
3983            count: 13,
3984        }
3985        .to_string();
3986        assert!(shown.contains("13 entries"), "{shown}");
3987        assert!(shown.contains("and 3 more"), "{shown}");
3988        assert!(shown.contains("--allow-destructive"), "{shown}");
3989        let drift = CoreError::DestructiveChangeRefused {
3990            gate: "drift".into(),
3991            keys: keys[..1].to_vec(),
3992            count: 1,
3993        }
3994        .to_string();
3995        assert!(drift.contains("--allow-drift"), "{drift}");
3996        assert!(drift.contains("1 entry"), "{drift}");
3997        let downgrade = CoreError::DestructiveChangeRefused {
3998            gate: "downgrade".into(),
3999            keys: vec!["CapApp".into()],
4000            count: 1,
4001        };
4002        let shown = downgrade.to_string();
4003        assert!(shown.contains("downgrade"), "{shown}");
4004        assert!(shown.contains("CapApp"), "{shown}");
4005        assert!(
4006            shown.contains("deploy projects needs --allow-downgrade"),
4007            "{shown}"
4008        );
4009        assert!(!shown.contains("apply needs"), "{shown}");
4010        let first = CoreError::DestructiveChangeRefused {
4011            gate: "first_deploy".into(),
4012            keys: vec!["CapApp".into()],
4013            count: 1,
4014        };
4015        let shown = first.to_string();
4016        assert!(shown.contains("first_deploy"), "{shown}");
4017        assert!(
4018            shown.contains("deploy projects needs --allow-first-change <reason>"),
4019            "{shown}"
4020        );
4021        // each gate has its own hint, and none names an `ign` command
4022        let hints: Vec<String> = [&downgrade, &first]
4023            .iter()
4024            .map(|e| e.hint().expect("hint"))
4025            .collect();
4026        assert!(hints[0].contains("--allow-downgrade"), "{}", hints[0]);
4027        assert!(hints[1].contains("--allow-first-change"), "{}", hints[1]);
4028        for hint in &hints {
4029            assert!(!hint.contains("ign "), "{hint}");
4030            assert!(!hint.contains("--allow-destructive"), "{hint}");
4031        }
4032
4033        let many: Vec<String> = (0..25).map(|i| format!("a/{i:02}")).collect();
4034        let blocked = CoreError::DeployBlocked { keys: many.clone() }.to_string();
4035        assert!(blocked.contains("and 15 more"), "{blocked}");
4036        assert!(!blocked.contains("a/10"), "{blocked}");
4037        let live = CoreError::DeployLiveChanged {
4038            run_id: "r".into(),
4039            paths: many[..10].to_vec(),
4040            count: 12,
4041        }
4042        .to_string();
4043        assert!(live.contains("and 2 more"), "{live}");
4044    }
4045}