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