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 std::fs;
136    use tempfile::tempdir;
137
138    #[test]
139    fn load_config_nonexistent_file() {
140        // Test loading config when file doesn't exist by temporarily changing directory
141        let temp_dir = tempdir().unwrap();
142        let original_dir = std::env::current_dir().unwrap();
143
144        // Change to temp directory where no config file exists
145        std::env::set_current_dir(&temp_dir).unwrap();
146
147        let result = load_config();
148
149        // Restore original directory
150        std::env::set_current_dir(original_dir).unwrap();
151
152        assert!(result.is_none());
153    }
154
155    #[test]
156    fn load_config_from_path_nonexistent_file() {
157        let dir = tempdir().unwrap();
158        let result = load_config_from_path(dir.path());
159        assert!(result.is_none());
160    }
161
162    #[test]
163    fn load_config_from_path_valid_config() {
164        let dir = tempdir().unwrap();
165        let config_path = dir.path().join("context-builder.toml");
166
167        let config_content = r#"
168output = "test-output.md"
169filter = ["rs", "toml"]
170ignore = ["target", ".git"]
171line_numbers = true
172preview = false
173token_count = true
174timestamped_output = true
175yes = false
176auto_diff = true
177diff_context_lines = 5
178diff_only = false
179encoding_strategy = "detect"
180"#;
181
182        fs::write(&config_path, config_content).unwrap();
183
184        let config = load_config_from_path(dir.path()).unwrap();
185        assert_eq!(config.output.unwrap(), "test-output.md");
186        assert_eq!(config.filter.unwrap(), vec!["rs", "toml"]);
187        assert_eq!(config.ignore.unwrap(), vec!["target", ".git"]);
188        assert!(config.line_numbers.unwrap());
189        assert!(!config.preview.unwrap());
190        assert!(config.token_count.unwrap());
191        assert!(config.timestamped_output.unwrap());
192        assert!(!config.yes.unwrap());
193        assert!(config.auto_diff.unwrap());
194        assert_eq!(config.diff_context_lines.unwrap(), 5);
195        assert!(!config.diff_only.unwrap());
196        assert_eq!(config.encoding_strategy.unwrap(), "detect");
197    }
198
199    #[test]
200    fn load_config_from_path_partial_config() {
201        let dir = tempdir().unwrap();
202        let config_path = dir.path().join("context-builder.toml");
203
204        let config_content = r#"
205output = "minimal.md"
206filter = ["py"]
207"#;
208
209        fs::write(&config_path, config_content).unwrap();
210
211        let config = load_config_from_path(dir.path()).unwrap();
212        assert_eq!(config.output.unwrap(), "minimal.md");
213        assert_eq!(config.filter.unwrap(), vec!["py"]);
214        assert!(config.ignore.is_none());
215        assert!(config.line_numbers.is_none());
216        assert!(config.auto_diff.is_none());
217    }
218
219    #[test]
220    fn load_config_from_path_invalid_toml() {
221        let dir = tempdir().unwrap();
222        let config_path = dir.path().join("context-builder.toml");
223
224        // Invalid TOML content
225        let config_content = r#"
226output = "test.md"
227invalid_toml [
228"#;
229
230        fs::write(&config_path, config_content).unwrap();
231
232        let config = load_config_from_path(dir.path());
233        assert!(config.is_none());
234    }
235
236    #[test]
237    fn load_config_from_path_empty_config() {
238        let dir = tempdir().unwrap();
239        let config_path = dir.path().join("context-builder.toml");
240
241        fs::write(&config_path, "").unwrap();
242
243        let config = load_config_from_path(dir.path()).unwrap();
244        assert!(config.output.is_none());
245        assert!(config.filter.is_none());
246        assert!(config.ignore.is_none());
247    }
248
249    #[test]
250    fn config_default_implementation() {
251        let config = Config::default();
252        assert!(config.output.is_none());
253        assert!(config.filter.is_none());
254        assert!(config.ignore.is_none());
255        assert!(config.line_numbers.is_none());
256        assert!(config.preview.is_none());
257        assert!(config.token_count.is_none());
258        assert!(config.output_folder.is_none());
259        assert!(config.timestamped_output.is_none());
260        assert!(config.yes.is_none());
261        assert!(config.auto_diff.is_none());
262        assert!(config.diff_context_lines.is_none());
263        assert!(config.diff_only.is_none());
264        assert!(config.encoding_strategy.is_none());
265        assert!(config.max_tokens.is_none());
266        assert!(config.signatures.is_none());
267        assert!(config.structure.is_none());
268        assert!(config.truncate.is_none());
269        assert!(config.visibility.is_none());
270    }
271}