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