code_digest/
config.rs

1//! Configuration file support for code-digest
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::CodeDigestError;
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 .digestignore
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
32/// Default configuration settings
33#[derive(Debug, Clone, Serialize, Deserialize, Default)]
34pub struct Defaults {
35    /// Default maximum tokens
36    pub max_tokens: Option<usize>,
37
38    /// Default LLM tool
39    #[serde(default)]
40    pub llm_tool: Option<String>,
41
42    /// Default to show progress
43    #[serde(default)]
44    pub progress: bool,
45
46    /// Default verbosity
47    #[serde(default)]
48    pub verbose: bool,
49
50    /// Default quiet mode
51    #[serde(default)]
52    pub quiet: bool,
53
54    /// Default directory
55    pub directory: Option<PathBuf>,
56
57    /// Default output file
58    pub output_file: Option<PathBuf>,
59}
60
61/// File priority configuration
62#[derive(Debug, Clone, Serialize, Deserialize)]
63pub struct Priority {
64    /// Glob pattern to match files
65    pub pattern: String,
66    /// Priority weight (higher = more important)
67    pub weight: f32,
68}
69
70impl ConfigFile {
71    /// Load configuration from a file
72    pub fn load_from_file(path: &Path) -> Result<Self, CodeDigestError> {
73        if !path.exists() {
74            return Err(CodeDigestError::InvalidPath(format!(
75                "Configuration file does not exist: {}",
76                path.display()
77            )));
78        }
79
80        let content = std::fs::read_to_string(path).map_err(|e| {
81            CodeDigestError::ConfigError(format!(
82                "Failed to read config file {}: {}",
83                path.display(),
84                e
85            ))
86        })?;
87
88        let config: ConfigFile = toml::from_str(&content).map_err(|e| {
89            CodeDigestError::ConfigError(format!(
90                "Failed to parse config file {}: {}",
91                path.display(),
92                e
93            ))
94        })?;
95
96        Ok(config)
97    }
98
99    /// Load configuration from default locations
100    pub fn load_default() -> Result<Option<Self>, CodeDigestError> {
101        // Try .code-digest.toml in current directory
102        let local_config = Path::new(".code-digest.toml");
103        if local_config.exists() {
104            return Ok(Some(Self::load_from_file(local_config)?));
105        }
106
107        // Try .digestrc.toml in current directory
108        let rc_config = Path::new(".digestrc.toml");
109        if rc_config.exists() {
110            return Ok(Some(Self::load_from_file(rc_config)?));
111        }
112
113        // Try in home directory
114        if let Some(home) = dirs::home_dir() {
115            let home_config = home.join(".code-digest.toml");
116            if home_config.exists() {
117                return Ok(Some(Self::load_from_file(&home_config)?));
118            }
119        }
120
121        Ok(None)
122    }
123
124    /// Apply configuration defaults to CLI config
125    pub fn apply_to_cli_config(&self, cli_config: &mut CliConfig) {
126        // Apply custom priorities from config file
127        cli_config.custom_priorities = self.priorities.clone();
128
129        // Only apply defaults if CLI didn't specify them
130        if cli_config.max_tokens.is_none() && self.defaults.max_tokens.is_some() {
131            cli_config.max_tokens = self.defaults.max_tokens;
132        }
133
134        if let Some(ref tool_str) = self.defaults.llm_tool {
135            // Only apply if CLI used default
136            if cli_config.llm_tool == LlmTool::default() {
137                match tool_str.as_str() {
138                    "gemini" => cli_config.llm_tool = LlmTool::Gemini,
139                    "codex" => cli_config.llm_tool = LlmTool::Codex,
140                    _ => {} // Ignore invalid tool names
141                }
142            }
143        }
144
145        // Apply boolean defaults only if they weren't explicitly set
146        if !cli_config.progress && self.defaults.progress {
147            cli_config.progress = self.defaults.progress;
148        }
149
150        if !cli_config.verbose && self.defaults.verbose {
151            cli_config.verbose = self.defaults.verbose;
152        }
153
154        if !cli_config.quiet && self.defaults.quiet {
155            cli_config.quiet = self.defaults.quiet;
156        }
157
158        // Apply directory default if CLI used default (".")
159        let current_paths = cli_config.get_directories();
160        if current_paths.len() == 1
161            && current_paths[0] == PathBuf::from(".")
162            && self.defaults.directory.is_some()
163        {
164            cli_config.paths = Some(vec![self.defaults.directory.clone().unwrap()]);
165        }
166
167        // Apply output file default if not specified
168        if cli_config.output_file.is_none() && self.defaults.output_file.is_some() {
169            cli_config.output_file = self.defaults.output_file.clone();
170        }
171    }
172}
173
174/// Create an example configuration file
175pub fn create_example_config() -> String {
176    let example = ConfigFile {
177        defaults: Defaults {
178            max_tokens: Some(150000),
179            llm_tool: Some("gemini".to_string()),
180            progress: true,
181            verbose: false,
182            quiet: false,
183            directory: None,
184            output_file: None,
185        },
186        priorities: vec![
187            Priority { pattern: "src/**/*.rs".to_string(), weight: 100.0 },
188            Priority { pattern: "src/main.rs".to_string(), weight: 150.0 },
189            Priority { pattern: "tests/**/*.rs".to_string(), weight: 50.0 },
190            Priority { pattern: "docs/**/*.md".to_string(), weight: 30.0 },
191            Priority { pattern: "*.toml".to_string(), weight: 80.0 },
192            Priority { pattern: "*.json".to_string(), weight: 60.0 },
193        ],
194        ignore: vec![
195            "target/**".to_string(),
196            "node_modules/**".to_string(),
197            "*.pyc".to_string(),
198            ".env".to_string(),
199        ],
200        include: vec!["!important/**".to_string()],
201    };
202
203    toml::to_string_pretty(&example)
204        .unwrap_or_else(|_| "# Failed to generate example config".to_string())
205}
206
207#[cfg(test)]
208mod tests {
209    use super::*;
210    use std::fs;
211    use tempfile::TempDir;
212
213    #[test]
214    fn test_config_file_parsing() {
215        let config_content = r#"
216ignore = [
217    "target/**",
218    "node_modules/**"
219]
220
221include = [
222    "!important/**"
223]
224
225[defaults]
226max_tokens = 100000
227llm_tool = "gemini"
228progress = true
229
230[[priorities]]
231pattern = "src/**/*.rs"
232weight = 100.0
233
234[[priorities]]
235pattern = "tests/**/*.rs"
236weight = 50.0
237"#;
238
239        let config: ConfigFile = toml::from_str(config_content).unwrap();
240
241        assert_eq!(config.defaults.max_tokens, Some(100000));
242        assert_eq!(config.defaults.llm_tool, Some("gemini".to_string()));
243        assert!(config.defaults.progress);
244        assert_eq!(config.priorities.len(), 2);
245        assert_eq!(config.priorities[0].pattern, "src/**/*.rs");
246        assert_eq!(config.priorities[0].weight, 100.0);
247        assert_eq!(config.ignore.len(), 2);
248        assert_eq!(config.include.len(), 1);
249    }
250
251    #[test]
252    fn test_config_file_loading() {
253        let temp_dir = TempDir::new().unwrap();
254        let config_path = temp_dir.path().join("config.toml");
255
256        let config_content = r#"
257[defaults]
258max_tokens = 50000
259progress = true
260"#;
261
262        fs::write(&config_path, config_content).unwrap();
263
264        let config = ConfigFile::load_from_file(&config_path).unwrap();
265        assert_eq!(config.defaults.max_tokens, Some(50000));
266        assert!(config.defaults.progress);
267    }
268
269    #[test]
270    fn test_apply_to_cli_config() {
271        let config_file = ConfigFile {
272            defaults: Defaults {
273                max_tokens: Some(75000),
274                llm_tool: Some("codex".to_string()),
275                progress: true,
276                verbose: true,
277                quiet: false,
278                directory: Some(PathBuf::from("/tmp")),
279                output_file: Some(PathBuf::from("output.md")),
280            },
281            priorities: vec![],
282            ignore: vec![],
283            include: vec![],
284        };
285
286        let mut cli_config = CliConfig {
287            prompt: None,
288            paths: Some(vec![PathBuf::from(".")]),
289            repo: None,
290            read_stdin: false,
291            output_file: None,
292            max_tokens: None,
293            llm_tool: LlmTool::default(),
294            quiet: false,
295            verbose: false,
296            config: None,
297            progress: false,
298            copy: false,
299            enhanced_context: false,
300            custom_priorities: vec![],
301        };
302
303        config_file.apply_to_cli_config(&mut cli_config);
304
305        assert_eq!(cli_config.max_tokens, Some(75000));
306        assert_eq!(cli_config.llm_tool, LlmTool::Codex);
307        assert!(cli_config.progress);
308        assert!(cli_config.verbose);
309        assert_eq!(cli_config.get_directories(), vec![PathBuf::from("/tmp")]);
310        assert_eq!(cli_config.output_file, Some(PathBuf::from("output.md")));
311    }
312
313    #[test]
314    fn test_example_config_generation() {
315        let example = create_example_config();
316        assert!(example.contains("[defaults]"));
317        assert!(example.contains("max_tokens"));
318        assert!(example.contains("[[priorities]]"));
319        assert!(example.contains("pattern"));
320        assert!(example.contains("weight"));
321    }
322}