Skip to main content

dynamic_cli/
utils.rs

1//! Utility functions for dynamic-cli
2//!
3//! This module provides common utility functions used across the framework,
4//! including type conversion, string validation, path manipulation, and
5//! formatting helpers.
6//!
7//! # Sections
8//!
9//! 1. **Formatting and Display** - Format lists, tables, sizes, durations
10//! 2. **String Validation** - Check and normalize strings
11//! 3. **Type Conversion** - Parse values with context
12//! 4. **Path Manipulation** - Normalize and check paths
13//! 5. **Test Helpers** - Common test utilities
14
15use crate::config::schema::ArgumentType;
16use crate::error::{DynamicCliError, ParseError, Result};
17use std::time::Duration;
18
19// ============================================================================
20// SECTION 1: FORMATTING AND DISPLAY
21// ============================================================================
22
23/// Format a list with numbers
24///
25/// Creates a numbered list with each item on a new line.
26///
27/// # Example
28///
29/// ```
30/// # use dynamic_cli::utils::format_numbered_list;
31/// let items = vec!["apple", "banana", "cherry"];
32/// let formatted = format_numbered_list(&items);
33/// assert_eq!(formatted, "  1. apple\n  2. banana\n  3. cherry");
34/// ```
35pub 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
44/// Format a simple table with headers and rows
45///
46/// Creates a text table with aligned columns.
47///
48/// # Example
49///
50/// ```
51/// # use dynamic_cli::utils::format_table;
52/// let headers = vec!["Name", "Age"];
53/// let rows = vec![
54///     vec!["Alice", "30"],
55///     vec!["Bob", "25"],
56/// ];
57/// let table = format_table(&headers, &rows);
58/// assert!(table.contains("Name"));
59/// assert!(table.contains("Alice"));
60/// ```
61pub fn format_table(headers: &[&str], rows: &[Vec<&str>]) -> String {
62    let mut output = String::new();
63
64    // Header
65    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    // Rows
71    for row in rows {
72        output.push_str(&row.join(" | "));
73        output.push('\n');
74    }
75
76    output
77}
78
79/// Format bytes as human-readable size
80///
81/// Converts byte count to KB, MB, GB, etc.
82///
83/// # Example
84///
85/// ```
86/// # use dynamic_cli::utils::format_bytes;
87/// assert_eq!(format_bytes(0), "0 B");
88/// assert_eq!(format_bytes(1024), "1.00 KB");
89/// assert_eq!(format_bytes(1_048_576), "1.00 MB");
90/// assert_eq!(format_bytes(1_073_741_824), "1.00 GB");
91/// ```
92pub 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
114/// Format duration in human-readable form
115///
116/// Converts duration to readable format (e.g., "1m 30s").
117///
118/// # Example
119///
120/// ```
121/// # use dynamic_cli::utils::format_duration;
122/// # use std::time::Duration;
123/// assert_eq!(format_duration(Duration::from_secs(0)), "0s");
124/// assert_eq!(format_duration(Duration::from_secs(45)), "45s");
125/// assert_eq!(format_duration(Duration::from_secs(90)), "1m 30s");
126/// assert_eq!(format_duration(Duration::from_secs(3665)), "1h 1m 5s");
127/// ```
128pub 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
154// ============================================================================
155// SECTION 2: STRING VALIDATION
156// ============================================================================
157
158/// Check if string is empty or only whitespace
159///
160/// # Example
161///
162/// ```
163/// # use dynamic_cli::utils::is_blank;
164/// assert!(is_blank(""));
165/// assert!(is_blank("   "));
166/// assert!(is_blank("\t\n"));
167/// assert!(!is_blank("hello"));
168/// assert!(!is_blank("  hello  "));
169/// ```
170pub fn is_blank(s: &str) -> bool {
171    s.trim().is_empty()
172}
173
174/// Normalize a string (trim and lowercase)
175///
176/// # Example
177///
178/// ```
179/// # use dynamic_cli::utils::normalize;
180/// assert_eq!(normalize("  Hello World  "), "hello world");
181/// assert_eq!(normalize("UPPERCASE"), "uppercase");
182/// ```
183pub fn normalize(s: &str) -> String {
184    s.trim().to_lowercase()
185}
186
187/// Truncate string to max length with ellipsis
188///
189/// # Example
190///
191/// ```
192/// # use dynamic_cli::utils::truncate;
193/// assert_eq!(truncate("Hello World", 8), "Hello...");
194/// assert_eq!(truncate("Hi", 10), "Hi");
195/// assert_eq!(truncate("Exact", 5), "Exact");
196/// ```
197pub 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
205/// Check if string looks like an email (basic validation)
206///
207/// This is a simple check, not RFC-compliant.
208///
209/// # Example
210///
211/// ```
212/// # use dynamic_cli::utils::is_valid_email;
213/// assert!(is_valid_email("user@example.com"));
214/// assert!(is_valid_email("name.surname@domain.co.uk"));
215/// assert!(!is_valid_email("invalid"));
216/// assert!(!is_valid_email("@example.com"));
217/// assert!(!is_valid_email("user@"));
218/// ```
219pub fn is_valid_email(s: &str) -> bool {
220    // Basic check: has @, has text before and after @, has . after @
221    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
233// ============================================================================
234// SECTION 3: TYPE CONVERSION
235// ============================================================================
236
237/// Parse string to integer with context
238///
239/// Returns a detailed error message on failure.
240///
241/// # Example
242///
243/// ```
244/// # use dynamic_cli::utils::parse_int;
245/// assert_eq!(parse_int("42", "count").unwrap(), 42);
246/// assert_eq!(parse_int("-10", "offset").unwrap(), -10);
247/// assert!(parse_int("abc", "count").is_err());
248/// ```
249pub 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
260/// Parse string to float with context
261///
262/// # Example
263///
264/// ```
265/// # use dynamic_cli::utils::parse_float;
266/// assert_eq!(parse_float("3.14", "pi").unwrap(), 3.14);
267/// assert_eq!(parse_float("42", "value").unwrap(), 42.0);
268/// assert!(parse_float("abc", "value").is_err());
269/// ```
270pub 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
281/// Parse string to bool
282///
283/// Accepts: true/false, yes/no, 1/0, on/off (case-insensitive).
284///
285/// # Example
286///
287/// ```
288/// # use dynamic_cli::utils::parse_bool;
289/// assert_eq!(parse_bool("true").unwrap(), true);
290/// assert_eq!(parse_bool("YES").unwrap(), true);
291/// assert_eq!(parse_bool("1").unwrap(), true);
292/// assert_eq!(parse_bool("on").unwrap(), true);
293/// assert_eq!(parse_bool("false").unwrap(), false);
294/// assert_eq!(parse_bool("no").unwrap(), false);
295/// assert_eq!(parse_bool("0").unwrap(), false);
296/// assert_eq!(parse_bool("off").unwrap(), false);
297/// assert!(parse_bool("maybe").is_err());
298/// ```
299pub 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
312/// Detect argument type from string value
313///
314/// Tries to detect the most appropriate type for a string value.
315///
316/// # Detection Order
317///
318/// 1. Bool (true/false/yes/no/1/0/on/off)
319/// 2. Integer (parseable as i64)
320/// 3. Float (parseable as f64 and contains '.')
321/// 4. Path (starts with /, ./, ../, or contains \)
322/// 5. String (default)
323///
324/// # Example
325///
326/// ```
327/// # use dynamic_cli::utils::detect_type;
328/// # use dynamic_cli::config::schema::ArgumentType;
329/// assert_eq!(detect_type("42"), ArgumentType::Integer);
330/// assert_eq!(detect_type("3.14"), ArgumentType::Float);
331/// assert_eq!(detect_type("true"), ArgumentType::Bool);
332/// assert_eq!(detect_type("/path/to/file"), ArgumentType::Path);
333/// assert_eq!(detect_type("hello"), ArgumentType::String);
334/// ```
335pub fn detect_type(value: &str) -> ArgumentType {
336    // Try bool
337    if parse_bool(value).is_ok() {
338        return ArgumentType::Bool;
339    }
340
341    // Try integer
342    if value.parse::<i64>().is_ok() {
343        return ArgumentType::Integer;
344    }
345
346    // Try float (must contain '.')
347    if value.contains('.') && value.parse::<f64>().is_ok() {
348        return ArgumentType::Float;
349    }
350
351    // Check if looks like a path
352    if value.starts_with('/')
353        || value.starts_with("./")
354        || value.starts_with("../")
355        || value.contains('\\')
356    {
357        return ArgumentType::Path;
358    }
359
360    // Default to string
361    ArgumentType::String
362}
363
364// ============================================================================
365// SECTION 4: PATH MANIPULATION
366// ============================================================================
367
368/// Normalize path separators (cross-platform)
369///
370/// Converts backslashes to forward slashes.
371///
372/// # Example
373///
374/// ```
375/// # use dynamic_cli::utils::normalize_path;
376/// assert_eq!(normalize_path("path\\to\\file"), "path/to/file");
377/// assert_eq!(normalize_path("path/to/file"), "path/to/file");
378/// ```
379pub fn normalize_path(path: &str) -> String {
380    path.replace('\\', "/")
381}
382
383/// Get file extension in lowercase
384///
385/// # Example
386///
387/// ```
388/// # use dynamic_cli::utils::get_extension;
389/// assert_eq!(get_extension("file.TXT"), Some("txt".to_string()));
390/// assert_eq!(get_extension("data.csv"), Some("csv".to_string()));
391/// assert_eq!(get_extension("no_extension"), None);
392/// assert_eq!(get_extension(".hidden"), None);
393/// ```
394pub 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
401/// Check if path has any of the given extensions
402///
403/// # Example
404///
405/// ```
406/// # use dynamic_cli::utils::has_extension;
407/// assert!(has_extension("data.csv", &["csv", "tsv"]));
408/// assert!(has_extension("config.yaml", &["yaml", "yml"]));
409/// assert!(!has_extension("data.txt", &["csv", "json"]));
410/// ```
411pub 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// ============================================================================
420// SECTION 5: TEST HELPERS
421// ============================================================================
422
423#[cfg(test)]
424pub mod test_helpers {
425    use crate::config::schema::*;
426    use crate::context::ExecutionContext;
427    use std::any::Any;
428
429    /// Create minimal valid configuration for tests
430    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    /// Create simple command definition
447    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    /// Default test context implementation
462    #[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// ============================================================================
479// TESTS
480// ============================================================================
481
482#[cfg(test)]
483mod tests {
484    use super::*;
485    use crate::context::ExecutionContext;
486
487    // ========================================================================
488    // SECTION 1: FORMATTING TESTS
489    // ========================================================================
490
491    #[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    // ========================================================================
549    // SECTION 2: VALIDATION TESTS
550    // ========================================================================
551
552    #[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    // ========================================================================
594    // SECTION 3: CONVERSION TESTS
595    // ========================================================================
596
597    #[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    // `3.14` is an intentional, intuitive decimal literal for testing string
613    // parsing — not a mistyped π. clippy::approx_constant would otherwise
614    // suggest std::f64::consts::PI, which is not what this test is about.
615    #[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    // ========================================================================
664    // SECTION 4: PATH TESTS
665    // ========================================================================
666
667    #[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    // ========================================================================
702    // SECTION 5: TEST HELPERS TESTS
703    // ========================================================================
704
705    #[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}