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