context_creator/
config.rs

1//! Configuration file support for context-creator
2//!
3//! This module handles loading and parsing configuration files in TOML format.
4//! Configuration files can specify defaults for CLI options and additional
5//! settings like file priorities and ignore patterns.
6
7use crate::cli::{Config as CliConfig, LlmTool};
8use crate::utils::error::ContextCreatorError;
9use serde::{Deserialize, Serialize};
10use std::path::{Path, PathBuf};
11
12/// Configuration file structure
13#[derive(Debug, Clone, Serialize, Deserialize, Default)]
14pub struct ConfigFile {
15    /// Default settings
16    #[serde(default)]
17    pub defaults: Defaults,
18
19    /// File priority configurations
20    #[serde(default)]
21    pub priorities: Vec<Priority>,
22
23    /// Ignore patterns beyond .gitignore and .context-creator-ignore
24    #[serde(default)]
25    pub ignore: Vec<String>,
26
27    /// Include patterns to force inclusion
28    #[serde(default)]
29    pub include: Vec<String>,
30
31    /// Token limits for different LLM tools
32    #[serde(default)]
33    pub tokens: TokenLimits,
34}
35
36/// Default configuration settings
37#[derive(Debug, Clone, Serialize, Deserialize, Default)]
38pub struct Defaults {
39    /// Default maximum tokens
40    pub max_tokens: Option<usize>,
41
42    /// Default LLM tool
43    #[serde(default)]
44    pub llm_tool: Option<String>,
45
46    /// Default to show progress
47    #[serde(default)]
48    pub progress: bool,
49
50    /// Default verbosity
51    #[serde(default)]
52    pub verbose: bool,
53
54    /// Default quiet mode
55    #[serde(default)]
56    pub quiet: bool,
57
58    /// Default directory
59    pub directory: Option<PathBuf>,
60
61    /// Default output file
62    pub output_file: Option<PathBuf>,
63}
64
65/// File priority configuration
66#[derive(Debug, Clone, Serialize, Deserialize)]
67pub struct Priority {
68    /// Glob pattern to match files
69    pub pattern: String,
70    /// Priority weight (higher = more important)
71    pub weight: f32,
72}
73
74/// Token limits configuration for different LLM tools
75#[derive(Debug, Clone, Serialize, Deserialize, Default)]
76pub struct TokenLimits {
77    /// Maximum tokens for Gemini
78    pub gemini: Option<usize>,
79    /// Maximum tokens for Codex
80    pub codex: Option<usize>,
81}
82
83impl ConfigFile {
84    /// Load configuration from a file
85    pub fn load_from_file(path: &Path) -> Result<Self, ContextCreatorError> {
86        if !path.exists() {
87            return Err(ContextCreatorError::InvalidPath(format!(
88                "Configuration file does not exist: {}",
89                path.display()
90            )));
91        }
92
93        let content = std::fs::read_to_string(path).map_err(|e| {
94            ContextCreatorError::ConfigError(format!(
95                "Failed to read config file {}: {}",
96                path.display(),
97                e
98            ))
99        })?;
100
101        let config: ConfigFile = toml::from_str(&content).map_err(|e| {
102            ContextCreatorError::ConfigError(format!(
103                "Failed to parse config file {}: {}",
104                path.display(),
105                e
106            ))
107        })?;
108
109        Ok(config)
110    }
111
112    /// Load configuration from default locations
113    pub fn load_default() -> Result<Option<Self>, ContextCreatorError> {
114        // Try .context-creator.toml in current directory
115        let local_config = Path::new(".context-creator.toml");
116        if local_config.exists() {
117            return Ok(Some(Self::load_from_file(local_config)?));
118        }
119
120        // Try .contextrc.toml in current directory
121        let rc_config = Path::new(".contextrc.toml");
122        if rc_config.exists() {
123            return Ok(Some(Self::load_from_file(rc_config)?));
124        }
125
126        // Try in home directory
127        if let Some(home) = dirs::home_dir() {
128            let home_config = home.join(".context-creator.toml");
129            if home_config.exists() {
130                return Ok(Some(Self::load_from_file(&home_config)?));
131            }
132        }
133
134        Ok(None)
135    }
136
137    /// Apply configuration defaults to CLI config
138    pub fn apply_to_cli_config(&self, cli_config: &mut CliConfig) {
139        // Apply custom priorities from config file
140        cli_config.custom_priorities = self.priorities.clone();
141
142        // Apply token limits from config file
143        cli_config.config_token_limits = Some(self.tokens.clone());
144
145        // Store defaults.max_tokens separately to distinguish from explicit CLI values
146        if cli_config.max_tokens.is_none() && self.defaults.max_tokens.is_some() {
147            cli_config.config_defaults_max_tokens = self.defaults.max_tokens;
148        }
149
150        if let Some(ref tool_str) = self.defaults.llm_tool {
151            // Only apply if CLI used default
152            if cli_config.llm_tool == LlmTool::default() {
153                match tool_str.as_str() {
154                    "gemini" => cli_config.llm_tool = LlmTool::Gemini,
155                    "codex" => cli_config.llm_tool = LlmTool::Codex,
156                    _ => {} // Ignore invalid tool names
157                }
158            }
159        }
160
161        // Apply boolean defaults only if they weren't explicitly set
162        if !cli_config.progress && self.defaults.progress {
163            cli_config.progress = self.defaults.progress;
164        }
165
166        if cli_config.verbose == 0 && self.defaults.verbose {
167            cli_config.verbose = 1; // Convert bool true to verbose level 1
168        }
169
170        if !cli_config.quiet && self.defaults.quiet {
171            cli_config.quiet = self.defaults.quiet;
172        }
173
174        // Apply directory default if CLI used default (".") AND no repo is specified
175        // This prevents conflict with --remote validation
176        let current_paths = cli_config.get_directories();
177        if current_paths.len() == 1
178            && current_paths[0] == PathBuf::from(".")
179            && self.defaults.directory.is_some()
180            && cli_config.remote.is_none()
181        {
182            cli_config.paths = Some(vec![self.defaults.directory.clone().unwrap()]);
183        }
184
185        // Apply output file default if not specified
186        if cli_config.output_file.is_none() && self.defaults.output_file.is_some() {
187            cli_config.output_file = self.defaults.output_file.clone();
188        }
189
190        // Apply ignore patterns from config file if no CLI ignore patterns provided
191        // CLI ignore patterns always take precedence over config file patterns
192        if cli_config.ignore.is_none() && !self.ignore.is_empty() {
193            cli_config.ignore = Some(self.ignore.clone());
194        }
195
196        // Apply include patterns from config file if no CLI include patterns provided
197        // CLI include patterns always take precedence over config file patterns
198        if cli_config.include.is_none() && !self.include.is_empty() {
199            cli_config.include = Some(self.include.clone());
200        }
201    }
202}
203
204/// Create an example configuration file
205pub fn create_example_config() -> String {
206    let example = ConfigFile {
207        defaults: Defaults {
208            max_tokens: Some(150000),
209            llm_tool: Some("gemini".to_string()),
210            progress: true,
211            verbose: false,
212            quiet: false,
213            directory: None,
214            output_file: None,
215        },
216        tokens: TokenLimits {
217            gemini: Some(2_000_000),
218            codex: Some(1_500_000),
219        },
220        priorities: vec![
221            Priority {
222                pattern: "src/**/*.rs".to_string(),
223                weight: 100.0,
224            },
225            Priority {
226                pattern: "src/main.rs".to_string(),
227                weight: 150.0,
228            },
229            Priority {
230                pattern: "tests/**/*.rs".to_string(),
231                weight: 50.0,
232            },
233            Priority {
234                pattern: "docs/**/*.md".to_string(),
235                weight: 30.0,
236            },
237            Priority {
238                pattern: "*.toml".to_string(),
239                weight: 80.0,
240            },
241            Priority {
242                pattern: "*.json".to_string(),
243                weight: 60.0,
244            },
245        ],
246        ignore: vec![
247            "target/**".to_string(),
248            "node_modules/**".to_string(),
249            "*.pyc".to_string(),
250            ".env".to_string(),
251        ],
252        include: vec!["!important/**".to_string()],
253    };
254
255    toml::to_string_pretty(&example)
256        .unwrap_or_else(|_| "# Failed to generate example config".to_string())
257}
258
259#[cfg(test)]
260mod tests {
261    use super::*;
262    use std::fs;
263    use tempfile::TempDir;
264
265    #[test]
266    fn test_config_file_parsing() {
267        let config_content = r#"
268ignore = [
269    "target/**",
270    "node_modules/**"
271]
272
273include = [
274    "!important/**"
275]
276
277[defaults]
278max_tokens = 100000
279llm_tool = "gemini"
280progress = true
281
282[[priorities]]
283pattern = "src/**/*.rs"
284weight = 100.0
285
286[[priorities]]
287pattern = "tests/**/*.rs"
288weight = 50.0
289"#;
290
291        let config: ConfigFile = toml::from_str(config_content).unwrap();
292
293        assert_eq!(config.defaults.max_tokens, Some(100000));
294        assert_eq!(config.defaults.llm_tool, Some("gemini".to_string()));
295        assert!(config.defaults.progress);
296        assert_eq!(config.priorities.len(), 2);
297        assert_eq!(config.priorities[0].pattern, "src/**/*.rs");
298        assert_eq!(config.priorities[0].weight, 100.0);
299        assert_eq!(config.ignore.len(), 2);
300        assert_eq!(config.include.len(), 1);
301    }
302
303    #[test]
304    fn test_config_file_loading() {
305        let temp_dir = TempDir::new().unwrap();
306        let config_path = temp_dir.path().join("config.toml");
307
308        let config_content = r#"
309[defaults]
310max_tokens = 50000
311progress = true
312"#;
313
314        fs::write(&config_path, config_content).unwrap();
315
316        let config = ConfigFile::load_from_file(&config_path).unwrap();
317        assert_eq!(config.defaults.max_tokens, Some(50000));
318        assert!(config.defaults.progress);
319    }
320
321    #[test]
322    fn test_apply_to_cli_config() {
323        let config_file = ConfigFile {
324            defaults: Defaults {
325                max_tokens: Some(75000),
326                llm_tool: Some("codex".to_string()),
327                progress: true,
328                verbose: true,
329                quiet: false,
330                directory: Some(PathBuf::from("/tmp")),
331                output_file: Some(PathBuf::from("output.md")),
332            },
333            tokens: TokenLimits::default(),
334            priorities: vec![],
335            ignore: vec![],
336            include: vec![],
337        };
338
339        let mut cli_config = CliConfig {
340            prompt: None,
341            paths: Some(vec![PathBuf::from(".")]),
342            include: None,
343            ignore: None,
344            remote: None,
345            read_stdin: false,
346            output_file: None,
347            max_tokens: None,
348            llm_tool: LlmTool::default(),
349            quiet: false,
350            verbose: 0,
351            log_format: crate::cli::LogFormat::default(),
352            config: None,
353            progress: false,
354            copy: false,
355            enhanced_context: false,
356            trace_imports: false,
357            include_callers: false,
358            include_types: false,
359            semantic_depth: 3,
360            custom_priorities: vec![],
361            config_token_limits: None,
362            config_defaults_max_tokens: None,
363        };
364
365        config_file.apply_to_cli_config(&mut cli_config);
366
367        assert_eq!(cli_config.config_defaults_max_tokens, Some(75000));
368        assert_eq!(cli_config.llm_tool, LlmTool::Codex);
369        assert!(cli_config.progress);
370        assert_eq!(cli_config.verbose, 1);
371        assert_eq!(cli_config.get_directories(), vec![PathBuf::from("/tmp")]);
372        assert_eq!(cli_config.output_file, Some(PathBuf::from("output.md")));
373    }
374
375    #[test]
376    fn test_example_config_generation() {
377        let example = create_example_config();
378        assert!(example.contains("[defaults]"));
379        assert!(example.contains("max_tokens"));
380        assert!(example.contains("[tokens]"));
381        assert!(example.contains("gemini"));
382        assert!(example.contains("codex"));
383        assert!(example.contains("[[priorities]]"));
384        assert!(example.contains("pattern"));
385        assert!(example.contains("weight"));
386    }
387
388    #[test]
389    fn test_token_limits_parsing() {
390        let config_content = r#"
391[tokens]
392gemini = 2000000
393codex = 1500000
394
395[defaults]
396max_tokens = 100000
397"#;
398
399        let config: ConfigFile = toml::from_str(config_content).unwrap();
400        assert_eq!(config.tokens.gemini, Some(2_000_000));
401        assert_eq!(config.tokens.codex, Some(1_500_000));
402        assert_eq!(config.defaults.max_tokens, Some(100_000));
403    }
404
405    #[test]
406    fn test_token_limits_partial_parsing() {
407        let config_content = r#"
408[tokens]
409gemini = 3000000
410# codex not specified, should use default
411
412[defaults]
413max_tokens = 150000
414"#;
415
416        let config: ConfigFile = toml::from_str(config_content).unwrap();
417        assert_eq!(config.tokens.gemini, Some(3_000_000));
418        assert_eq!(config.tokens.codex, None);
419    }
420
421    #[test]
422    fn test_token_limits_empty_section() {
423        let config_content = r#"
424[tokens]
425# No limits specified
426
427[defaults]
428max_tokens = 200000
429"#;
430
431        let config: ConfigFile = toml::from_str(config_content).unwrap();
432        assert_eq!(config.tokens.gemini, None);
433        assert_eq!(config.tokens.codex, None);
434    }
435
436    #[test]
437    fn test_apply_to_cli_config_with_token_limits() {
438        let config_file = ConfigFile {
439            defaults: Defaults {
440                max_tokens: Some(75000),
441                llm_tool: Some("gemini".to_string()),
442                progress: true,
443                verbose: false,
444                quiet: false,
445                directory: None,
446                output_file: None,
447            },
448            tokens: TokenLimits {
449                gemini: Some(2_500_000),
450                codex: Some(1_800_000),
451            },
452            priorities: vec![],
453            ignore: vec![],
454            include: vec![],
455        };
456
457        let mut cli_config = CliConfig {
458            prompt: None,
459            paths: Some(vec![PathBuf::from(".")]),
460            include: None,
461            ignore: None,
462            remote: None,
463            read_stdin: false,
464            output_file: None,
465            max_tokens: None,
466            llm_tool: LlmTool::default(),
467            quiet: false,
468            verbose: 0,
469            log_format: crate::cli::LogFormat::default(),
470            config: None,
471            progress: false,
472            copy: false,
473            enhanced_context: false,
474            trace_imports: false,
475            include_callers: false,
476            include_types: false,
477            semantic_depth: 3,
478            custom_priorities: vec![],
479            config_token_limits: None,
480            config_defaults_max_tokens: None,
481        };
482
483        config_file.apply_to_cli_config(&mut cli_config);
484
485        // Token limits should be stored but not directly applied to max_tokens
486        assert_eq!(cli_config.config_defaults_max_tokens, Some(75000)); // From defaults
487        assert!(cli_config.config_token_limits.is_some());
488        let token_limits = cli_config.config_token_limits.as_ref().unwrap();
489        assert_eq!(token_limits.gemini, Some(2_500_000));
490        assert_eq!(token_limits.codex, Some(1_800_000));
491    }
492}