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