Skip to main content

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