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 #[allow(dead_code)] 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}