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}