Skip to main content

hivemind/cli/
output.rs

1//! CLI output formatting (JSON, table).
2//!
3//! All CLI output supports structured formats for machine consumption.
4
5use crate::core::error::{ErrorCategory, ExitCode, HivemindError};
6use comfy_table::{Cell, Table};
7use serde::Serialize;
8
9/// Output format for CLI commands.
10#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, clap::ValueEnum)]
11pub enum OutputFormat {
12    /// Human-readable table format.
13    #[default]
14    Table,
15    /// Machine-readable JSON format.
16    Json,
17    /// YAML output format.
18    Yaml,
19}
20
21/// Structured CLI response.
22#[derive(Debug, Serialize)]
23pub struct CliResponse<T> {
24    pub success: bool,
25    #[serde(skip_serializing_if = "Option::is_none")]
26    pub data: Option<T>,
27    #[serde(skip_serializing_if = "Option::is_none")]
28    pub error: Option<ErrorOutput>,
29}
30
31/// Structured error output.
32#[derive(Debug, Serialize)]
33pub struct ErrorOutput {
34    pub category: String,
35    pub code: String,
36    pub message: String,
37    pub origin: String,
38    #[serde(skip_serializing_if = "Option::is_none")]
39    pub hint: Option<String>,
40}
41
42impl From<&HivemindError> for ErrorOutput {
43    fn from(err: &HivemindError) -> Self {
44        Self {
45            category: err.category.to_string(),
46            code: err.code.clone(),
47            message: err.message.clone(),
48            origin: err.origin.clone(),
49            hint: err.recovery_hint.clone(),
50        }
51    }
52}
53
54impl<T: Serialize> CliResponse<T> {
55    /// Creates a successful response with data.
56    pub fn success(data: T) -> Self {
57        Self {
58            success: true,
59            data: Some(data),
60            error: None,
61        }
62    }
63
64    /// Creates an error response.
65    pub fn error(err: &HivemindError) -> CliResponse<()> {
66        CliResponse {
67            success: false,
68            data: None,
69            error: Some(ErrorOutput::from(err)),
70        }
71    }
72}
73
74/// Outputs data in the specified format.
75pub fn output<T: Serialize>(data: T, format: OutputFormat) -> std::io::Result<()> {
76    match format {
77        OutputFormat::Json => {
78            let response = CliResponse::success(data);
79            println!("{}", serde_json::to_string_pretty(&response)?);
80        }
81        OutputFormat::Table => {
82            println!("{}", serde_json::to_string_pretty(&data)?);
83        }
84        OutputFormat::Yaml => {
85            let response = CliResponse::success(data);
86            if let Ok(yaml) = serde_yaml::to_string(&response) {
87                print!("{yaml}");
88            }
89        }
90    }
91    Ok(())
92}
93
94/// Outputs an error in the specified format.
95pub fn output_error(err: &HivemindError, format: OutputFormat) -> ExitCode {
96    match format {
97        OutputFormat::Json => {
98            let response = CliResponse::<()>::error(err);
99            if let Ok(json) = serde_json::to_string(&response) {
100                eprintln!("{json}");
101            }
102        }
103        OutputFormat::Yaml => {
104            let response = CliResponse::<()>::error(err);
105            if let Ok(yaml) = serde_yaml::to_string(&response) {
106                eprint!("{yaml}");
107            }
108        }
109        OutputFormat::Table => {
110            eprintln!("Error: {err}");
111            if let Some(hint) = &err.recovery_hint {
112                eprintln!("Hint: {hint}");
113            }
114
115            let response = CliResponse::<()>::error(err);
116            if let Ok(json) = serde_json::to_string(&response) {
117                eprintln!("{json}");
118            }
119        }
120    }
121    error_to_exit_code(err)
122}
123
124/// Maps error codes to exit codes per CLI operational semantics.
125fn error_to_exit_code(err: &HivemindError) -> ExitCode {
126    if matches!(err.category, ErrorCategory::Scope) {
127        return ExitCode::PermissionDenied;
128    }
129
130    match err.code.as_str() {
131        c if c.contains("not_found") => ExitCode::NotFound,
132        c if c.contains("conflict")
133            || c.contains("immutable")
134            || c.contains("in_use")
135            || c.contains("in_active_flow")
136            || c.contains("already_attached")
137            || c.contains("already_terminal")
138            || c.contains("already_running")
139            || c.contains("not_running")
140            || c.contains("requires_force")
141            || c.contains("not_paused") =>
142        {
143            ExitCode::Conflict
144        }
145        "override_not_permitted" => ExitCode::PermissionDenied,
146        _ => ExitCode::Error,
147    }
148}
149
150/// Helper to create a table with headers.
151#[must_use]
152pub fn create_table(headers: &[&str]) -> Table {
153    let mut table = Table::new();
154    table.set_header(headers.iter().map(|h| Cell::new(*h)));
155    table
156}
157
158/// Trait for types that can be displayed as a table row.
159pub trait TableRow {
160    fn to_row(&self) -> Vec<String>;
161}
162
163#[cfg(test)]
164mod tests {
165    use super::*;
166
167    #[derive(Serialize)]
168    struct TestData {
169        name: String,
170        value: i32,
171    }
172
173    #[test]
174    fn cli_response_success_serialization() {
175        let data = TestData {
176            name: "test".to_string(),
177            value: 42,
178        };
179        let response = CliResponse::success(data);
180        let json = serde_json::to_string(&response).unwrap();
181
182        assert!(json.contains("\"success\":true"));
183        assert!(json.contains("\"name\":\"test\""));
184    }
185
186    #[test]
187    fn cli_response_error_serialization() {
188        let err =
189            HivemindError::user("invalid", "Invalid input", "cli:test").with_hint("Try again");
190        let response = CliResponse::<()>::error(&err);
191        let json = serde_json::to_string(&response).unwrap();
192
193        assert!(json.contains("\"success\":false"));
194        assert!(json.contains("\"code\":\"invalid\""));
195    }
196}