dynamic_cli/parser/type_parser.rs
1//! Type parsing functions
2//!
3//! This module provides functions to parse string values into typed values
4//! according to the [`ArgumentType`] specification. Each type has dedicated
5//! parsing logic with detailed error messages.
6//!
7//! # Supported Types
8//!
9//! - **String**: Pass-through (no conversion)
10//! - **Integer**: Signed 64-bit integers (i64)
11//! - **Float**: 64-bit floating point (f64)
12//! - **Bool**: true/false, yes/no, 1/0, on/off (case-insensitive)
13//! - **Path**: File system paths (validated as PathBuf)
14//!
15//! # Example
16//!
17//! ```
18//! use dynamic_cli::parser::type_parser::{parse_value, parse_integer};
19//! use dynamic_cli::config::schema::ArgumentType;
20//!
21//! // Parse a value according to its type
22//! let result = parse_value("42", ArgumentType::Integer).unwrap();
23//! assert_eq!(result, "42");
24//!
25//! // Parse directly to specific type
26//! let number = parse_integer("42").unwrap();
27//! assert_eq!(number, 42);
28//! ```
29
30use crate::config::schema::ArgumentType;
31use crate::error::{ParseError, Result};
32use std::path::PathBuf;
33
34/// Parse a string value according to its expected type
35///
36/// This is the main entry point for type parsing. It dispatches to
37/// type-specific parsers and validates the conversion.
38///
39/// # Arguments
40///
41/// * `value` - The string value to parse
42/// * `arg_type` - The expected type for this value
43///
44/// # Returns
45///
46/// Returns the original string value if parsing succeeds. The actual
47/// conversion to the target type is done to validate the input, but
48/// we return the string to maintain flexibility in the HashMap storage.
49///
50/// # Errors
51///
52/// Returns [`ParseError::TypeParseError`] if the value cannot be
53/// converted to the expected type.
54///
55/// # Example
56///
57/// ```
58/// use dynamic_cli::parser::type_parser::parse_value;
59/// use dynamic_cli::config::schema::ArgumentType;
60///
61/// // Valid integer
62/// assert!(parse_value("123", ArgumentType::Integer).is_ok());
63///
64/// // Invalid integer
65/// assert!(parse_value("abc", ArgumentType::Integer).is_err());
66///
67/// // Valid boolean
68/// assert!(parse_value("yes", ArgumentType::Bool).is_ok());
69///
70/// // Valid path
71/// assert!(parse_value("/tmp/file.txt", ArgumentType::Path).is_ok());
72/// ```
73pub fn parse_value(value: &str, arg_type: ArgumentType) -> Result<String> {
74 // Validate by attempting conversion to target type
75 match arg_type {
76 ArgumentType::String => {
77 // String is always valid
78 Ok(value.to_string())
79 }
80
81 ArgumentType::Integer => {
82 // Try to parse as i64
83 parse_integer(value)?;
84 Ok(value.to_string())
85 }
86
87 ArgumentType::Float => {
88 // Try to parse as f64
89 parse_float(value)?;
90 Ok(value.to_string())
91 }
92
93 ArgumentType::Bool => {
94 // Try to parse as boolean
95 parse_bool(value)?;
96 Ok(value.to_string())
97 }
98
99 ArgumentType::Path => {
100 // Try to parse as PathBuf
101 parse_path(value)?;
102 Ok(value.to_string())
103 }
104 }
105}
106
107/// Parse a string as a signed integer
108///
109/// Accepts standard integer formats including:
110/// - Positive numbers: "42"
111/// - Negative numbers: "-42"
112/// - With underscores: "1_000_000"
113///
114/// # Arguments
115///
116/// * `value` - The string to parse
117///
118/// # Returns
119///
120/// The parsed integer value as i64
121///
122/// # Errors
123///
124/// Returns [`ParseError::TypeParseError`] if the string cannot be
125/// parsed as a valid integer or if it overflows i64.
126///
127/// # Example
128///
129/// ```
130/// use dynamic_cli::parser::type_parser::parse_integer;
131///
132/// assert_eq!(parse_integer("42").unwrap(), 42);
133/// assert_eq!(parse_integer("-123").unwrap(), -123);
134/// assert_eq!(parse_integer("1000").unwrap(), 1000);
135///
136/// // These will fail
137/// assert!(parse_integer("abc").is_err());
138/// assert!(parse_integer("12.5").is_err());
139/// ```
140pub fn parse_integer(value: &str) -> Result<i64> {
141 value.parse::<i64>().map_err(|e| {
142 ParseError::TypeParseError {
143 arg_name: "value".to_string(),
144 expected_type: "integer".to_string(),
145 value: value.to_string(),
146 details: Some(e.to_string()),
147 }
148 .into()
149 })
150}
151
152/// Parse a string as a floating-point number
153///
154/// Accepts standard float formats including:
155/// - Integers: "42" → 42.0
156/// - Decimals: "3.14"
157/// - Scientific notation: "1e-10", "2.5E+3"
158/// - Special values: "inf", "-inf", "NaN"
159///
160/// # Arguments
161///
162/// * `value` - The string to parse
163///
164/// # Returns
165///
166/// The parsed floating-point value as f64
167///
168/// # Errors
169///
170/// Returns [`ParseError::TypeParseError`] if the string cannot be
171/// parsed as a valid float.
172///
173/// # Example
174///
175/// ```
176/// use dynamic_cli::parser::type_parser::parse_float;
177///
178/// assert_eq!(parse_float("3.14").unwrap(), 3.14);
179/// assert_eq!(parse_float("42").unwrap(), 42.0);
180/// assert_eq!(parse_float("-1.5").unwrap(), -1.5);
181/// assert_eq!(parse_float("1e-3").unwrap(), 0.001);
182///
183/// // These will fail
184/// assert!(parse_float("abc").is_err());
185/// assert!(parse_float("12.34.56").is_err());
186/// ```
187pub fn parse_float(value: &str) -> Result<f64> {
188 value.parse::<f64>().map_err(|e| {
189 ParseError::TypeParseError {
190 arg_name: "value".to_string(),
191 expected_type: "float".to_string(),
192 value: value.to_string(),
193 details: Some(e.to_string()),
194 }
195 .into()
196 })
197}
198
199/// Parse a string as a boolean
200///
201/// Accepts multiple representations (case-insensitive):
202/// - **true**: "true", "yes", "y", "1", "on"
203/// - **false**: "false", "no", "n", "0", "off"
204///
205/// This flexibility allows users to use natural language or
206/// common programming conventions.
207///
208/// # Arguments
209///
210/// * `value` - The string to parse
211///
212/// # Returns
213///
214/// The parsed boolean value
215///
216/// # Errors
217///
218/// Returns [`ParseError::TypeParseError`] if the string is not
219/// a recognized boolean value.
220///
221/// # Example
222///
223/// ```
224/// use dynamic_cli::parser::type_parser::parse_bool;
225///
226/// // True values
227/// assert_eq!(parse_bool("true").unwrap(), true);
228/// assert_eq!(parse_bool("YES").unwrap(), true);
229/// assert_eq!(parse_bool("1").unwrap(), true);
230/// assert_eq!(parse_bool("on").unwrap(), true);
231///
232/// // False values
233/// assert_eq!(parse_bool("false").unwrap(), false);
234/// assert_eq!(parse_bool("NO").unwrap(), false);
235/// assert_eq!(parse_bool("0").unwrap(), false);
236/// assert_eq!(parse_bool("off").unwrap(), false);
237///
238/// // Invalid values
239/// assert!(parse_bool("maybe").is_err());
240/// assert!(parse_bool("2").is_err());
241/// ```
242pub fn parse_bool(value: &str) -> Result<bool> {
243 let normalized = value.trim().to_lowercase();
244
245 match normalized.as_str() {
246 // True values
247 "true" | "yes" | "y" | "1" | "on" => Ok(true),
248
249 // False values
250 "false" | "no" | "n" | "0" | "off" => Ok(false),
251
252 // Invalid value
253 _ => Err(ParseError::TypeParseError {
254 arg_name: "value".to_string(),
255 expected_type: "bool".to_string(),
256 value: value.to_string(),
257 details: Some("expected true/false, yes/no, 1/0, on/off".to_string()),
258 }
259 .into()),
260 }
261}
262
263/// Parse a string as a file system path
264///
265/// Validates that the string can be converted to a valid PathBuf.
266/// Note: This does NOT check if the path exists on the file system -
267/// that validation is done separately in the validator module.
268///
269/// # Arguments
270///
271/// * `value` - The string to parse as a path
272///
273/// # Returns
274///
275/// The parsed PathBuf
276///
277/// # Errors
278///
279/// Returns [`ParseError::TypeParseError`] if the string contains
280/// invalid path characters (very rare on Unix, more common on Windows).
281///
282/// # Example
283///
284/// ```
285/// use dynamic_cli::parser::type_parser::parse_path;
286///
287/// // Valid paths
288/// assert!(parse_path("/tmp/file.txt").is_ok());
289/// assert!(parse_path("./relative/path").is_ok());
290/// assert!(parse_path("C:\\Windows\\System32").is_ok());
291///
292/// // Empty path is technically valid (current directory)
293/// assert!(parse_path("").is_ok());
294/// ```
295pub fn parse_path(value: &str) -> Result<PathBuf> {
296 // PathBuf::from() is very permissive - it accepts almost any string
297 // More strict validation (existence, permissions) is done by the validator module
298 let path = PathBuf::from(value);
299
300 // We could add more validation here if needed, but for now
301 // we trust that PathBuf::from() will handle platform-specific rules
302 Ok(path)
303}
304
305#[cfg(test)]
306mod tests {
307 use super::*;
308
309 // ========================================================================
310 // parse_value tests
311 // ========================================================================
312
313 #[test]
314 fn test_parse_value_string() {
315 let result = parse_value("hello world", ArgumentType::String).unwrap();
316 assert_eq!(result, "hello world");
317
318 // Empty string is valid
319 let result = parse_value("", ArgumentType::String).unwrap();
320 assert_eq!(result, "");
321 }
322
323 #[test]
324 fn test_parse_value_integer_valid() {
325 assert!(parse_value("42", ArgumentType::Integer).is_ok());
326 assert!(parse_value("-123", ArgumentType::Integer).is_ok());
327 assert!(parse_value("0", ArgumentType::Integer).is_ok());
328 }
329
330 #[test]
331 fn test_parse_value_integer_invalid() {
332 assert!(parse_value("abc", ArgumentType::Integer).is_err());
333 assert!(parse_value("12.5", ArgumentType::Integer).is_err());
334 assert!(parse_value("", ArgumentType::Integer).is_err());
335 }
336
337 #[test]
338 fn test_parse_value_float_valid() {
339 assert!(parse_value("3.14", ArgumentType::Float).is_ok());
340 assert!(parse_value("42", ArgumentType::Float).is_ok());
341 assert!(parse_value("-1.5", ArgumentType::Float).is_ok());
342 }
343
344 #[test]
345 fn test_parse_value_float_invalid() {
346 assert!(parse_value("abc", ArgumentType::Float).is_err());
347 assert!(parse_value("", ArgumentType::Float).is_err());
348 }
349
350 #[test]
351 fn test_parse_value_bool_valid() {
352 assert!(parse_value("true", ArgumentType::Bool).is_ok());
353 assert!(parse_value("false", ArgumentType::Bool).is_ok());
354 assert!(parse_value("yes", ArgumentType::Bool).is_ok());
355 assert!(parse_value("no", ArgumentType::Bool).is_ok());
356 }
357
358 #[test]
359 fn test_parse_value_bool_invalid() {
360 assert!(parse_value("maybe", ArgumentType::Bool).is_err());
361 assert!(parse_value("2", ArgumentType::Bool).is_err());
362 }
363
364 #[test]
365 fn test_parse_value_path_valid() {
366 assert!(parse_value("/tmp/file", ArgumentType::Path).is_ok());
367 assert!(parse_value("./relative", ArgumentType::Path).is_ok());
368 }
369
370 // ========================================================================
371 // parse_integer tests
372 // ========================================================================
373
374 #[test]
375 fn test_parse_integer_positive() {
376 assert_eq!(parse_integer("42").unwrap(), 42);
377 assert_eq!(parse_integer("0").unwrap(), 0);
378 assert_eq!(parse_integer("999999").unwrap(), 999999);
379 }
380
381 #[test]
382 fn test_parse_integer_negative() {
383 assert_eq!(parse_integer("-42").unwrap(), -42);
384 assert_eq!(parse_integer("-1").unwrap(), -1);
385 }
386
387 // Note: Rust's parse() does not support underscores in string literals
388 // Underscores are only a syntax feature when writing Rust code
389 // If we want to support this, we'd need to strip underscores first
390
391 #[test]
392 fn test_parse_integer_invalid() {
393 assert!(parse_integer("abc").is_err());
394 assert!(parse_integer("12.5").is_err());
395 assert!(parse_integer("").is_err());
396 assert!(parse_integer("12a").is_err());
397 }
398
399 #[test]
400 fn test_parse_integer_overflow() {
401 // i64::MAX + 1 should fail
402 let too_large = "9223372036854775808";
403 assert!(parse_integer(too_large).is_err());
404 }
405
406 // ========================================================================
407 // parse_float tests
408 // ========================================================================
409
410 #[test]
411 fn test_parse_float_integer() {
412 assert_eq!(parse_float("42").unwrap(), 42.0);
413 assert_eq!(parse_float("0").unwrap(), 0.0);
414 }
415
416 #[test]
417 // `3.14` is an intentional, intuitive decimal literal for testing string
418 // parsing — not a mistyped π. clippy::approx_constant would otherwise
419 // suggest std::f64::consts::PI, which is not what this test is about.
420 #[allow(clippy::approx_constant)]
421 fn test_parse_float_decimal() {
422 assert_eq!(parse_float("3.14").unwrap(), 3.14);
423 assert_eq!(parse_float("-1.5").unwrap(), -1.5);
424 assert_eq!(parse_float("0.5").unwrap(), 0.5);
425 }
426
427 #[test]
428 fn test_parse_float_scientific() {
429 assert_eq!(parse_float("1e3").unwrap(), 1000.0);
430 assert_eq!(parse_float("1.5e2").unwrap(), 150.0);
431 assert_eq!(parse_float("1e-3").unwrap(), 0.001);
432 }
433
434 #[test]
435 fn test_parse_float_special_values() {
436 assert!(parse_float("inf").unwrap().is_infinite());
437 assert!(parse_float("-inf").unwrap().is_infinite());
438 assert!(parse_float("NaN").unwrap().is_nan());
439 }
440
441 #[test]
442 fn test_parse_float_invalid() {
443 assert!(parse_float("abc").is_err());
444 assert!(parse_float("").is_err());
445 assert!(parse_float("12.34.56").is_err());
446 }
447
448 // ========================================================================
449 // parse_bool tests
450 // ========================================================================
451
452 #[test]
453 fn test_parse_bool_true_variants() {
454 // All true variants
455 assert!(parse_bool("true").unwrap());
456 assert!(parse_bool("True").unwrap());
457 assert!(parse_bool("TRUE").unwrap());
458 assert!(parse_bool("yes").unwrap());
459 assert!(parse_bool("YES").unwrap());
460 assert!(parse_bool("y").unwrap());
461 assert!(parse_bool("Y").unwrap());
462 assert!(parse_bool("1").unwrap());
463 assert!(parse_bool("on").unwrap());
464 assert!(parse_bool("ON").unwrap());
465 }
466
467 #[test]
468 fn test_parse_bool_false_variants() {
469 // All false variants
470 assert!(!parse_bool("false").unwrap());
471 assert!(!parse_bool("False").unwrap());
472 assert!(!parse_bool("FALSE").unwrap());
473 assert!(!parse_bool("no").unwrap());
474 assert!(!parse_bool("NO").unwrap());
475 assert!(!parse_bool("n").unwrap());
476 assert!(!parse_bool("N").unwrap());
477 assert!(!parse_bool("0").unwrap());
478 assert!(!parse_bool("off").unwrap());
479 assert!(!parse_bool("OFF").unwrap());
480 }
481
482 #[test]
483 fn test_parse_bool_with_whitespace() {
484 assert!(parse_bool(" true ").unwrap());
485 assert!(!parse_bool("\tfalse\n").unwrap());
486 }
487
488 #[test]
489 fn test_parse_bool_invalid() {
490 assert!(parse_bool("maybe").is_err());
491 assert!(parse_bool("2").is_err());
492 assert!(parse_bool("").is_err());
493 assert!(parse_bool("tr").is_err());
494 }
495
496 // ========================================================================
497 // parse_path tests
498 // ========================================================================
499
500 #[test]
501 fn test_parse_path_unix_style() {
502 let path = parse_path("/tmp/file.txt").unwrap();
503 assert_eq!(path.to_str().unwrap(), "/tmp/file.txt");
504 }
505
506 #[test]
507 fn test_parse_path_relative() {
508 let path = parse_path("./relative/path").unwrap();
509 assert!(path.to_str().unwrap().contains("relative"));
510 }
511
512 #[test]
513 fn test_parse_path_windows_style() {
514 // This should work on all platforms (PathBuf is permissive)
515 let path = parse_path("C:\\Windows\\System32").unwrap();
516 assert!(path.to_str().is_some());
517 }
518
519 #[test]
520 fn test_parse_path_empty() {
521 // Empty path is technically valid (current directory)
522 let path = parse_path("").unwrap();
523 assert_eq!(path, PathBuf::from(""));
524 }
525
526 #[test]
527 fn test_parse_path_with_spaces() {
528 let path = parse_path("/path/with spaces/file.txt").unwrap();
529 assert!(path.to_str().unwrap().contains("spaces"));
530 }
531
532 // ========================================================================
533 // Integration tests
534 // ========================================================================
535
536 #[test]
537 fn test_all_types_roundtrip() {
538 // Test that parse_value works for all types
539 let test_cases = vec![
540 ("hello", ArgumentType::String),
541 ("42", ArgumentType::Integer),
542 ("3.14", ArgumentType::Float),
543 ("true", ArgumentType::Bool),
544 ("/tmp/file", ArgumentType::Path),
545 ];
546
547 for (value, arg_type) in test_cases {
548 let result = parse_value(value, arg_type);
549 assert!(
550 result.is_ok(),
551 "Failed to parse '{}' as {:?}",
552 value,
553 arg_type
554 );
555 assert_eq!(result.unwrap(), value);
556 }
557 }
558
559 #[test]
560 fn test_error_messages_contain_details() {
561 // Verify that error messages are helpful
562 let result = parse_integer("not_a_number");
563 assert!(result.is_err());
564
565 let error = result.unwrap_err();
566 let error_msg = format!("{}", error);
567 assert!(error_msg.contains("integer"));
568 assert!(error_msg.contains("not_a_number"));
569 }
570}