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