Skip to main content

ralph/cli/machine/
error.rs

1//! Machine-command error classification and JSON stderr emission.
2//!
3//! Responsibilities:
4//! - Convert machine command failures into stable, versioned error documents.
5//! - Keep app-facing recovery/error codes centralized on the CLI side.
6//! - Sanitize/redact error details before they reach stderr.
7//!
8//! Not handled here:
9//! - Machine command routing or success-document emission.
10//! - Human CLI error rendering.
11//! - App-side recovery presentation.
12//!
13//! Invariants/assumptions:
14//! - Machine command failures must emit JSON on stderr instead of prose.
15//! - Unknown failures stay structured and redacted.
16//! - Error codes remain stable unless the machine contract version changes.
17
18use anyhow::Result;
19
20use crate::contracts::{MACHINE_ERROR_VERSION, MachineErrorCode, MachineErrorDocument};
21
22pub fn print_machine_error(err: &anyhow::Error) -> Result<()> {
23    eprintln!(
24        "{}",
25        serde_json::to_string_pretty(&build_machine_error_document(err))?
26    );
27    Ok(())
28}
29
30fn build_machine_error_document(err: &anyhow::Error) -> MachineErrorDocument {
31    let detail = sanitized_detail(err);
32    let normalized = detail.to_ascii_lowercase();
33
34    let (code, message, retryable) = if normalized.contains("task mutation conflict for") {
35        (
36            MachineErrorCode::TaskMutationConflict,
37            "Task changed on disk before Ralph could apply the mutation.",
38            false,
39        )
40    } else if normalized.contains("permission denied") {
41        (
42            MachineErrorCode::PermissionDenied,
43            "Permission denied.",
44            false,
45        )
46    } else if normalized.contains("queue file") && normalized.contains("no such file") {
47        (
48            MachineErrorCode::QueueCorrupted,
49            "No Ralph queue file found.",
50            false,
51        )
52    } else if normalized.contains("queue validation failed")
53        || normalized.contains("done archive validation failed")
54        || (normalized.contains("queue")
55            && (normalized.contains("corrupt") || normalized.contains("invalid")))
56        || normalized.contains("duplicate id")
57        || normalized.contains("invalid timestamp")
58    {
59        (
60            MachineErrorCode::QueueCorrupted,
61            "Queue data appears corrupted.",
62            false,
63        )
64    } else if normalized.contains("load project config")
65        || normalized.contains("load global config")
66        || normalized.contains("unsupported config version")
67        || (normalized.contains("unknown field") && normalized.contains("config"))
68    {
69        (
70            MachineErrorCode::ConfigIncompatible,
71            "Workspace config is incompatible with this Ralph version.",
72            false,
73        )
74    } else if normalized.contains("version")
75        && (normalized.contains("minimum supported version")
76            || normalized.contains("newer than supported")
77            || normalized.contains("too old")
78            || normalized.contains("too new"))
79    {
80        (
81            MachineErrorCode::VersionMismatch,
82            "Ralph CLI version is incompatible with this app.",
83            false,
84        )
85    } else if normalized.contains("network")
86        || normalized.contains("connection")
87        || normalized.contains("timed out")
88    {
89        (
90            MachineErrorCode::NetworkError,
91            "Network operation failed.",
92            false,
93        )
94    } else if normalized.contains("resource temporarily unavailable")
95        || normalized.contains("resource busy")
96        || normalized.contains("file locked")
97        || normalized.contains("operation would block")
98        || normalized.contains("device or resource busy")
99        || normalized.contains("eagain")
100        || normalized.contains("ewouldblock")
101        || normalized.contains("ebusy")
102    {
103        (
104            MachineErrorCode::ResourceBusy,
105            "Resource temporarily unavailable.",
106            true,
107        )
108    } else if normalized.contains("parse")
109        || normalized.contains("decode")
110        || normalized.contains("json")
111    {
112        (
113            MachineErrorCode::ParseError,
114            "Unable to parse CLI output.",
115            false,
116        )
117    } else {
118        (
119            MachineErrorCode::Unknown,
120            "Ralph CLI command failed.",
121            false,
122        )
123    };
124
125    let detail = if detail == message {
126        None
127    } else {
128        Some(detail)
129    };
130
131    MachineErrorDocument {
132        version: MACHINE_ERROR_VERSION,
133        code,
134        message: message.to_string(),
135        detail,
136        retryable,
137    }
138}
139
140fn sanitized_detail(err: &anyhow::Error) -> String {
141    let redacted = crate::redaction::redact_text(&format!("{:#}", err));
142    let trimmed = redacted.trim();
143    if trimmed.is_empty() {
144        return "Ralph CLI command failed.".to_string();
145    }
146
147    let truncated: String = trimmed.chars().take(2_000).collect();
148    if truncated.chars().count() == trimmed.chars().count() {
149        truncated
150    } else {
151        format!("{truncated}…")
152    }
153}
154
155#[cfg(test)]
156mod tests {
157    use super::*;
158
159    #[test]
160    fn build_machine_error_document_classifies_queue_missing() {
161        let err = anyhow::anyhow!(
162            "read queue file /tmp/example/.ralph/queue.jsonc: No such file or directory (os error 2)"
163        );
164
165        let document = build_machine_error_document(&err);
166        assert_eq!(document.code, MachineErrorCode::QueueCorrupted);
167        assert_eq!(document.message, "No Ralph queue file found.");
168        assert!(!document.retryable);
169        assert!(
170            document
171                .detail
172                .as_deref()
173                .unwrap_or_default()
174                .contains("queue.jsonc")
175        );
176    }
177
178    #[test]
179    fn build_machine_error_document_classifies_task_conflict() {
180        let err = anyhow::anyhow!(
181            "Task mutation conflict for RQ-0001: expected updated_at 2026-03-30T00:00:00Z, found 2026-03-30T00:01:00Z."
182        );
183
184        let document = build_machine_error_document(&err);
185        assert_eq!(document.code, MachineErrorCode::TaskMutationConflict);
186        assert_eq!(
187            document.message,
188            "Task changed on disk before Ralph could apply the mutation."
189        );
190        assert!(!document.retryable);
191    }
192
193    #[test]
194    fn build_machine_error_document_sanitizes_unknown_failures() {
195        let err = anyhow::anyhow!("unexpected bearer sk-test-123 failure");
196
197        let document = build_machine_error_document(&err);
198        assert_eq!(document.code, MachineErrorCode::Unknown);
199        assert_eq!(document.message, "Ralph CLI command failed.");
200        let detail = document
201            .detail
202            .expect("unknown failures keep sanitized detail");
203        assert!(!detail.contains("sk-test-123"));
204        assert!(detail.contains("[REDACTED]"));
205    }
206}