1use anyhow::Result;
2use clap::ValueEnum;
3use serde::Serialize;
4use serde_json::Value;
5use std::io::{self, IsTerminal};
6use std::sync::OnceLock;
7
8pub fn set_agent_name(name: &str) {
10 let sanitized = sanitize_agent_name(name);
11 if !sanitized.is_empty() {
12 let _ = ACTIVE_AGENT_NAME.set(sanitized);
13 }
14}
15
16pub fn current_agent_name() -> Option<String> {
18 ACTIVE_AGENT_NAME.get().cloned()
19}
20
21static ACTIVE_AGENT_NAME: OnceLock<String> = OnceLock::new();
22
23fn sanitize_agent_name(raw: &str) -> String {
24 raw.chars()
25 .map(|c| {
26 if c == '/' || c == '\\' {
27 '_'
28 } else if c.is_control() || c.is_whitespace() {
29 '-'
30 } else {
31 c
32 }
33 })
34 .collect::<String>()
35 .trim_matches('-')
36 .to_string()
37}
38
39pub fn cents_to_brl(cents: i64) -> String {
40 format!("R$ {:.2}", cents as f64 / 100.0)
41}
42
43#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, ValueEnum, Default)]
44#[serde(rename_all = "kebab-case")]
45pub enum OutputFormat {
46 #[default]
48 Text,
49 Json,
51}
52
53#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize)]
54#[serde(rename_all = "kebab-case")]
55pub enum CommandName {
56 Signup,
58 Login,
60 Verify,
62 Logout,
64 Whoami,
66 Account,
68 Balance,
70 Deposit,
72 PixList,
74 PixSend,
76 BrcodeDecode,
78 History,
80 Limits,
82 McpServe,
84 SetupMcp,
86}
87
88impl CommandName {
89 pub fn label(&self) -> &'static str {
90 match self {
91 Self::Signup => "signup",
92 Self::Login => "login",
93 Self::Verify => "verify",
94 Self::Logout => "logout",
95 Self::Whoami => "whoami",
96 Self::Account => "account",
97 Self::Balance => "balance",
98 Self::Deposit => "deposit",
99 Self::PixList => "pix-list",
100 Self::PixSend => "pix-send",
101 Self::BrcodeDecode => "brcode-decode",
102 Self::History => "history",
103 Self::Limits => "limits",
104 Self::McpServe => "mcp serve",
105 Self::SetupMcp => "setup mcp",
106 }
107 }
108
109 pub fn requires_receipt(&self) -> bool {
110 matches!(self, Self::PixSend)
111 }
112}
113
114#[derive(Serialize)]
115pub struct OutputEnvelope {
116 pub command: CommandName,
117 pub data: Value,
118}
119
120const COLOR_RESET: &str = "\x1b[0m";
121const COLOR_BOLD: &str = "\x1b[1m";
122const COLOR_DIM: &str = "\x1b[2m";
123const COLOR_CYAN: &str = "\x1b[36m";
124const COLOR_GREEN: &str = "\x1b[32m";
125const COLOR_YELLOW: &str = "\x1b[33m";
126const COLOR_RED: &str = "\x1b[31m";
127
128pub(crate) fn truncate_text(value: &str, max: usize) -> String {
129 if max == 0 {
130 return String::new();
131 }
132
133 if value.chars().count() <= max {
134 return value.to_string();
135 }
136
137 let mut shortened = value
138 .chars()
139 .take(max.saturating_sub(1))
140 .collect::<String>();
141 if max == 1 {
142 shortened.clear();
143 }
144 shortened.push('…');
145 shortened
146}
147
148pub enum StatusColor {
150 Ok,
152 Warn,
154 Info,
156 Err,
158}
159
160impl StatusColor {
161 fn ansi(&self) -> &'static str {
162 match self {
163 Self::Ok => COLOR_GREEN,
164 Self::Warn => COLOR_YELLOW,
165 Self::Info => COLOR_CYAN,
166 Self::Err => COLOR_RED,
167 }
168 }
169}
170
171pub fn emit_status(tag: &str, message: &str, color: StatusColor) {
173 if io::stdout().is_terminal() {
174 let c = color.ansi();
175 println!("> {c}{COLOR_BOLD}[{tag}]{COLOR_RESET} {message}");
176 } else {
177 println!("> [{tag}] {message}");
178 }
179}
180
181pub fn emit_line(message: &str) {
183 if io::stdout().is_terminal() {
184 println!("> {COLOR_DIM}{message}{COLOR_RESET}");
185 } else {
186 println!("> {message}");
187 }
188}
189
190pub fn emit_message(
191 command: CommandName,
192 message: &str,
193 output: OutputFormat,
194 quiet: bool,
195) -> Result<()> {
196 if quiet {
197 return Ok(());
198 }
199
200 match output {
201 OutputFormat::Text => {
202 emit_status("OK", message, StatusColor::Ok);
203 }
204 OutputFormat::Json => {
205 let envelope = OutputEnvelope {
206 command,
207 data: Value::String(message.to_string()),
208 };
209 let json = serde_json::to_string_pretty(&envelope)?;
210 println!("{json}");
211 }
212 }
213 Ok(())
214}
215
216pub fn emit_data<T>(
217 command: CommandName,
218 payload: &T,
219 output: OutputFormat,
220 quiet: bool,
221) -> Result<()>
222where
223 T: Serialize,
224{
225 if quiet {
226 return Ok(());
227 }
228
229 match output {
230 OutputFormat::Text => {
231 let payload = serde_json::to_value(payload)?;
232 let text = match payload {
233 Value::Object(values) => {
234 if command.requires_receipt() {
235 render_receipt(command.label(), &values)
236 } else {
237 render_kv(command.label(), &values)
238 }
239 }
240 _ => serde_json::to_string_pretty(&payload)?,
241 };
242 println!("{text}");
243 }
244 OutputFormat::Json => {
245 let envelope = OutputEnvelope {
246 command,
247 data: serde_json::to_value(payload)?,
248 };
249 let json = serde_json::to_string_pretty(&envelope)?;
250 println!("{json}");
251 }
252 }
253 Ok(())
254}
255
256fn render_receipt(command_label: &str, values: &serde_json::Map<String, Value>) -> String {
257 let rows: Vec<(String, String)> = values
258 .iter()
259 .map(|(key, value)| (key.to_string(), render_value(value)))
260 .collect();
261
262 let max_key = rows
263 .iter()
264 .map(|(key, _)| key.len())
265 .max()
266 .unwrap_or(5)
267 .max(5);
268 let max_value = rows
269 .iter()
270 .map(|(_, value)| value.len())
271 .max()
272 .unwrap_or(5)
273 .max(5);
274 let title = format!("AGENT.PAY // {}", command_label.to_uppercase());
275 let key_width = max_key.max(title.len()).max(12);
276 let value_width = max_value.max(12);
277
278 let mut output = String::new();
279 output.push_str(&format!(
280 "┌{}┬{}┐\n",
281 "─".repeat(key_width + 2),
282 "─".repeat(value_width + 2)
283 ));
284 output.push_str(&format!(
285 "│ {:^key_width$} │ {:^value_width$} │\n",
286 title, "VALUE"
287 ));
288 output.push_str(&format!(
289 "├{}┼{}┤\n",
290 "─".repeat(key_width + 2),
291 "─".repeat(value_width + 2)
292 ));
293 output.push_str(&format!(
294 "│ {:^key_width$} │ {:^value_width$} │\n",
295 "FIELD", "CONTENT"
296 ));
297 output.push_str(&format!(
298 "├{}┼{}┤\n",
299 "─".repeat(key_width + 2),
300 "─".repeat(value_width + 2)
301 ));
302
303 for (index, (key, value)) in rows.iter().enumerate() {
304 output.push_str(&format!(
305 "│ {:<key_width$} │ {:<value_width$} │\n",
306 key, value,
307 ));
308 if index + 1 < rows.len() {
309 output.push_str(&format!(
310 "├{}┼{}┤\n",
311 "─".repeat(key_width + 2),
312 "─".repeat(value_width + 2)
313 ));
314 }
315 }
316
317 output.push_str(&format!(
318 "└{}┴{}┘",
319 "─".repeat(key_width + 2),
320 "─".repeat(value_width + 2)
321 ));
322 output
323}
324
325fn render_value(value: &Value) -> String {
326 match value {
327 Value::Null => "null".into(),
328 Value::Bool(value) => value.to_string(),
329 Value::Number(value) => value.to_string(),
330 Value::String(value) => value.clone(),
331 Value::Array(values) => {
332 if values.is_empty() {
333 "[]".into()
334 } else if values.len() <= 4 && values.iter().all(is_simple_scalar) {
335 let values = values
336 .iter()
337 .map(render_value)
338 .collect::<Vec<_>>()
339 .join(", ");
340 format!("[{values}]")
341 } else {
342 format!("[{} items]", values.len())
343 }
344 }
345 Value::Object(values) => format!("{{{} fields}}", values.len()),
346 }
347}
348
349fn style_kv_key(value: &str) -> String {
350 if io::stdout().is_terminal() {
351 format!("{COLOR_BOLD}{COLOR_CYAN}{value}{COLOR_RESET}")
352 } else {
353 value.to_string()
354 }
355}
356
357fn style_kv_value(value: &str) -> String {
358 if io::stdout().is_terminal() {
359 format!("{COLOR_GREEN}{value}{COLOR_RESET}")
360 } else {
361 value.to_string()
362 }
363}
364
365fn render_kv(command_label: &str, values: &serde_json::Map<String, Value>) -> String {
366 if values.is_empty() {
367 return format!("{}: no data", command_label);
368 }
369
370 let max_key = values.keys().map(|key| key.len()).max().unwrap_or(4).max(4);
371
372 let mut output = String::new();
373 for (index, (key, value)) in values.iter().enumerate() {
374 if index > 0 {
375 output.push('\n');
376 }
377 let padded_key = format!("{:width$}", key, width = max_key);
378 output.push_str(&format!(
379 "{}: {}",
380 style_kv_key(&padded_key),
381 style_kv_value(&render_value(value)),
382 ));
383 }
384 output
385}
386
387fn is_simple_scalar(value: &Value) -> bool {
388 matches!(
389 value,
390 Value::Null | Value::Bool(_) | Value::Number(_) | Value::String(_)
391 )
392}