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, and `[keys]`: which keys the reviewer's actions answer
9//!   to. All per-user choices, not properties of the repo, so none of them
10//!   lives in it.
11//!
12//! HARD RULE (ADR 0012): config tunes classification hints and tool behaviour.
13//! It can never remove a file or hunk from enumeration — enumeration runs before
14//! and independently of anything in this module, and nothing here is consulted
15//! by the parser or the invariants.
16
17use std::collections::BTreeMap;
18use std::path::{Path, PathBuf};
19
20use globset::{Glob, GlobSet, GlobSetBuilder};
21use serde::{Deserialize, Serialize};
22
23use crate::EngineError;
24
25pub const CONFIG_FILE_NAME: &str = ".differential.toml";
26pub const USER_CONFIG_DIR: &str = "differential";
27pub const USER_CONFIG_FILE_NAME: &str = "config.toml";
28
29#[derive(Debug, Default, Deserialize)]
30#[serde(deny_unknown_fields)]
31struct RawConfig {
32    #[serde(default)]
33    classify: RawClassify,
34    /// Rejected with a migration hint — [grouping] moved to the user config.
35    #[serde(default)]
36    grouping: Option<toml::Table>,
37    // Reserved for later milestones; accepted so the file format is stable.
38    // `IgnoredAny` says exactly that — the table is parsed and discarded,
39    // where a `toml::Table` was allocated in full and then discarded, with a
40    // `let _ =` further down whose only job was to quiet the compiler about
41    // a field nothing reads.
42    //
43    // Named with a leading underscore because nothing reads them and nothing
44    // should: the `#[serde(rename)]` keeps the file's own spelling.
45    #[serde(default, rename = "ordering")]
46    _ordering: serde::de::IgnoredAny,
47    #[serde(default, rename = "stack")]
48    _stack: serde::de::IgnoredAny,
49}
50
51/// The user-level file: `[grouping]`, `[review]` and `[keys]`.
52#[derive(Debug, Default, Deserialize)]
53#[serde(deny_unknown_fields)]
54struct RawUserConfig {
55    #[serde(default)]
56    grouping: GroupingConfig,
57    #[serde(default)]
58    review: ReviewConfig,
59    #[serde(default)]
60    keys: KeysConfig,
61}
62
63/// Everything `parse_user` reads, so `load` assigns one value rather than
64/// growing a second assignment every time the user file gains a table.
65///
66/// Serialisable, because the reviewer's config modal writes it back whole
67/// ([`Config::save_user`]).
68#[derive(Debug, Clone, Default, PartialEq, Serialize)]
69pub struct UserConfig {
70    pub grouping: GroupingConfig,
71    pub review: ReviewConfig,
72    #[serde(skip_serializing_if = "KeysConfig::is_empty")]
73    pub keys: KeysConfig,
74}
75
76/// Which agent to run, by name.
77///
78/// It used to be a free argv, and that was the wrong shape. The grouping stage
79/// does not merely spawn a process: it hands the agent a tool allowlist, a
80/// fetch command and a prompt written for what that agent can do (ADR 0022).
81/// An arbitrary argv gets the prompt and none of the rest, so it was a knob
82/// that looked like it worked. A name selects an invocation this crate builds
83/// whole, and adding an agent is adding a variant here.
84///
85/// The name also answers what a reviewer is shown while they wait — the argv
86/// never could, at four times the width of the line it had.
87///
88/// **Four of the five keep the model read-only; `Pi` does not** (ADR 0033).
89/// Read [`Agent::read_only`] and [`ReadOnly::is_enforced`] before choosing one.
90#[derive(
91    Debug,
92    Clone,
93    Copy,
94    Default,
95    PartialEq,
96    Eq,
97    Deserialize,
98    Serialize,
99    strum::IntoStaticStr,
100    strum::VariantArray,
101)]
102#[serde(rename_all = "kebab-case")]
103#[strum(serialize_all = "kebab-case")]
104pub enum Agent {
105    /// Headless `claude`, read-only by tool allowlist (ADR 0022).
106    #[default]
107    ClaudeCode,
108    /// Headless `codex exec`, read-only by OS sandbox (Seatbelt, bubblewrap).
109    Codex,
110    /// Headless `droid exec`, read-only by default — the tier is what we do
111    /// not pass.
112    Droid,
113    /// Headless `copilot`, read-only by tool allowlist and an explicit deny.
114    Copilot,
115    /// Headless `pi`, **read-only is NOT enforced** (ADR 0033).
116    ///
117    /// Pi ships no sandbox and no per-command allowlist, and its `-t` flag
118    /// toggles whole tools. The model needs `bash` to run the fetch command
119    /// and `git diff`, and `bash` also lets it write, commit and push. Nothing
120    /// but the prompt stops it. Choose this agent only knowing that.
121    Pi,
122}
123
124impl Agent {
125    /// Every variant, in declaration order, from strum's `VariantArray`: the
126    /// derive is what keeps the list whole, so there is no hand-kept array to
127    /// forget a variant in.
128    pub const ALL: &'static [Agent] = <Agent as strum::VariantArray>::VARIANTS;
129
130    /// The name this answers to in the config file. strum's `IntoStaticStr`,
131    /// renamed as serde renames it; `every_*_name_round_trips` in this module
132    /// pins the two derives to the same spelling.
133    pub fn key(self) -> &'static str {
134        self.into()
135    }
136
137    /// Whether anyone has ever run this agent's command line.
138    ///
139    /// Not a quality judgement — a claim about provenance, and the only honest
140    /// one this crate can make. Every argv here is written from its agent's
141    /// documentation, and a test can assert the string this crate builds but
142    /// never that the CLI on the other end accepts it. CI cannot either: the
143    /// binary is not installed and its flags move between releases.
144    ///
145    /// `true` means `dfr agents --probe` passed all four checks against the
146    /// real CLI, and the argv is in this repository because of that run.
147    ///
148    /// **`false` means likely wrong, not merely unchecked.** Of the three
149    /// checked so far, two were broken: Claude Code's allowlist did not bind
150    /// without `--permission-mode default`, and Codex was passing
151    /// `--ask-for-approval`, which its `exec` subcommand rejects outright. Both
152    /// came from documentation that was accurate about the product and wrong
153    /// about the entry point. Nothing suggests the unchecked two are better.
154    ///
155    /// A caller that offers a user this list must say so, for the same reason
156    /// it must say what [`Agent::read_only`] answers: the person choosing is
157    /// the person who carries it.
158    ///
159    /// This flips when someone runs the probe and the argv lands — never
160    /// because it looks right.
161    pub fn proven(self) -> bool {
162        match self {
163            // Probed on a real call: all four checks passed.
164            Agent::ClaudeCode | Agent::Codex | Agent::Pi => true,
165            // Written from documentation. Droid needs a paid Factory plan and
166            // Copilot a Copilot seat, so neither has been run.
167            Agent::Droid | Agent::Copilot => false,
168        }
169    }
170
171    /// What stops this agent writing, if anything.
172    ///
173    /// A caller that shows a user the list of agents MUST show this too. The
174    /// person picking a name is the person who needs to know, and exactly one
175    /// answer here is [`ReadOnly::NotEnforced`].
176    pub fn read_only(self) -> ReadOnly {
177        match self {
178            Agent::ClaudeCode | Agent::Copilot => ReadOnly::ToolAllowlist,
179            Agent::Codex => ReadOnly::OsSandbox,
180            Agent::Droid => ReadOnly::AgentDefault,
181            Agent::Pi => ReadOnly::NotEnforced,
182        }
183    }
184}
185
186/// What keeps an agent from writing.
187///
188/// An enum rather than a `bool` plus a sentence, because the three enforcing
189/// answers are not interchangeable and a reader deciding whether to trust one
190/// needs to know which they have. An OS sandbox holds against a model that
191/// tries; an allowlist holds against a model that asks; a default holds only
192/// until someone adds a flag.
193#[derive(Debug, Clone, Copy, PartialEq, Eq)]
194pub enum ReadOnly {
195    /// The agent may run only the tools it was given, and writing is not one.
196    ToolAllowlist,
197    /// The agent may run anything and the kernel refuses the writes.
198    OsSandbox,
199    /// The agent is read-only until told otherwise, and it is not told.
200    AgentDefault,
201    /// **Nothing stops it.** The agent can write, commit and push, and only the
202    /// prompt asks it not to. See [`Agent::Pi`] and ADR 0033 for why one agent
203    /// is here and why that was a choice rather than an oversight.
204    NotEnforced,
205}
206
207impl ReadOnly {
208    pub fn is_enforced(self) -> bool {
209        !matches!(self, ReadOnly::NotEnforced)
210    }
211}
212
213/// `[grouping]` — pure data; the application layer turns it into an LLM
214/// backend (ADR 0018, 0020).
215#[derive(Debug, Clone, Default, PartialEq, Eq, Deserialize, Serialize)]
216#[serde(deny_unknown_fields)]
217pub struct GroupingConfig {
218    /// Which agent runs the grouping call. Default: `claude-code`.
219    #[serde(default, skip_serializing_if = "Option::is_none")]
220    pub agent: Option<Agent>,
221    /// How long to wait for it. Default: [`DEFAULT_TIMEOUT_SECS`].
222    ///
223    /// This one stays a number because it tunes the agent rather than replacing
224    /// it: a slow machine or a large change may genuinely need longer.
225    #[serde(default, skip_serializing_if = "Option::is_none")]
226    pub timeout_secs: Option<u64>,
227}
228
229/// How long a grouping call may run when `[grouping].timeout_secs` is unset.
230/// Every agent's backend starts from it.
231pub const DEFAULT_TIMEOUT_SECS: u64 = 1200;
232
233/// Which palette the terminal reviewer wears, by name.
234///
235/// A name, for the same reason [`Agent`] is one: a palette is not a colour the
236/// caller supplies but a whole coherent set the renderer builds — thirty-one
237/// fields plus the syntax theme the code itself is painted with, all derived
238/// together so the chrome and the code cannot disagree (ADR 0024). A free-form
239/// colour list would be a knob that looked like it worked.
240///
241/// Adding a theme is adding a variant here and a seed in the renderer.
242#[derive(
243    Debug,
244    Clone,
245    Copy,
246    Default,
247    PartialEq,
248    Eq,
249    Deserialize,
250    Serialize,
251    strum::IntoStaticStr,
252    strum::VariantArray,
253)]
254#[serde(rename_all = "kebab-case")]
255#[strum(serialize_all = "kebab-case")]
256pub enum ThemeName {
257    /// The original palette: a dark slate ground with a cyan accent.
258    #[default]
259    Dark,
260    OneDark,
261    OneLight,
262    GruvboxDark,
263    GruvboxLight,
264    SolarizedDark,
265    SolarizedLight,
266    CatppuccinMocha,
267    CatppuccinLatte,
268    Dracula,
269    Monokai,
270}
271
272/// `[review]` — how the terminal reviewer looks, and how much of a file it
273/// shows around a hunk.
274///
275/// Presentation only: it can widen what is *displayed* around a hunk and can
276/// never change which hunks exist. Enumeration is total and runs before any of
277/// this (ADR 0005, 0012).
278#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)]
279#[serde(deny_unknown_fields)]
280pub struct ReviewConfig {
281    /// Context lines shown either side of a hunk before any expansion.
282    #[serde(default = "default_context")]
283    pub context: usize,
284    /// Lines one `z` at a context boundary row pulls in.
285    #[serde(default = "default_context_step")]
286    pub context_step: usize,
287    /// Which diff layout a review opens in, before the reader says otherwise.
288    ///
289    /// A DEFAULT, not a setting: `s` still toggles, and the toggle is recorded
290    /// per review. A review that has recorded a choice keeps it whatever this
291    /// says, so changing it never moves a layout under someone mid-read.
292    #[serde(default)]
293    pub diff: DiffLayout,
294    /// Which palette to wear. Default: `dark`.
295    #[serde(default)]
296    pub theme: ThemeName,
297    /// The command that opens a file at a line when the reader presses `e`.
298    ///
299    /// `{file}` and `{line}` say where the path and the line go. A command
300    /// naming neither gets the path appended and opens the file at the top.
301    /// Unset falls back to `$VISUAL` and then `$EDITOR`, which the application
302    /// layer reads — the environment is an adapter's to touch, not this
303    /// module's (ADR 0038).
304    #[serde(default)]
305    pub editor: Option<String>,
306}
307
308/// How the reviewer lays a hunk out.
309///
310/// An enum rather than a bool because a config key is permanent, and a third
311/// layout would otherwise need a second key contradicting the first.
312#[derive(
313    Debug,
314    Clone,
315    Copy,
316    PartialEq,
317    Eq,
318    Default,
319    Deserialize,
320    Serialize,
321    strum::IntoStaticStr,
322    strum::VariantArray,
323)]
324#[serde(rename_all = "lowercase")]
325#[strum(serialize_all = "lowercase")]
326pub enum DiffLayout {
327    /// Old and new side by side.
328    #[default]
329    Split,
330    /// One column, removals above additions.
331    Unified,
332}
333
334impl DiffLayout {
335    /// Every variant, in declaration order, from strum's `VariantArray`: the
336    /// derive is what keeps the list whole, so there is no hand-kept array to
337    /// forget a variant in.
338    pub const ALL: &'static [DiffLayout] = <DiffLayout as strum::VariantArray>::VARIANTS;
339
340    pub fn is_split(self) -> bool {
341        matches!(self, DiffLayout::Split)
342    }
343
344    /// The name this answers to in the config file. strum's `IntoStaticStr`,
345    /// renamed as serde renames it; `every_*_name_round_trips` in this module
346    /// pins the two derives to the same spelling.
347    pub fn key(self) -> &'static str {
348        self.into()
349    }
350}
351
352impl ThemeName {
353    /// Every variant, in declaration order, from strum's `VariantArray`: the
354    /// derive is what keeps the list whole, so there is no hand-kept array to
355    /// forget a variant in.
356    pub const ALL: &'static [ThemeName] = <ThemeName as strum::VariantArray>::VARIANTS;
357
358    /// The name this answers to in the config file. strum's `IntoStaticStr`,
359    /// renamed as serde renames it; `every_*_name_round_trips` in this module
360    /// pins the two derives to the same spelling.
361    pub fn key(self) -> &'static str {
362        self.into()
363    }
364}
365
366/// The placeholder standing for the path to open.
367pub const EDITOR_FILE: &str = "{file}";
368/// The placeholder standing for the line to open it at.
369pub const EDITOR_LINE: &str = "{line}";
370
371/// A parsed `[review].editor` — the command that opens a file at a line.
372///
373/// **A command, not a name**, and the only key in this module that is. `agent`
374/// and `theme` are names because the invocation behind each carries more than
375/// an argv: an agent is handed a tool allowlist and a prompt written for what
376/// it can do (ADR 0022, 0033), and a palette is thirty-odd values derived
377/// together so the chrome and the code cannot disagree (ADR 0024). In both, a
378/// free-form value would have been a knob that looked like it worked.
379///
380/// Neither reason reaches an editor. The invocation carries a path and a line
381/// and nothing else, every editor spells the line differently, and a name
382/// would have frozen the list of editors a reader may use into an enum in this
383/// crate. So the reader writes the command (ADR 0038).
384///
385/// Parsed once, at load, so a command that cannot be split is an error the
386/// reader sees when they start rather than when they press the key.
387#[derive(Debug, Clone, PartialEq, Eq)]
388pub struct EditorCommand {
389    argv: Vec<String>,
390    carries_line: bool,
391    carries_file: bool,
392}
393
394impl EditorCommand {
395    /// Split the command into words. `origin` names the file or the variable
396    /// it came from, for the error.
397    ///
398    /// `shlex` does the splitting rather than a hand-rolled scanner (design
399    /// rule 5): quoting is the whole of the problem here, and a path with a
400    /// space in it is the case that would have found a hand-rolled bug.
401    pub fn parse(text: &str, origin: &str) -> Result<EditorCommand, EngineError> {
402        let fail = |msg: String| EngineError::Config {
403            path: origin.to_string(),
404            msg,
405        };
406        let argv = shlex::split(text).ok_or_else(|| {
407            fail(format!(
408                "editor command does not split into words \
409                 (an unbalanced quote?): {text:?}"
410            ))
411        })?;
412        if argv.is_empty() {
413            return Err(fail("editor command is empty".to_string()));
414        }
415        // The first word is the program, and a placeholder there would make
416        // the program the FILE. On a source file carrying the executable bit
417        // that is not a failed spawn to shrug at — it is `e` running the file
418        // under the cursor. Caught here, where every other malformed value is.
419        if argv[0].contains(EDITOR_FILE) || argv[0].contains(EDITOR_LINE) {
420            return Err(fail(format!(
421                "the first word is the program to run, and it may not be a \
422                 placeholder: {:?}",
423                argv[0]
424            )));
425        }
426        Ok(EditorCommand {
427            carries_line: argv.iter().any(|w| w.contains(EDITOR_LINE)),
428            carries_file: argv.iter().any(|w| w.contains(EDITOR_FILE)),
429            argv,
430        })
431    }
432
433    /// The argv that opens `file` at `line`.
434    ///
435    /// A command naming no `{file}` gets the path appended, which is what
436    /// makes a bare `$EDITOR` work; it opens the file at the top, and
437    /// [`carries_line`](Self::carries_line) is what lets the caller say so.
438    ///
439    /// **`{line}` is substituted before `{file}`.** A path holding the literal
440    /// text `{line}` would otherwise be read as a placeholder by the second
441    /// pass. `str::replace` never re-scans what it inserts, so one order is
442    /// all it takes.
443    ///
444    /// The path is rendered lossily, as every path in the renderer above this
445    /// already is — `schema::FileEntry::path` is a `String`.
446    ///
447    /// Substitution reaches every word but the first, which [`parse`](Self::parse)
448    /// has already refused to let hold a placeholder.
449    pub fn argv(&self, file: &Path, line: u32) -> Vec<String> {
450        let path = file.to_string_lossy();
451        let line = line.to_string();
452        let mut argv: Vec<String> = self
453            .argv
454            .iter()
455            .map(|w| w.replace(EDITOR_LINE, &line).replace(EDITOR_FILE, &path))
456            .collect();
457        if !self.carries_file {
458            argv.push(path.into_owned());
459        }
460        argv
461    }
462
463    /// Whether the command says where the line goes. `false` means the editor
464    /// opens the file at the top, which the reader is owed a word about.
465    pub fn carries_line(&self) -> bool {
466        self.carries_line
467    }
468
469    /// The program, for an error message that names what failed.
470    pub fn program(&self) -> &str {
471        &self.argv[0]
472    }
473}
474
475const fn default_context() -> usize {
476    3
477}
478
479const fn default_context_step() -> usize {
480    10
481}
482
483impl Default for ReviewConfig {
484    fn default() -> Self {
485        ReviewConfig {
486            context: default_context(),
487            context_step: default_context_step(),
488            diff: DiffLayout::default(),
489            theme: ThemeName::default(),
490            editor: None,
491        }
492    }
493}
494
495/// Something the terminal reviewer does on a key, by name (ADR 0036).
496///
497/// A name rather than a key, because a key is the reader's to choose and the
498/// thing it does is not. `[keys]` maps these to key strings; the renderer owns
499/// what a key string means and what the defaults are, so this crate never
500/// learns a terminal's vocabulary. One name means one thing wherever it
501/// works: `down` moves in the plan pane, the file list and the findings list
502/// alike, so a reader binds it once.
503///
504/// Adding an action is adding a variant here, its name in [`Action::key`],
505/// and its default keys and its arm in the renderer.
506#[derive(
507    Debug,
508    Clone,
509    Copy,
510    PartialEq,
511    Eq,
512    PartialOrd,
513    Ord,
514    Hash,
515    Deserialize,
516    Serialize,
517    strum::IntoStaticStr,
518    strum::VariantArray,
519)]
520#[serde(rename_all = "kebab-case")]
521#[strum(serialize_all = "kebab-case")]
522pub enum Action {
523    ToggleFocus,
524    Open,
525    Close,
526    Down,
527    Up,
528    NextGroup,
529    PrevGroup,
530    HalfPageDown,
531    HalfPageUp,
532    Top,
533    Bottom,
534    NextHunk,
535    PrevHunk,
536    ToggleSplit,
537    ToggleWrap,
538    ShiftRight,
539    ShiftLeft,
540    ShiftReset,
541    GrowDiff,
542    ShrinkDiff,
543    Fold,
544    Files,
545    /// Hand the terminal to the reader's own editor, on the line under the
546    /// cursor. The command it runs is `[review].editor` (ADR 0038); this is
547    /// only the key that asks for it.
548    ExternalEditor,
549    Findings,
550    Search,
551    ToggleReviewed,
552    Select,
553    Comment,
554    Delete,
555    ClearNotes,
556    Copy,
557    Reply,
558    Resolve,
559    Refetch,
560    Publish,
561    Back,
562}
563
564impl Action {
565    /// Every variant, in declaration order, from strum's `VariantArray`: the
566    /// derive is what keeps the list whole, so there is no hand-kept array to
567    /// forget a variant in.
568    pub const ALL: &'static [Action] = <Action as strum::VariantArray>::VARIANTS;
569
570    /// The name this answers to in the config file. strum's `IntoStaticStr`,
571    /// renamed as serde renames it; `every_*_name_round_trips` in this module
572    /// pins the two derives to the same spelling.
573    pub fn key(self) -> &'static str {
574        self.into()
575    }
576}
577
578/// `[keys]` — which keys an action answers to, as the reader wrote them.
579///
580/// An action named here takes EXACTLY these keys, in every place it works:
581/// its defaults are replaced, not extended, so `[]` unbinds it. An action not
582/// named keeps its defaults.
583///
584/// The strings stay strings in this crate. What `"ctrl-d"` means, whether it
585/// parses, and whether two actions now share a key are the renderer's
586/// questions, answered by one library call before a terminal is touched
587/// (ADR 0036).
588#[derive(Debug, Clone, Default, PartialEq, Eq, Deserialize, Serialize)]
589#[serde(transparent)]
590pub struct KeysConfig(pub BTreeMap<Action, Vec<String>>);
591
592impl KeysConfig {
593    /// No action rebound: the file needs no `[keys]` table at all.
594    pub fn is_empty(&self) -> bool {
595        self.0.is_empty()
596    }
597
598    /// One action's keys as `[keys]` writes them: `["ctrl-j", "d d"]`. The
599    /// config modal shows and edits a row in exactly the file's syntax, so
600    /// nothing a reader types there means something else in the file.
601    pub fn render_list(keys: &[String]) -> String {
602        let list = keys.iter().cloned().map(toml::Value::String).collect();
603        toml::Value::Array(list).to_string()
604    }
605
606    /// The inverse of [`render_list`](Self::render_list), with TOML's own
607    /// error when the text is not a list of strings.
608    pub fn parse_list(text: &str) -> Result<Vec<String>, String> {
609        #[derive(Deserialize)]
610        struct One {
611            v: Vec<String>,
612        }
613        toml::from_str::<One>(&format!("v = {text}"))
614            .map(|one| one.v)
615            .map_err(|e| e.message().to_string())
616    }
617}
618
619#[derive(Debug, Default, Deserialize)]
620#[serde(deny_unknown_fields)]
621struct RawClassify {
622    #[serde(default)]
623    generated: Vec<String>,
624    #[serde(default)]
625    not_generated: Vec<String>,
626    #[serde(default)]
627    attributes: Option<Vec<String>>,
628}
629
630#[derive(Debug)]
631pub struct Config {
632    /// Additive globs marking files as generated (noise-tier hint).
633    pub generated: GlobSet,
634    /// Overrides: never mark these generated. Wins over everything.
635    pub not_generated: GlobSet,
636    /// gitattributes attribute names honoured as "generated" declarations.
637    /// Defaults to [`DEFAULT_ATTRIBUTES`]; setting the key **replaces** the
638    /// list rather than adding to it.
639    pub attributes: Vec<String>,
640    /// From the USER config, never the repo (agents differ per user).
641    pub grouping: GroupingConfig,
642    /// From the USER config: how much context the reviewer shows.
643    pub review: ReviewConfig,
644    /// From the USER config: which keys the reviewer's actions answer to.
645    pub keys: KeysConfig,
646}
647
648/// gitattributes names honoured as a "generated" declaration when
649/// `[classify].attributes` is absent.
650///
651/// Two, because the convention is per-forge and a repository does not choose
652/// its forge to suit this tool. `linguist-generated` is GitHub's, via Linguist;
653/// `gitlab-generated` is GitLab's, and GitLab already honours it to collapse a
654/// file in an MR diff — so a GitLab repository has usually declared its
655/// generated files years before it meets this tool, and should not have to
656/// declare them again.
657///
658/// The cost of an extra name is small and one-directional: a file has to carry
659/// the attribute to match, and a repository that does not use a forge's
660/// convention has nothing to match. A missed declaration is the expensive
661/// direction — the file is offered to the model, grouped as real work, and read
662/// by the reviewer.
663pub const DEFAULT_ATTRIBUTES: &[&str] = &["linguist-generated", "gitlab-generated"];
664
665fn default_attributes() -> Vec<String> {
666    DEFAULT_ATTRIBUTES.iter().map(|s| s.to_string()).collect()
667}
668
669impl Default for Config {
670    fn default() -> Self {
671        Config {
672            generated: GlobSet::empty(),
673            not_generated: GlobSet::empty(),
674            attributes: default_attributes(),
675            grouping: GroupingConfig::default(),
676            review: ReviewConfig::default(),
677            keys: KeysConfig::default(),
678        }
679    }
680}
681
682/// `<user config dir>/differential/config.toml`.
683///
684/// The directory comes from `ConfigSource`; the two path components are
685/// contract, not adapter, so they stay here.
686pub fn user_config_path<S: crate::ports::ConfigSource>(src: &S) -> Option<PathBuf> {
687    Some(
688        src.user_config_dir()?
689            .join(USER_CONFIG_DIR)
690            .join(USER_CONFIG_FILE_NAME),
691    )
692}
693
694impl Config {
695    /// Resolution, per file: explicit path > default location > defaults.
696    /// A missing file means defaults; a malformed file is a hard error, never
697    /// silently ignored.
698    ///
699    /// Repo file: `<repo-root>/.differential.toml` — classification hints.
700    /// User file: `~/.config/differential/config.toml` — `[grouping]`.
701    pub fn load<S: crate::ports::ConfigSource>(
702        src: &S,
703        repo_root: &Path,
704        repo_override: Option<&Path>,
705        user_override: Option<&Path>,
706    ) -> Result<Config, EngineError> {
707        let repo_default = Some(repo_root.join(CONFIG_FILE_NAME));
708        let mut config = match resolve(src, repo_override, repo_default)? {
709            Some((text, origin)) => Self::parse(&text, &origin)?,
710            None => Config::default(),
711        };
712        let user = Self::load_user(src, user_override)?;
713        config.grouping = user.grouping;
714        config.review = user.review;
715        config.keys = user.keys;
716        Ok(config)
717    }
718
719    /// The USER file alone: `[grouping]`, `[review]` and `[keys]`, and no
720    /// repository.
721    ///
722    /// [`load`](Self::load) needs a repository root to find the repo file.
723    /// `dfr agents` has none — which agent you would run is a per-user choice
724    /// and the question is answerable from anywhere. Rather than hand it a
725    /// directory it has no use for, the user half is its own call, and `load`
726    /// goes through it so there is one answer to "where does the user file
727    /// live".
728    ///
729    /// A missing file means defaults; a malformed one is a hard error.
730    pub fn load_user<S: crate::ports::ConfigSource>(
731        src: &S,
732        user_override: Option<&Path>,
733    ) -> Result<UserConfig, EngineError> {
734        match resolve(src, user_override, user_config_path(src))? {
735            Some((text, origin)) => Self::parse_user(&text, &origin),
736            None => Ok(UserConfig::default()),
737        }
738    }
739
740    /// Parse the REPO file: classification hints only. A `[grouping]` table
741    /// here is a hard error with a pointer to its new home.
742    pub fn parse(text: &str, origin: &str) -> Result<Config, EngineError> {
743        let raw: RawConfig = toml::from_str(text).map_err(|e| EngineError::Config {
744            path: origin.to_string(),
745            msg: e.to_string(),
746        })?;
747        if raw.grouping.is_some() {
748            return Err(EngineError::Config {
749                path: origin.to_string(),
750                msg: "[grouping] moved to the user config \
751                      (~/.config/differential/config.toml): the agent command is a \
752                      per-user choice, not a repo setting"
753                    .to_string(),
754            });
755        }
756        Ok(Config {
757            generated: build_globs(&raw.classify.generated, origin)?,
758            not_generated: build_globs(&raw.classify.not_generated, origin)?,
759            attributes: raw.classify.attributes.unwrap_or_else(default_attributes),
760            grouping: GroupingConfig::default(),
761            review: ReviewConfig::default(),
762            keys: KeysConfig::default(),
763        })
764    }
765
766    /// Parse the USER file: `[grouping]`, `[review]` and `[keys]`.
767    pub fn parse_user(text: &str, origin: &str) -> Result<UserConfig, EngineError> {
768        let raw: RawUserConfig = toml::from_str(text).map_err(|e| EngineError::Config {
769            path: origin.to_string(),
770            msg: e.to_string(),
771        })?;
772        Ok(UserConfig {
773            grouping: raw.grouping,
774            review: raw.review,
775            keys: raw.keys,
776        })
777    }
778}
779
780impl Config {
781    /// The user file as TOML, whole. What [`save_user`](Self::save_user)
782    /// writes: every `[review]` value, the `[grouping]` values that are set,
783    /// and `[keys]` only when an action is rebound.
784    pub fn render_user(user: &UserConfig) -> String {
785        toml::to_string_pretty(user).expect("the user config is plain data and always serialises")
786    }
787
788    /// Write the user file at `path`, REPLACING it (ADR 0037).
789    ///
790    /// Whole-file on purpose: the reviewer's config modal edits every
791    /// setting, and a rewrite is the one form that cannot disagree with what
792    /// it shows. The cost is the file's comments and layout, which the modal
793    /// says before it saves.
794    ///
795    /// The text is parsed back before it is written, so the file on disk is
796    /// always one [`parse_user`](Self::parse_user) accepts, and accepts as
797    /// this value.
798    pub fn save_user<S: crate::ports::ConfigSource>(
799        src: &S,
800        path: &Path,
801        user: &UserConfig,
802    ) -> Result<(), EngineError> {
803        let text = Self::render_user(user);
804        let origin = path.display().to_string();
805        let back = Self::parse_user(&text, &origin)?;
806        if &back != user {
807            return Err(EngineError::Config {
808                path: origin,
809                msg: "the config did not read back as it was written".into(),
810            });
811        }
812        src.save(path, &text)
813    }
814}
815
816/// Read (contents, origin) for `explicit > default`, where a missing default
817/// is fine but a missing EXPLICIT path is a hard error.
818///
819/// The policy — which file, what precedence, what absence means — is here; the
820/// port only hands back bytes. The two read methods exist so that an
821/// explicit-but-missing path reports the same message it always did.
822fn resolve<S: crate::ports::ConfigSource>(
823    src: &S,
824    explicit: Option<&Path>,
825    default: Option<PathBuf>,
826) -> Result<Option<(String, String)>, EngineError> {
827    match explicit {
828        Some(p) => Ok(Some((src.read_required(p)?, p.display().to_string()))),
829        None => {
830            let Some(p) = default else {
831                return Ok(None);
832            };
833            Ok(src.read(&p)?.map(|text| (text, p.display().to_string())))
834        }
835    }
836}
837
838fn build_globs(patterns: &[String], origin: &str) -> Result<GlobSet, EngineError> {
839    let mut b = GlobSetBuilder::new();
840    for p in patterns {
841        let glob = Glob::new(p).map_err(|e| EngineError::Config {
842            path: origin.to_string(),
843            msg: format!("bad glob {p:?}: {e}"),
844        })?;
845        b.add(glob);
846    }
847    b.build().map_err(|e| EngineError::Config {
848        path: origin.to_string(),
849        msg: e.to_string(),
850    })
851}
852
853#[cfg(test)]
854mod tests {
855    /// The real filesystem: these assertions are about resolution policy
856    /// (precedence, what absence means), which is what `load` owns.
857    const SRC: crate::store::OsConfigSource = crate::store::OsConfigSource;
858
859    use super::*;
860
861    #[test]
862    fn defaults_when_empty() {
863        let c = Config::parse("", "test").unwrap();
864        assert_eq!(c.attributes, ["linguist-generated", "gitlab-generated"]);
865        // Both forge conventions out of the box: a repository does not choose
866        // its forge to suit this tool, and a missed declaration is the
867        // expensive direction — the file is offered to the model, grouped as
868        // real work, and read.
869        assert_eq!(c.attributes, DEFAULT_ATTRIBUTES);
870        assert!(!c.generated.is_match("anything"));
871    }
872
873    #[test]
874    fn globs_and_overrides() {
875        let c = Config::parse(
876            r#"
877[classify]
878generated = ["**/__snapshots__/**", "migrations/**"]
879not_generated = ["important.lock"]
880attributes = ["linguist-generated", "custom-generated"]
881"#,
882            "test",
883        )
884        .unwrap();
885        assert!(c.generated.is_match("ui/__snapshots__/x.snap"));
886        assert!(c.generated.is_match("migrations/0001_init.sql"));
887        assert!(!c.generated.is_match("src/main.rs"));
888        assert!(c.not_generated.is_match("important.lock"));
889        // Setting the key REPLACES the default list; it does not extend it.
890        // A repo naming only its own convention loses the forge ones, which is
891        // the behaviour to know about rather than to discover.
892        assert_eq!(c.attributes, ["linguist-generated", "custom-generated"]);
893        let only_own =
894            Config::parse("[classify]\nattributes = [\"custom-generated\"]", "test").unwrap();
895        assert_eq!(only_own.attributes, ["custom-generated"]);
896    }
897
898    #[test]
899    fn malformed_config_is_a_hard_error() {
900        assert!(Config::parse("classify = 5", "test").is_err());
901        assert!(Config::parse("[classify]\nnope = true", "test").is_err());
902    }
903
904    #[test]
905    fn reserved_sections_are_accepted() {
906        Config::parse("[ordering]\nfuture = 1\n[stack]\nns = \"y\"", "test").unwrap();
907    }
908
909    #[test]
910    fn grouping_in_repo_config_errors_with_migration_hint() {
911        let err = Config::parse("[grouping]\nagent = \"claude-code\"", "test").unwrap_err();
912        assert!(err.to_string().contains("user config"), "{err}");
913    }
914
915    #[test]
916    fn the_diff_layout_defaults_to_split_and_accepts_either_name() {
917        // Absent means split. A reader who has never opened the config gets the
918        // side-by-side layout.
919        let u = Config::parse_user("[review]\ncontext = 3", "test").unwrap();
920        assert_eq!(u.review.diff, DiffLayout::Split);
921        assert!(u.review.diff.is_split());
922
923        let u = Config::parse_user("[review]\ndiff = \"unified\"", "test").unwrap();
924        assert_eq!(u.review.diff, DiffLayout::Unified);
925        assert!(!u.review.diff.is_split());
926        assert_eq!(u.review.context, 3, "setting one key must not zero another");
927
928        let u = Config::parse_user("[review]\ndiff = \"split\"", "test").unwrap();
929        assert_eq!(u.review.diff, DiffLayout::Split);
930
931        // A typo is an error, not a silent fallback to the default.
932        assert!(Config::parse_user("[review]\ndiff = \"side\"", "test").is_err());
933    }
934
935    #[test]
936    fn user_config_parses_grouping_and_review() {
937        let u = Config::parse_user(
938            "[grouping]\nagent = \"claude-code\"\ntimeout_secs = 60",
939            "test",
940        )
941        .unwrap();
942        assert_eq!(u.grouping.agent, Some(Agent::ClaudeCode));
943        assert_eq!(u.grouping.timeout_secs, Some(60));
944        // An absent [review] means the defaults, not zero context.
945        assert_eq!(u.review.context, 3);
946        assert_eq!(u.review.context_step, 10);
947
948        let u = Config::parse_user("[review]\ncontext_step = 25", "test").unwrap();
949        assert_eq!(u.review.context_step, 25);
950        assert_eq!(u.review.context, 3, "one key set must not zero the other");
951
952        // Unknown keys and unknown sections stay hard errors.
953        assert!(Config::parse_user("[grouping]\nmodel = \"x\"", "test").is_err());
954
955        // An agent nobody implements is a hard error that names every one that
956        // exists. A silent fall back to the default would run a different agent
957        // than the one asked for, and the cache key would agree with neither.
958        let err = Config::parse_user("[grouping]\nagent = \"gpt\"", "test").unwrap_err();
959        let text = err.to_string();
960        for &agent in Agent::ALL {
961            assert!(
962                text.contains(agent.key()),
963                "the error must name {}: {text}",
964                agent.key()
965            );
966        }
967
968        // And the argv this key used to take is now one of those errors, not a
969        // command that gets spawned without its allowlist.
970        assert!(Config::parse_user("[grouping]\nagent = [\"my-llm\"]", "test").is_err());
971        assert!(Config::parse_user("[review]\nlines = 5", "test").is_err());
972        assert!(Config::parse_user("[classify]\ngenerated = []", "test").is_err());
973    }
974
975    #[test]
976    fn every_agent_name_round_trips() {
977        // `key` comes from strum and parsing from serde: two derives, each
978        // with its own rename rule, so the two can drift. They may not: `key` is what the docs print, what
979        // `dfr agents` lists and what an error message offers, and a name a
980        // user copies from any of those must parse.
981        for &agent in Agent::ALL {
982            let toml = format!("[grouping]\nagent = \"{}\"", agent.key());
983            let u = Config::parse_user(&toml, "test")
984                .unwrap_or_else(|e| panic!("{} must parse: {e}", agent.key()));
985            assert_eq!(u.grouping.agent, Some(agent), "{}", agent.key());
986        }
987    }
988
989    #[test]
990    fn which_agents_have_actually_been_run_is_pinned() {
991        // `proven` is a claim about what a human ran, so nothing can check it
992        // automatically. Pinning the two lists is the next best thing: moving
993        // an agent between them has to be deliberate, and the reviewer of that
994        // diff is being asked "did you run the probe?".
995        let proven: Vec<&str> = Agent::ALL
996            .iter()
997            .filter(|a| a.proven())
998            .map(|a| a.key())
999            .collect();
1000        assert_eq!(proven, vec!["claude-code", "codex", "pi"]);
1001
1002        let unproven: Vec<&str> = Agent::ALL
1003            .iter()
1004            .filter(|a| !a.proven())
1005            .map(|a| a.key())
1006            .collect();
1007        assert_eq!(unproven, vec!["droid", "copilot"]);
1008
1009        // The default must be one somebody has run. Anything else ships a
1010        // command line nobody has tried to the people who configured nothing.
1011        assert!(Agent::default().proven(), "the default must be proven");
1012    }
1013
1014    #[test]
1015    fn exactly_one_agent_does_not_enforce_read_only() {
1016        // The tier is a fact a user must be shown, so it is pinned here rather
1017        // than left to a doc comment. Pi ships no sandbox and no per-command
1018        // allowlist (ADR 0033); the other four refuse a write.
1019        let unenforced: Vec<&str> = Agent::ALL
1020            .iter()
1021            .filter(|a| !a.read_only().is_enforced())
1022            .map(|a| a.key())
1023            .collect();
1024        assert_eq!(unenforced, vec!["pi"], "{unenforced:?}");
1025        assert!(
1026            Agent::default().read_only().is_enforced(),
1027            "the default must be enforced"
1028        );
1029    }
1030
1031    /// A theme is a per-user choice like the agent, named for the same reason:
1032    /// serde renders the valid names for free, and adding one is a variant.
1033    #[test]
1034    fn user_config_parses_the_theme_and_names_the_valid_ones() {
1035        let u = Config::parse_user("[review]\ntheme = \"gruvbox-light\"", "test").unwrap();
1036        assert_eq!(u.review.theme, ThemeName::GruvboxLight);
1037        // Absent is the default, and does not zero the other keys.
1038        let u = Config::parse_user("[review]\ncontext = 8", "test").unwrap();
1039        assert_eq!(u.review.theme, ThemeName::Dark);
1040        assert_eq!(u.review.context, 8);
1041
1042        // An unknown name is an error that says which ones exist.
1043        let err = Config::parse_user("[review]\ntheme = \"nosferatu\"", "test").unwrap_err();
1044        let msg = err.to_string();
1045        for name in [
1046            "dark",
1047            "light",
1048            "gruvbox-dark",
1049            "solarized-light",
1050            "monokai",
1051        ] {
1052            assert!(msg.contains(name), "{name} missing from: {msg}");
1053        }
1054    }
1055
1056    /// The editor is a COMMAND where `agent` and `theme` are names, so the
1057    /// thing to pin is that the command survives splitting and that the two
1058    /// placeholders land where the reader put them.
1059    #[test]
1060    fn the_editor_command_puts_the_path_and_the_line_where_the_reader_said() {
1061        let u = Config::parse_user("[review]\neditor = \"nvim +{line} {file}\"", "test").unwrap();
1062        let cmd = EditorCommand::parse(u.review.editor.as_deref().unwrap(), "test").unwrap();
1063        assert_eq!(
1064            cmd.argv(Path::new("/w/src/x.rs"), 42),
1065            ["nvim", "+42", "/w/src/x.rs"]
1066        );
1067        assert!(cmd.carries_line());
1068        assert_eq!(cmd.program(), "nvim");
1069
1070        // Both placeholders in ONE word, which is how several editors spell it.
1071        // Splitting has to happen before substitution or this becomes three.
1072        let cmd = EditorCommand::parse("code -g {file}:{line}", "test").unwrap();
1073        assert_eq!(
1074            cmd.argv(Path::new("/w/src/x.rs"), 7),
1075            ["code", "-g", "/w/src/x.rs:7"]
1076        );
1077
1078        // A quoted program with a space stays one word. This is the case a
1079        // hand-rolled splitter gets wrong, and why `shlex` does it.
1080        let cmd =
1081            EditorCommand::parse("\"/Applications/My Editor\" --at {line} {file}", "test").unwrap();
1082        assert_eq!(
1083            cmd.argv(Path::new("/w/x.rs"), 3),
1084            ["/Applications/My Editor", "--at", "3", "/w/x.rs"]
1085        );
1086    }
1087
1088    /// A bare `$EDITOR` is the common case and names no placeholder at all.
1089    /// It must still open the file — at the top, and `carries_line` is what
1090    /// lets the caller say so rather than leave the reader wondering.
1091    #[test]
1092    fn a_command_naming_no_placeholder_still_gets_the_path() {
1093        let cmd = EditorCommand::parse("vim", "test").unwrap();
1094        assert_eq!(cmd.argv(Path::new("/w/x.rs"), 42), ["vim", "/w/x.rs"]);
1095        assert!(!cmd.carries_line());
1096
1097        // Flags are kept, and the path still lands last.
1098        let cmd = EditorCommand::parse("emacsclient -nw", "test").unwrap();
1099        assert_eq!(
1100            cmd.argv(Path::new("/w/x.rs"), 42),
1101            ["emacsclient", "-nw", "/w/x.rs"]
1102        );
1103        assert!(!cmd.carries_line());
1104
1105        // `{line}` without `{file}`: the line is honoured and the path is
1106        // still appended, so `+42` in front of it is the vim spelling.
1107        let cmd = EditorCommand::parse("vim +{line}", "test").unwrap();
1108        assert_eq!(
1109            cmd.argv(Path::new("/w/x.rs"), 42),
1110            ["vim", "+42", "/w/x.rs"]
1111        );
1112        assert!(cmd.carries_line());
1113    }
1114
1115    /// Order is load-bearing: `{line}` goes first, so a path that happens to
1116    /// hold the text `{line}` is inserted and never read again.
1117    #[test]
1118    fn a_path_holding_a_placeholder_is_not_read_as_one() {
1119        let cmd = EditorCommand::parse("nvim +{line} {file}", "test").unwrap();
1120        assert_eq!(
1121            cmd.argv(Path::new("/w/{line}/x.rs"), 9),
1122            ["nvim", "+9", "/w/{line}/x.rs"]
1123        );
1124        // And the other way: a path holding `{file}` is not re-expanded
1125        // either, because `str::replace` does not re-scan what it inserts.
1126        assert_eq!(
1127            cmd.argv(Path::new("/w/{file}/x.rs"), 9),
1128            ["nvim", "+9", "/w/{file}/x.rs"]
1129        );
1130    }
1131
1132    /// A command that cannot be run is an error at load, not a key that does
1133    /// nothing when it is pressed.
1134    #[test]
1135    fn an_unrunnable_editor_command_is_an_error() {
1136        let err = EditorCommand::parse("", "test").unwrap_err();
1137        assert!(err.to_string().contains("empty"), "{err}");
1138        // Whitespace alone splits to nothing, which is the same emptiness.
1139        assert!(EditorCommand::parse("   ", "test").is_err());
1140        // An unbalanced quote: `shlex` refuses, and so do we.
1141        let err = EditorCommand::parse("vim \"unclosed", "test").unwrap_err();
1142        assert!(err.to_string().contains("quote"), "{err}");
1143    }
1144
1145    /// A placeholder in the FIRST word would make the program the file. A
1146    /// source file with the executable bit set would then be run by `e`, so
1147    /// this is refused at load rather than left to the operating system —
1148    /// which, for that file, would not refuse it at all.
1149    #[test]
1150    fn the_program_word_may_not_be_a_placeholder() {
1151        for bad in ["{file}", "{file} {line}", "{line}", "pre{file}post vim"] {
1152            let err = EditorCommand::parse(bad, "test").unwrap_err().to_string();
1153            assert!(err.contains("first word"), "{bad:?} gave {err}");
1154        }
1155        // A placeholder anywhere else is the whole point of the feature.
1156        assert!(EditorCommand::parse("vim +{line} {file}", "test").is_ok());
1157        assert!(EditorCommand::parse("code -g {file}:{line}", "test").is_ok());
1158    }
1159
1160    /// The house rule for every key in this table: setting one must not zero
1161    /// another, and the key is absent by default.
1162    #[test]
1163    fn the_editor_key_is_optional_and_independent() {
1164        let u = Config::parse_user("[review]\ncontext = 8", "test").unwrap();
1165        assert_eq!(u.review.editor, None);
1166
1167        let u = Config::parse_user("[review]\neditor = \"hx {file}:{line}\"", "test").unwrap();
1168        assert_eq!(u.review.editor.as_deref(), Some("hx {file}:{line}"));
1169        assert_eq!(u.review.context, 3);
1170        assert_eq!(u.review.context_step, 10);
1171        assert_eq!(u.review.theme, ThemeName::Dark);
1172        assert_eq!(u.review.diff, DiffLayout::Split);
1173    }
1174
1175    /// An editor is the reader's, not the repository's — the same reason a
1176    /// palette is. Nothing enforces it by hand; the repo file has no
1177    /// `[review]` field and denies unknown ones.
1178    #[test]
1179    fn an_editor_in_the_repo_config_is_rejected() {
1180        assert!(Config::parse("[review]\neditor = \"vim\"", "test").is_err());
1181    }
1182
1183    /// The repo file cannot set it: a palette is the reader's, not the
1184    /// repository's. Nothing enforces this by hand — `RawConfig` has no
1185    /// `[review]` and denies unknown fields.
1186    #[test]
1187    fn a_theme_in_the_repo_config_is_rejected() {
1188        let err = Config::parse("[review]\ntheme = \"one-light\"", "test").unwrap_err();
1189        assert!(err.to_string().contains("review"), "{err}");
1190    }
1191
1192    #[test]
1193    fn load_composes_repo_and_user_files() {
1194        let tmp = tempfile::TempDir::new().unwrap();
1195        let repo_file = tmp.path().join("repo.toml");
1196        let user_file = tmp.path().join("user.toml");
1197        std::fs::write(&repo_file, "[classify]\ngenerated = [\"gen/**\"]").unwrap();
1198        std::fs::write(
1199            &user_file,
1200            "[grouping]\nagent = \"claude-code\"\n[review]\ncontext = 8\n[keys]\ntop = [\"Q\"]",
1201        )
1202        .unwrap();
1203        let c = Config::load(
1204            &crate::store::OsConfigSource,
1205            tmp.path(),
1206            Some(&repo_file),
1207            Some(&user_file),
1208        )
1209        .unwrap();
1210        assert!(c.generated.is_match("gen/x"));
1211        assert_eq!(c.grouping.agent, Some(Agent::ClaudeCode));
1212        assert_eq!(c.review.context, 8);
1213        assert_eq!(c.keys.0[&Action::Top], ["Q"]);
1214
1215        // Explicit-but-missing paths are hard errors; absent defaults are not.
1216        assert!(
1217            Config::load(&SRC, tmp.path(), Some(Path::new("/nope")), Some(&user_file)).is_err()
1218        );
1219        assert!(Config::load(&SRC, tmp.path(), None, Some(&user_file)).is_ok());
1220    }
1221
1222    #[test]
1223    fn keys_map_action_names_to_the_strings_as_written() {
1224        let u =
1225            Config::parse_user("[keys]\nnext-group = [\"ctrl-j\"]\npublish = []", "test").unwrap();
1226        assert_eq!(u.keys.0[&Action::NextGroup], ["ctrl-j"]);
1227        // An empty list is a statement, not an absence: it unbinds.
1228        assert_eq!(u.keys.0[&Action::Publish], Vec::<String>::new());
1229        assert!(
1230            !u.keys.0.contains_key(&Action::Top),
1231            "unnamed keeps defaults"
1232        );
1233        // Absent means no overrides at all.
1234        assert!(Config::parse_user("", "test").unwrap().keys.0.is_empty());
1235    }
1236
1237    #[test]
1238    fn an_unknown_action_is_an_error_naming_every_action() {
1239        let err = Config::parse_user("[keys]\nexplode = [\"x\"]", "test").unwrap_err();
1240        let text = err.to_string();
1241        for &action in Action::ALL {
1242            assert!(
1243                text.contains(action.key()),
1244                "must name {}: {text}",
1245                action.key()
1246            );
1247        }
1248        // A bare string where a list goes is an error too, not a one-key list:
1249        // the shape is the same for one key as for three.
1250        assert!(Config::parse_user("[keys]\ntop = \"g\"", "test").is_err());
1251    }
1252
1253    #[test]
1254    fn keys_are_the_users_and_not_the_repos() {
1255        let err = Config::parse("[keys]\ntop = [\"g\"]", "test").unwrap_err();
1256        assert!(err.to_string().contains("keys"), "{err}");
1257    }
1258
1259    #[test]
1260    fn every_action_name_round_trips() {
1261        for &action in Action::ALL {
1262            let text = format!("[keys]\n{} = []", action.key());
1263            let u = Config::parse_user(&text, "test").unwrap();
1264            assert!(
1265                u.keys.0.contains_key(&action),
1266                "{} did not parse",
1267                action.key()
1268            );
1269        }
1270    }
1271
1272    #[test]
1273    fn a_rendered_user_config_reads_back_as_itself() {
1274        let full = Config::parse_user(
1275            "[grouping]\nagent = \"codex\"\ntimeout_secs = 60\n\
1276             [review]\ntheme = \"gruvbox-light\"\ncontext = 8\ncontext_step = 4\ndiff = \"unified\"\n\
1277             [keys]\nnext-group = [\"ctrl-j\"]\npublish = []",
1278            "test",
1279        )
1280        .unwrap();
1281        for user in [full, UserConfig::default()] {
1282            let text = Config::render_user(&user);
1283            assert_eq!(Config::parse_user(&text, "test").unwrap(), user, "{text}");
1284        }
1285        // Nothing rebound, nothing about agents: no table for either.
1286        let text = Config::render_user(&UserConfig::default());
1287        assert!(
1288            !text.contains("[keys]") && !text.contains("agent"),
1289            "{text}"
1290        );
1291    }
1292
1293    #[test]
1294    fn save_user_writes_the_file_and_its_directory() {
1295        let tmp = tempfile::TempDir::new().unwrap();
1296        let path = tmp.path().join("differential").join("config.toml");
1297        let mut user = UserConfig::default();
1298        user.review.theme = ThemeName::Dracula;
1299        Config::save_user(&SRC, &path, &user).unwrap();
1300        let back = Config::load_user(&SRC, Some(&path)).unwrap();
1301        assert_eq!(back, user);
1302    }
1303
1304    #[test]
1305    fn every_theme_and_layout_name_round_trips() {
1306        for &theme in ThemeName::ALL {
1307            // The `match` is the exhaustiveness guard `ALL` cannot be.
1308            match theme {
1309                ThemeName::Dark
1310                | ThemeName::OneDark
1311                | ThemeName::OneLight
1312                | ThemeName::GruvboxDark
1313                | ThemeName::GruvboxLight
1314                | ThemeName::SolarizedDark
1315                | ThemeName::SolarizedLight
1316                | ThemeName::CatppuccinMocha
1317                | ThemeName::CatppuccinLatte
1318                | ThemeName::Dracula
1319                | ThemeName::Monokai => {}
1320            }
1321            let text = format!("[review]\ntheme = \"{}\"", theme.key());
1322            assert_eq!(
1323                Config::parse_user(&text, "test").unwrap().review.theme,
1324                theme
1325            );
1326        }
1327        for &diff in DiffLayout::ALL {
1328            match diff {
1329                DiffLayout::Split | DiffLayout::Unified => {}
1330            }
1331            let text = format!("[review]\ndiff = \"{}\"", diff.key());
1332            assert_eq!(Config::parse_user(&text, "test").unwrap().review.diff, diff);
1333        }
1334    }
1335
1336    #[test]
1337    fn a_key_list_round_trips_in_the_files_syntax() {
1338        let keys = vec!["ctrl-j".to_string(), "d d".to_string(), "\"".to_string()];
1339        let text = KeysConfig::render_list(&keys);
1340        assert_eq!(KeysConfig::parse_list(&text).unwrap(), keys, "{text}");
1341        assert_eq!(KeysConfig::parse_list("[]").unwrap(), Vec::<String>::new());
1342        assert!(KeysConfig::parse_list("ctrl-j").is_err());
1343        assert!(KeysConfig::parse_list("[1]").is_err());
1344    }
1345}