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!(envelope.command.as_str(), "agent tick" | "agent run once");
103
104    if !is_agent_tick {
105        println!("{}", title.apply_to(format!("schwab {}", envelope.command)));
106        println!(
107            "status: {}",
108            if envelope.success {
109                ok.apply_to("success")
110            } else {
111                err.apply_to("error")
112            }
113        );
114    }
115
116    if envelope.data != Value::Null {
117        if let Some(body) = format_agent_pretty_body(&envelope.command, &envelope.data) {
118            if is_agent_tick {
119                println!("{}", dim.apply_to(format!("── {} ──", envelope.timestamp)));
120            } else {
121                println!();
122            }
123            println!("{body}");
124        } else {
125            println!("\ndata:");
126            println!(
127                "{}",
128                serde_json::to_string_pretty(&envelope.data).unwrap_or_else(|_| "null".into())
129            );
130        }
131    }
132
133    if !envelope.warnings.is_empty() {
134        println!("\n{}", Style::new().yellow().apply_to("warnings:"));
135        for w in &envelope.warnings {
136            println!("  - {w}");
137        }
138    }
139
140    if !envelope.errors.is_empty() {
141        println!("\n{}", Style::new().red().apply_to("errors:"));
142        for e in &envelope.errors {
143            println!("  - {e}");
144        }
145    }
146
147    if !envelope.next_actions.is_empty() {
148        println!("\n{}", Style::new().dim().apply_to("next:"));
149        for n in &envelope.next_actions {
150            println!("  {n}");
151        }
152    }
153}
154
155fn format_agent_pretty_body(command: &str, data: &Value) -> Option<String> {
156    match command {
157        "agent tick" | "agent run once" => Some(crate::agent::format::format_tick_data(data)),
158        "agent status" => Some(crate::agent::format::format_status_data(data)),
159        "agent validate" => Some(crate::agent::format::format_validate_data(data)),
160        "agent background" => Some(crate::agent::format::format_background_data(data)),
161        _ => None,
162    }
163}
164
165fn to_markdown(envelope: &ResponseEnvelope) -> String {
166    let mut md = String::new();
167    md.push_str(&format!("# schwab {}\n\n", envelope.command));
168    md.push_str(&format!(
169        "**success:** {}\n\n",
170        if envelope.success { "true" } else { "false" }
171    ));
172    if envelope.data != Value::Null {
173        md.push_str("## data\n\n```json\n");
174        md.push_str(&serde_json::to_string_pretty(&envelope.data).unwrap_or_default());
175        md.push_str("\n```\n");
176    }
177    if !envelope.next_actions.is_empty() {
178        md.push_str("\n## next_actions\n\n");
179        for action in &envelope.next_actions {
180            md.push_str(&format!("- `{action}`\n"));
181        }
182    }
183    md
184}
185
186#[cfg(test)]
187mod tests {
188    use super::*;
189
190    #[test]
191    fn envelope_err_and_inputs() {
192        let e = ResponseEnvelope::ok("x", Value::Null).with_inputs(serde_json::json!({"a": 1}));
193        assert!(e.success);
194        let e = ResponseEnvelope::err("x", "nope");
195        assert!(!e.success);
196    }
197}