Skip to main content

differential_engine/
config.rs

1//! Configuration, split by ownership (ADR 0012, amended by ADR 0018-era split):
2//!
3//! - **Repo-level** `.differential.toml` at the target repo's root —
4//!   classification hints only. Shared by everyone reviewing the repo.
5//! - **User-level** `~/.config/differential/config.toml` (XDG) — `[grouping]`:
6//!   which agent CLI to run and its timeout, and `[review]`: how much context
7//!   the reviewer shows around a hunk. Both are per-user choices, not
8//!   properties of the repo, so neither lives in it.
9//!
10//! HARD RULE (ADR 0012): config tunes classification hints and tool behaviour.
11//! It can never remove a file or hunk from enumeration — enumeration runs before
12//! and independently of anything in this module, and nothing here is consulted
13//! by the parser or the invariants.
14
15use 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    /// Rejected with a migration hint — [grouping] moved to the user config.
32    #[serde(default)]
33    grouping: Option<toml::Table>,
34    // Reserved for later milestones; accepted so the file format is stable.
35    #[serde(default)]
36    ordering: toml::Table,
37    #[serde(default)]
38    stack: toml::Table,
39}
40
41/// The user-level file: `[grouping]` and `[review]`.
42#[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/// Everything `parse_user` reads, so `load` assigns one value rather than
52/// growing a second assignment every time the user file gains a table.
53#[derive(Debug, Default)]
54pub struct UserConfig {
55    pub grouping: GroupingConfig,
56    pub review: ReviewConfig,
57}
58
59/// `[grouping]` — pure data; the pipeline turns it into an LLM backend.
60#[derive(Debug, Clone, Default, Deserialize)]
61#[serde(deny_unknown_fields)]
62pub struct GroupingConfig {
63    /// Backend argv (prompt on stdin, completion on stdout). Default: the
64    /// validated tools-denied claude invocation.
65    #[serde(default)]
66    pub command: Option<Vec<String>>,
67    #[serde(default)]
68    pub timeout_secs: Option<u64>,
69}
70
71/// `[review]` — how much of a file the terminal reviewer shows around a hunk.
72///
73/// Presentation only: it can widen what is *displayed* around a hunk and can
74/// never change which hunks exist. Enumeration is total and runs before any of
75/// this (ADR 0005, 0012).
76#[derive(Debug, Clone, Deserialize)]
77#[serde(deny_unknown_fields)]
78pub struct ReviewConfig {
79    /// Context lines shown either side of a hunk before any expansion.
80    #[serde(default = "default_context")]
81    pub context: usize,
82    /// Lines one `z` at a context boundary row pulls in.
83    #[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    /// Additive globs marking files as generated (noise-tier hint).
118    pub generated: GlobSet,
119    /// Overrides: never mark these generated. Wins over everything.
120    pub not_generated: GlobSet,
121    /// gitattributes attribute names honoured as "generated" declarations.
122    pub attributes: Vec<String>,
123    /// From the USER config, never the repo (agents differ per user).
124    pub grouping: GroupingConfig,
125    /// From the USER config: how much context the reviewer shows.
126    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
141/// `<user config dir>/differential/config.toml`.
142///
143/// The directory comes from `ConfigSource`; the two path components are
144/// contract, not adapter, so they stay here.
145pub 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    /// Resolution, per file: explicit path > default location > defaults.
155    /// A missing file means defaults; a malformed file is a hard error, never
156    /// silently ignored.
157    ///
158    /// Repo file: `<repo-root>/.differential.toml` — classification hints.
159    /// User file: `~/.config/differential/config.toml` — `[grouping]`.
160    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    /// Parse the REPO file: classification hints only. A `[grouping]` table
183    /// here is a hard error with a pointer to its new home.
184    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); // reserved
199        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    /// Parse the USER file: `[grouping]` and `[review]`.
212    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
224/// Read (contents, origin) for `explicit > default`, where a missing default
225/// is fine but a missing EXPLICIT path is a hard error.
226///
227/// The policy — which file, what precedence, what absence means — is here; the
228/// port only hands back bytes. The two read methods exist so that an
229/// explicit-but-missing path reports the same message it always did.
230fn 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    /// The real filesystem: these assertions are about resolution policy
264    /// (precedence, what absence means), which is what `load` owns.
265    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        // An absent [review] means the defaults, not zero context.
325        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        // Unknown keys and unknown sections stay hard errors.
333        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        // Explicit-but-missing paths are hard errors; absent defaults are not.
364        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}