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]`: 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}