Skip to main content

context_builder/
config.rs

1use serde::Deserialize;
2use std::fs;
3use std::path::Path;
4
5/// Global configuration loaded from `context-builder.toml`.
6///
7/// Any field left as `None` means "use the CLI default / do not override".
8/// Command-line arguments always take precedence over values provided here.
9///
10/// Example `context-builder.toml`:
11/// ```toml
12/// output = "context.md"
13/// output_folder = "docs"
14/// timestamped_output = true
15/// auto_diff = true
16/// diff_only = true         # Emit only change summary + modified file diffs (no full file bodies)
17/// filter = ["rs", "toml"]
18/// ignore = ["target", ".git"]
19/// line_numbers = false
20/// diff_context_lines = 5
21/// ```
22///
23#[derive(Deserialize, Debug, Default, Clone)]
24pub struct Config {
25    /// Output file name (or base name when `timestamped_output = true`)
26    pub output: Option<String>,
27
28    /// File extensions to include (no leading dot, e.g. `rs`, `toml`)
29    pub filter: Option<Vec<String>>,
30
31    /// File / directory names to ignore (exact name matches)
32    pub ignore: Option<Vec<String>>,
33
34    /// Add line numbers to code blocks
35    pub line_numbers: Option<bool>,
36
37    /// Preview only the file tree (no file output)
38    pub preview: Option<bool>,
39
40    /// Token counting mode
41    pub token_count: Option<bool>,
42
43    /// Optional folder to place the generated output file(s) in
44    pub output_folder: Option<String>,
45
46    /// If true, append a UTC timestamp to the output file name (before extension)
47    pub timestamped_output: Option<bool>,
48
49    /// Assume "yes" for overwrite / processing confirmations
50    pub yes: Option<bool>,
51
52    /// Enable automatic diff generation (requires `timestamped_output = true`)
53    pub auto_diff: Option<bool>,
54
55    /// Override number of unified diff context lines (falls back to env or default = 3)
56    pub diff_context_lines: Option<usize>,
57
58    /// When true, emit ONLY:
59    /// - Header + file tree
60    /// - Change Summary
61    /// - Per-file diffs for modified files
62    ///
63    /// Excludes full file contents section entirely. Added files appear only in the
64    /// change summary (and are marked Added) but their full content is omitted.
65    pub diff_only: Option<bool>,
66
67    /// Encoding handling strategy for non-UTF-8 files.
68    /// - "detect": Attempt to detect and transcode to UTF-8 (default)
69    /// - "strict": Only include valid UTF-8 files, skip others
70    /// - "skip": Skip all non-UTF-8 files without transcoding attempts
71    pub encoding_strategy: Option<String>,
72
73    /// Maximum token budget for the output. Files are truncated/skipped when exceeded.
74    pub max_tokens: Option<usize>,
75
76    /// Extract function/class signatures only (requires tree-sitter feature)
77    pub signatures: Option<bool>,
78
79    /// Extract code structure (imports, exports, symbol counts) - requires tree-sitter feature
80    pub structure: Option<bool>,
81
82    /// Truncation mode for max-tokens: "smart" (AST boundaries) or "byte"
83    pub truncate: Option<String>,
84
85    /// Filter signatures by visibility: "all", "public", or "private"
86    pub visibility: Option<String>,
87}
88
89/// Load configuration from `context-builder.toml` in the current working directory.
90/// Returns `None` if the file does not exist or cannot be parsed.
91pub fn load_config() -> Option<Config> {
92    let config_path = Path::new("context-builder.toml");
93    if config_path.exists() {
94        let content = fs::read_to_string(config_path).ok()?;
95        match toml::from_str(&content) {
96            Ok(config) => Some(config),
97            Err(e) => {
98                eprintln!(
99                    "⚠️  Failed to parse context-builder.toml: {}. Config will be ignored.",
100                    e
101                );
102                None
103            }
104        }
105    } else {
106        None
107    }
108}
109
110/// Load configuration from `context-builder.toml` in the specified project root directory.
111/// Returns `None` if the file does not exist or cannot be parsed.
112pub fn load_config_from_path(project_root: &Path) -> Option<Config> {
113    let config_path = project_root.join("context-builder.toml");
114    if config_path.exists() {
115        let content = fs::read_to_string(&config_path).ok()?;
116        match toml::from_str(&content) {
117            Ok(config) => Some(config),
118            Err(e) => {
119                eprintln!(
120                    "⚠️  Failed to parse {}: {}. Config will be ignored.",
121                    config_path.display(),
122                    e
123                );
124                None
125            }
126        }
127    } else {
128        None
129    }
130}
131
132#[cfg(test)]
133mod tests {
134    use super::*;
135    use serial_test::serial;
136    use std::fs;
137    use tempfile::tempdir;
138
139    #[test]
140    #[serial]
141    fn load_config_nonexistent_file() {
142        // Test loading config when file doesn't exist by temporarily changing directory
143        let temp_dir = tempdir().unwrap();
144        let original_dir = std::env::current_dir().unwrap();
145
146        // Change to temp directory where no config file exists
147        std::env::set_current_dir(&temp_dir).unwrap();
148
149        let result = load_config();
150
151        // Restore original directory
152        std::env::set_current_dir(original_dir).unwrap();
153
154        assert!(result.is_none());
155    }
156
157    #[test]
158    fn load_config_from_path_nonexistent_file() {
159        let dir = tempdir().unwrap();
160        let result = load_config_from_path(dir.path());
161        assert!(result.is_none());
162    }
163
164    #[test]
165    fn load_config_from_path_valid_config() {
166        let dir = tempdir().unwrap();
167        let config_path = dir.path().join("context-builder.toml");
168
169        let config_content = r#"
170output = "test-output.md"
171filter = ["rs", "toml"]
172ignore = ["target", ".git"]
173line_numbers = true
174preview = false
175token_count = true
176timestamped_output = true
177yes = false
178auto_diff = true
179diff_context_lines = 5
180diff_only = false
181encoding_strategy = "detect"
182"#;
183
184        fs::write(&config_path, config_content).unwrap();
185
186        let config = load_config_from_path(dir.path()).unwrap();
187        assert_eq!(config.output.unwrap(), "test-output.md");
188        assert_eq!(config.filter.unwrap(), vec!["rs", "toml"]);
189        assert_eq!(config.ignore.unwrap(), vec!["target", ".git"]);
190        assert!(config.line_numbers.unwrap());
191        assert!(!config.preview.unwrap());
192        assert!(config.token_count.unwrap());
193        assert!(config.timestamped_output.unwrap());
194        assert!(!config.yes.unwrap());
195        assert!(config.auto_diff.unwrap());
196        assert_eq!(config.diff_context_lines.unwrap(), 5);
197        assert!(!config.diff_only.unwrap());
198        assert_eq!(config.encoding_strategy.unwrap(), "detect");
199    }
200
201    #[test]
202    fn load_config_from_path_partial_config() {
203        let dir = tempdir().unwrap();
204        let config_path = dir.path().join("context-builder.toml");
205
206        let config_content = r#"
207output = "minimal.md"
208filter = ["py"]
209"#;
210
211        fs::write(&config_path, config_content).unwrap();
212
213        let config = load_config_from_path(dir.path()).unwrap();
214        assert_eq!(config.output.unwrap(), "minimal.md");
215        assert_eq!(config.filter.unwrap(), vec!["py"]);
216        assert!(config.ignore.is_none());
217        assert!(config.line_numbers.is_none());
218        assert!(config.auto_diff.is_none());
219    }
220
221    #[test]
222    fn load_config_from_path_invalid_toml() {
223        let dir = tempdir().unwrap();
224        let config_path = dir.path().join("context-builder.toml");
225
226        // Invalid TOML content
227        let config_content = r#"
228output = "test.md"
229invalid_toml [
230"#;
231
232        fs::write(&config_path, config_content).unwrap();
233
234        let config = load_config_from_path(dir.path());
235        assert!(config.is_none());
236    }
237
238    #[test]
239    fn load_config_from_path_empty_config() {
240        let dir = tempdir().unwrap();
241        let config_path = dir.path().join("context-builder.toml");
242
243        fs::write(&config_path, "").unwrap();
244
245        let config = load_config_from_path(dir.path()).unwrap();
246        assert!(config.output.is_none());
247        assert!(config.filter.is_none());
248        assert!(config.ignore.is_none());
249    }
250
251    #[test]
252    fn config_default_implementation() {
253        let config = Config::default();
254        assert!(config.output.is_none());
255        assert!(config.filter.is_none());
256        assert!(config.ignore.is_none());
257        assert!(config.line_numbers.is_none());
258        assert!(config.preview.is_none());
259        assert!(config.token_count.is_none());
260        assert!(config.output_folder.is_none());
261        assert!(config.timestamped_output.is_none());
262        assert!(config.yes.is_none());
263        assert!(config.auto_diff.is_none());
264        assert!(config.diff_context_lines.is_none());
265        assert!(config.diff_only.is_none());
266        assert!(config.encoding_strategy.is_none());
267        assert!(config.max_tokens.is_none());
268        assert!(config.signatures.is_none());
269        assert!(config.structure.is_none());
270        assert!(config.truncate.is_none());
271        assert!(config.visibility.is_none());
272    }
273
274    #[test]
275    #[serial]
276    fn load_config_invalid_toml_in_cwd() {
277        let temp_dir = tempdir().unwrap();
278        let original_dir = std::env::current_dir().unwrap();
279
280        std::env::set_current_dir(&temp_dir).unwrap();
281
282        let config_path = temp_dir.path().join("context-builder.toml");
283        let invalid_toml = r#"
284output = "test.md"
285invalid_toml [
286"#;
287        fs::write(&config_path, invalid_toml).unwrap();
288
289        let result = load_config();
290
291        std::env::set_current_dir(original_dir).unwrap();
292
293        assert!(result.is_none());
294    }
295
296    #[test]
297    #[serial]
298    fn load_config_valid_in_cwd() {
299        let temp_dir = tempdir().unwrap();
300        let original_dir = std::env::current_dir().unwrap();
301
302        std::env::set_current_dir(&temp_dir).unwrap();
303
304        let config_path = temp_dir.path().join("context-builder.toml");
305        let valid_toml = r#"
306output = "context.md"
307filter = ["rs"]
308"#;
309        fs::write(&config_path, valid_toml).unwrap();
310
311        let result = load_config();
312
313        std::env::set_current_dir(original_dir).unwrap();
314
315        assert!(result.is_some());
316        let config = result.unwrap();
317        assert_eq!(config.output, Some("context.md".to_string()));
318    }
319}