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!(
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}