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
77/// Load configuration from `context-builder.toml` in the current working directory.
78/// Returns `None` if the file does not exist or cannot be parsed.
79pub fn load_config() -> Option<Config> {
80    let config_path = Path::new("context-builder.toml");
81    if config_path.exists() {
82        let content = fs::read_to_string(config_path).ok()?;
83        toml::from_str(&content).ok()
84    } else {
85        None
86    }
87}
88
89/// Load configuration from `context-builder.toml` in the specified project root directory.
90/// Returns `None` if the file does not exist or cannot be parsed.
91pub fn load_config_from_path(project_root: &Path) -> Option<Config> {
92    let config_path = project_root.join("context-builder.toml");
93    if config_path.exists() {
94        let content = fs::read_to_string(config_path).ok()?;
95        toml::from_str(&content).ok()
96    } else {
97        None
98    }
99}
100
101#[cfg(test)]
102mod tests {
103    use super::*;
104    use std::fs;
105    use tempfile::tempdir;
106
107    #[test]
108    fn load_config_nonexistent_file() {
109        // Test loading config when file doesn't exist by temporarily changing directory
110        let temp_dir = tempdir().unwrap();
111        let original_dir = std::env::current_dir().unwrap();
112
113        // Change to temp directory where no config file exists
114        std::env::set_current_dir(&temp_dir).unwrap();
115
116        let result = load_config();
117
118        // Restore original directory
119        std::env::set_current_dir(original_dir).unwrap();
120
121        assert!(result.is_none());
122    }
123
124    #[test]
125    fn load_config_from_path_nonexistent_file() {
126        let dir = tempdir().unwrap();
127        let result = load_config_from_path(dir.path());
128        assert!(result.is_none());
129    }
130
131    #[test]
132    fn load_config_from_path_valid_config() {
133        let dir = tempdir().unwrap();
134        let config_path = dir.path().join("context-builder.toml");
135
136        let config_content = r#"
137output = "test-output.md"
138filter = ["rs", "toml"]
139ignore = ["target", ".git"]
140line_numbers = true
141preview = false
142token_count = true
143timestamped_output = true
144yes = false
145auto_diff = true
146diff_context_lines = 5
147diff_only = false
148encoding_strategy = "detect"
149"#;
150
151        fs::write(&config_path, config_content).unwrap();
152
153        let config = load_config_from_path(dir.path()).unwrap();
154        assert_eq!(config.output.unwrap(), "test-output.md");
155        assert_eq!(config.filter.unwrap(), vec!["rs", "toml"]);
156        assert_eq!(config.ignore.unwrap(), vec!["target", ".git"]);
157        assert!(config.line_numbers.unwrap());
158        assert!(!config.preview.unwrap());
159        assert!(config.token_count.unwrap());
160        assert!(config.timestamped_output.unwrap());
161        assert!(!config.yes.unwrap());
162        assert!(config.auto_diff.unwrap());
163        assert_eq!(config.diff_context_lines.unwrap(), 5);
164        assert!(!config.diff_only.unwrap());
165        assert_eq!(config.encoding_strategy.unwrap(), "detect");
166    }
167
168    #[test]
169    fn load_config_from_path_partial_config() {
170        let dir = tempdir().unwrap();
171        let config_path = dir.path().join("context-builder.toml");
172
173        let config_content = r#"
174output = "minimal.md"
175filter = ["py"]
176"#;
177
178        fs::write(&config_path, config_content).unwrap();
179
180        let config = load_config_from_path(dir.path()).unwrap();
181        assert_eq!(config.output.unwrap(), "minimal.md");
182        assert_eq!(config.filter.unwrap(), vec!["py"]);
183        assert!(config.ignore.is_none());
184        assert!(config.line_numbers.is_none());
185        assert!(config.auto_diff.is_none());
186    }
187
188    #[test]
189    fn load_config_from_path_invalid_toml() {
190        let dir = tempdir().unwrap();
191        let config_path = dir.path().join("context-builder.toml");
192
193        // Invalid TOML content
194        let config_content = r#"
195output = "test.md"
196invalid_toml [
197"#;
198
199        fs::write(&config_path, config_content).unwrap();
200
201        let config = load_config_from_path(dir.path());
202        assert!(config.is_none());
203    }
204
205    #[test]
206    fn load_config_from_path_empty_config() {
207        let dir = tempdir().unwrap();
208        let config_path = dir.path().join("context-builder.toml");
209
210        fs::write(&config_path, "").unwrap();
211
212        let config = load_config_from_path(dir.path()).unwrap();
213        assert!(config.output.is_none());
214        assert!(config.filter.is_none());
215        assert!(config.ignore.is_none());
216    }
217
218    #[test]
219    fn config_default_implementation() {
220        let config = Config::default();
221        assert!(config.output.is_none());
222        assert!(config.filter.is_none());
223        assert!(config.ignore.is_none());
224        assert!(config.line_numbers.is_none());
225        assert!(config.preview.is_none());
226        assert!(config.token_count.is_none());
227        assert!(config.output_folder.is_none());
228        assert!(config.timestamped_output.is_none());
229        assert!(config.yes.is_none());
230        assert!(config.auto_diff.is_none());
231        assert!(config.diff_context_lines.is_none());
232        assert!(config.diff_only.is_none());
233        assert!(config.encoding_strategy.is_none());
234    }
235}