dynamic_cli/config/
loader.rs

1//! Configuration file loading
2//!
3//! This module provides functions to load configuration from
4//! YAML or JSON files, with automatic format detection.
5//!
6//! # Supported Formats
7//!
8//! - YAML (`.yaml`, `.yml`)
9//! - JSON (`.json`)
10//!
11//! # Example
12//!
13//! ```no_run
14//! use dynamic_cli::config::loader::load_config;
15//! use std::path::Path;
16//!
17//! let config = load_config("commands.yaml").unwrap();
18//! println!("Loaded {} commands", config.commands.len());
19//! ```
20
21use crate::config::schema::CommandsConfig;
22use crate::error::{ConfigError, DynamicCliError, Result};
23use std::fs;
24use std::path::Path;
25
26/// Load configuration from a file
27///
28/// Automatically detects the format (YAML or JSON) based on
29/// the file extension and parses the content accordingly.
30///
31/// # Supported Extensions
32///
33/// - `.yaml`, `.yml` → YAML parser
34/// - `.json` → JSON parser
35///
36/// # Arguments
37///
38/// * `path` - Path to the configuration file
39///
40/// # Returns
41///
42/// Parsed [`CommandsConfig`] on success
43///
44/// # Errors
45///
46/// - [`ConfigError::FileNotFound`] if the file doesn't exist
47/// - [`ConfigError::UnsupportedFormat`] if the extension is not recognized
48/// - [`ConfigError::YamlParse`] or [`ConfigError::JsonParse`] if parsing fails
49///
50/// # Example
51///
52/// ```no_run
53/// use dynamic_cli::config::loader::load_config;
54///
55/// // Load YAML configuration
56/// let config = load_config("commands.yaml")?;
57/// # Ok::<(), dynamic_cli::error::DynamicCliError>(())
58/// ```
59pub fn load_config<P: AsRef<Path>>(path: P) -> Result<CommandsConfig> {
60    let path = path.as_ref();
61
62    // Check if file exists
63    if !path.exists() {
64        return Err(ConfigError::FileNotFound {
65            path: path.to_path_buf(),
66        }
67        .into());
68    }
69
70    // Detect format from extension
71    let extension = path
72        .extension()
73        .and_then(|ext| ext.to_str())
74        .ok_or_else(|| ConfigError::UnsupportedFormat {
75            extension: "<none>".to_string(),
76        })?;
77
78    // Read file content
79    let content = fs::read_to_string(path).map_err(DynamicCliError::from)?;
80
81    // Parse according to format
82    match extension.to_lowercase().as_str() {
83        "yaml" | "yml" => load_yaml(&content),
84        "json" => load_json(&content),
85        other => Err(ConfigError::UnsupportedFormat {
86            extension: other.to_string(),
87        }
88        .into()),
89    }
90}
91
92/// Load configuration from a YAML string
93///
94/// Parses YAML content and deserializes it into a [`CommandsConfig`].
95/// Provides detailed error messages with line and column information
96/// when parsing fails.
97///
98/// # Arguments
99///
100/// * `content` - YAML string to parse
101///
102/// # Returns
103///
104/// Parsed [`CommandsConfig`] on success
105///
106/// # Errors
107///
108/// - [`ConfigError::YamlParse`] if the YAML is invalid or doesn't match the schema
109///
110/// # Example
111///
112/// ```
113/// use dynamic_cli::config::loader::load_yaml;
114///
115/// let yaml = r#"
116/// metadata:
117///   version: "1.0.0"
118///   prompt: "test"
119/// commands: []
120/// global_options: []
121/// "#;
122///
123/// let config = load_yaml(yaml).unwrap();
124/// assert_eq!(config.metadata.version, "1.0.0");
125/// ```
126pub fn load_yaml(content: &str) -> Result<CommandsConfig> {
127    serde_yaml::from_str(content).map_err(|e| {
128        // Extract position information from error
129        ConfigError::yaml_parse_with_location(e).into()
130    })
131}
132
133/// Load configuration from a JSON string
134///
135/// Parses JSON content and deserializes it into a [`CommandsConfig`].
136/// Provides detailed error messages with line and column information
137/// when parsing fails.
138///
139/// # Arguments
140///
141/// * `content` - JSON string to parse
142///
143/// # Returns
144///
145/// Parsed [`CommandsConfig`] on success
146///
147/// # Errors
148///
149/// - [`ConfigError::JsonParse`] if the JSON is invalid or doesn't match the schema
150///
151/// # Example
152///
153/// ```
154/// use dynamic_cli::config::loader::load_json;
155///
156/// let json = r#"
157/// {
158///   "metadata": {
159///     "version": "1.0.0",
160///     "prompt": "test"
161///   },
162///   "commands": [],
163///   "global_options": []
164/// }
165/// "#;
166///
167/// let config = load_json(json).unwrap();
168/// assert_eq!(config.metadata.version, "1.0.0");
169/// ```
170pub fn load_json(content: &str) -> Result<CommandsConfig> {
171    serde_json::from_str(content).map_err(|e| {
172        // Extract position information from error
173        ConfigError::json_parse_with_location(e).into()
174    })
175}
176
177#[cfg(test)]
178mod tests {
179    use super::*;
180    use std::io::Write;
181    use tempfile::NamedTempFile;
182
183    /// Helper to create a temporary file with content
184    fn create_temp_file(content: &str, extension: &str) -> NamedTempFile {
185        let mut file = tempfile::Builder::new()
186            .suffix(extension)
187            .tempfile()
188            .unwrap();
189
190        file.write_all(content.as_bytes()).unwrap();
191        file.flush().unwrap();
192        file
193    }
194
195    #[test]
196    fn test_load_yaml_valid() {
197        let yaml = r#"
198metadata:
199  version: "1.0.0"
200  prompt: "test"
201  prompt_suffix: " > "
202commands:
203  - name: hello
204    aliases: []
205    description: "Say hello"
206    required: false
207    arguments: []
208    options: []
209    implementation: "hello_handler"
210global_options: []
211        "#;
212
213        let config = load_yaml(yaml).unwrap();
214
215        assert_eq!(config.metadata.version, "1.0.0");
216        assert_eq!(config.metadata.prompt, "test");
217        assert_eq!(config.commands.len(), 1);
218        assert_eq!(config.commands[0].name, "hello");
219    }
220
221    #[test]
222    fn test_load_yaml_invalid_syntax() {
223        let yaml = r#"
224metadata:
225  version: "1.0.0"
226  prompt: "test"
227commands: [
228        "#; // Invalid YAML - unclosed array
229
230        let result = load_yaml(yaml);
231
232        assert!(result.is_err());
233        match result.unwrap_err() {
234            DynamicCliError::Config(ConfigError::YamlParse { .. }) => {}
235            other => panic!("Expected YamlParse error, got {:?}", other),
236        }
237    }
238
239    #[test]
240    fn test_load_json_valid() {
241        let json = r#"
242{
243  "metadata": {
244    "version": "1.0.0",
245    "prompt": "test",
246    "prompt_suffix": " > "
247  },
248  "commands": [
249    {
250      "name": "hello",
251      "aliases": [],
252      "description": "Say hello",
253      "required": false,
254      "arguments": [],
255      "options": [],
256      "implementation": "hello_handler"
257    }
258  ],
259  "global_options": []
260}
261        "#;
262
263        let config = load_json(json).unwrap();
264
265        assert_eq!(config.metadata.version, "1.0.0");
266        assert_eq!(config.commands.len(), 1);
267        assert_eq!(config.commands[0].name, "hello");
268    }
269
270    #[test]
271    fn test_load_json_invalid_syntax() {
272        let json = r#"
273{
274  "metadata": {
275    "version": "1.0.0",
276    "prompt": "test"
277  },
278  "commands": [
279        "#; // Invalid JSON - unclosed array
280
281        let result = load_json(json);
282
283        assert!(result.is_err());
284        match result.unwrap_err() {
285            DynamicCliError::Config(ConfigError::JsonParse { .. }) => {}
286            other => panic!("Expected JsonParse error, got {:?}", other),
287        }
288    }
289
290    #[test]
291    fn test_load_config_yaml_file() {
292        let yaml = r#"
293metadata:
294  version: "1.0.0"
295  prompt: "test"
296commands: []
297global_options: []
298        "#;
299
300        let file = create_temp_file(yaml, ".yaml");
301        let config = load_config(file.path()).unwrap();
302
303        assert_eq!(config.metadata.version, "1.0.0");
304    }
305
306    #[test]
307    fn test_load_config_yml_extension() {
308        let yaml = r#"
309metadata:
310  version: "1.0.0"
311  prompt: "test"
312commands: []
313global_options: []
314        "#;
315
316        let file = create_temp_file(yaml, ".yml");
317        let config = load_config(file.path()).unwrap();
318
319        assert_eq!(config.metadata.version, "1.0.0");
320    }
321
322    #[test]
323    fn test_load_config_json_file() {
324        let json = r#"
325{
326  "metadata": {
327    "version": "1.0.0",
328    "prompt": "test"
329  },
330  "commands": [],
331  "global_options": []
332}
333        "#;
334
335        let file = create_temp_file(json, ".json");
336        let config = load_config(file.path()).unwrap();
337
338        assert_eq!(config.metadata.version, "1.0.0");
339    }
340
341    #[test]
342    fn test_load_config_file_not_found() {
343        let result = load_config("nonexistent_file.yaml");
344
345        assert!(result.is_err());
346        match result.unwrap_err() {
347            DynamicCliError::Config(ConfigError::FileNotFound { path }) => {
348                assert!(path.to_str().unwrap().contains("nonexistent_file.yaml"));
349            }
350            other => panic!("Expected FileNotFound error, got {:?}", other),
351        }
352    }
353
354    #[test]
355    fn test_load_config_unsupported_extension() {
356        let content = "some content";
357        let file = create_temp_file(content, ".txt");
358
359        let result = load_config(file.path());
360
361        assert!(result.is_err());
362        match result.unwrap_err() {
363            DynamicCliError::Config(ConfigError::UnsupportedFormat { extension }) => {
364                assert_eq!(extension, "txt");
365            }
366            other => panic!("Expected UnsupportedFormat error, got {:?}", other),
367        }
368    }
369
370    #[test]
371    fn test_load_config_no_extension() {
372        let content = "some content";
373
374        // Create a file without extension
375        let mut file = tempfile::Builder::new()
376            .suffix("") // No suffix
377            .tempfile()
378            .unwrap();
379
380        file.write_all(content.as_bytes()).unwrap();
381        file.flush().unwrap();
382
383        // Rename to remove any extension
384        let path_without_ext = file.path().with_file_name("configfile");
385        std::fs::copy(file.path(), &path_without_ext).unwrap();
386
387        let result = load_config(&path_without_ext);
388
389        // Cleanup
390        let _ = std::fs::remove_file(&path_without_ext);
391
392        assert!(result.is_err());
393        match result.unwrap_err() {
394            DynamicCliError::Config(ConfigError::UnsupportedFormat { .. }) => {}
395            other => panic!("Expected UnsupportedFormat error, got {:?}", other),
396        }
397    }
398
399    #[test]
400    fn test_load_yaml_with_complex_structure() {
401        let yaml = r#"
402metadata:
403  version: "2.0.0"
404  prompt: "myapp"
405  prompt_suffix: " $ "
406commands:
407  - name: process
408    aliases: [proc, p]
409    description: "Process data"
410    required: true
411    arguments:
412      - name: input
413        arg_type: path
414        required: true
415        description: "Input file"
416        validation:
417          - must_exist: true
418          - extensions: [csv, tsv]
419    options:
420      - name: output
421        short: o
422        long: output
423        option_type: path
424        required: false
425        default: "output.txt"
426        description: "Output file"
427        choices: []
428    implementation: "process_handler"
429global_options:
430  - name: verbose
431    short: v
432    long: verbose
433    option_type: bool
434    required: false
435    description: "Verbose output"
436    choices: []
437        "#;
438
439        let config = load_yaml(yaml).unwrap();
440
441        assert_eq!(config.metadata.version, "2.0.0");
442        assert_eq!(config.commands.len(), 1);
443        assert_eq!(config.commands[0].arguments.len(), 1);
444        assert_eq!(config.commands[0].options.len(), 1);
445        assert_eq!(config.global_options.len(), 1);
446    }
447
448    #[test]
449    fn test_load_json_with_complex_structure() {
450        let json = r#"
451{
452  "metadata": {
453    "version": "2.0.0",
454    "prompt": "myapp"
455  },
456  "commands": [
457    {
458      "name": "process",
459      "aliases": ["proc"],
460      "description": "Process data",
461      "required": true,
462      "arguments": [
463        {
464          "name": "input",
465          "arg_type": "path",
466          "required": true,
467          "description": "Input file",
468          "validation": [
469            {"must_exist": true},
470            {"extensions": ["csv"]}
471          ]
472        }
473      ],
474      "options": [],
475      "implementation": "process_handler"
476    }
477  ],
478  "global_options": []
479}
480        "#;
481
482        let config = load_json(json).unwrap();
483
484        assert_eq!(config.metadata.version, "2.0.0");
485        assert_eq!(config.commands[0].arguments.len(), 1);
486    }
487
488    #[test]
489    fn test_error_contains_position_yaml() {
490        // YAML with actual syntax error (unclosed array)
491        let yaml_syntax_error = "{{{";
492
493        let result = load_yaml(yaml_syntax_error);
494
495        // Should fail due to YAML syntax error
496        assert!(result.is_err());
497
498        // Verify it's a YamlParse error
499        match result.unwrap_err() {
500            DynamicCliError::Config(ConfigError::YamlParse { .. }) => {
501                // Success - we got the expected error type
502            }
503            other => panic!("Expected YamlParse error, got {:?}", other),
504        }
505    }
506
507    #[test]
508    fn test_case_insensitive_extension() {
509        let yaml = r#"
510metadata:
511  version: "1.0.0"
512  prompt: "test"
513commands: []
514global_options: []
515        "#;
516
517        // Test with uppercase extension
518        let file = create_temp_file(yaml, ".YAML");
519        let config = load_config(file.path()).unwrap();
520
521        assert_eq!(config.metadata.version, "1.0.0");
522    }
523}