Skip to main content

agentis_pay/
display.rs

1use anyhow::Result;
2use clap::ValueEnum;
3use serde::Serialize;
4use serde_json::Value;
5use std::io::{self, IsTerminal};
6use std::sync::OnceLock;
7
8/// Set the active agent name (display-safe). Only the first call takes effect.
9pub 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
16/// Return the current agent name, if one was explicitly set.
17pub 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    /// Human-oriented output.
47    #[default]
48    Text,
49    /// Machine-readable output.
50    Json,
51}
52
53#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize)]
54#[serde(rename_all = "kebab-case")]
55pub enum CommandName {
56    /// Sign-up flow.
57    Signup,
58    /// Login flow.
59    Login,
60    /// PIN verification flow.
61    Verify,
62    /// Logout flow.
63    Logout,
64    /// Session inspection.
65    Whoami,
66    /// Account details.
67    Account,
68    /// Balance query.
69    Balance,
70    /// Deposit key listing.
71    Deposit,
72    /// Pix key list.
73    PixList,
74    /// Pix transfer.
75    PixSend,
76    /// PIX BR Code decoding.
77    BrcodeDecode,
78    /// Transaction history.
79    History,
80    /// Pix limits.
81    Limits,
82    /// MCP server.
83    McpServe,
84    /// MCP setup.
85    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
148/// Color categories for tagged status output.
149pub enum StatusColor {
150    /// Green — success/completion: [OK], [DONE], [SENT]
151    Ok,
152    /// Yellow — warnings/holds: [HOLD], [WARN]
153    Warn,
154    /// Cyan — auth/info: [AUTH], [INFO]
155    Info,
156    /// Red — errors: [ERR]
157    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
171/// Print a tagged status line: `> [TAG] message`
172pub 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
181/// Print a plain prefixed line: `> message`
182pub 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}