Skip to main content

edict/commands/protocol/
exit_policy.rs

1//! Exit-code and stderr policy for protocol commands.
2//!
3//! Protocol commands use a three-tier exit-code scheme:
4//! - Exit 0: command succeeded, status communicated via stdout (ready, blocked, etc.)
5//! - Exit 1: operational failure (config not found, tool missing, parse error)
6//! - Exit 2: usage error (bad arguments — handled by clap before we get here)
7//!
8//! Key principle: agents branch on stdout status fields (ready/blocked/etc.),
9//! NOT on shell exit codes. Exit 0 means "I produced valid guidance output";
10//! the status field within that output tells you what to do.
11//!
12//! Stderr is reserved for true operational errors (exit 1/2). Status information
13//! like "blocked" or "needs-review" goes to stdout as part of the guidance output.
14
15use std::process::ExitCode;
16
17use super::render::{ProtocolGuidance, ProtocolStatus};
18use crate::commands::doctor::OutputFormat;
19
20/// Exit codes for protocol commands.
21///
22/// These are intentionally a small, fixed set. Agents should NOT branch on
23/// these codes — they exist for shell-level error detection only.
24#[derive(Debug, Clone, Copy, PartialEq, Eq)]
25#[repr(u8)]
26pub enum ProtocolExitCode {
27    /// Command produced valid guidance output (status in stdout).
28    Success = 0,
29    /// Operational failure: config missing, tool unavailable, parse error.
30    OperationalError = 1,
31    /// Usage error: bad arguments. (Typically handled by clap.)
32    UsageError = 2,
33}
34
35impl From<ProtocolExitCode> for ExitCode {
36    fn from(code: ProtocolExitCode) -> ExitCode {
37        ExitCode::from(code as u8)
38    }
39}
40
41/// Error type for protocol commands that need to set a specific exit code.
42///
43/// This integrates with the ExitError pattern in main.rs so that protocol
44/// commands can signal operational failures (exit 1) via the standard
45/// error-handling path.
46#[derive(Debug, thiserror::Error)]
47#[error("edict protocol: {context}: {detail}")]
48pub struct ProtocolExitError {
49    pub code: ProtocolExitCode,
50    pub context: String,
51    pub detail: String,
52}
53
54impl ProtocolExitError {
55    /// Create an operational error (exit 1).
56    pub fn operational(context: impl Into<String>, detail: impl Into<String>) -> Self {
57        Self {
58            code: ProtocolExitCode::OperationalError,
59            context: context.into(),
60            detail: detail.into(),
61        }
62    }
63
64    /// Convert to an ExitError for main.rs error handling.
65    pub fn into_exit_error(self) -> crate::error::ExitError {
66        crate::error::ExitError::new(self.code as u8, self.to_string())
67    }
68}
69
70/// Result of a protocol command execution.
71///
72/// Bundles the guidance output with the appropriate exit code.
73/// The caller (main.rs) uses this to print output and set the process exit code.
74#[allow(dead_code)]
75pub struct ProtocolResult {
76    pub exit_code: ProtocolExitCode,
77    pub guidance: Option<ProtocolGuidance>,
78}
79
80impl ProtocolResult {
81    /// Command succeeded — guidance is ready to render.
82    #[allow(dead_code)]
83    pub fn success(guidance: ProtocolGuidance) -> Self {
84        Self {
85            exit_code: ProtocolExitCode::Success,
86            guidance: Some(guidance),
87        }
88    }
89
90    /// Operational error — no guidance produced.
91    /// The error message will be written to stderr by the caller.
92    #[allow(dead_code)]
93    pub fn operational_error() -> Self {
94        Self {
95            exit_code: ProtocolExitCode::OperationalError,
96            guidance: None,
97        }
98    }
99}
100
101/// All ProtocolStatus variants map to exit code 0 (Success).
102///
103/// This is the key design decision: blocked, needs-review, etc. are all
104/// valid guidance states, not errors. The agent reads the status field
105/// in stdout to decide what to do next.
106#[allow(dead_code)]
107pub fn exit_code_for_status(_status: ProtocolStatus) -> ProtocolExitCode {
108    // Every status is a successful guidance output.
109    // Agents branch on the status field, not the exit code.
110    ProtocolExitCode::Success
111}
112
113/// Write a diagnostic message to stderr for operational errors.
114///
115/// Only call this for exit code 1 (operational failures).
116/// Never use stderr for status information like "blocked" or "clean".
117#[allow(dead_code)]
118pub fn write_stderr_diagnostic(context: &str, detail: &str) {
119    eprintln!("edict protocol: {context}: {detail}");
120}
121
122/// Render guidance to stdout and return the appropriate exit code.
123///
124/// This is the single exit path for all protocol commands that produce
125/// valid guidance. The exit code is always 0 (Success) because the
126/// guidance itself contains the status information.
127#[allow(dead_code)]
128pub fn render_and_exit(
129    guidance: &ProtocolGuidance,
130    format: OutputFormat,
131) -> anyhow::Result<ProtocolExitCode> {
132    let output = super::render::render(guidance, format)
133        .map_err(|e| anyhow::anyhow!("render error: {}", e))?;
134    println!("{}", output);
135    Ok(exit_code_for_status(guidance.status))
136}
137
138/// Render guidance to stdout and return Ok(()).
139///
140/// Convenience wrapper around `render_and_exit` for commands that return
141/// `anyhow::Result<()>`. All ProtocolStatus variants produce exit 0.
142/// Operational errors should use `ProtocolExitError` instead.
143pub fn render_guidance(guidance: &ProtocolGuidance, format: OutputFormat) -> anyhow::Result<()> {
144    let output = super::render::render(guidance, format)
145        .map_err(|e| anyhow::anyhow!("render error: {}", e))?;
146    println!("{}", output);
147    Ok(())
148}
149
150#[cfg(test)]
151mod tests {
152    use super::*;
153    use crate::commands::protocol::render::ProtocolGuidance;
154
155    #[test]
156    fn all_statuses_map_to_success() {
157        let statuses = vec![
158            ProtocolStatus::Ready,
159            ProtocolStatus::Blocked,
160            ProtocolStatus::Resumable,
161            ProtocolStatus::NeedsReview,
162            ProtocolStatus::HasResources,
163            ProtocolStatus::Clean,
164            ProtocolStatus::HasWork,
165            ProtocolStatus::Fresh,
166        ];
167        for status in statuses {
168            assert_eq!(
169                exit_code_for_status(status),
170                ProtocolExitCode::Success,
171                "status {:?} should map to Success",
172                status
173            );
174        }
175    }
176
177    #[test]
178    fn exit_code_values() {
179        assert_eq!(ProtocolExitCode::Success as u8, 0);
180        assert_eq!(ProtocolExitCode::OperationalError as u8, 1);
181        assert_eq!(ProtocolExitCode::UsageError as u8, 2);
182    }
183
184    #[test]
185    fn exit_code_to_std_exit_code() {
186        let code: ExitCode = ProtocolExitCode::Success.into();
187        // ExitCode doesn't implement PartialEq, but we can verify the conversion compiles
188        let _ = code;
189
190        let code: ExitCode = ProtocolExitCode::OperationalError.into();
191        let _ = code;
192
193        let code: ExitCode = ProtocolExitCode::UsageError.into();
194        let _ = code;
195    }
196
197    #[test]
198    fn protocol_result_success() {
199        let guidance = ProtocolGuidance::new("start");
200        let result = ProtocolResult::success(guidance);
201        assert_eq!(result.exit_code, ProtocolExitCode::Success);
202        assert!(result.guidance.is_some());
203    }
204
205    #[test]
206    fn protocol_result_operational_error() {
207        let result = ProtocolResult::operational_error();
208        assert_eq!(result.exit_code, ProtocolExitCode::OperationalError);
209        assert!(result.guidance.is_none());
210    }
211
212    #[test]
213    fn blocked_status_still_exits_zero() {
214        let mut guidance = ProtocolGuidance::new("start");
215        guidance.blocked("bone claimed by another agent".to_string());
216        assert_eq!(
217            exit_code_for_status(guidance.status),
218            ProtocolExitCode::Success
219        );
220    }
221
222    #[test]
223    fn needs_review_status_still_exits_zero() {
224        let mut guidance = ProtocolGuidance::new("review");
225        guidance.status = ProtocolStatus::NeedsReview;
226        assert_eq!(
227            exit_code_for_status(guidance.status),
228            ProtocolExitCode::Success
229        );
230    }
231
232    #[test]
233    fn has_resources_status_still_exits_zero() {
234        let mut guidance = ProtocolGuidance::new("cleanup");
235        guidance.status = ProtocolStatus::HasResources;
236        assert_eq!(
237            exit_code_for_status(guidance.status),
238            ProtocolExitCode::Success
239        );
240    }
241
242    #[test]
243    fn protocol_exit_error_operational() {
244        let err = ProtocolExitError::operational("start", "config not found");
245        assert_eq!(err.code, ProtocolExitCode::OperationalError);
246        assert_eq!(err.context, "start");
247        assert_eq!(err.detail, "config not found");
248        let msg = err.to_string();
249        assert!(msg.contains("edict protocol: start: config not found"));
250    }
251
252    #[test]
253    fn protocol_exit_error_to_exit_error() {
254        let err = ProtocolExitError::operational("cleanup", "bus not available");
255        let exit_err = err.into_exit_error();
256        // ExitError::WithCode { code: 1, message: ... }
257        assert_eq!(exit_err.exit_code(), ExitCode::from(1u8));
258    }
259
260    #[test]
261    fn operational_error_exit_code_is_one() {
262        let err = ProtocolExitError::operational("start", "tool missing");
263        assert_eq!(err.code as u8, 1);
264    }
265
266    #[test]
267    fn stderr_diagnostic_format() {
268        // Just verify write_stderr_diagnostic doesn't panic.
269        // The actual stderr output is tested via integration tests.
270        write_stderr_diagnostic("start", "config not found");
271    }
272}