1use std::io::{self, Write};
2
3use anyhow::{Context, Result, bail};
4use clap::{ArgGroup, Args, value_parser};
5use std::fmt;
6
7pub mod account;
8pub mod balance;
9pub mod brcode;
10pub mod deposit;
11pub mod history;
12pub mod limits;
13pub mod login;
14pub mod logout;
15pub mod mcp;
16pub mod pix;
17pub mod signup;
18pub mod skill;
19pub mod update;
20pub mod verify;
21pub mod whoami;
22
23#[derive(Args, Debug, Clone)]
24pub struct SignupArgs {
25 #[arg(short, long, value_name = "EMAIL", value_parser = value_parser!(String))]
27 pub email: Option<String>,
28
29 #[arg(short = 'c', long, value_name = "CPF", value_parser = parse_digits)]
31 pub cpf: Option<String>,
32
33 #[arg(short, long, value_name = "PHONE", value_parser = parse_digits)]
35 pub phone: Option<String>,
36
37 #[arg(
39 short = 'a',
40 long = "agent-name",
41 value_name = "NAME",
42 env = "AGENTIS_PAY_AGENT_NAME"
43 )]
44 pub agent_name: Option<String>,
45}
46
47#[derive(Args, Debug, Clone)]
48pub struct LoginArgs {
49 #[arg(long)]
51 pub web: bool,
52
53 #[arg(long, conflicts_with = "web")]
55 pub pin: bool,
56
57 #[arg(short, long, value_name = "EMAIL", value_parser = value_parser!(String))]
59 pub email: Option<String>,
60
61 #[arg(short = 'p', long = "phone", value_name = "PHONE", value_parser = parse_digits)]
63 pub phone: Option<String>,
64
65 #[arg(long)]
68 pub open: bool,
69
70 #[arg(
72 short = 'a',
73 long = "agent-name",
74 value_name = "NAME",
75 env = "AGENTIS_PAY_AGENT_NAME"
76 )]
77 pub agent_name: Option<String>,
78}
79
80#[derive(Args, Debug, Clone)]
81pub struct VerifyArgs {
82 #[arg(value_name = "PIN")]
84 pub pin: String,
85}
86
87#[derive(Debug, Clone)]
88pub enum LoginIdentifier {
89 Email(String),
90 Phone(String),
91}
92
93impl LoginIdentifier {
94 pub fn label(&self) -> &'static str {
95 match self {
96 Self::Email(_) => "email",
97 Self::Phone(_) => "phone",
98 }
99 }
100}
101
102impl fmt::Display for LoginIdentifier {
103 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
104 match self {
105 Self::Email(value) => write!(f, "Email {value}"),
106 Self::Phone(value) => write!(f, "Phone {value}"),
107 }
108 }
109}
110
111pub fn prompt_login_identifier() -> Result<LoginIdentifier> {
112 println!("> Select login method:");
113 println!(" 1) Email");
114 println!(" 2) Phone");
115 let choice = prompt("Select [1/2]:")?;
116
117 match choice.as_str() {
118 "1" => Ok(LoginIdentifier::Email(prompt("Email:")?)),
119 "2" => Ok(LoginIdentifier::Phone(prompt("Phone:")?)),
120 _ => bail!("invalid selection"),
121 }
122}
123
124#[derive(Args, Debug, Clone)]
125#[command(
126 group(ArgGroup::new("pix-recipient").required(true).args(["key", "brcode"])),
127 group(
128 ArgGroup::new("pix-amount")
129 .required(true)
130 .args(["amount_cents", "amount"])
131 )
132)]
133pub struct PixSendArgs {
134 #[arg(short, long, value_name = "KEY", conflicts_with = "brcode")]
136 pub key: Option<String>,
137
138 #[arg(
140 short = 'b',
141 long,
142 value_name = "BR_CODE",
143 required = false,
144 conflicts_with = "key"
145 )]
146 pub brcode: Option<String>,
147
148 #[arg(
150 short = 'a',
151 long,
152 value_name = "CENTS",
153 value_parser = parse_positive_cents,
154 conflicts_with = "amount"
155 )]
156 pub amount_cents: Option<i64>,
157
158 #[arg(long, value_name = "BRL", value_parser = parse_brl_amount, conflicts_with = "amount_cents")]
160 pub amount: Option<i64>,
161
162 #[arg(short, long, value_name = "NOTE")]
164 pub note: Option<String>,
165
166 #[arg(long, value_name = "AGENT_MESSAGE")]
168 pub agent_message: String,
169}
170
171#[derive(Args, Debug, Clone)]
172pub struct BrcodeArgs {
173 #[arg(value_name = "BRCODE")]
175 pub code: String,
176}
177
178#[derive(Args, Debug, Clone)]
179pub struct HistoryArgs {
180 #[arg(value_name = "TRANSACTION_ID")]
182 pub id: Option<String>,
183
184 #[arg(
186 short,
187 long,
188 default_value_t = 20u32,
189 value_name = "COUNT",
190 value_parser = parse_history_limit
191 )]
192 pub limit: u32,
193
194 #[arg(short, long, value_name = "CURSOR")]
196 pub cursor: Option<String>,
197
198 #[cfg(debug_assertions)]
200 #[arg(long)]
201 pub demo: bool,
202}
203
204pub fn prompt(label: &str) -> Result<String> {
205 print!("{label} ");
206 io::stdout()
207 .flush()
208 .context("failed to flush stdout for prompt")?;
209 let mut value = String::new();
210 io::stdin()
211 .read_line(&mut value)
212 .context("failed to read user input")?;
213 let value = value.trim().to_string();
214 if value.is_empty() {
215 bail!("input cannot be empty");
216 }
217
218 Ok(value)
219}
220
221fn parse_positive_cents(value: &str) -> std::result::Result<i64, String> {
222 let parsed = value
223 .parse::<i64>()
224 .map_err(|_| format!("{value} is not a valid amount in cents"))?;
225
226 if parsed <= 0 {
227 return Err("amount must be greater than zero".into());
228 }
229
230 Ok(parsed)
231}
232
233fn parse_brl_amount(value: &str) -> std::result::Result<i64, String> {
240 let trimmed = value.trim();
241 if trimmed.is_empty() {
242 return Err("amount cannot be empty".into());
243 }
244
245 if !trimmed
246 .chars()
247 .all(|c| c.is_ascii_digit() || matches!(c, '.' | ','))
248 {
249 return Err(format!(
250 "{value} is not a valid amount; pass a plain number like 12.34"
251 ));
252 }
253
254 let separator_count = trimmed.chars().filter(|c| matches!(c, '.' | ',')).count();
255 if separator_count > 1 {
256 return Err(format!(
257 "{value} is not a valid amount; use a single decimal separator like 12.34 or 12,34"
258 ));
259 }
260
261 let amount_cents = if let Some(sep_index) = trimmed.find(['.', ',']) {
262 let integer_part = &trimmed[..sep_index];
263 let fraction_part = &trimmed[sep_index + 1..];
264
265 if fraction_part.len() > 2 {
266 return Err(format!(
267 "{value} has too many decimal digits; use at most 2 (e.g. 12.34)"
268 ));
269 }
270
271 let integer: i64 = if integer_part.is_empty() {
272 0
273 } else {
274 integer_part
275 .parse()
276 .map_err(|_| format!("{value} is not a valid amount"))?
277 };
278
279 let cents: i64 = if fraction_part.is_empty() {
280 0
281 } else {
282 let parsed: i64 = fraction_part
283 .parse()
284 .map_err(|_| format!("{value} is not a valid amount"))?;
285 if fraction_part.len() == 1 {
286 parsed * 10
287 } else {
288 parsed
289 }
290 };
291
292 integer
293 .checked_mul(100)
294 .and_then(|base| base.checked_add(cents))
295 .ok_or_else(|| String::from("amount is too large"))?
296 } else {
297 trimmed
298 .parse::<i64>()
299 .map_err(|_| format!("{value} is not a valid amount"))?
300 .checked_mul(100)
301 .ok_or_else(|| String::from("amount is too large"))?
302 };
303
304 if amount_cents <= 0 {
305 return Err("amount must be greater than zero".into());
306 }
307
308 Ok(amount_cents)
309}
310
311fn parse_history_limit(value: &str) -> std::result::Result<u32, String> {
312 let parsed = value
313 .parse::<u32>()
314 .map_err(|_| format!("{value} is not a valid history limit"))?;
315
316 if parsed == 0 {
317 return Err("history limit must be greater than zero".into());
318 }
319
320 if parsed > 50 {
321 return Err("history limit cannot exceed 50".into());
322 }
323
324 Ok(parsed)
325}
326
327fn parse_digits(value: &str) -> std::result::Result<String, String> {
328 let trimmed = value.trim();
329 let has_digits_only = trimmed
330 .chars()
331 .all(|c| c.is_ascii_digit() || matches!(c, '+' | '.' | ' ' | '(' | ')'));
332
333 if !has_digits_only {
334 return Err(
335 "must contain only digits and common separators (+, ., spaces, parentheses)".into(),
336 );
337 }
338
339 let normalized: String = trimmed.chars().filter(|c| c.is_ascii_digit()).collect();
340 if normalized.is_empty() {
341 return Err("must contain at least one digit".into());
342 }
343
344 Ok(normalized)
345}
346
347#[cfg(test)]
348mod tests {
349 use super::*;
350
351 #[test]
352 fn parse_positive_cents_rejects_zero() {
353 assert!(parse_positive_cents("0").is_err());
354 }
355
356 #[test]
357 fn parse_positive_cents_rejects_negative() {
358 assert!(parse_positive_cents("-1").is_err());
359 }
360
361 #[test]
362 fn parse_positive_cents_accepts_valid_value() {
363 assert_eq!(parse_positive_cents("1250").expect("valid"), 1250);
364 }
365
366 #[test]
367 fn parse_amount_brl_accepts_dot_decimal() {
368 assert_eq!(parse_brl_amount("12.34").expect("valid"), 1234);
369 }
370
371 #[test]
372 fn parse_amount_brl_accepts_comma_decimal() {
373 assert_eq!(parse_brl_amount("12,34").expect("valid"), 1234);
374 }
375
376 #[test]
377 fn parse_amount_brl_rejects_zero() {
378 assert!(parse_brl_amount("0").is_err());
379 }
380
381 #[test]
382 fn parse_amount_brl_accepts_whole_number() {
383 assert_eq!(parse_brl_amount("50").expect("valid"), 5000);
384 }
385
386 #[test]
387 fn parse_amount_brl_accepts_single_decimal_digit() {
388 assert_eq!(parse_brl_amount("12.5").expect("valid"), 1250);
389 assert_eq!(parse_brl_amount("12,5").expect("valid"), 1250);
390 }
391
392 #[test]
393 fn parse_amount_brl_accepts_leading_decimal() {
394 assert_eq!(parse_brl_amount(".50").expect("valid"), 50);
395 assert_eq!(parse_brl_amount(",99").expect("valid"), 99);
396 }
397
398 #[test]
399 fn parse_amount_brl_rejects_multiple_separators() {
400 assert!(parse_brl_amount("12,34,56").is_err());
401 assert!(parse_brl_amount("1.234,56").is_err());
402 assert!(parse_brl_amount("1,234.56").is_err());
403 }
404
405 #[test]
406 fn parse_amount_brl_rejects_three_decimal_digits() {
407 assert!(parse_brl_amount("12.345").is_err());
408 assert!(parse_brl_amount("12,345").is_err());
409 }
410
411 #[test]
412 fn parse_amount_brl_rejects_letters() {
413 assert!(parse_brl_amount("R$12.34").is_err());
414 assert!(parse_brl_amount("abc").is_err());
415 }
416
417 #[test]
418 fn parse_amount_brl_rejects_empty() {
419 assert!(parse_brl_amount("").is_err());
420 assert!(parse_brl_amount(" ").is_err());
421 }
422
423 #[test]
424 fn parse_digits_accepts_international_phone() {
425 assert_eq!(
426 parse_digits("+55 (11) 9 9999 9999").expect("valid"),
427 "5511999999999".to_string()
428 );
429 }
430
431 #[test]
432 fn parse_digits_rejects_hyphens() {
433 assert!(parse_digits("1-2-3").is_err());
434 assert!(parse_digits("+55 (11) 9 9999-9999").is_err());
435 }
436
437 #[test]
438 fn parse_digits_rejects_non_digits() {
439 assert!(parse_digits("abc-123").is_err());
440 }
441
442 #[test]
443 fn parse_digits_rejects_empty_string() {
444 assert!(parse_digits(" + .().").is_err());
445 }
446}