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            paths: Some(vec![PathBuf::from(".")]),
341            semantic_depth: 3,
342            ..CliConfig::default()
343        };
344
345        config_file.apply_to_cli_config(&mut cli_config);
346
347        assert_eq!(cli_config.config_defaults_max_tokens, Some(75000));
348        assert_eq!(cli_config.llm_tool, LlmTool::Codex);
349        assert!(cli_config.progress);
350        assert_eq!(cli_config.verbose, 1);
351        assert_eq!(cli_config.get_directories(), vec![PathBuf::from("/tmp")]);
352        assert_eq!(cli_config.output_file, Some(PathBuf::from("output.md")));
353    }
354
355    #[test]
356    fn test_example_config_generation() {
357        let example = create_example_config();
358        assert!(example.contains("[defaults]"));
359        assert!(example.contains("max_tokens"));
360        assert!(example.contains("[tokens]"));
361        assert!(example.contains("gemini"));
362        assert!(example.contains("codex"));
363        assert!(example.contains("[[priorities]]"));
364        assert!(example.contains("pattern"));
365        assert!(example.contains("weight"));
366    }
367
368    #[test]
369    fn test_token_limits_parsing() {
370        let config_content = r#"
371[tokens]
372gemini = 2000000
373codex = 1500000
374
375[defaults]
376max_tokens = 100000
377"#;
378
379        let config: ConfigFile = toml::from_str(config_content).unwrap();
380        assert_eq!(config.tokens.gemini, Some(2_000_000));
381        assert_eq!(config.tokens.codex, Some(1_500_000));
382        assert_eq!(config.defaults.max_tokens, Some(100_000));
383    }
384
385    #[test]
386    fn test_token_limits_partial_parsing() {
387        let config_content = r#"
388[tokens]
389gemini = 3000000
390# codex not specified, should use default
391
392[defaults]
393max_tokens = 150000
394"#;
395
396        let config: ConfigFile = toml::from_str(config_content).unwrap();
397        assert_eq!(config.tokens.gemini, Some(3_000_000));
398        assert_eq!(config.tokens.codex, None);
399    }
400
401    #[test]
402    fn test_token_limits_empty_section() {
403        let config_content = r#"
404[tokens]
405# No limits specified
406
407[defaults]
408max_tokens = 200000
409"#;
410
411        let config: ConfigFile = toml::from_str(config_content).unwrap();
412        assert_eq!(config.tokens.gemini, None);
413        assert_eq!(config.tokens.codex, None);
414    }
415
416    #[test]
417    fn test_apply_to_cli_config_with_token_limits() {
418        let config_file = ConfigFile {
419            defaults: Defaults {
420                max_tokens: Some(75000),
421                llm_tool: Some("gemini".to_string()),
422                progress: true,
423                verbose: false,
424                quiet: false,
425                directory: None,
426                output_file: None,
427            },
428            tokens: TokenLimits {
429                gemini: Some(2_500_000),
430                codex: Some(1_800_000),
431            },
432            priorities: vec![],
433            ignore: vec![],
434            include: vec![],
435        };
436
437        let mut cli_config = CliConfig {
438            paths: Some(vec![PathBuf::from(".")]),
439            semantic_depth: 3,
440            ..CliConfig::default()
441        };
442
443        config_file.apply_to_cli_config(&mut cli_config);
444
445        // Token limits should be stored but not directly applied to max_tokens
446        assert_eq!(cli_config.config_defaults_max_tokens, Some(75000)); // From defaults
447        assert!(cli_config.config_token_limits.is_some());
448        let token_limits = cli_config.config_token_limits.as_ref().unwrap();
449        assert_eq!(token_limits.gemini, Some(2_500_000));
450        assert_eq!(token_limits.codex, Some(1_800_000));
451    }
452}