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