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    /// CLI self-update.
87    Update,
88}
89
90impl CommandName {
91    pub fn label(&self) -> &'static str {
92        match self {
93            Self::Signup => "signup",
94            Self::Login => "login",
95            Self::Verify => "verify",
96            Self::Logout => "logout",
97            Self::Whoami => "whoami",
98            Self::Account => "account",
99            Self::Balance => "balance",
100            Self::Deposit => "deposit",
101            Self::PixList => "pix-list",
102            Self::PixSend => "pix-send",
103            Self::BrcodeDecode => "brcode-decode",
104            Self::History => "history",
105            Self::Limits => "limits",
106            Self::McpServe => "mcp serve",
107            Self::SetupMcp => "setup mcp",
108            Self::Update => "update",
109        }
110    }
111
112    pub fn requires_receipt(&self) -> bool {
113        matches!(self, Self::PixSend)
114    }
115}
116
117#[derive(Serialize)]
118pub struct OutputEnvelope {
119    pub command: CommandName,
120    pub data: Value,
121}
122
123const COLOR_RESET: &str = "\x1b[0m";
124const COLOR_BOLD: &str = "\x1b[1m";
125const COLOR_DIM: &str = "\x1b[2m";
126const COLOR_CYAN: &str = "\x1b[36m";
127const COLOR_GREEN: &str = "\x1b[32m";
128const COLOR_YELLOW: &str = "\x1b[33m";
129const COLOR_RED: &str = "\x1b[31m";
130
131pub(crate) fn truncate_text(value: &str, max: usize) -> String {
132    if max == 0 {
133        return String::new();
134    }
135
136    if value.chars().count() <= max {
137        return value.to_string();
138    }
139
140    let mut shortened = value
141        .chars()
142        .take(max.saturating_sub(1))
143        .collect::<String>();
144    if max == 1 {
145        shortened.clear();
146    }
147    shortened.push('…');
148    shortened
149}
150
151/// Color categories for tagged status output.
152pub enum StatusColor {
153    /// Green — success/completion: [OK], [DONE], [SENT]
154    Ok,
155    /// Yellow — warnings/holds: [HOLD], [WARN]
156    Warn,
157    /// Cyan — auth/info: [AUTH], [INFO]
158    Info,
159    /// Red — errors: [ERR]
160    Err,
161}
162
163impl StatusColor {
164    fn ansi(&self) -> &'static str {
165        match self {
166            Self::Ok => COLOR_GREEN,
167            Self::Warn => COLOR_YELLOW,
168            Self::Info => COLOR_CYAN,
169            Self::Err => COLOR_RED,
170        }
171    }
172}
173
174/// Print a tagged status line: `> [TAG] message`
175pub fn emit_status(tag: &str, message: &str, color: StatusColor) {
176    if io::stdout().is_terminal() {
177        let c = color.ansi();
178        println!("> {c}{COLOR_BOLD}[{tag}]{COLOR_RESET} {message}");
179    } else {
180        println!("> [{tag}] {message}");
181    }
182}
183
184/// Print a plain prefixed line: `> message`
185pub fn emit_line(message: &str) {
186    if io::stdout().is_terminal() {
187        println!("> {COLOR_DIM}{message}{COLOR_RESET}");
188    } else {
189        println!("> {message}");
190    }
191}
192
193pub fn emit_message(
194    command: CommandName,
195    message: &str,
196    output: OutputFormat,
197    quiet: bool,
198) -> Result<()> {
199    if quiet {
200        return Ok(());
201    }
202
203    match output {
204        OutputFormat::Text => {
205            emit_status("OK", message, StatusColor::Ok);
206        }
207        OutputFormat::Json => {
208            let envelope = OutputEnvelope {
209                command,
210                data: Value::String(message.to_string()),
211            };
212            let json = serde_json::to_string_pretty(&envelope)?;
213            println!("{json}");
214        }
215    }
216    Ok(())
217}
218
219pub fn emit_data<T>(
220    command: CommandName,
221    payload: &T,
222    output: OutputFormat,
223    quiet: bool,
224) -> Result<()>
225where
226    T: Serialize,
227{
228    if quiet {
229        return Ok(());
230    }
231
232    match output {
233        OutputFormat::Text => {
234            let payload = serde_json::to_value(payload)?;
235            let text = match payload {
236                Value::Object(values) => {
237                    if command.requires_receipt() {
238                        render_receipt(command.label(), &values)
239                    } else {
240                        render_kv(command.label(), &values)
241                    }
242                }
243                _ => serde_json::to_string_pretty(&payload)?,
244            };
245            println!("{text}");
246        }
247        OutputFormat::Json => {
248            let envelope = OutputEnvelope {
249                command,
250                data: serde_json::to_value(payload)?,
251            };
252            let json = serde_json::to_string_pretty(&envelope)?;
253            println!("{json}");
254        }
255    }
256    Ok(())
257}
258
259fn render_receipt(command_label: &str, values: &serde_json::Map<String, Value>) -> String {
260    let rows: Vec<(String, String)> = values
261        .iter()
262        .map(|(key, value)| (key.to_string(), render_value(value)))
263        .collect();
264
265    let max_key = rows
266        .iter()
267        .map(|(key, _)| key.len())
268        .max()
269        .unwrap_or(5)
270        .max(5);
271    let max_value = rows
272        .iter()
273        .map(|(_, value)| value.len())
274        .max()
275        .unwrap_or(5)
276        .max(5);
277    let title = format!("AGENT.PAY // {}", command_label.to_uppercase());
278    let key_width = max_key.max(title.len()).max(12);
279    let value_width = max_value.max(12);
280
281    let mut output = String::new();
282    output.push_str(&format!(
283        "┌{}┬{}┐\n",
284        "─".repeat(key_width + 2),
285        "─".repeat(value_width + 2)
286    ));
287    output.push_str(&format!(
288        "│ {:^key_width$} │ {:^value_width$} │\n",
289        title, "VALUE"
290    ));
291    output.push_str(&format!(
292        "├{}┼{}┤\n",
293        "─".repeat(key_width + 2),
294        "─".repeat(value_width + 2)
295    ));
296    output.push_str(&format!(
297        "│ {:^key_width$} │ {:^value_width$} │\n",
298        "FIELD", "CONTENT"
299    ));
300    output.push_str(&format!(
301        "├{}┼{}┤\n",
302        "─".repeat(key_width + 2),
303        "─".repeat(value_width + 2)
304    ));
305
306    for (index, (key, value)) in rows.iter().enumerate() {
307        output.push_str(&format!(
308            "│ {:<key_width$} │ {:<value_width$} │\n",
309            key, value,
310        ));
311        if index + 1 < rows.len() {
312            output.push_str(&format!(
313                "├{}┼{}┤\n",
314                "─".repeat(key_width + 2),
315                "─".repeat(value_width + 2)
316            ));
317        }
318    }
319
320    output.push_str(&format!(
321        "└{}┴{}┘",
322        "─".repeat(key_width + 2),
323        "─".repeat(value_width + 2)
324    ));
325    output
326}
327
328fn render_value(value: &Value) -> String {
329    match value {
330        Value::Null => "null".into(),
331        Value::Bool(value) => value.to_string(),
332        Value::Number(value) => value.to_string(),
333        Value::String(value) => value.clone(),
334        Value::Array(values) => {
335            if values.is_empty() {
336                "[]".into()
337            } else if values.len() <= 4 && values.iter().all(is_simple_scalar) {
338                let values = values
339                    .iter()
340                    .map(render_value)
341                    .collect::<Vec<_>>()
342                    .join(", ");
343                format!("[{values}]")
344            } else {
345                format!("[{} items]", values.len())
346            }
347        }
348        Value::Object(values) => format!("{{{} fields}}", values.len()),
349    }
350}
351
352fn style_kv_key(value: &str) -> String {
353    if io::stdout().is_terminal() {
354        format!("{COLOR_BOLD}{COLOR_CYAN}{value}{COLOR_RESET}")
355    } else {
356        value.to_string()
357    }
358}
359
360fn style_kv_value(value: &str) -> String {
361    if io::stdout().is_terminal() {
362        format!("{COLOR_GREEN}{value}{COLOR_RESET}")
363    } else {
364        value.to_string()
365    }
366}
367
368fn render_kv(command_label: &str, values: &serde_json::Map<String, Value>) -> String {
369    if values.is_empty() {
370        return format!("{}: no data", command_label);
371    }
372
373    let max_key = values.keys().map(|key| key.len()).max().unwrap_or(4).max(4);
374
375    let mut output = String::new();
376    for (index, (key, value)) in values.iter().enumerate() {
377        if index > 0 {
378            output.push('\n');
379        }
380        let padded_key = format!("{:width$}", key, width = max_key);
381        output.push_str(&format!(
382            "{}: {}",
383            style_kv_key(&padded_key),
384            style_kv_value(&render_value(value)),
385        ));
386    }
387    output
388}
389
390fn is_simple_scalar(value: &Value) -> bool {
391    matches!(
392        value,
393        Value::Null | Value::Bool(_) | Value::Number(_) | Value::String(_)
394    )
395}