context_builder/
config_resolver.rs

1//! Configuration resolution module for context-builder.
2//!
3//! This module provides centralized logic for merging CLI arguments with configuration
4//! file values, implementing proper precedence rules and handling complex scenarios
5//! like timestamping and output folder resolution.
6
7use chrono::Utc;
8use std::path::{Path, PathBuf};
9
10use crate::cli::Args;
11use crate::config::Config;
12
13/// Resolved configuration combining CLI arguments and config file values
14#[derive(Debug, Clone)]
15pub struct ResolvedConfig {
16    pub input: String,
17    pub output: String,
18    pub filter: Vec<String>,
19    pub ignore: Vec<String>,
20    pub line_numbers: bool,
21    pub preview: bool,
22    pub token_count: bool,
23    pub yes: bool,
24    pub diff_only: bool,
25    pub clear_cache: bool,
26    pub auto_diff: bool,
27    pub diff_context_lines: usize,
28    pub init: bool,
29}
30
31/// Result of configuration resolution including the final config and any warnings
32#[derive(Debug)]
33pub struct ConfigResolution {
34    pub config: ResolvedConfig,
35    pub warnings: Vec<String>,
36}
37
38/// Resolves final configuration by merging CLI arguments with config file values.
39///
40/// Precedence rules (highest to lowest):
41/// 1. Explicit CLI arguments (non-default values)
42/// 2. Configuration file values
43/// 3. CLI default values
44///
45/// Special handling:
46/// - `output` field supports timestamping and output folder resolution
47/// - Boolean flags respect explicit CLI usage vs defaults
48/// - Arrays (filter, ignore) use CLI if non-empty, otherwise config file
49pub fn resolve_final_config(mut args: Args, config: Option<Config>) -> ConfigResolution {
50    let mut warnings = Vec::new();
51
52    // Start with CLI defaults, then apply config file, then explicit CLI overrides
53    let final_config = if let Some(config) = config {
54        apply_config_to_args(&mut args, &config, &mut warnings);
55        resolve_output_path(&mut args, &config, &mut warnings);
56        config
57    } else {
58        Config::default()
59    };
60
61    let resolved = ResolvedConfig {
62        input: args.input,
63        output: args.output,
64        filter: args.filter,
65        ignore: args.ignore,
66        line_numbers: args.line_numbers,
67        preview: args.preview,
68        token_count: args.token_count,
69        yes: args.yes,
70        diff_only: args.diff_only,
71        clear_cache: args.clear_cache,
72        auto_diff: final_config.auto_diff.unwrap_or(false),
73        diff_context_lines: final_config.diff_context_lines.unwrap_or(3),
74        init: args.init,
75    };
76
77    ConfigResolution {
78        config: resolved,
79        warnings,
80    }
81}
82
83/// Apply configuration file values to CLI arguments based on precedence rules
84fn apply_config_to_args(args: &mut Args, config: &Config, warnings: &mut Vec<String>) {
85    // Output: only apply config if CLI is using default value
86    if args.output == "output.md"
87        && let Some(ref output) = config.output
88    {
89        args.output = output.clone();
90    }
91
92    // Filter: CLI takes precedence if non-empty
93    if args.filter.is_empty()
94        && let Some(ref filter) = config.filter
95    {
96        args.filter = filter.clone();
97    }
98
99    // Ignore: CLI takes precedence if non-empty
100    if args.ignore.is_empty()
101        && let Some(ref ignore) = config.ignore
102    {
103        args.ignore = ignore.clone();
104    }
105
106    // Boolean flags: config applies only if CLI is using default (false)
107    // Note: We can't distinguish between explicit --no-flag and default false,
108    // so config file can only enable features, not disable them
109    if !args.line_numbers
110        && let Some(line_numbers) = config.line_numbers
111    {
112        args.line_numbers = line_numbers;
113    }
114
115    if !args.preview
116        && let Some(preview) = config.preview
117    {
118        args.preview = preview;
119    }
120
121    if !args.token_count
122        && let Some(token_count) = config.token_count
123    {
124        args.token_count = token_count;
125    }
126
127    if !args.yes
128        && let Some(yes) = config.yes
129    {
130        args.yes = yes;
131    }
132
133    // diff_only: config can enable it, but CLI flag always takes precedence
134    if !args.diff_only
135        && let Some(true) = config.diff_only
136    {
137        args.diff_only = true;
138    }
139
140    // Validate auto_diff configuration
141    if let Some(true) = config.auto_diff
142        && config.timestamped_output != Some(true)
143    {
144        warnings.push(
145            "auto_diff is enabled but timestamped_output is not enabled. \
146            Auto-diff requires timestamped_output = true to function properly."
147                .to_string(),
148        );
149    }
150}
151
152/// Resolve output path including timestamping and output folder logic
153fn resolve_output_path(args: &mut Args, config: &Config, warnings: &mut Vec<String>) {
154    let mut output_folder_path: Option<PathBuf> = None;
155
156    // Apply output folder first
157    if let Some(ref output_folder) = config.output_folder {
158        let mut path = PathBuf::from(output_folder);
159        path.push(&args.output);
160        args.output = path.to_string_lossy().to_string();
161        output_folder_path = Some(PathBuf::from(output_folder));
162    }
163
164    // Apply timestamping if enabled
165    if let Some(true) = config.timestamped_output {
166        let timestamp = Utc::now().format("%Y%m%d%H%M%S").to_string();
167        let path = Path::new(&args.output);
168
169        let stem = path
170            .file_stem()
171            .and_then(|s| s.to_str())
172            .unwrap_or("output");
173
174        let extension = path.extension().and_then(|s| s.to_str()).unwrap_or("md");
175
176        let new_filename = format!("{}_{}.{}", stem, timestamp, extension);
177
178        if let Some(output_folder) = output_folder_path {
179            args.output = output_folder
180                .join(new_filename)
181                .to_string_lossy()
182                .to_string();
183        } else {
184            let new_path = path.with_file_name(new_filename);
185            args.output = new_path.to_string_lossy().to_string();
186        }
187    }
188
189    // Validate output folder exists if specified
190    if let Some(ref output_folder) = config.output_folder {
191        let folder_path = Path::new(output_folder);
192        if !folder_path.exists() {
193            warnings.push(format!(
194                "Output folder '{}' does not exist. It will be created if possible.",
195                output_folder
196            ));
197        }
198    }
199}
200
201/// Check if CLI arguments have been explicitly set vs using defaults.
202/// This is a best-effort detection since clap doesn't provide this information directly.
203#[allow(dead_code)]
204fn detect_explicit_args() -> ExplicitArgs {
205    let args: Vec<String> = std::env::args().collect();
206
207    ExplicitArgs {
208        output: args.iter().any(|arg| arg == "-o" || arg == "--output"),
209        filter: args.iter().any(|arg| arg == "-f" || arg == "--filter"),
210        ignore: args.iter().any(|arg| arg == "-i" || arg == "--ignore"),
211        line_numbers: args.iter().any(|arg| arg == "--line-numbers"),
212        preview: args.iter().any(|arg| arg == "--preview"),
213        token_count: args.iter().any(|arg| arg == "--token-count"),
214        yes: args.iter().any(|arg| arg == "-y" || arg == "--yes"),
215        diff_only: args.iter().any(|arg| arg == "--diff-only"),
216    }
217}
218
219/// Tracks which CLI arguments were explicitly provided vs using defaults
220#[allow(dead_code)]
221struct ExplicitArgs {
222    output: bool,
223    filter: bool,
224    ignore: bool,
225    line_numbers: bool,
226    preview: bool,
227    token_count: bool,
228    yes: bool,
229    diff_only: bool,
230}
231
232#[cfg(test)]
233mod tests {
234    use super::*;
235
236    #[test]
237    fn test_config_precedence_cli_over_config() {
238        let args = Args {
239            input: "src".to_string(),
240            output: "custom.md".to_string(), // Explicit CLI value
241            filter: vec!["rs".to_string()],  // Explicit CLI value
242            ignore: vec![],
243            line_numbers: true, // Explicit CLI value
244            preview: false,
245            token_count: false,
246            yes: false,
247            diff_only: false,
248            clear_cache: false,
249            init: false,
250        };
251
252        let config = Config {
253            output: Some("config.md".to_string()),  // Should be ignored
254            filter: Some(vec!["toml".to_string()]), // Should be ignored
255            line_numbers: Some(false),              // Should be ignored
256            preview: Some(true),                    // Should apply
257            ..Default::default()
258        };
259
260        let resolution = resolve_final_config(args.clone(), Some(config));
261
262        assert_eq!(resolution.config.output, "custom.md"); // CLI wins
263        assert_eq!(resolution.config.filter, vec!["rs"]); // CLI wins
264        assert!(resolution.config.line_numbers); // CLI wins
265        assert!(resolution.config.preview); // Config applies
266    }
267
268    #[test]
269    fn test_config_applies_when_cli_uses_defaults() {
270        let args = Args {
271            input: "src".to_string(),
272            output: "output.md".to_string(), // Default value
273            filter: vec![],                  // Default value
274            ignore: vec![],                  // Default value
275            line_numbers: false,             // Default value
276            preview: false,                  // Default value
277            token_count: false,              // Default value
278            yes: false,                      // Default value
279            diff_only: false,                // Default value
280            clear_cache: false,
281            init: false,
282        };
283
284        let config = Config {
285            output: Some("from_config.md".to_string()),
286            filter: Some(vec!["rs".to_string(), "toml".to_string()]),
287            ignore: Some(vec!["target".to_string()]),
288            line_numbers: Some(true),
289            preview: Some(true),
290            token_count: Some(true),
291            yes: Some(true),
292            diff_only: Some(true),
293            ..Default::default()
294        };
295
296        let resolution = resolve_final_config(args, Some(config));
297
298        assert_eq!(resolution.config.output, "from_config.md");
299        assert_eq!(
300            resolution.config.filter,
301            vec!["rs".to_string(), "toml".to_string()]
302        );
303        assert_eq!(resolution.config.ignore, vec!["target".to_string()]);
304        assert!(resolution.config.line_numbers);
305        assert!(resolution.config.preview);
306        assert!(resolution.config.token_count);
307        assert!(resolution.config.yes);
308        assert!(resolution.config.diff_only);
309    }
310
311    #[test]
312    fn test_timestamped_output_resolution() {
313        let args = Args {
314            input: "src".to_string(),
315            output: "test.md".to_string(),
316            filter: vec![],
317            ignore: vec![],
318            line_numbers: false,
319            preview: false,
320            token_count: false,
321            yes: false,
322            diff_only: false,
323            clear_cache: false,
324            init: false,
325        };
326
327        let config = Config {
328            timestamped_output: Some(true),
329            ..Default::default()
330        };
331
332        let resolution = resolve_final_config(args, Some(config));
333
334        // Output should have timestamp format: test_YYYYMMDDHHMMSS.md
335        assert!(resolution.config.output.starts_with("test_"));
336        assert!(resolution.config.output.ends_with(".md"));
337        assert!(resolution.config.output.len() > "test_.md".len());
338    }
339
340    #[test]
341    fn test_output_folder_resolution() {
342        let args = Args {
343            input: "src".to_string(),
344            output: "test.md".to_string(),
345            filter: vec![],
346            ignore: vec![],
347            line_numbers: false,
348            preview: false,
349            token_count: false,
350            yes: false,
351            diff_only: false,
352            clear_cache: false,
353            init: false,
354        };
355
356        let config = Config {
357            output_folder: Some("docs".to_string()),
358            ..Default::default()
359        };
360
361        let resolution = resolve_final_config(args, Some(config));
362
363        assert!(resolution.config.output.contains("docs"));
364        assert!(resolution.config.output.ends_with("test.md"));
365    }
366
367    #[test]
368    fn test_output_folder_with_timestamping() {
369        let args = Args {
370            input: "src".to_string(),
371            output: "test.md".to_string(),
372            filter: vec![],
373            ignore: vec![],
374            line_numbers: false,
375            preview: false,
376            token_count: false,
377            yes: false,
378            diff_only: false,
379            clear_cache: false,
380            init: false,
381        };
382
383        let config = Config {
384            output_folder: Some("docs".to_string()),
385            timestamped_output: Some(true),
386            ..Default::default()
387        };
388
389        let resolution = resolve_final_config(args, Some(config));
390
391        assert!(resolution.config.output.contains("docs"));
392        assert!(resolution.config.output.contains("test_"));
393        assert!(resolution.config.output.ends_with(".md"));
394    }
395
396    #[test]
397    fn test_auto_diff_without_timestamping_warning() {
398        let args = Args {
399            input: "src".to_string(),
400            output: "test.md".to_string(),
401            filter: vec![],
402            ignore: vec![],
403            line_numbers: false,
404            preview: false,
405            token_count: false,
406            yes: false,
407            diff_only: false,
408            clear_cache: false,
409            init: false,
410        };
411
412        let config = Config {
413            auto_diff: Some(true),
414            timestamped_output: Some(false), // This should generate a warning
415            ..Default::default()
416        };
417
418        let resolution = resolve_final_config(args, Some(config));
419
420        assert!(!resolution.warnings.is_empty());
421        assert!(resolution.warnings[0].contains("auto_diff"));
422        assert!(resolution.warnings[0].contains("timestamped_output"));
423    }
424
425    #[test]
426    fn test_no_config_uses_cli_defaults() {
427        let args = Args {
428            input: "src".to_string(),
429            output: "output.md".to_string(),
430            filter: vec![],
431            ignore: vec![],
432            line_numbers: false,
433            preview: false,
434            token_count: false,
435            yes: false,
436            diff_only: false,
437            clear_cache: false,
438            init: false,
439        };
440
441        let resolution = resolve_final_config(args.clone(), None);
442
443        assert_eq!(resolution.config.input, args.input);
444        assert_eq!(resolution.config.output, args.output);
445        assert_eq!(resolution.config.filter, args.filter);
446        assert_eq!(resolution.config.ignore, args.ignore);
447        assert_eq!(resolution.config.line_numbers, args.line_numbers);
448        assert_eq!(resolution.config.preview, args.preview);
449        assert_eq!(resolution.config.token_count, args.token_count);
450        assert_eq!(resolution.config.yes, args.yes);
451        assert_eq!(resolution.config.diff_only, args.diff_only);
452        assert!(!resolution.config.auto_diff);
453        assert_eq!(resolution.config.diff_context_lines, 3);
454        assert!(resolution.warnings.is_empty());
455    }
456}