1use crate::config::schema::ArgumentType;
16use crate::error::{DynamicCliError, ParseError, Result};
17use std::time::Duration;
18
19pub fn format_numbered_list<T: std::fmt::Display>(items: &[T]) -> String {
36 items
37 .iter()
38 .enumerate()
39 .map(|(i, item)| format!(" {}. {}", i + 1, item))
40 .collect::<Vec<_>>()
41 .join("\n")
42}
43
44pub fn format_table(headers: &[&str], rows: &[Vec<&str>]) -> String {
62 let mut output = String::new();
63
64 output.push_str(&headers.join(" | "));
66 output.push('\n');
67 output.push_str(&"-".repeat(headers.iter().map(|h| h.len() + 3).sum()));
68 output.push('\n');
69
70 for row in rows {
72 output.push_str(&row.join(" | "));
73 output.push('\n');
74 }
75
76 output
77}
78
79pub fn format_bytes(bytes: u64) -> String {
93 const UNITS: &[&str] = &["B", "KB", "MB", "GB", "TB"];
94
95 if bytes == 0 {
96 return "0 B".to_string();
97 }
98
99 let mut size = bytes as f64;
100 let mut unit_idx = 0;
101
102 while size >= 1024.0 && unit_idx < UNITS.len() - 1 {
103 size /= 1024.0;
104 unit_idx += 1;
105 }
106
107 if unit_idx == 0 {
108 format!("{} {}", bytes, UNITS[0])
109 } else {
110 format!("{:.2} {}", size, UNITS[unit_idx])
111 }
112}
113
114pub fn format_duration(duration: Duration) -> String {
129 let total_secs = duration.as_secs();
130
131 if total_secs == 0 {
132 return "0s".to_string();
133 }
134
135 let hours = total_secs / 3600;
136 let minutes = (total_secs % 3600) / 60;
137 let seconds = total_secs % 60;
138
139 let mut parts = Vec::new();
140
141 if hours > 0 {
142 parts.push(format!("{}h", hours));
143 }
144 if minutes > 0 {
145 parts.push(format!("{}m", minutes));
146 }
147 if seconds > 0 || parts.is_empty() {
148 parts.push(format!("{}s", seconds));
149 }
150
151 parts.join(" ")
152}
153
154pub fn is_blank(s: &str) -> bool {
171 s.trim().is_empty()
172}
173
174pub fn normalize(s: &str) -> String {
184 s.trim().to_lowercase()
185}
186
187pub fn truncate(s: &str, max_len: usize) -> String {
198 if s.len() <= max_len {
199 s.to_string()
200 } else {
201 format!("{}...", &s[..max_len.saturating_sub(3)])
202 }
203}
204
205pub fn is_valid_email(s: &str) -> bool {
220 let parts: Vec<&str> = s.split('@').collect();
222
223 if parts.len() != 2 {
224 return false;
225 }
226
227 let local = parts[0];
228 let domain = parts[1];
229
230 !local.is_empty() && !domain.is_empty() && domain.contains('.')
231}
232
233pub fn parse_int(value: &str, field_name: &str) -> Result<i64> {
250 value.parse::<i64>().map_err(|_| {
251 DynamicCliError::Parse(ParseError::TypeParseError {
252 arg_name: field_name.to_string(),
253 expected_type: "integer".to_string(),
254 value: value.to_string(),
255 details: Some("must be a valid integer".to_string()),
256 })
257 })
258}
259
260pub fn parse_float(value: &str, field_name: &str) -> Result<f64> {
271 value.parse::<f64>().map_err(|_| {
272 DynamicCliError::Parse(ParseError::TypeParseError {
273 arg_name: field_name.to_string(),
274 expected_type: "float".to_string(),
275 value: value.to_string(),
276 details: Some("must be a valid floating-point number".to_string()),
277 })
278 })
279}
280
281pub fn parse_bool(value: &str) -> Result<bool> {
300 match value.trim().to_lowercase().as_str() {
301 "true" | "yes" | "1" | "on" => Ok(true),
302 "false" | "no" | "0" | "off" => Ok(false),
303 _ => Err(DynamicCliError::Parse(ParseError::TypeParseError {
304 arg_name: "value".to_string(),
305 expected_type: "bool".to_string(),
306 value: value.to_string(),
307 details: Some("must be one of: true, false, yes, no, 1, 0, on, off".to_string()),
308 })),
309 }
310}
311
312pub fn detect_type(value: &str) -> ArgumentType {
336 if parse_bool(value).is_ok() {
338 return ArgumentType::Bool;
339 }
340
341 if value.parse::<i64>().is_ok() {
343 return ArgumentType::Integer;
344 }
345
346 if value.contains('.') && value.parse::<f64>().is_ok() {
348 return ArgumentType::Float;
349 }
350
351 if value.starts_with('/')
353 || value.starts_with("./")
354 || value.starts_with("../")
355 || value.contains('\\')
356 {
357 return ArgumentType::Path;
358 }
359
360 ArgumentType::String
362}
363
364pub fn normalize_path(path: &str) -> String {
380 path.replace('\\', "/")
381}
382
383pub fn get_extension(path: &str) -> Option<String> {
395 let path = std::path::Path::new(path);
396 path.extension()
397 .and_then(|ext| ext.to_str())
398 .map(|ext| ext.to_lowercase())
399}
400
401pub fn has_extension(path: &str, extensions: &[&str]) -> bool {
412 if let Some(ext) = get_extension(path) {
413 extensions.iter().any(|&e| e.to_lowercase() == ext)
414 } else {
415 false
416 }
417}
418
419#[cfg(test)]
424pub mod test_helpers {
425 use crate::config::schema::*;
426 use crate::context::ExecutionContext;
427 use std::any::Any;
428
429 pub fn create_test_config(prompt: &str, commands: Vec<&str>) -> CommandsConfig {
431 CommandsConfig {
432 metadata: Metadata {
433 version: "1.0.0".to_string(),
434 prompt: prompt.to_string(),
435 prompt_suffix: " > ".to_string(),
436 },
437 commands: commands
438 .into_iter()
439 .map(|name| create_test_command(name, false))
440 .collect(),
441 global_options: vec![],
442 directives: vec![],
443 }
444 }
445
446 pub fn create_test_command(name: &str, required: bool) -> CommandDefinition {
448 CommandDefinition {
449 name: name.to_string(),
450 aliases: vec![],
451 description: format!("Test command: {}", name),
452 required,
453 arguments: vec![],
454 options: vec![],
455 implementation: format!("{}_handler", name),
456 continue_on_failure: false,
457 requires_success: false,
458 }
459 }
460
461 #[derive(Default, Debug)]
463 pub struct TestContext {
464 pub executed: Vec<String>,
465 }
466
467 impl ExecutionContext for TestContext {
468 fn as_any(&self) -> &dyn Any {
469 self
470 }
471
472 fn as_any_mut(&mut self) -> &mut dyn Any {
473 self
474 }
475 }
476}
477
478#[cfg(test)]
483mod tests {
484 use super::*;
485 use crate::context::ExecutionContext;
486
487 #[test]
492 fn test_format_numbered_list_empty() {
493 let items: Vec<&str> = vec![];
494 assert_eq!(format_numbered_list(&items), "");
495 }
496
497 #[test]
498 fn test_format_numbered_list_single() {
499 let items = vec!["apple"];
500 assert_eq!(format_numbered_list(&items), " 1. apple");
501 }
502
503 #[test]
504 fn test_format_numbered_list_multiple() {
505 let items = vec!["apple", "banana", "cherry"];
506 let result = format_numbered_list(&items);
507 assert!(result.contains("1. apple"));
508 assert!(result.contains("2. banana"));
509 assert!(result.contains("3. cherry"));
510 }
511
512 #[test]
513 fn test_format_table_simple() {
514 let headers = vec!["Name", "Age"];
515 let rows = vec![vec!["Alice", "30"], vec!["Bob", "25"]];
516 let table = format_table(&headers, &rows);
517
518 assert!(table.contains("Name"));
519 assert!(table.contains("Alice"));
520 assert!(table.contains("30"));
521 }
522
523 #[test]
524 fn test_format_bytes_zero() {
525 assert_eq!(format_bytes(0), "0 B");
526 }
527
528 #[test]
529 fn test_format_bytes_various_sizes() {
530 assert_eq!(format_bytes(512), "512 B");
531 assert_eq!(format_bytes(1024), "1.00 KB");
532 assert_eq!(format_bytes(1_048_576), "1.00 MB");
533 assert_eq!(format_bytes(1_073_741_824), "1.00 GB");
534 }
535
536 #[test]
537 fn test_format_duration_zero() {
538 assert_eq!(format_duration(Duration::from_secs(0)), "0s");
539 }
540
541 #[test]
542 fn test_format_duration_various() {
543 assert_eq!(format_duration(Duration::from_secs(45)), "45s");
544 assert_eq!(format_duration(Duration::from_secs(90)), "1m 30s");
545 assert_eq!(format_duration(Duration::from_secs(3665)), "1h 1m 5s");
546 }
547
548 #[test]
553 fn test_is_blank_various() {
554 assert!(is_blank(""));
555 assert!(is_blank(" "));
556 assert!(is_blank("\t\n"));
557 assert!(!is_blank("hello"));
558 assert!(!is_blank(" hello "));
559 }
560
561 #[test]
562 fn test_normalize() {
563 assert_eq!(normalize(" Hello World "), "hello world");
564 assert_eq!(normalize("UPPERCASE"), "uppercase");
565 assert_eq!(normalize("MixedCase"), "mixedcase");
566 }
567
568 #[test]
569 fn test_truncate_long_string() {
570 assert_eq!(truncate("Hello World", 8), "Hello...");
571 }
572
573 #[test]
574 fn test_truncate_short_string() {
575 assert_eq!(truncate("Hi", 10), "Hi");
576 assert_eq!(truncate("Exact", 5), "Exact");
577 }
578
579 #[test]
580 fn test_is_valid_email_valid() {
581 assert!(is_valid_email("user@example.com"));
582 assert!(is_valid_email("name.surname@domain.co.uk"));
583 }
584
585 #[test]
586 fn test_is_valid_email_invalid() {
587 assert!(!is_valid_email("invalid"));
588 assert!(!is_valid_email("@example.com"));
589 assert!(!is_valid_email("user@"));
590 assert!(!is_valid_email("no-at-sign.com"));
591 }
592
593 #[test]
598 fn test_parse_int_valid() {
599 assert_eq!(parse_int("42", "count").unwrap(), 42);
600 assert_eq!(parse_int("-10", "offset").unwrap(), -10);
601 assert_eq!(parse_int("0", "zero").unwrap(), 0);
602 }
603
604 #[test]
605 fn test_parse_int_invalid() {
606 assert!(parse_int("abc", "count").is_err());
607 assert!(parse_int("3.14", "count").is_err());
608 assert!(parse_int("", "count").is_err());
609 }
610
611 #[test]
612 #[allow(clippy::approx_constant)]
616 fn test_parse_float_valid() {
617 assert_eq!(parse_float("3.14", "pi").unwrap(), 3.14);
618 assert_eq!(parse_float("42", "value").unwrap(), 42.0);
619 assert_eq!(parse_float("-1.5", "neg").unwrap(), -1.5);
620 }
621
622 #[test]
623 fn test_parse_bool_various() {
624 assert!(parse_bool("true").unwrap());
625 assert!(parse_bool("YES").unwrap());
626 assert!(parse_bool("1").unwrap());
627 assert!(parse_bool("on").unwrap());
628
629 assert!(!parse_bool("false").unwrap());
630 assert!(!parse_bool("no").unwrap());
631 assert!(!parse_bool("0").unwrap());
632 assert!(!parse_bool("off").unwrap());
633
634 assert!(parse_bool("maybe").is_err());
635 }
636
637 #[test]
638 fn test_detect_type_integer() {
639 assert_eq!(detect_type("42"), ArgumentType::Integer);
640 assert_eq!(detect_type("-10"), ArgumentType::Integer);
641 }
642
643 #[test]
644 fn test_detect_type_float() {
645 assert_eq!(detect_type("3.14"), ArgumentType::Float);
646 assert_eq!(detect_type("-1.5"), ArgumentType::Float);
647 }
648
649 #[test]
650 fn test_detect_type_bool() {
651 assert_eq!(detect_type("true"), ArgumentType::Bool);
652 assert_eq!(detect_type("false"), ArgumentType::Bool);
653 assert_eq!(detect_type("yes"), ArgumentType::Bool);
654 }
655
656 #[test]
657 fn test_detect_type_path() {
658 assert_eq!(detect_type("/usr/bin"), ArgumentType::Path);
659 assert_eq!(detect_type("./file"), ArgumentType::Path);
660 assert_eq!(detect_type("..\\path"), ArgumentType::Path);
661 }
662
663 #[test]
668 fn test_normalize_path_windows() {
669 assert_eq!(normalize_path("path\\to\\file"), "path/to/file");
670 }
671
672 #[test]
673 fn test_normalize_path_unix() {
674 assert_eq!(normalize_path("path/to/file"), "path/to/file");
675 }
676
677 #[test]
678 fn test_get_extension_valid() {
679 assert_eq!(get_extension("file.TXT"), Some("txt".to_string()));
680 assert_eq!(get_extension("data.csv"), Some("csv".to_string()));
681 }
682
683 #[test]
684 fn test_get_extension_none() {
685 assert_eq!(get_extension("no_extension"), None);
686 assert_eq!(get_extension(".hidden"), None);
687 }
688
689 #[test]
690 fn test_has_extension_match() {
691 assert!(has_extension("data.csv", &["csv", "tsv"]));
692 assert!(has_extension("config.YAML", &["yaml", "yml"]));
693 }
694
695 #[test]
696 fn test_has_extension_no_match() {
697 assert!(!has_extension("data.txt", &["csv", "json"]));
698 assert!(!has_extension("no_ext", &["txt"]));
699 }
700
701 #[test]
706 fn test_create_test_config() {
707 let config = test_helpers::create_test_config("test", vec!["cmd1", "cmd2"]);
708 assert_eq!(config.metadata.prompt, "test");
709 assert_eq!(config.commands.len(), 2);
710 }
711
712 #[test]
713 fn test_create_test_command() {
714 let cmd = test_helpers::create_test_command("test", true);
715 assert_eq!(cmd.name, "test");
716 assert!(cmd.required);
717 }
718
719 #[test]
720 fn test_test_context_downcast() {
721 let mut ctx = test_helpers::TestContext::default();
722 ctx.executed.push("test".to_string());
723
724 let ctx_ref = &ctx as &dyn ExecutionContext;
725 let downcast = crate::context::downcast_ref::<test_helpers::TestContext>(ctx_ref);
726 assert!(downcast.is_some());
727 assert_eq!(downcast.unwrap().executed.len(), 1);
728 }
729}