Skip to main content

schwab_cli/
output.rs

1use chrono::Utc;
2use clap::ValueEnum;
3use console::Style;
4use serde::{Deserialize, Serialize};
5use serde_json::Value;
6
7#[derive(Debug, Clone, Copy, Default, ValueEnum, Serialize, Deserialize, PartialEq, Eq)]
8#[value(rename_all = "kebab-case")]
9pub enum OutputFormat {
10    #[default]
11    Pretty,
12    Json,
13    Md,
14}
15
16#[derive(Debug, Clone, Serialize, Deserialize)]
17pub struct ResponseEnvelope {
18    pub success: bool,
19    pub command: String,
20    pub inputs: Value,
21    pub data: Value,
22    pub warnings: Vec<String>,
23    pub errors: Vec<String>,
24    #[serde(rename = "next_actions")]
25    pub next_actions: Vec<String>,
26    pub timestamp: String,
27}
28
29impl ResponseEnvelope {
30    pub fn ok(command: impl Into<String>, data: Value) -> Self {
31        Self {
32            success: true,
33            command: command.into(),
34            inputs: Value::Null,
35            data,
36            warnings: vec![],
37            errors: vec![],
38            next_actions: vec![],
39            timestamp: Utc::now().to_rfc3339(),
40        }
41    }
42
43    pub fn err(command: impl Into<String>, message: impl Into<String>) -> Self {
44        Self {
45            success: false,
46            command: command.into(),
47            inputs: Value::Null,
48            data: Value::Null,
49            warnings: vec![],
50            errors: vec![message.into()],
51            next_actions: vec![],
52            timestamp: Utc::now().to_rfc3339(),
53        }
54    }
55
56    pub fn with_inputs(mut self, inputs: Value) -> Self {
57        self.inputs = inputs;
58        self
59    }
60
61    /// Attach non-fatal warnings to the response envelope.
62    #[allow(dead_code)] // used from lib/tests; bin crate root triggers false positive
63    pub fn with_warnings(mut self, warnings: Vec<String>) -> Self {
64        self.warnings = warnings;
65        self
66    }
67
68    pub fn with_next_actions(mut self, actions: Vec<String>) -> Self {
69        self.next_actions = actions;
70        self
71    }
72}
73
74#[derive(Debug, Clone)]
75pub struct OutputSink;
76
77impl OutputSink {
78    pub fn stdout() -> Self {
79        Self
80    }
81
82    pub fn write(&self, envelope: &ResponseEnvelope, format: OutputFormat) {
83        match format {
84            OutputFormat::Json => {
85                println!(
86                    "{}",
87                    serde_json::to_string_pretty(envelope).unwrap_or_else(|_| "{}".into())
88                );
89            }
90            OutputFormat::Md => println!("{}", to_markdown(envelope)),
91            OutputFormat::Pretty => print_pretty(envelope),
92        }
93    }
94}
95
96fn print_pretty(envelope: &ResponseEnvelope) {
97    let title = Style::new().cyan().bold();
98    let ok = Style::new().green().bold();
99    let err = Style::new().red().bold();
100    let dim = Style::new().dim();
101
102    let is_agent_tick = matches!(
103        envelope.command.as_str(),
104        "agent tick" | "agent run once"
105    );
106
107    if !is_agent_tick {
108        println!("{}", title.apply_to(format!("schwab {}", envelope.command)));
109        println!(
110            "status: {}",
111            if envelope.success {
112                ok.apply_to("success")
113            } else {
114                err.apply_to("error")
115            }
116        );
117    }
118
119    if envelope.data != Value::Null {
120        if let Some(body) = format_agent_pretty_body(&envelope.command, &envelope.data) {
121            if is_agent_tick {
122                println!(
123                    "{}",
124                    dim.apply_to(format!("── {} ──", envelope.timestamp))
125                );
126            } else {
127                println!();
128            }
129            println!("{body}");
130        } else {
131            println!("\ndata:");
132            println!(
133                "{}",
134                serde_json::to_string_pretty(&envelope.data).unwrap_or_else(|_| "null".into())
135            );
136        }
137    }
138
139    if !envelope.warnings.is_empty() {
140        println!("\n{}", Style::new().yellow().apply_to("warnings:"));
141        for w in &envelope.warnings {
142            println!("  - {w}");
143        }
144    }
145
146    if !envelope.errors.is_empty() {
147        println!("\n{}", Style::new().red().apply_to("errors:"));
148        for e in &envelope.errors {
149            println!("  - {e}");
150        }
151    }
152
153    if !envelope.next_actions.is_empty() {
154        println!("\n{}", Style::new().dim().apply_to("next:"));
155        for n in &envelope.next_actions {
156            println!("  {n}");
157        }
158    }
159}
160
161fn format_agent_pretty_body(command: &str, data: &Value) -> Option<String> {
162    match command {
163        "agent tick" | "agent run once" => Some(crate::agent::format::format_tick_data(data)),
164        "agent status" => Some(crate::agent::format::format_status_data(data)),
165        "agent validate" => Some(crate::agent::format::format_validate_data(data)),
166        "agent background" => Some(crate::agent::format::format_background_data(data)),
167        _ => None,
168    }
169}
170
171fn to_markdown(envelope: &ResponseEnvelope) -> String {
172    let mut md = String::new();
173    md.push_str(&format!("# schwab {}\n\n", envelope.command));
174    md.push_str(&format!(
175        "**success:** {}\n\n",
176        if envelope.success { "true" } else { "false" }
177    ));
178    if envelope.data != Value::Null {
179        md.push_str("## data\n\n```json\n");
180        md.push_str(&serde_json::to_string_pretty(&envelope.data).unwrap_or_default());
181        md.push_str("\n```\n");
182    }
183    if !envelope.next_actions.is_empty() {
184        md.push_str("\n## next_actions\n\n");
185        for action in &envelope.next_actions {
186            md.push_str(&format!("- `{action}`\n"));
187        }
188    }
189    md
190}
191
192#[cfg(test)]
193mod tests {
194    use super::*;
195
196    #[test]
197    fn envelope_err_and_inputs() {
198        let e = ResponseEnvelope::ok("x", Value::Null)
199            .with_inputs(serde_json::json!({"a": 1}));
200        assert!(e.success);
201        let e = ResponseEnvelope::err("x", "nope");
202        assert!(!e.success);
203    }
204}