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("not_paused") =>
141        {
142            ExitCode::Conflict
143        }
144        "override_not_permitted" => ExitCode::PermissionDenied,
145        _ => ExitCode::Error,
146    }
147}
148
149/// Helper to create a table with headers.
150#[must_use]
151pub fn create_table(headers: &[&str]) -> Table {
152    let mut table = Table::new();
153    table.set_header(headers.iter().map(|h| Cell::new(*h)));
154    table
155}
156
157/// Trait for types that can be displayed as a table row.
158pub trait TableRow {
159    fn to_row(&self) -> Vec<String>;
160}
161
162#[cfg(test)]
163mod tests {
164    use super::*;
165
166    #[derive(Serialize)]
167    struct TestData {
168        name: String,
169        value: i32,
170    }
171
172    #[test]
173    fn cli_response_success_serialization() {
174        let data = TestData {
175            name: "test".to_string(),
176            value: 42,
177        };
178        let response = CliResponse::success(data);
179        let json = serde_json::to_string(&response).unwrap();
180
181        assert!(json.contains("\"success\":true"));
182        assert!(json.contains("\"name\":\"test\""));
183    }
184
185    #[test]
186    fn cli_response_error_serialization() {
187        let err =
188            HivemindError::user("invalid", "Invalid input", "cli:test").with_hint("Try again");
189        let response = CliResponse::<()>::error(&err);
190        let json = serde_json::to_string(&response).unwrap();
191
192        assert!(json.contains("\"success\":false"));
193        assert!(json.contains("\"code\":\"invalid\""));
194    }
195}