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    /// Maximum tokens for Claude
82    pub claude: Option<usize>,
83    /// Maximum tokens for Ollama
84    pub ollama: Option<usize>,
85}
86
87impl ConfigFile {
88    /// Load configuration from a file
89    pub fn load_from_file(path: &Path) -> Result<Self, ContextCreatorError> {
90        if !path.exists() {
91            return Err(ContextCreatorError::InvalidPath(format!(
92                "Configuration file does not exist: {}",
93                path.display()
94            )));
95        }
96
97        let content = std::fs::read_to_string(path).map_err(|e| {
98            ContextCreatorError::ConfigError(format!(
99                "Failed to read config file {}: {}",
100                path.display(),
101                e
102            ))
103        })?;
104
105        let config: ConfigFile = toml::from_str(&content).map_err(|e| {
106            ContextCreatorError::ConfigError(format!(
107                "Failed to parse config file {}: {}",
108                path.display(),
109                e
110            ))
111        })?;
112
113        Ok(config)
114    }
115
116    /// Load configuration from default locations
117    pub fn load_default() -> Result<Option<Self>, ContextCreatorError> {
118        // Try .context-creator.toml in current directory
119        let local_config = Path::new(".context-creator.toml");
120        if local_config.exists() {
121            return Ok(Some(Self::load_from_file(local_config)?));
122        }
123
124        // Try .contextrc.toml in current directory
125        let rc_config = Path::new(".contextrc.toml");
126        if rc_config.exists() {
127            return Ok(Some(Self::load_from_file(rc_config)?));
128        }
129
130        // Try in home directory
131        if let Some(home) = dirs::home_dir() {
132            let home_config = home.join(".context-creator.toml");
133            if home_config.exists() {
134                return Ok(Some(Self::load_from_file(&home_config)?));
135            }
136        }
137
138        Ok(None)
139    }
140
141    /// Apply configuration defaults to CLI config
142    pub fn apply_to_cli_config(&self, cli_config: &mut CliConfig) {
143        // Apply custom priorities from config file
144        cli_config.custom_priorities = self.priorities.clone();
145
146        // Apply token limits from config file
147        cli_config.config_token_limits = Some(self.tokens.clone());
148
149        // Store defaults.max_tokens separately to distinguish from explicit CLI values
150        if cli_config.max_tokens.is_none() && self.defaults.max_tokens.is_some() {
151            cli_config.config_defaults_max_tokens = self.defaults.max_tokens;
152        }
153
154        if let Some(ref tool_str) = self.defaults.llm_tool {
155            // Only apply if CLI used default
156            if cli_config.llm_tool == LlmTool::default() {
157                match tool_str.as_str() {
158                    "gemini" => cli_config.llm_tool = LlmTool::Gemini,
159                    "codex" => cli_config.llm_tool = LlmTool::Codex,
160                    "claude" => cli_config.llm_tool = LlmTool::Claude,
161                    "ollama" => cli_config.llm_tool = LlmTool::Ollama,
162                    _ => {} // Ignore invalid tool names
163                }
164            }
165        }
166
167        // Apply boolean defaults only if they weren't explicitly set
168        if !cli_config.progress && self.defaults.progress {
169            cli_config.progress = self.defaults.progress;
170        }
171
172        if cli_config.verbose == 0 && self.defaults.verbose {
173            cli_config.verbose = 1; // Convert bool true to verbose level 1
174        }
175
176        if !cli_config.quiet && self.defaults.quiet {
177            cli_config.quiet = self.defaults.quiet;
178        }
179
180        // Apply directory default if CLI used default (".") AND no repo is specified
181        // This prevents conflict with --remote validation
182        let current_paths = cli_config.get_directories();
183        if current_paths.len() == 1
184            && current_paths[0] == PathBuf::from(".")
185            && self.defaults.directory.is_some()
186            && cli_config.remote.is_none()
187        {
188            cli_config.paths = Some(vec![self.defaults.directory.clone().unwrap()]);
189        }
190
191        // Apply output file default if not specified
192        if cli_config.output_file.is_none() && self.defaults.output_file.is_some() {
193            cli_config.output_file = self.defaults.output_file.clone();
194        }
195
196        // Apply ignore patterns from config file if no CLI ignore patterns provided
197        // CLI ignore patterns always take precedence over config file patterns
198        if cli_config.ignore.is_none() && !self.ignore.is_empty() {
199            cli_config.ignore = Some(self.ignore.clone());
200        }
201
202        // Apply include patterns from config file if no CLI include patterns provided
203        // CLI include patterns always take precedence over config file patterns
204        if cli_config.include.is_none() && !self.include.is_empty() {
205            cli_config.include = Some(self.include.clone());
206        }
207    }
208}
209
210/// Create an example configuration file
211pub fn create_example_config() -> String {
212    let example = ConfigFile {
213        defaults: Defaults {
214            max_tokens: Some(150000),
215            llm_tool: Some("gemini".to_string()),
216            progress: true,
217            verbose: false,
218            quiet: false,
219            directory: None,
220            output_file: None,
221        },
222        tokens: TokenLimits {
223            gemini: Some(2_000_000),
224            codex: Some(1_500_000),
225            claude: Some(200_000),
226            ollama: Some(8_192),
227        },
228        priorities: vec![
229            Priority {
230                pattern: "src/**/*.rs".to_string(),
231                weight: 100.0,
232            },
233            Priority {
234                pattern: "src/main.rs".to_string(),
235                weight: 150.0,
236            },
237            Priority {
238                pattern: "tests/**/*.rs".to_string(),
239                weight: 50.0,
240            },
241            Priority {
242                pattern: "docs/**/*.md".to_string(),
243                weight: 30.0,
244            },
245            Priority {
246                pattern: "*.toml".to_string(),
247                weight: 80.0,
248            },
249            Priority {
250                pattern: "*.json".to_string(),
251                weight: 60.0,
252            },
253        ],
254        ignore: vec![
255            "target/**".to_string(),
256            "node_modules/**".to_string(),
257            "*.pyc".to_string(),
258            ".env".to_string(),
259        ],
260        include: vec!["!important/**".to_string()],
261    };
262
263    toml::to_string_pretty(&example)
264        .unwrap_or_else(|_| "# Failed to generate example config".to_string())
265}
266
267#[cfg(test)]
268mod tests {
269    use super::*;
270    use std::fs;
271    use tempfile::TempDir;
272
273    #[test]
274    fn test_config_file_parsing() {
275        let config_content = r#"
276ignore = [
277    "target/**",
278    "node_modules/**"
279]
280
281include = [
282    "!important/**"
283]
284
285[defaults]
286max_tokens = 100000
287llm_tool = "gemini"
288progress = true
289
290[[priorities]]
291pattern = "src/**/*.rs"
292weight = 100.0
293
294[[priorities]]
295pattern = "tests/**/*.rs"
296weight = 50.0
297"#;
298
299        let config: ConfigFile = toml::from_str(config_content).unwrap();
300
301        assert_eq!(config.defaults.max_tokens, Some(100000));
302        assert_eq!(config.defaults.llm_tool, Some("gemini".to_string()));
303        assert!(config.defaults.progress);
304        assert_eq!(config.priorities.len(), 2);
305        assert_eq!(config.priorities[0].pattern, "src/**/*.rs");
306        assert_eq!(config.priorities[0].weight, 100.0);
307        assert_eq!(config.ignore.len(), 2);
308        assert_eq!(config.include.len(), 1);
309    }
310
311    #[test]
312    fn test_config_file_loading() {
313        let temp_dir = TempDir::new().unwrap();
314        let config_path = temp_dir.path().join("config.toml");
315
316        let config_content = r#"
317[defaults]
318max_tokens = 50000
319progress = true
320"#;
321
322        fs::write(&config_path, config_content).unwrap();
323
324        let config = ConfigFile::load_from_file(&config_path).unwrap();
325        assert_eq!(config.defaults.max_tokens, Some(50000));
326        assert!(config.defaults.progress);
327    }
328
329    #[test]
330    fn test_apply_to_cli_config() {
331        let config_file = ConfigFile {
332            defaults: Defaults {
333                max_tokens: Some(75000),
334                llm_tool: Some("codex".to_string()),
335                progress: true,
336                verbose: true,
337                quiet: false,
338                directory: Some(PathBuf::from("/tmp")),
339                output_file: Some(PathBuf::from("output.md")),
340            },
341            tokens: TokenLimits::default(),
342            priorities: vec![],
343            ignore: vec![],
344            include: vec![],
345        };
346
347        let mut cli_config = CliConfig {
348            paths: Some(vec![PathBuf::from(".")]),
349            semantic_depth: 3,
350            ..CliConfig::default()
351        };
352
353        config_file.apply_to_cli_config(&mut cli_config);
354
355        assert_eq!(cli_config.config_defaults_max_tokens, Some(75000));
356        assert_eq!(cli_config.llm_tool, LlmTool::Codex);
357        assert!(cli_config.progress);
358        assert_eq!(cli_config.verbose, 1);
359        assert_eq!(cli_config.get_directories(), vec![PathBuf::from("/tmp")]);
360        assert_eq!(cli_config.output_file, Some(PathBuf::from("output.md")));
361    }
362
363    #[test]
364    fn test_example_config_generation() {
365        let example = create_example_config();
366        assert!(example.contains("[defaults]"));
367        assert!(example.contains("max_tokens"));
368        assert!(example.contains("[tokens]"));
369        assert!(example.contains("gemini"));
370        assert!(example.contains("codex"));
371        assert!(example.contains("[[priorities]]"));
372        assert!(example.contains("pattern"));
373        assert!(example.contains("weight"));
374    }
375
376    #[test]
377    fn test_token_limits_parsing() {
378        let config_content = r#"
379[tokens]
380gemini = 2000000
381codex = 1500000
382
383[defaults]
384max_tokens = 100000
385"#;
386
387        let config: ConfigFile = toml::from_str(config_content).unwrap();
388        assert_eq!(config.tokens.gemini, Some(2_000_000));
389        assert_eq!(config.tokens.codex, Some(1_500_000));
390        assert_eq!(config.defaults.max_tokens, Some(100_000));
391    }
392
393    #[test]
394    fn test_token_limits_partial_parsing() {
395        let config_content = r#"
396[tokens]
397gemini = 3000000
398# codex not specified, should use default
399
400[defaults]
401max_tokens = 150000
402"#;
403
404        let config: ConfigFile = toml::from_str(config_content).unwrap();
405        assert_eq!(config.tokens.gemini, Some(3_000_000));
406        assert_eq!(config.tokens.codex, None);
407    }
408
409    #[test]
410    fn test_token_limits_empty_section() {
411        let config_content = r#"
412[tokens]
413# No limits specified
414
415[defaults]
416max_tokens = 200000
417"#;
418
419        let config: ConfigFile = toml::from_str(config_content).unwrap();
420        assert_eq!(config.tokens.gemini, None);
421        assert_eq!(config.tokens.codex, None);
422    }
423
424    #[test]
425    fn test_apply_to_cli_config_with_token_limits() {
426        let config_file = ConfigFile {
427            defaults: Defaults {
428                max_tokens: Some(75000),
429                llm_tool: Some("gemini".to_string()),
430                progress: true,
431                verbose: false,
432                quiet: false,
433                directory: None,
434                output_file: None,
435            },
436            tokens: TokenLimits {
437                gemini: Some(2_500_000),
438                codex: Some(1_800_000),
439                claude: None,
440                ollama: None,
441            },
442            priorities: vec![],
443            ignore: vec![],
444            include: vec![],
445        };
446
447        let mut cli_config = CliConfig {
448            paths: Some(vec![PathBuf::from(".")]),
449            semantic_depth: 3,
450            ..CliConfig::default()
451        };
452
453        config_file.apply_to_cli_config(&mut cli_config);
454
455        // Token limits should be stored but not directly applied to max_tokens
456        assert_eq!(cli_config.config_defaults_max_tokens, Some(75000)); // From defaults
457        assert!(cli_config.config_token_limits.is_some());
458        let token_limits = cli_config.config_token_limits.as_ref().unwrap();
459        assert_eq!(token_limits.gemini, Some(2_500_000));
460        assert_eq!(token_limits.codex, Some(1_800_000));
461    }
462}