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