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