1use std::path::{Path, PathBuf};
16
17use globset::{Glob, GlobSet, GlobSetBuilder};
18use serde::Deserialize;
19
20use crate::EngineError;
21
22pub const CONFIG_FILE_NAME: &str = ".differential.toml";
23pub const USER_CONFIG_DIR: &str = "differential";
24pub const USER_CONFIG_FILE_NAME: &str = "config.toml";
25
26#[derive(Debug, Default, Deserialize)]
27#[serde(deny_unknown_fields)]
28struct RawConfig {
29 #[serde(default)]
30 classify: RawClassify,
31 #[serde(default)]
33 grouping: Option<toml::Table>,
34 #[serde(default)]
36 ordering: toml::Table,
37 #[serde(default)]
38 stack: toml::Table,
39}
40
41#[derive(Debug, Default, Deserialize)]
43#[serde(deny_unknown_fields)]
44struct RawUserConfig {
45 #[serde(default)]
46 grouping: GroupingConfig,
47 #[serde(default)]
48 review: ReviewConfig,
49}
50
51#[derive(Debug, Default)]
54pub struct UserConfig {
55 pub grouping: GroupingConfig,
56 pub review: ReviewConfig,
57}
58
59#[derive(Debug, Clone, Default, Deserialize)]
61#[serde(deny_unknown_fields)]
62pub struct GroupingConfig {
63 #[serde(default)]
66 pub command: Option<Vec<String>>,
67 #[serde(default)]
68 pub timeout_secs: Option<u64>,
69}
70
71#[derive(Debug, Clone, Deserialize)]
77#[serde(deny_unknown_fields)]
78pub struct ReviewConfig {
79 #[serde(default = "default_context")]
81 pub context: usize,
82 #[serde(default = "default_context_step")]
84 pub context_step: usize,
85}
86
87const fn default_context() -> usize {
88 3
89}
90
91const fn default_context_step() -> usize {
92 10
93}
94
95impl Default for ReviewConfig {
96 fn default() -> Self {
97 ReviewConfig {
98 context: default_context(),
99 context_step: default_context_step(),
100 }
101 }
102}
103
104#[derive(Debug, Default, Deserialize)]
105#[serde(deny_unknown_fields)]
106struct RawClassify {
107 #[serde(default)]
108 generated: Vec<String>,
109 #[serde(default)]
110 not_generated: Vec<String>,
111 #[serde(default)]
112 attributes: Option<Vec<String>>,
113}
114
115#[derive(Debug)]
116pub struct Config {
117 pub generated: GlobSet,
119 pub not_generated: GlobSet,
121 pub attributes: Vec<String>,
123 pub grouping: GroupingConfig,
125 pub review: ReviewConfig,
127}
128
129impl Default for Config {
130 fn default() -> Self {
131 Config {
132 generated: GlobSet::empty(),
133 not_generated: GlobSet::empty(),
134 attributes: vec!["linguist-generated".to_string()],
135 grouping: GroupingConfig::default(),
136 review: ReviewConfig::default(),
137 }
138 }
139}
140
141pub fn user_config_path<S: crate::ports::ConfigSource>(src: &S) -> Option<PathBuf> {
146 Some(
147 src.user_config_dir()?
148 .join(USER_CONFIG_DIR)
149 .join(USER_CONFIG_FILE_NAME),
150 )
151}
152
153impl Config {
154 pub fn load<S: crate::ports::ConfigSource>(
161 src: &S,
162 repo_root: &Path,
163 repo_override: Option<&Path>,
164 user_override: Option<&Path>,
165 ) -> Result<Config, EngineError> {
166 let repo_default = Some(repo_root.join(CONFIG_FILE_NAME));
167 let mut config = match resolve(src, repo_override, repo_default)? {
168 Some((text, origin)) => Self::parse(&text, &origin)?,
169 None => Config::default(),
170 };
171 let user_default = src
172 .user_config_dir()
173 .map(|d| d.join(USER_CONFIG_DIR).join(USER_CONFIG_FILE_NAME));
174 if let Some((text, origin)) = resolve(src, user_override, user_default)? {
175 let user = Self::parse_user(&text, &origin)?;
176 config.grouping = user.grouping;
177 config.review = user.review;
178 }
179 Ok(config)
180 }
181
182 pub fn parse(text: &str, origin: &str) -> Result<Config, EngineError> {
185 let raw: RawConfig = toml::from_str(text).map_err(|e| EngineError::Config {
186 path: origin.to_string(),
187 msg: e.to_string(),
188 })?;
189 if raw.grouping.is_some() {
190 return Err(EngineError::Config {
191 path: origin.to_string(),
192 msg: "[grouping] moved to the user config \
193 (~/.config/differential/config.toml): the agent command is a \
194 per-user choice, not a repo setting"
195 .to_string(),
196 });
197 }
198 let _ = (&raw.ordering, &raw.stack); Ok(Config {
200 generated: build_globs(&raw.classify.generated, origin)?,
201 not_generated: build_globs(&raw.classify.not_generated, origin)?,
202 attributes: raw
203 .classify
204 .attributes
205 .unwrap_or_else(|| vec!["linguist-generated".to_string()]),
206 grouping: GroupingConfig::default(),
207 review: ReviewConfig::default(),
208 })
209 }
210
211 pub fn parse_user(text: &str, origin: &str) -> Result<UserConfig, EngineError> {
213 let raw: RawUserConfig = toml::from_str(text).map_err(|e| EngineError::Config {
214 path: origin.to_string(),
215 msg: e.to_string(),
216 })?;
217 Ok(UserConfig {
218 grouping: raw.grouping,
219 review: raw.review,
220 })
221 }
222}
223
224fn resolve<S: crate::ports::ConfigSource>(
231 src: &S,
232 explicit: Option<&Path>,
233 default: Option<PathBuf>,
234) -> Result<Option<(String, String)>, EngineError> {
235 match explicit {
236 Some(p) => Ok(Some((src.read_required(p)?, p.display().to_string()))),
237 None => {
238 let Some(p) = default else {
239 return Ok(None);
240 };
241 Ok(src.read(&p)?.map(|text| (text, p.display().to_string())))
242 }
243 }
244}
245
246fn build_globs(patterns: &[String], origin: &str) -> Result<GlobSet, EngineError> {
247 let mut b = GlobSetBuilder::new();
248 for p in patterns {
249 let glob = Glob::new(p).map_err(|e| EngineError::Config {
250 path: origin.to_string(),
251 msg: format!("bad glob {p:?}: {e}"),
252 })?;
253 b.add(glob);
254 }
255 b.build().map_err(|e| EngineError::Config {
256 path: origin.to_string(),
257 msg: e.to_string(),
258 })
259}
260
261#[cfg(test)]
262mod tests {
263 const SRC: crate::store::OsConfigSource = crate::store::OsConfigSource;
266
267 use super::*;
268
269 #[test]
270 fn defaults_when_empty() {
271 let c = Config::parse("", "test").unwrap();
272 assert_eq!(c.attributes, vec!["linguist-generated"]);
273 assert!(!c.generated.is_match("anything"));
274 }
275
276 #[test]
277 fn globs_and_overrides() {
278 let c = Config::parse(
279 r#"
280[classify]
281generated = ["**/__snapshots__/**", "migrations/**"]
282not_generated = ["important.lock"]
283attributes = ["linguist-generated", "custom-generated"]
284"#,
285 "test",
286 )
287 .unwrap();
288 assert!(c.generated.is_match("ui/__snapshots__/x.snap"));
289 assert!(c.generated.is_match("migrations/0001_init.sql"));
290 assert!(!c.generated.is_match("src/main.rs"));
291 assert!(c.not_generated.is_match("important.lock"));
292 assert_eq!(c.attributes.len(), 2);
293 }
294
295 #[test]
296 fn malformed_config_is_a_hard_error() {
297 assert!(Config::parse("classify = 5", "test").is_err());
298 assert!(Config::parse("[classify]\nnope = true", "test").is_err());
299 }
300
301 #[test]
302 fn reserved_sections_are_accepted() {
303 Config::parse("[ordering]\nfuture = 1\n[stack]\nns = \"y\"", "test").unwrap();
304 }
305
306 #[test]
307 fn grouping_in_repo_config_errors_with_migration_hint() {
308 let err = Config::parse("[grouping]\ncommand = [\"x\"]", "test").unwrap_err();
309 assert!(err.to_string().contains("user config"), "{err}");
310 }
311
312 #[test]
313 fn user_config_parses_grouping_and_review() {
314 let u = Config::parse_user(
315 "[grouping]\ncommand = [\"my-llm\", \"--flag\"]\ntimeout_secs = 60",
316 "test",
317 )
318 .unwrap();
319 assert_eq!(
320 u.grouping.command.as_deref(),
321 Some(&["my-llm".to_string(), "--flag".to_string()][..])
322 );
323 assert_eq!(u.grouping.timeout_secs, Some(60));
324 assert_eq!(u.review.context, 3);
326 assert_eq!(u.review.context_step, 10);
327
328 let u = Config::parse_user("[review]\ncontext_step = 25", "test").unwrap();
329 assert_eq!(u.review.context_step, 25);
330 assert_eq!(u.review.context, 3, "one key set must not zero the other");
331
332 assert!(Config::parse_user("[grouping]\nmodel = \"x\"", "test").is_err());
334 assert!(Config::parse_user("[review]\nlines = 5", "test").is_err());
335 assert!(Config::parse_user("[classify]\ngenerated = []", "test").is_err());
336 }
337
338 #[test]
339 fn load_composes_repo_and_user_files() {
340 let tmp = tempfile::TempDir::new().unwrap();
341 let repo_file = tmp.path().join("repo.toml");
342 let user_file = tmp.path().join("user.toml");
343 std::fs::write(&repo_file, "[classify]\ngenerated = [\"gen/**\"]").unwrap();
344 std::fs::write(
345 &user_file,
346 "[grouping]\ncommand = [\"agent\"]\n[review]\ncontext = 8",
347 )
348 .unwrap();
349 let c = Config::load(
350 &crate::store::OsConfigSource,
351 tmp.path(),
352 Some(&repo_file),
353 Some(&user_file),
354 )
355 .unwrap();
356 assert!(c.generated.is_match("gen/x"));
357 assert_eq!(
358 c.grouping.command.as_deref(),
359 Some(&["agent".to_string()][..])
360 );
361 assert_eq!(c.review.context, 8);
362
363 assert!(
365 Config::load(&SRC, tmp.path(), Some(Path::new("/nope")), Some(&user_file)).is_err()
366 );
367 assert!(Config::load(&SRC, tmp.path(), None, Some(&user_file)).is_ok());
368 }
369}