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]`: which palette the
7//! reviewer wears, how much context it shows around a hunk, and which diff
8//! layout it opens in. All per-user choices, not properties of the repo, so
9//! none of them lives in it.
10//!
11//! HARD RULE (ADR 0012): config tunes classification hints and tool behaviour.
12//! It can never remove a file or hunk from enumeration — enumeration runs before
13//! and independently of anything in this module, and nothing here is consulted
14//! by the parser or the invariants.
15
16use std::path::{Path, PathBuf};
17
18use globset::{Glob, GlobSet, GlobSetBuilder};
19use serde::Deserialize;
20
21use crate::EngineError;
22
23pub const CONFIG_FILE_NAME: &str = ".differential.toml";
24pub const USER_CONFIG_DIR: &str = "differential";
25pub const USER_CONFIG_FILE_NAME: &str = "config.toml";
26
27#[derive(Debug, Default, Deserialize)]
28#[serde(deny_unknown_fields)]
29struct RawConfig {
30 #[serde(default)]
31 classify: RawClassify,
32 /// Rejected with a migration hint — [grouping] moved to the user config.
33 #[serde(default)]
34 grouping: Option<toml::Table>,
35 // Reserved for later milestones; accepted so the file format is stable.
36 // `IgnoredAny` says exactly that — the table is parsed and discarded,
37 // where a `toml::Table` was allocated in full and then discarded, with a
38 // `let _ =` further down whose only job was to quiet the compiler about
39 // a field nothing reads.
40 //
41 // Named with a leading underscore because nothing reads them and nothing
42 // should: the `#[serde(rename)]` keeps the file's own spelling.
43 #[serde(default, rename = "ordering")]
44 _ordering: serde::de::IgnoredAny,
45 #[serde(default, rename = "stack")]
46 _stack: serde::de::IgnoredAny,
47}
48
49/// The user-level file: `[grouping]` and `[review]`.
50#[derive(Debug, Default, Deserialize)]
51#[serde(deny_unknown_fields)]
52struct RawUserConfig {
53 #[serde(default)]
54 grouping: GroupingConfig,
55 #[serde(default)]
56 review: ReviewConfig,
57}
58
59/// Everything `parse_user` reads, so `load` assigns one value rather than
60/// growing a second assignment every time the user file gains a table.
61#[derive(Debug, Default)]
62pub struct UserConfig {
63 pub grouping: GroupingConfig,
64 pub review: ReviewConfig,
65}
66
67/// Which agent to run, by name.
68///
69/// It used to be a free argv, and that was the wrong shape. The grouping stage
70/// does not merely spawn a process: it hands the agent a tool allowlist, a
71/// fetch command and a prompt written for what that agent can do (ADR 0022).
72/// An arbitrary argv gets the prompt and none of the rest, so it was a knob
73/// that looked like it worked. A name selects an invocation this crate builds
74/// whole, and adding an agent is adding a variant here.
75///
76/// The name also answers what a reviewer is shown while they wait — the argv
77/// never could, at four times the width of the line it had.
78///
79/// **Four of the five keep the model read-only; `Pi` does not** (ADR 0033).
80/// Read [`Agent::read_only`] and [`ReadOnly::is_enforced`] before choosing one.
81#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Deserialize)]
82#[serde(rename_all = "kebab-case")]
83pub enum Agent {
84 /// Headless `claude`, read-only by tool allowlist (ADR 0022).
85 #[default]
86 ClaudeCode,
87 /// Headless `codex exec`, read-only by OS sandbox (Seatbelt, bubblewrap).
88 Codex,
89 /// Headless `droid exec`, read-only by default — the tier is what we do
90 /// not pass.
91 Droid,
92 /// Headless `copilot`, read-only by tool allowlist and an explicit deny.
93 Copilot,
94 /// Headless `pi`, **read-only is NOT enforced** (ADR 0033).
95 ///
96 /// Pi ships no sandbox and no per-command allowlist, and its `-t` flag
97 /// toggles whole tools. The model needs `bash` to run the fetch command
98 /// and `git diff`, and `bash` also lets it write, commit and push. Nothing
99 /// but the prompt stops it. Choose this agent only knowing that.
100 Pi,
101}
102
103impl Agent {
104 /// Every agent, so a lister does not keep its own copy of the list.
105 ///
106 /// The array is exhaustive by hand, which a `match` would enforce and an
107 /// array cannot. `all_agents_are_listed` in this module is that check.
108 pub const ALL: [Agent; 5] = [
109 Agent::ClaudeCode,
110 Agent::Codex,
111 Agent::Droid,
112 Agent::Copilot,
113 Agent::Pi,
114 ];
115
116 /// The name this agent answers to in `[grouping].agent`.
117 ///
118 /// Hand-written rather than derived, because serde renames on the way IN
119 /// and there is no way to ask it for the string on the way out without a
120 /// second derive. The `match` is the guard: a new variant does not compile
121 /// until it has a name here.
122 pub fn key(self) -> &'static str {
123 match self {
124 Agent::ClaudeCode => "claude-code",
125 Agent::Codex => "codex",
126 Agent::Droid => "droid",
127 Agent::Copilot => "copilot",
128 Agent::Pi => "pi",
129 }
130 }
131
132 /// Whether anyone has ever run this agent's command line.
133 ///
134 /// Not a quality judgement — a claim about provenance, and the only honest
135 /// one this crate can make. Every argv here is written from its agent's
136 /// documentation, and a test can assert the string this crate builds but
137 /// never that the CLI on the other end accepts it. CI cannot either: the
138 /// binary is not installed and its flags move between releases.
139 ///
140 /// `true` means `dfr agents --probe` passed all four checks against the
141 /// real CLI, and the argv is in this repository because of that run.
142 ///
143 /// **`false` means likely wrong, not merely unchecked.** Of the three
144 /// checked so far, two were broken: Claude Code's allowlist did not bind
145 /// without `--permission-mode default`, and Codex was passing
146 /// `--ask-for-approval`, which its `exec` subcommand rejects outright. Both
147 /// came from documentation that was accurate about the product and wrong
148 /// about the entry point. Nothing suggests the unchecked two are better.
149 ///
150 /// A caller that offers a user this list must say so, for the same reason
151 /// it must say what [`Agent::read_only`] answers: the person choosing is
152 /// the person who carries it.
153 ///
154 /// This flips when someone runs the probe and the argv lands — never
155 /// because it looks right.
156 pub fn proven(self) -> bool {
157 match self {
158 // Probed on a real call: all four checks passed.
159 Agent::ClaudeCode | Agent::Codex | Agent::Pi => true,
160 // Written from documentation. Droid needs a paid Factory plan and
161 // Copilot a Copilot seat, so neither has been run.
162 Agent::Droid | Agent::Copilot => false,
163 }
164 }
165
166 /// What stops this agent writing, if anything.
167 ///
168 /// A caller that shows a user the list of agents MUST show this too. The
169 /// person picking a name is the person who needs to know, and exactly one
170 /// answer here is [`ReadOnly::NotEnforced`].
171 pub fn read_only(self) -> ReadOnly {
172 match self {
173 Agent::ClaudeCode | Agent::Copilot => ReadOnly::ToolAllowlist,
174 Agent::Codex => ReadOnly::OsSandbox,
175 Agent::Droid => ReadOnly::AgentDefault,
176 Agent::Pi => ReadOnly::NotEnforced,
177 }
178 }
179}
180
181/// What keeps an agent from writing.
182///
183/// An enum rather than a `bool` plus a sentence, because the three enforcing
184/// answers are not interchangeable and a reader deciding whether to trust one
185/// needs to know which they have. An OS sandbox holds against a model that
186/// tries; an allowlist holds against a model that asks; a default holds only
187/// until someone adds a flag.
188#[derive(Debug, Clone, Copy, PartialEq, Eq)]
189pub enum ReadOnly {
190 /// The agent may run only the tools it was given, and writing is not one.
191 ToolAllowlist,
192 /// The agent may run anything and the kernel refuses the writes.
193 OsSandbox,
194 /// The agent is read-only until told otherwise, and it is not told.
195 AgentDefault,
196 /// **Nothing stops it.** The agent can write, commit and push, and only the
197 /// prompt asks it not to. See [`Agent::Pi`] and ADR 0033 for why one agent
198 /// is here and why that was a choice rather than an oversight.
199 NotEnforced,
200}
201
202impl ReadOnly {
203 pub fn is_enforced(self) -> bool {
204 !matches!(self, ReadOnly::NotEnforced)
205 }
206}
207
208/// `[grouping]` — pure data; the application layer turns it into an LLM
209/// backend (ADR 0018, 0020).
210#[derive(Debug, Clone, Default, Deserialize)]
211#[serde(deny_unknown_fields)]
212pub struct GroupingConfig {
213 /// Which agent runs the grouping call. Default: `claude-code`.
214 #[serde(default)]
215 pub agent: Option<Agent>,
216 /// How long to wait for it. Default: 1200 seconds.
217 ///
218 /// This one stays a number because it tunes the agent rather than replacing
219 /// it: a slow machine or a large change may genuinely need longer.
220 #[serde(default)]
221 pub timeout_secs: Option<u64>,
222}
223
224/// Which palette the terminal reviewer wears, by name.
225///
226/// A name, for the same reason [`Agent`] is one: a palette is not a colour the
227/// caller supplies but a whole coherent set the renderer builds — thirty-one
228/// fields plus the syntax theme the code itself is painted with, all derived
229/// together so the chrome and the code cannot disagree (ADR 0024). A free-form
230/// colour list would be a knob that looked like it worked.
231///
232/// Adding a theme is adding a variant here and a seed in the renderer.
233#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Deserialize)]
234#[serde(rename_all = "kebab-case")]
235pub enum ThemeName {
236 /// The original palette: a dark slate ground with a cyan accent.
237 #[default]
238 Dark,
239 OneDark,
240 OneLight,
241 GruvboxDark,
242 GruvboxLight,
243 SolarizedDark,
244 SolarizedLight,
245 CatppuccinMocha,
246 CatppuccinLatte,
247 Dracula,
248 Monokai,
249}
250
251/// `[review]` — how the terminal reviewer looks, and how much of a file it
252/// shows around a hunk.
253///
254/// Presentation only: it can widen what is *displayed* around a hunk and can
255/// never change which hunks exist. Enumeration is total and runs before any of
256/// this (ADR 0005, 0012).
257#[derive(Debug, Clone, Deserialize)]
258#[serde(deny_unknown_fields)]
259pub struct ReviewConfig {
260 /// Context lines shown either side of a hunk before any expansion.
261 #[serde(default = "default_context")]
262 pub context: usize,
263 /// Lines one `z` at a context boundary row pulls in.
264 #[serde(default = "default_context_step")]
265 pub context_step: usize,
266 /// Which diff layout a review opens in, before the reader says otherwise.
267 ///
268 /// A DEFAULT, not a setting: `s` still toggles, and the toggle is recorded
269 /// per review. A review that has recorded a choice keeps it whatever this
270 /// says, so changing it never moves a layout under someone mid-read.
271 #[serde(default)]
272 pub diff: DiffLayout,
273 /// Which palette to wear. Default: `dark`.
274 #[serde(default)]
275 pub theme: ThemeName,
276}
277
278/// How the reviewer lays a hunk out.
279///
280/// An enum rather than a bool because a config key is permanent, and a third
281/// layout would otherwise need a second key contradicting the first.
282#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Deserialize)]
283#[serde(rename_all = "lowercase")]
284pub enum DiffLayout {
285 /// Old and new side by side.
286 #[default]
287 Split,
288 /// One column, removals above additions.
289 Unified,
290}
291
292impl DiffLayout {
293 pub fn is_split(self) -> bool {
294 matches!(self, DiffLayout::Split)
295 }
296}
297
298const fn default_context() -> usize {
299 3
300}
301
302const fn default_context_step() -> usize {
303 10
304}
305
306impl Default for ReviewConfig {
307 fn default() -> Self {
308 ReviewConfig {
309 context: default_context(),
310 context_step: default_context_step(),
311 diff: DiffLayout::default(),
312 theme: ThemeName::default(),
313 }
314 }
315}
316
317#[derive(Debug, Default, Deserialize)]
318#[serde(deny_unknown_fields)]
319struct RawClassify {
320 #[serde(default)]
321 generated: Vec<String>,
322 #[serde(default)]
323 not_generated: Vec<String>,
324 #[serde(default)]
325 attributes: Option<Vec<String>>,
326}
327
328#[derive(Debug)]
329pub struct Config {
330 /// Additive globs marking files as generated (noise-tier hint).
331 pub generated: GlobSet,
332 /// Overrides: never mark these generated. Wins over everything.
333 pub not_generated: GlobSet,
334 /// gitattributes attribute names honoured as "generated" declarations.
335 /// Defaults to [`DEFAULT_ATTRIBUTES`]; setting the key **replaces** the
336 /// list rather than adding to it.
337 pub attributes: Vec<String>,
338 /// From the USER config, never the repo (agents differ per user).
339 pub grouping: GroupingConfig,
340 /// From the USER config: how much context the reviewer shows.
341 pub review: ReviewConfig,
342}
343
344/// gitattributes names honoured as a "generated" declaration when
345/// `[classify].attributes` is absent.
346///
347/// Two, because the convention is per-forge and a repository does not choose
348/// its forge to suit this tool. `linguist-generated` is GitHub's, via Linguist;
349/// `gitlab-generated` is GitLab's, and GitLab already honours it to collapse a
350/// file in an MR diff — so a GitLab repository has usually declared its
351/// generated files years before it meets this tool, and should not have to
352/// declare them again.
353///
354/// The cost of an extra name is small and one-directional: a file has to carry
355/// the attribute to match, and a repository that does not use a forge's
356/// convention has nothing to match. A missed declaration is the expensive
357/// direction — the file is offered to the model, grouped as real work, and read
358/// by the reviewer.
359pub const DEFAULT_ATTRIBUTES: &[&str] = &["linguist-generated", "gitlab-generated"];
360
361fn default_attributes() -> Vec<String> {
362 DEFAULT_ATTRIBUTES.iter().map(|s| s.to_string()).collect()
363}
364
365impl Default for Config {
366 fn default() -> Self {
367 Config {
368 generated: GlobSet::empty(),
369 not_generated: GlobSet::empty(),
370 attributes: default_attributes(),
371 grouping: GroupingConfig::default(),
372 review: ReviewConfig::default(),
373 }
374 }
375}
376
377/// `<user config dir>/differential/config.toml`.
378///
379/// The directory comes from `ConfigSource`; the two path components are
380/// contract, not adapter, so they stay here.
381pub fn user_config_path<S: crate::ports::ConfigSource>(src: &S) -> Option<PathBuf> {
382 Some(
383 src.user_config_dir()?
384 .join(USER_CONFIG_DIR)
385 .join(USER_CONFIG_FILE_NAME),
386 )
387}
388
389impl Config {
390 /// Resolution, per file: explicit path > default location > defaults.
391 /// A missing file means defaults; a malformed file is a hard error, never
392 /// silently ignored.
393 ///
394 /// Repo file: `<repo-root>/.differential.toml` — classification hints.
395 /// User file: `~/.config/differential/config.toml` — `[grouping]`.
396 pub fn load<S: crate::ports::ConfigSource>(
397 src: &S,
398 repo_root: &Path,
399 repo_override: Option<&Path>,
400 user_override: Option<&Path>,
401 ) -> Result<Config, EngineError> {
402 let repo_default = Some(repo_root.join(CONFIG_FILE_NAME));
403 let mut config = match resolve(src, repo_override, repo_default)? {
404 Some((text, origin)) => Self::parse(&text, &origin)?,
405 None => Config::default(),
406 };
407 let user = Self::load_user(src, user_override)?;
408 config.grouping = user.grouping;
409 config.review = user.review;
410 Ok(config)
411 }
412
413 /// The USER file alone: `[grouping]` and `[review]`, and no repository.
414 ///
415 /// [`load`](Self::load) needs a repository root to find the repo file.
416 /// `dfr agents` has none — which agent you would run is a per-user choice
417 /// and the question is answerable from anywhere. Rather than hand it a
418 /// directory it has no use for, the user half is its own call, and `load`
419 /// goes through it so there is one answer to "where does the user file
420 /// live".
421 ///
422 /// A missing file means defaults; a malformed one is a hard error.
423 pub fn load_user<S: crate::ports::ConfigSource>(
424 src: &S,
425 user_override: Option<&Path>,
426 ) -> Result<UserConfig, EngineError> {
427 match resolve(src, user_override, user_config_path(src))? {
428 Some((text, origin)) => Self::parse_user(&text, &origin),
429 None => Ok(UserConfig::default()),
430 }
431 }
432
433 /// Parse the REPO file: classification hints only. A `[grouping]` table
434 /// here is a hard error with a pointer to its new home.
435 pub fn parse(text: &str, origin: &str) -> Result<Config, EngineError> {
436 let raw: RawConfig = toml::from_str(text).map_err(|e| EngineError::Config {
437 path: origin.to_string(),
438 msg: e.to_string(),
439 })?;
440 if raw.grouping.is_some() {
441 return Err(EngineError::Config {
442 path: origin.to_string(),
443 msg: "[grouping] moved to the user config \
444 (~/.config/differential/config.toml): the agent command is a \
445 per-user choice, not a repo setting"
446 .to_string(),
447 });
448 }
449 Ok(Config {
450 generated: build_globs(&raw.classify.generated, origin)?,
451 not_generated: build_globs(&raw.classify.not_generated, origin)?,
452 attributes: raw.classify.attributes.unwrap_or_else(default_attributes),
453 grouping: GroupingConfig::default(),
454 review: ReviewConfig::default(),
455 })
456 }
457
458 /// Parse the USER file: `[grouping]` and `[review]`.
459 pub fn parse_user(text: &str, origin: &str) -> Result<UserConfig, EngineError> {
460 let raw: RawUserConfig = toml::from_str(text).map_err(|e| EngineError::Config {
461 path: origin.to_string(),
462 msg: e.to_string(),
463 })?;
464 Ok(UserConfig {
465 grouping: raw.grouping,
466 review: raw.review,
467 })
468 }
469}
470
471/// Read (contents, origin) for `explicit > default`, where a missing default
472/// is fine but a missing EXPLICIT path is a hard error.
473///
474/// The policy — which file, what precedence, what absence means — is here; the
475/// port only hands back bytes. The two read methods exist so that an
476/// explicit-but-missing path reports the same message it always did.
477fn resolve<S: crate::ports::ConfigSource>(
478 src: &S,
479 explicit: Option<&Path>,
480 default: Option<PathBuf>,
481) -> Result<Option<(String, String)>, EngineError> {
482 match explicit {
483 Some(p) => Ok(Some((src.read_required(p)?, p.display().to_string()))),
484 None => {
485 let Some(p) = default else {
486 return Ok(None);
487 };
488 Ok(src.read(&p)?.map(|text| (text, p.display().to_string())))
489 }
490 }
491}
492
493fn build_globs(patterns: &[String], origin: &str) -> Result<GlobSet, EngineError> {
494 let mut b = GlobSetBuilder::new();
495 for p in patterns {
496 let glob = Glob::new(p).map_err(|e| EngineError::Config {
497 path: origin.to_string(),
498 msg: format!("bad glob {p:?}: {e}"),
499 })?;
500 b.add(glob);
501 }
502 b.build().map_err(|e| EngineError::Config {
503 path: origin.to_string(),
504 msg: e.to_string(),
505 })
506}
507
508#[cfg(test)]
509mod tests {
510 /// The real filesystem: these assertions are about resolution policy
511 /// (precedence, what absence means), which is what `load` owns.
512 const SRC: crate::store::OsConfigSource = crate::store::OsConfigSource;
513
514 use super::*;
515
516 #[test]
517 fn defaults_when_empty() {
518 let c = Config::parse("", "test").unwrap();
519 assert_eq!(c.attributes, ["linguist-generated", "gitlab-generated"]);
520 // Both forge conventions out of the box: a repository does not choose
521 // its forge to suit this tool, and a missed declaration is the
522 // expensive direction — the file is offered to the model, grouped as
523 // real work, and read.
524 assert_eq!(c.attributes, DEFAULT_ATTRIBUTES);
525 assert!(!c.generated.is_match("anything"));
526 }
527
528 #[test]
529 fn globs_and_overrides() {
530 let c = Config::parse(
531 r#"
532[classify]
533generated = ["**/__snapshots__/**", "migrations/**"]
534not_generated = ["important.lock"]
535attributes = ["linguist-generated", "custom-generated"]
536"#,
537 "test",
538 )
539 .unwrap();
540 assert!(c.generated.is_match("ui/__snapshots__/x.snap"));
541 assert!(c.generated.is_match("migrations/0001_init.sql"));
542 assert!(!c.generated.is_match("src/main.rs"));
543 assert!(c.not_generated.is_match("important.lock"));
544 // Setting the key REPLACES the default list; it does not extend it.
545 // A repo naming only its own convention loses the forge ones, which is
546 // the behaviour to know about rather than to discover.
547 assert_eq!(c.attributes, ["linguist-generated", "custom-generated"]);
548 let only_own =
549 Config::parse("[classify]\nattributes = [\"custom-generated\"]", "test").unwrap();
550 assert_eq!(only_own.attributes, ["custom-generated"]);
551 }
552
553 #[test]
554 fn malformed_config_is_a_hard_error() {
555 assert!(Config::parse("classify = 5", "test").is_err());
556 assert!(Config::parse("[classify]\nnope = true", "test").is_err());
557 }
558
559 #[test]
560 fn reserved_sections_are_accepted() {
561 Config::parse("[ordering]\nfuture = 1\n[stack]\nns = \"y\"", "test").unwrap();
562 }
563
564 #[test]
565 fn grouping_in_repo_config_errors_with_migration_hint() {
566 let err = Config::parse("[grouping]\nagent = \"claude-code\"", "test").unwrap_err();
567 assert!(err.to_string().contains("user config"), "{err}");
568 }
569
570 #[test]
571 fn the_diff_layout_defaults_to_split_and_accepts_either_name() {
572 // Absent means split. A reader who has never opened the config gets the
573 // side-by-side layout.
574 let u = Config::parse_user("[review]\ncontext = 3", "test").unwrap();
575 assert_eq!(u.review.diff, DiffLayout::Split);
576 assert!(u.review.diff.is_split());
577
578 let u = Config::parse_user("[review]\ndiff = \"unified\"", "test").unwrap();
579 assert_eq!(u.review.diff, DiffLayout::Unified);
580 assert!(!u.review.diff.is_split());
581 assert_eq!(u.review.context, 3, "setting one key must not zero another");
582
583 let u = Config::parse_user("[review]\ndiff = \"split\"", "test").unwrap();
584 assert_eq!(u.review.diff, DiffLayout::Split);
585
586 // A typo is an error, not a silent fallback to the default.
587 assert!(Config::parse_user("[review]\ndiff = \"side\"", "test").is_err());
588 }
589
590 #[test]
591 fn user_config_parses_grouping_and_review() {
592 let u = Config::parse_user(
593 "[grouping]\nagent = \"claude-code\"\ntimeout_secs = 60",
594 "test",
595 )
596 .unwrap();
597 assert_eq!(u.grouping.agent, Some(Agent::ClaudeCode));
598 assert_eq!(u.grouping.timeout_secs, Some(60));
599 // An absent [review] means the defaults, not zero context.
600 assert_eq!(u.review.context, 3);
601 assert_eq!(u.review.context_step, 10);
602
603 let u = Config::parse_user("[review]\ncontext_step = 25", "test").unwrap();
604 assert_eq!(u.review.context_step, 25);
605 assert_eq!(u.review.context, 3, "one key set must not zero the other");
606
607 // Unknown keys and unknown sections stay hard errors.
608 assert!(Config::parse_user("[grouping]\nmodel = \"x\"", "test").is_err());
609
610 // An agent nobody implements is a hard error that names every one that
611 // exists. A silent fall back to the default would run a different agent
612 // than the one asked for, and the cache key would agree with neither.
613 let err = Config::parse_user("[grouping]\nagent = \"gpt\"", "test").unwrap_err();
614 let text = err.to_string();
615 for agent in Agent::ALL {
616 assert!(
617 text.contains(agent.key()),
618 "the error must name {}: {text}",
619 agent.key()
620 );
621 }
622
623 // And the argv this key used to take is now one of those errors, not a
624 // command that gets spawned without its allowlist.
625 assert!(Config::parse_user("[grouping]\nagent = [\"my-llm\"]", "test").is_err());
626 assert!(Config::parse_user("[review]\nlines = 5", "test").is_err());
627 assert!(Config::parse_user("[classify]\ngenerated = []", "test").is_err());
628 }
629
630 #[test]
631 fn every_agent_name_round_trips() {
632 // `key` is hand-written and serde renames on the way in, so the two
633 // can drift. They may not: `key` is what the docs print, what
634 // `dfr agents` lists and what an error message offers, and a name a
635 // user copies from any of those must parse.
636 for agent in Agent::ALL {
637 let toml = format!("[grouping]\nagent = \"{}\"", agent.key());
638 let u = Config::parse_user(&toml, "test")
639 .unwrap_or_else(|e| panic!("{} must parse: {e}", agent.key()));
640 assert_eq!(u.grouping.agent, Some(agent), "{}", agent.key());
641 }
642 }
643
644 #[test]
645 fn all_agents_are_listed() {
646 // `Agent::ALL` is an array, so nothing makes it exhaustive but this.
647 // A variant missing from it is an agent nobody can find: it would not
648 // appear in `dfr agents`, and the "valid names" error would not offer
649 // it.
650 fn covered(agent: Agent) -> bool {
651 Agent::ALL.contains(&agent)
652 }
653 // The `match` is the point. Adding a variant breaks this line, and the
654 // fix is to add it to `ALL` as well.
655 for agent in Agent::ALL {
656 match agent {
657 Agent::ClaudeCode | Agent::Codex | Agent::Droid | Agent::Copilot | Agent::Pi => {
658 assert!(covered(agent))
659 }
660 }
661 }
662 assert_eq!(Agent::ALL.len(), 5, "a new agent belongs in ALL");
663
664 // No two agents share a name.
665 let mut keys: Vec<&str> = Agent::ALL.iter().map(|a| a.key()).collect();
666 keys.sort_unstable();
667 let before = keys.len();
668 keys.dedup();
669 assert_eq!(keys.len(), before, "two agents share a name: {keys:?}");
670 }
671
672 #[test]
673 fn which_agents_have_actually_been_run_is_pinned() {
674 // `proven` is a claim about what a human ran, so nothing can check it
675 // automatically. Pinning the two lists is the next best thing: moving
676 // an agent between them has to be deliberate, and the reviewer of that
677 // diff is being asked "did you run the probe?".
678 let proven: Vec<&str> = Agent::ALL
679 .iter()
680 .filter(|a| a.proven())
681 .map(|a| a.key())
682 .collect();
683 assert_eq!(proven, vec!["claude-code", "codex", "pi"]);
684
685 let unproven: Vec<&str> = Agent::ALL
686 .iter()
687 .filter(|a| !a.proven())
688 .map(|a| a.key())
689 .collect();
690 assert_eq!(unproven, vec!["droid", "copilot"]);
691
692 // The default must be one somebody has run. Anything else ships a
693 // command line nobody has tried to the people who configured nothing.
694 assert!(Agent::default().proven(), "the default must be proven");
695 }
696
697 #[test]
698 fn exactly_one_agent_does_not_enforce_read_only() {
699 // The tier is a fact a user must be shown, so it is pinned here rather
700 // than left to a doc comment. Pi ships no sandbox and no per-command
701 // allowlist (ADR 0033); the other four refuse a write.
702 let unenforced: Vec<&str> = Agent::ALL
703 .iter()
704 .filter(|a| !a.read_only().is_enforced())
705 .map(|a| a.key())
706 .collect();
707 assert_eq!(unenforced, vec!["pi"], "{unenforced:?}");
708 assert!(
709 Agent::default().read_only().is_enforced(),
710 "the default must be enforced"
711 );
712 }
713
714 /// A theme is a per-user choice like the agent, named for the same reason:
715 /// serde renders the valid names for free, and adding one is a variant.
716 #[test]
717 fn user_config_parses_the_theme_and_names_the_valid_ones() {
718 let u = Config::parse_user("[review]\ntheme = \"gruvbox-light\"", "test").unwrap();
719 assert_eq!(u.review.theme, ThemeName::GruvboxLight);
720 // Absent is the default, and does not zero the other keys.
721 let u = Config::parse_user("[review]\ncontext = 8", "test").unwrap();
722 assert_eq!(u.review.theme, ThemeName::Dark);
723 assert_eq!(u.review.context, 8);
724
725 // An unknown name is an error that says which ones exist.
726 let err = Config::parse_user("[review]\ntheme = \"nosferatu\"", "test").unwrap_err();
727 let msg = err.to_string();
728 for name in [
729 "dark",
730 "light",
731 "gruvbox-dark",
732 "solarized-light",
733 "monokai",
734 ] {
735 assert!(msg.contains(name), "{name} missing from: {msg}");
736 }
737 }
738
739 /// The repo file cannot set it: a palette is the reader's, not the
740 /// repository's. Nothing enforces this by hand — `RawConfig` has no
741 /// `[review]` and denies unknown fields.
742 #[test]
743 fn a_theme_in_the_repo_config_is_rejected() {
744 let err = Config::parse("[review]\ntheme = \"one-light\"", "test").unwrap_err();
745 assert!(err.to_string().contains("review"), "{err}");
746 }
747
748 #[test]
749 fn load_composes_repo_and_user_files() {
750 let tmp = tempfile::TempDir::new().unwrap();
751 let repo_file = tmp.path().join("repo.toml");
752 let user_file = tmp.path().join("user.toml");
753 std::fs::write(&repo_file, "[classify]\ngenerated = [\"gen/**\"]").unwrap();
754 std::fs::write(
755 &user_file,
756 "[grouping]\nagent = \"claude-code\"\n[review]\ncontext = 8",
757 )
758 .unwrap();
759 let c = Config::load(
760 &crate::store::OsConfigSource,
761 tmp.path(),
762 Some(&repo_file),
763 Some(&user_file),
764 )
765 .unwrap();
766 assert!(c.generated.is_match("gen/x"));
767 assert_eq!(c.grouping.agent, Some(Agent::ClaudeCode));
768 assert_eq!(c.review.context, 8);
769
770 // Explicit-but-missing paths are hard errors; absent defaults are not.
771 assert!(
772 Config::load(&SRC, tmp.path(), Some(Path::new("/nope")), Some(&user_file)).is_err()
773 );
774 assert!(Config::load(&SRC, tmp.path(), None, Some(&user_file)).is_ok());
775 }
776}