1use super::{ErrorCategory, ErrorCode, ReliabilityCategoryKind, ReliabilityReasonCode};
35use crate::incident::IncidentReasonCode;
36use serde::{Deserialize, Serialize};
37
38#[derive(Debug, Clone, Serialize, Deserialize)]
40pub struct CodeExplanation {
41 pub code: String,
42 pub namespace: CodeNamespace,
43 pub name: String,
44 pub category: String,
45 pub description: String,
46 pub remediation: Vec<String>,
47 #[serde(skip_serializing_if = "Option::is_none")]
50 pub requires_restart: Option<bool>,
51 #[serde(skip_serializing_if = "Option::is_none")]
54 pub doc_url: Option<String>,
55}
56
57#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
59#[serde(rename_all = "snake_case")]
60pub enum CodeNamespace {
61 Error,
63 Reliability,
65 Incident,
67}
68
69#[must_use]
73pub fn lookup(raw: &str) -> Option<CodeExplanation> {
74 let normalized = raw.trim().to_ascii_uppercase();
75 if let Some(c) = lookup_reliability(&normalized) {
76 return Some(c);
77 }
78 if let Some(c) = lookup_error(&normalized) {
79 return Some(c);
80 }
81 if let Some(c) = lookup_incident(&normalized) {
82 return Some(c);
83 }
84 None
85}
86
87#[must_use]
89pub fn is_known(raw: &str) -> bool {
90 lookup(raw).is_some()
91}
92
93fn lookup_reliability(code: &str) -> Option<CodeExplanation> {
94 let v = ReliabilityReasonCode::from_code_str(code)?;
95 Some(CodeExplanation {
96 code: v.code().to_string(),
97 namespace: CodeNamespace::Reliability,
98 name: v.name().to_string(),
99 category: reliability_category_str(v.category()).to_string(),
100 description: v.remediation_hint().to_string(),
101 remediation: vec![v.remediation_hint().to_string()],
102 requires_restart: Some(v.requires_restart()),
103 doc_url: None,
104 })
105}
106
107fn lookup_error(code: &str) -> Option<CodeExplanation> {
108 for c in error_code_all() {
109 if c.code_string() == code {
110 let entry = c.entry();
111 return Some(CodeExplanation {
112 code: entry.code,
113 namespace: CodeNamespace::Error,
114 name: format!("{c:?}"),
115 category: error_category_str(entry.category).to_string(),
116 description: entry.message,
117 remediation: entry.remediation,
118 requires_restart: None,
119 doc_url: entry.doc_url,
120 });
121 }
122 }
123 None
124}
125
126fn lookup_incident(code: &str) -> Option<CodeExplanation> {
132 let v = IncidentReasonCode::from_code_str(code)?;
133 Some(CodeExplanation {
134 code: v.code().to_string(),
135 namespace: CodeNamespace::Incident,
136 name: format!("{v:?}"),
137 category: incident_category(v).to_string(),
138 description: incident_description(v).to_string(),
139 remediation: incident_remediation(v),
140 requires_restart: None,
141 doc_url: None,
142 })
143}
144
145const fn incident_category(v: IncidentReasonCode) -> &'static str {
148 use IncidentReasonCode as I;
149 match v {
150 I::NoAdmissibleWorkers | I::HardPreflight => "admission",
151 I::CriticalPressure | I::DiskFull => "disk_pressure",
152 I::InsufficientSlots | I::QueueAmbiguity => "capacity",
153 I::ActiveProjectExclusion => "path_deps",
154 I::MissingRuntimeToolchainTarget => "capability",
155 I::OsArchMismatch | I::WrongUserPathWorkerBinary => "worker_binary",
156 I::TelemetryStale => "telemetry",
157 I::CircuitOpen => "worker",
158 I::DaemonSocketRefused => "daemon",
159 I::LocalFallback => "fallback",
160 I::ProofRefusal => "proof",
161 I::RsyncVanishedFile | I::ArtifactMiss => "transfer",
162 I::ToolchainDrift => "self_test",
163 }
164}
165
166const fn incident_description(v: IncidentReasonCode) -> &'static str {
168 use IncidentReasonCode as I;
169 match v {
170 I::NoAdmissibleWorkers => "No worker passed admission — every candidate was rejected.",
171 I::CriticalPressure => {
172 "A worker (or the pool) was under critical disk/memory pressure and could not admit work."
173 }
174 I::InsufficientSlots => "Not enough free build slots to admit the request right now.",
175 I::HardPreflight => "A hard preflight check rejected the only candidate worker(s).",
176 I::ActiveProjectExclusion => {
177 "The active project root was excluded from offload by a path-dependency or topology rule."
178 }
179 I::MissingRuntimeToolchainTarget => {
180 "A required runtime, toolchain, or Rust target was missing on the worker(s)."
181 }
182 I::OsArchMismatch => {
183 "The worker's OS/arch did not match the required artifact/target triple."
184 }
185 I::TelemetryStale => "Worker telemetry was stale or its age could not be determined.",
186 I::CircuitOpen => "The worker's circuit breaker was open (repeated failures isolated it).",
187 I::DaemonSocketRefused => {
188 "The daemon Unix socket refused or could not accept the connection."
189 }
190 I::LocalFallback => {
191 "The build fell back to local execution (fail-open) instead of offloading."
192 }
193 I::ProofRefusal => {
194 "Proof mode (RCH_REQUIRE_REMOTE) refused to proceed because remote execution could not be guaranteed."
195 }
196 I::RsyncVanishedFile => "rsync reported a source file that vanished mid-transfer.",
197 I::ArtifactMiss => "An expected build artifact was missing on retrieval.",
198 I::QueueAmbiguity => "A build's local/remote job identity could not be correlated cleanly.",
199 I::DiskFull => "The target disk was full.",
200 I::WrongUserPathWorkerBinary => {
201 "A wrong-user or wrong-path/arch rch-wkr binary was detected on the worker."
202 }
203 I::ToolchainDrift => {
204 "A self-test canary built successfully but its bytes differ from the orchestrator's reference because the worker's toolchain differs — a healthy worker, not a failure."
205 }
206 }
207}
208
209fn incident_remediation(v: IncidentReasonCode) -> Vec<String> {
213 use IncidentReasonCode as I;
214 let steps: &[&str] = match v {
215 I::NoAdmissibleWorkers => &[
216 "Run `rch status --fleet` to see whether the fleet is absent, overloaded, or missing a capability.",
217 "Run `rch admit \"<command>\"` for the per-candidate rejection reasons.",
218 ],
219 I::CriticalPressure => &[
220 "Inspect pressure with `rch doctor --reliability --scope pressure`.",
221 "Reclaim space with `rch cache clean --older <dur> --execute` (the daemon's reaper protects active builds).",
222 ],
223 I::InsufficientSlots => &[
224 "Queue instead of falling back: keep `RCH_QUEUE_WHEN_BUSY=1` (the default).",
225 "Watch `rch queue`; add workers only if the fleet is genuinely under-provisioned.",
226 ],
227 I::HardPreflight => &[
228 "Run `rch admit \"<command>\"` to see which preflight check failed.",
229 "Fix the named capability/topology issue, or target another worker with `RCH_WORKER`.",
230 ],
231 I::ActiveProjectExclusion => &[
232 "Inspect the resolved plan: `rch diagnose \"<command>\" --json` (data.placement).",
233 "Ensure required sibling repos exist under the canonical project root on the worker.",
234 ],
235 I::MissingRuntimeToolchainTarget => &[
236 "Refresh worker facts: `rch workers capabilities --refresh`.",
237 "Install the missing toolchain/target on the worker, or `rch fleet deploy --worker <id> --force`.",
238 ],
239 I::OsArchMismatch => &[
240 "Re-deploy the correct target triple: `rch fleet deploy --worker <id> --force --verify`.",
241 "Confirm the needed target with `rch workers capabilities --refresh`.",
242 ],
243 I::TelemetryStale => &[
244 "Inspect freshness via `rch status --remediation` (telemetry band).",
245 "Usually self-heals on the next probe; check host distance/poll interval if it persists.",
246 ],
247 I::CircuitOpen => &[
248 "The breaker self-heals (open, half-open, closed) after a good probe — fix the worker, then `rch workers probe <id>`.",
249 "Do not `rch workers disable` a transiently-open worker; it auto-rejoins.",
250 ],
251 I::DaemonSocketRefused => &[
252 "Restart the daemon (it reclaims a stale socket): `rch daemon restart`.",
253 "Never `rm` the socket by hand; `rch doctor --fix` repairs common wiring.",
254 ],
255 I::LocalFallback => &[
256 "Expected fail-open behavior when remote is not worth it or unavailable — no action usually needed.",
257 "Force offload with `RCH_FORCE_REMOTE=1`, or forbid local fallback with `RCH_REQUIRE_REMOTE=1` (proof mode).",
258 ],
259 I::ProofRefusal => &[
260 "Proof mode is fail-closed by design — fix the underlying remote issue rather than retrying locally.",
261 "The proof intent is recorded and replays when capacity returns; inspect `rch status --remediation --json`.",
262 "Keep the command as direct argv after `--`; shell-wrapped commands are refused (RCH-E301).",
263 ],
264 I::RsyncVanishedFile => &[
265 "Usually transient (a file changed mid-transfer) — retry the build.",
266 "If persistent, exclude churny build dirs via `[transfer] exclude_patterns`.",
267 ],
268 I::ArtifactMiss => &[
269 "Re-run the build; inspect the remote target dir and artifact patterns with `rch diagnose \"<command>\" --json`.",
270 "Ensure the worker has disk headroom: `rch doctor --reliability --scope pressure`.",
271 ],
272 I::QueueAmbiguity => &[
273 "Inspect active/queued builds and their ids with `rch queue`.",
274 "Clear a wedged build with `rch cancel <id>`; the daemon reattaches recoverable jobs on restart.",
275 ],
276 I::DiskFull => &[
277 "Reclaim worker disk: `rch cache clean --older <dur> --execute` (the reaper protects active builds).",
278 "Inspect with `rch doctor --reliability --scope pressure`.",
279 ],
280 I::WrongUserPathWorkerBinary => &[
281 "Re-deploy atomically: `rch fleet deploy --worker <id> --force --verify` — do not hand-patch the worker.",
282 "Verify with `rch fleet verify --worker <id>`.",
283 ],
284 I::ToolchainDrift => &[
285 "Advisory only — the worker is healthy; its `rustc` nightly merely differs from the orchestrator's, so codegen differs. No action required.",
286 "To silence the advisory, align toolchains: `rch fleet deploy --worker <id>` after matching the worker's `rust-toolchain`, or compare with `rch workers capabilities --refresh`.",
287 ],
288 };
289 steps.iter().map(|s| (*s).to_string()).collect()
290}
291
292const fn reliability_category_str(c: ReliabilityCategoryKind) -> &'static str {
294 match c {
295 ReliabilityCategoryKind::Topology => "topology",
296 ReliabilityCategoryKind::DiskPressure => "disk_pressure",
297 ReliabilityCategoryKind::ProcessTriage => "process_triage",
298 ReliabilityCategoryKind::RepoConvergence => "repo_convergence",
299 ReliabilityCategoryKind::HelperCompatibility => "helper_compatibility",
300 ReliabilityCategoryKind::RolloutPosture => "rollout_posture",
301 ReliabilityCategoryKind::SchemaCompatibility => "schema_compatibility",
302 ReliabilityCategoryKind::MirrorOwnership => "mirror_ownership",
303 }
304}
305
306const fn error_category_str(c: ErrorCategory) -> &'static str {
308 match c {
309 ErrorCategory::Config => "config",
310 ErrorCategory::Network => "network",
311 ErrorCategory::Worker => "worker",
312 ErrorCategory::Build => "build",
313 ErrorCategory::Transfer => "transfer",
314 ErrorCategory::Internal => "internal",
315 }
316}
317
318#[must_use]
320pub fn list_all() -> Vec<CodeExplanation> {
321 let mut out: Vec<CodeExplanation> = Vec::new();
322 for v in ReliabilityReasonCode::ALL {
323 if let Some(e) = lookup_reliability(v.code()) {
324 out.push(e);
325 }
326 }
327 for c in error_code_all() {
328 let s = c.code_string();
329 if let Some(e) = lookup_error(&s) {
330 out.push(e);
331 }
332 }
333 for v in IncidentReasonCode::ALL {
334 if let Some(e) = lookup_incident(v.code()) {
335 out.push(e);
336 }
337 }
338 out.sort_by(|a, b| a.code.cmp(&b.code));
339 out
340}
341
342#[must_use]
346pub fn list_by_category(category: &str) -> Vec<CodeExplanation> {
347 let cat = category.trim().to_ascii_lowercase();
348 list_all()
349 .into_iter()
350 .filter(|e| e.category == cat)
351 .collect()
352}
353
354#[must_use]
357pub fn known_categories() -> Vec<String> {
358 let mut categories: Vec<String> = list_all().into_iter().map(|e| e.category).collect();
359 categories.sort();
360 categories.dedup();
361 categories
362}
363
364#[must_use]
366pub fn is_known_category(category: &str) -> bool {
367 let cat = category.trim().to_ascii_lowercase();
368 !cat.is_empty() && known_categories().iter().any(|known| known == &cat)
369}
370
371fn error_code_all() -> &'static [ErrorCode] {
373 ErrorCode::all()
374}
375
376#[must_use]
379pub fn render_human(e: &CodeExplanation) -> String {
380 let mut out = String::with_capacity(512);
381 out.push_str(&format!("{} {}\n", e.code, e.name));
382 out.push_str(&format!(
383 "Category: {:<30} Namespace: {}\n",
384 e.category,
385 match e.namespace {
386 CodeNamespace::Error => "error (RCH-Ennn)",
387 CodeNamespace::Reliability => "reliability (RCH-Rnnn)",
388 CodeNamespace::Incident => "incident (RCH-Innn)",
389 }
390 ));
391 if let Some(rr) = e.requires_restart {
392 out.push_str(&format!("Requires restart: {rr}\n"));
393 }
394 out.push_str("\nDescription:\n");
395 out.push_str(&format!(" {}\n", e.description));
396 if !e.remediation.is_empty() {
397 out.push_str("\nRemediation:\n");
398 for step in &e.remediation {
399 out.push_str(&format!(" - {step}\n"));
400 }
401 }
402 if let Some(url) = &e.doc_url {
403 out.push_str(&format!("\nReference: {url}\n"));
404 }
405 out
406}
407
408#[cfg(test)]
409mod tests {
410 use super::*;
411
412 #[test]
413 fn test_lookup_reliability_code() {
414 let e = lookup("RCH-R104").expect("R104 known");
415 assert_eq!(e.code, "RCH-R104");
416 assert_eq!(e.namespace, CodeNamespace::Reliability);
417 assert_eq!(e.category, "disk_pressure");
418 assert!(!e.description.is_empty());
419 assert!(!e.remediation.is_empty());
420 assert!(e.requires_restart.is_some());
421 }
422
423 #[test]
424 fn test_lookup_error_code() {
425 let e = lookup("RCH-E001").expect("E001 known");
426 assert_eq!(e.code, "RCH-E001");
427 assert_eq!(e.namespace, CodeNamespace::Error);
428 assert_eq!(e.category, "config");
429 assert!(!e.description.is_empty());
430 assert!(e.requires_restart.is_none());
431 }
432
433 #[test]
434 fn test_lookup_dependency_preflight_error_code() {
435 let e = lookup("RCH-E410").expect("dependency preflight missing known");
436 assert_eq!(e.code, "RCH-E410");
437 assert_eq!(e.namespace, CodeNamespace::Error);
438 assert_eq!(e.name, "DependencyPreflightMissing");
439 assert_eq!(e.category, "transfer");
440 assert!(e.description.contains("missing required path"));
441 assert!(
442 e.remediation
443 .iter()
444 .any(|step| step.contains("missing remote path"))
445 );
446 }
447
448 #[test]
449 fn test_lookup_unknown_returns_none() {
450 assert!(lookup("RCH-R999").is_none());
451 assert!(lookup("RCH-E999").is_none());
452 assert!(lookup("not-a-code").is_none());
453 assert!(lookup("").is_none());
454 }
455
456 #[test]
457 fn test_lookup_trims_whitespace() {
458 let e = lookup(" RCH-R001 ").expect("trimmed lookup hits");
459 assert_eq!(e.code, "RCH-R001");
460 }
461
462 #[test]
463 fn test_lookup_is_case_insensitive() {
464 let e = lookup("rch-e001").expect("lowercase error-code lookup hits");
465 assert_eq!(e.code, "RCH-E001");
466
467 let e = lookup("rch-r104").expect("lowercase reliability-code lookup hits");
468 assert_eq!(e.code, "RCH-R104");
469 }
470
471 #[test]
472 fn test_is_known() {
473 assert!(is_known("RCH-R001"));
474 assert!(is_known("RCH-E001"));
475 assert!(!is_known("RCH-X001"));
476 }
477
478 #[test]
479 fn test_list_all_includes_both_namespaces() {
480 let all = list_all();
481 let reliability_count = all
482 .iter()
483 .filter(|e| e.namespace == CodeNamespace::Reliability)
484 .count();
485 let error_count = all
486 .iter()
487 .filter(|e| e.namespace == CodeNamespace::Error)
488 .count();
489 assert!(reliability_count >= 40, "expected ≥40 reliability codes");
490 assert!(error_count >= 50, "expected ≥50 error codes");
491 }
492
493 #[test]
494 fn test_list_all_sorted_by_code() {
495 let all = list_all();
496 for w in all.windows(2) {
497 assert!(w[0].code <= w[1].code, "list_all not sorted by code");
498 }
499 }
500
501 #[test]
502 fn test_list_all_no_duplicates() {
503 use std::collections::HashSet;
504 let all = list_all();
505 let codes: HashSet<&str> = all.iter().map(|e| e.code.as_str()).collect();
506 assert_eq!(codes.len(), all.len(), "duplicate code in list_all");
507 }
508
509 #[test]
510 fn test_list_by_category_filters() {
511 let dp = list_by_category("disk_pressure");
512 assert!(!dp.is_empty());
513 for e in &dp {
514 assert_eq!(e.category, "disk_pressure");
515 }
516 }
517
518 #[test]
519 fn test_list_by_category_unknown_returns_empty() {
520 assert!(list_by_category("nonexistent_category").is_empty());
521 }
522
523 #[test]
524 fn test_known_categories_are_sorted_unique_and_complete() {
525 let categories = known_categories();
526 assert!(!categories.is_empty());
527 for w in categories.windows(2) {
528 assert!(w[0] < w[1], "known_categories must be sorted and unique");
529 }
530 assert!(categories.iter().any(|c| c == "disk_pressure"));
531 assert!(categories.iter().any(|c| c == "worker"));
532 assert!(categories.iter().any(|c| c == "topology"));
533 }
534
535 #[test]
536 fn test_is_known_category_trims_and_normalizes_case() {
537 assert!(is_known_category(" disk_pressure "));
538 assert!(is_known_category("WORKER"));
539 assert!(is_known_category("Topology"));
540 assert!(!is_known_category(""));
541 assert!(!is_known_category("nonexistent_category"));
542 }
543
544 #[test]
545 fn test_list_by_category_case_insensitive() {
546 let lower = list_by_category("topology");
547 let upper = list_by_category("TOPOLOGY");
548 let mixed = list_by_category("Topology");
549 assert_eq!(lower.len(), upper.len());
550 assert_eq!(lower.len(), mixed.len());
551 assert!(!lower.is_empty());
552 }
553
554 #[test]
555 fn test_render_human_includes_code_and_name() {
556 let e = lookup("RCH-R104").unwrap();
557 let rendered = render_human(&e);
558 assert!(rendered.contains("RCH-R104"));
559 assert!(rendered.contains(&e.name));
560 assert!(rendered.contains("Description:"));
561 assert!(rendered.contains("Remediation:"));
562 }
563
564 #[test]
565 fn test_render_human_omits_requires_restart_for_error_codes() {
566 let e = lookup("RCH-E001").unwrap();
567 let rendered = render_human(&e);
568 assert!(!rendered.contains("Requires restart"));
569 }
570
571 #[test]
572 fn test_serde_roundtrip() {
573 let e = lookup("RCH-R001").unwrap();
574 let json = serde_json::to_string(&e).unwrap();
575 let back: CodeExplanation = serde_json::from_str(&json).unwrap();
576 assert_eq!(e.code, back.code);
577 assert_eq!(e.namespace, back.namespace);
578 assert_eq!(e.category, back.category);
579 }
580
581 #[test]
582 fn test_error_code_all_consistent_with_code_string() {
583 use std::collections::HashSet;
586 let codes: HashSet<String> = error_code_all().iter().map(|c| c.code_string()).collect();
587 assert_eq!(codes.len(), error_code_all().len());
588 }
589
590 #[test]
591 fn test_lookup_incident_only_code() {
592 let e = lookup("RCH-I012").expect("RCH-I012 known");
595 assert_eq!(e.code, "RCH-I012");
596 assert_eq!(e.namespace, CodeNamespace::Incident);
597 assert_eq!(e.category, "proof");
598 assert!(!e.description.is_empty());
599 assert!(!e.remediation.is_empty());
600 assert!(e.requires_restart.is_none());
601 }
602
603 #[test]
604 fn test_lookup_incident_code_case_and_whitespace_tolerant() {
605 let e = lookup(" rch-i001 ").expect("RCH-I001 known");
606 assert_eq!(e.code, "RCH-I001");
607 assert_eq!(e.namespace, CodeNamespace::Incident);
608 }
609
610 #[test]
611 fn test_every_incident_code_resolves_with_description_and_remediation() {
612 for v in IncidentReasonCode::ALL {
613 let e = lookup(v.code()).unwrap_or_else(|| panic!("{} must resolve", v.code()));
614 assert_eq!(e.namespace, CodeNamespace::Incident);
615 assert!(
616 !e.description.is_empty(),
617 "{} has empty description",
618 v.code()
619 );
620 assert!(
621 !e.remediation.is_empty(),
622 "{} has empty remediation",
623 v.code()
624 );
625 assert!(!e.category.is_empty(), "{} has empty category", v.code());
626 }
627 }
628
629 #[test]
630 fn test_list_all_includes_incident_namespace() {
631 let all = list_all();
632 let incident_count = all
633 .iter()
634 .filter(|e| e.namespace == CodeNamespace::Incident)
635 .count();
636 assert_eq!(
637 incident_count,
638 IncidentReasonCode::ALL.len(),
639 "list_all must include every incident reason code"
640 );
641 assert!(incident_count >= 17, "expected at least 17 incident codes");
642 }
643
644 #[test]
645 fn test_render_human_labels_incident_namespace() {
646 let e = lookup("RCH-I011").unwrap();
647 let rendered = render_human(&e);
648 assert!(rendered.contains("RCH-I011"));
649 assert!(rendered.contains("incident (RCH-Innn)"));
650 assert!(rendered.contains("Remediation:"));
651 }
652}