Skip to main content

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