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