Skip to main content

gwm/
config.rs

1use crate::error::{GwmError, Result};
2use serde::{Deserialize, Serialize};
3use std::collections::{BTreeMap, HashMap};
4use std::path::{Path, PathBuf};
5
6pub const CONFIG_FILE: &str = ".gwm.toml";
7
8#[derive(Debug, Clone, Default, Serialize, Deserialize)]
9#[serde(deny_unknown_fields)]
10pub struct Config {
11  #[serde(default)]
12  pub worktree: WorktreeConfig,
13  #[serde(default)]
14  pub bootstrap: BootstrapConfig,
15  #[serde(default)]
16  pub hooks: LifecycleHooksConfig,
17  #[serde(default)]
18  pub doctor: DoctorConfig,
19  #[serde(default)]
20  pub tui: TuiConfig,
21  #[serde(default)]
22  pub theme: ThemeConfig,
23  #[serde(default)]
24  pub git_tui: GitTuiConfig,
25  #[serde(default)]
26  pub review: ReviewConfig,
27  /// `[[labels]]` table — declarative GitHub label set pushed via
28  /// `gwm labels push`. Issue #81. Absent block resolves to an empty
29  /// vec, so `gwm labels push` is a no-op on configs that never opt in.
30  /// Whitespace in `name` is preserved verbatim (e.g. `"good first
31  /// issue"`); colour falls back to a deterministic pastel hash at
32  /// push time when omitted.
33  #[serde(default)]
34  pub labels: Vec<LabelConfig>,
35  /// `[[milestones]]` table — declarative GitHub milestone set pushed
36  /// via `gwm milestones push`. Issue #82. Same opt-in / no-op shape
37  /// as `labels`. `due_on` accepts both `YYYY-MM-DD` (the milestones
38  /// module materialises end-of-day UTC) and full RFC3339; `state`
39  /// defaults to `"open"` when omitted.
40  #[serde(default)]
41  pub milestones: Vec<MilestoneConfig>,
42  /// `[[branch_types]]` — per-repo override of the allowed branch types.
43  /// Empty (the default) means the built-in list from `naming::BRANCH_TYPES`
44  /// is used, keeping zero-friction for existing repos. See
45  /// [`Config::resolved_branch_types`] for the single lookup site shared
46  /// by `BranchSpec::validate`, `gwm types` and the TUI create picker.
47  #[serde(rename = "branch_types", default)]
48  pub branch_types: Vec<BranchType>,
49  /// `[aliases]` table — repo-level CLI aliases expanded BEFORE clap
50  /// parses argv (issue #86). Maps alias name to argv-shell-tokenised
51  /// expansion (e.g. `wip = "create feat 0 wip"`). `BTreeMap` so the
52  /// ordering surfaced by `gwm aliases list` is deterministic.
53  ///
54  /// Absent block resolves to an empty map — aliasing disabled, no
55  /// behaviour change for repos that never opt in. Shadowing a
56  /// built-in subcommand or visible alias is a config error surfaced
57  /// at load time by [`crate::aliases::validate_aliases`]; same for
58  /// values containing shell pipeline metachars.
59  #[serde(default)]
60  pub aliases: BTreeMap<String, String>,
61  /// `[gitmoji]` table — branch type to Gitmoji shortcode overrides used
62  /// by `gwm types --gitmoji` and `gwm commit-prefix`.
63  #[serde(default)]
64  pub gitmoji: BTreeMap<String, String>,
65  #[serde(default)]
66  pub issue_template: IssueTemplateConfig,
67  #[serde(default)]
68  pub pr_template: PrTemplateConfig,
69  /// `[exec]` — named command profiles for `gwm exec --profile <name>`
70  /// (issue #324). Absent block resolves to no profiles, so the inline
71  /// `gwm exec -- <cmd>` surface is unchanged. Frozen for 1.0: a profile's
72  /// `command` is an argv **array** (no shell), diverging from the
73  /// string-shell `command` of `[git_tui]` / `[review]`.
74  #[serde(default)]
75  pub exec: ExecConfig,
76  /// `[clean]` — named directory-set profiles for `gwm clean --profile
77  /// <name>` (issue #324). Absent block resolves to no profiles, so
78  /// `gwm clean` keeps cleaning the built-in `target`/`node_modules`/
79  /// `dist`/`build` set. A profile's `dirs` is a COMPLETE set that
80  /// replaces the built-ins, never adds to them.
81  #[serde(default)]
82  pub clean: CleanConfig,
83}
84
85/// `[exec]` — named command profiles for `gwm exec` (issue #324).
86///
87/// Each `[exec.profiles.<name>]` carries the argv to run via
88/// `gwm exec --profile <name>`. The block is opt-in: an absent `[exec]`
89/// resolves to an empty profile map, leaving the inline `gwm exec -- <cmd>`
90/// surface untouched.
91#[derive(Debug, Clone, Default, Serialize, Deserialize)]
92#[serde(deny_unknown_fields)]
93pub struct ExecConfig {
94  /// `[exec] jobs` — the global default parallelism for `gwm exec` (issue
95  /// #324). `1` or absent ⇒ sequential (live, inherited stdio — the MVP
96  /// behaviour); `> 1` ⇒ bounded parallel with per-worktree captured output.
97  /// Precedence: `--jobs` flag > `[exec.profiles.<name>].jobs` > this > `1`.
98  #[serde(default)]
99  pub jobs: Option<u32>,
100  /// `[exec.profiles.<name>]` sub-tables. `BTreeMap` for a deterministic
101  /// ordering when surfaced.
102  #[serde(default)]
103  pub profiles: BTreeMap<String, ExecProfile>,
104}
105
106/// One `[exec.profiles.<name>]` entry.
107///
108/// `command` is an argv **array** (`["cargo", "test"]`) executed with **no
109/// shell** — the same contract as the inline `gwm exec -- <cmd>`. This
110/// diverges from `[git_tui]` / `[review]`, whose `command` is a single
111/// shell line; the divergence is intentional and frozen for 1.0.
112#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
113#[serde(deny_unknown_fields)]
114pub struct ExecProfile {
115  /// argv to run in each worktree. Required — a profile with no command is
116  /// a config error at load time (`deny_unknown_fields` + no `serde(default)`).
117  pub command: Vec<String>,
118  /// Per-profile parallelism override (issue #324). Overrides `[exec] jobs`
119  /// when this profile runs; the `--jobs` flag still wins over it.
120  #[serde(default)]
121  pub jobs: Option<u32>,
122}
123
124/// `[clean]` — named directory-set profiles for `gwm clean` (issue #324).
125///
126/// Opt-in like [`ExecConfig`]: an absent `[clean]` resolves to an empty
127/// profile map, so `gwm clean` keeps using the built-in directory set.
128#[derive(Debug, Clone, Default, Serialize, Deserialize)]
129#[serde(deny_unknown_fields)]
130pub struct CleanConfig {
131  /// `[clean.profiles.<name>]` sub-tables. The `default` profile, when
132  /// present, is what `gwm clean` uses **without** `--profile`.
133  #[serde(default)]
134  pub profiles: BTreeMap<String, CleanProfile>,
135}
136
137/// One `[clean.profiles.<name>]` entry.
138///
139/// `dirs` is a **complete** directory set that **replaces** the built-in
140/// `target`/`node_modules`/`dist`/`build` — it never adds to them. The
141/// safety gate (git-ignored + no tracked files + skip symlinks) still
142/// applies to every listed directory.
143#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
144#[serde(deny_unknown_fields)]
145pub struct CleanProfile {
146  /// Complete set of directory names to reclaim. Required — a profile with
147  /// no `dirs` is a config error at load time.
148  pub dirs: Vec<String>,
149}
150
151/// One `[[labels]]` entry. `name` is the GitHub key (unique per repo);
152/// `description` and `color` are optional, with the colour resolved by
153/// the labels module at push time (deterministic pastel by default,
154/// overridable via `--random-colors`).
155#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
156#[serde(deny_unknown_fields)]
157pub struct LabelConfig {
158  pub name: String,
159  #[serde(default)]
160  pub description: Option<String>,
161  /// 6-character hex colour without a leading `#` (e.g. `"d73a4a"`).
162  /// Validation is deferred to push time so a typo doesn't break
163  /// config load for unrelated subcommands.
164  #[serde(default)]
165  pub color: Option<String>,
166}
167
168/// One `[[milestones]]` entry. `title` is the GitHub key (unique per
169/// repo). `description`, `due_on`, and `state` are optional; the
170/// milestones module validates `due_on` (YYYY-MM-DD or RFC3339) and
171/// `state` (`"open"` | `"closed"`) at push time so a typo doesn't
172/// break unrelated subcommands.
173#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
174#[serde(deny_unknown_fields)]
175pub struct MilestoneConfig {
176  pub title: String,
177  #[serde(default)]
178  pub description: Option<String>,
179  /// Due date. Accepted forms: `YYYY-MM-DD` (treated as end-of-day
180  /// UTC at push time) or full RFC3339 (`2026-07-15T17:00:00Z`).
181  #[serde(default)]
182  pub due_on: Option<String>,
183  /// `"open"` (default) or `"closed"`. Validated at push time.
184  #[serde(default)]
185  pub state: Option<String>,
186}
187
188/// One entry of the `[[branch_types]]` table in `.gwm.toml`. The struct
189/// is also produced by [`crate::naming::default_branch_types`] when the
190/// config block is absent, so both the configured and built-in flavours
191/// share the same shape downstream.
192#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
193#[serde(deny_unknown_fields)]
194pub struct BranchType {
195  pub name: String,
196  pub description: String,
197}
198
199#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
200#[serde(deny_unknown_fields)]
201pub struct IssueTemplateConfig {
202  #[serde(default)]
203  pub default: Option<String>,
204  #[serde(default)]
205  pub by_type: BTreeMap<String, IssueTemplateTypeConfig>,
206}
207
208#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
209#[serde(deny_unknown_fields)]
210pub struct IssueTemplateTypeConfig {
211  #[serde(default)]
212  pub template: Option<String>,
213  #[serde(default)]
214  pub surface: Option<String>,
215  #[serde(default)]
216  pub title_prefix: Option<String>,
217  #[serde(default)]
218  pub labels: Vec<String>,
219}
220
221/// `[pr_template]` config block (issue #84). `default` is a workdir-
222/// relative path to a Markdown template used as the fallback PR body;
223/// `by_type` maps a branch type to either a per-type `path` or an
224/// inline `body` string. The resolver in `pr_templates` picks the most
225/// specific entry (per-type wins over default) and runs the templating
226/// engine on the result.
227#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
228#[serde(deny_unknown_fields)]
229pub struct PrTemplateConfig {
230  #[serde(default)]
231  pub default: Option<String>,
232  #[serde(default)]
233  pub by_type: BTreeMap<String, PrTemplateTypeConfig>,
234}
235
236/// Per-branch-type override under `[pr_template.by_type.<type>]`. Either
237/// `path` (a workdir-relative Markdown file) or `body` (an inline
238/// string) must be set; setting both is allowed and inline `body` wins
239/// over `path` so a stable on-disk template can be carried in `path`
240/// while a focused `body` override takes precedence for a specific
241/// branch type. The resolver in `pr_templates` enforces this ordering;
242/// see `inline_body_wins_over_path_when_both_set` in
243/// `tests/pr_templates_tests.rs` for the pinned contract.
244#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
245#[serde(deny_unknown_fields)]
246pub struct PrTemplateTypeConfig {
247  #[serde(default)]
248  pub path: Option<String>,
249  #[serde(default)]
250  pub body: Option<String>,
251}
252
253/// Origin of the resolved branch-type list — surfaced verbatim under
254/// `gwm types` so users can tell at a glance whether they're looking at
255/// their `.gwm.toml` override or the built-in defaults.
256#[derive(Debug, Clone, Copy, PartialEq, Eq)]
257pub enum BranchTypesSource {
258  /// No `[[branch_types]]` block in `.gwm.toml` (or it's empty) — the
259  /// built-in list from `naming::BRANCH_TYPES` is in effect.
260  Default,
261  /// At least one `[[branch_types]]` entry was loaded from `.gwm.toml`.
262  Config,
263}
264
265impl BranchTypesSource {
266  /// Human-readable label rendered as the footer of `gwm types`.
267  pub fn label(self) -> &'static str {
268    match self {
269      Self::Default => "built-in defaults",
270      Self::Config => ".gwm.toml",
271    }
272  }
273}
274
275/// Pair returned by [`Config::resolved_branch_types`] — the list to feed
276/// into validation / display, plus the [`BranchTypesSource`] that
277/// produced it.
278#[derive(Debug, Clone)]
279pub struct ResolvedBranchTypes {
280  pub types: Vec<BranchType>,
281  pub source: BranchTypesSource,
282}
283
284#[derive(Debug, Clone, Serialize, Deserialize)]
285#[serde(deny_unknown_fields)]
286pub struct WorktreeConfig {
287  #[serde(default = "default_worktree_base")]
288  pub base: String,
289  #[serde(default = "default_path_pattern")]
290  pub path_pattern: String,
291  #[serde(default = "default_branch_pattern")]
292  pub branch_pattern: String,
293}
294
295impl Default for WorktreeConfig {
296  fn default() -> Self {
297    Self {
298      base: default_worktree_base(),
299      path_pattern: default_path_pattern(),
300      branch_pattern: default_branch_pattern(),
301    }
302  }
303}
304
305fn default_worktree_base() -> String {
306  "{home}/cc-worktree/{repo}".into()
307}
308
309fn default_path_pattern() -> String {
310  "{type}-{issue}-{desc}".into()
311}
312
313fn default_branch_pattern() -> String {
314  "{type}/#{issue}-{desc}".into()
315}
316
317#[derive(Debug, Clone, Default, Serialize, Deserialize)]
318#[serde(deny_unknown_fields)]
319pub struct BootstrapConfig {
320  #[serde(default)]
321  pub copy: Vec<CopyStep>,
322  #[serde(default)]
323  pub guard: Vec<Guard>,
324  #[serde(default)]
325  pub no_symlink: Vec<NoSymlink>,
326  #[serde(default)]
327  pub command: Vec<CommandStep>,
328  #[serde(default)]
329  pub fallback: HashMap<String, FallbackContent>,
330}
331
332#[derive(Debug, Clone, Serialize, Deserialize)]
333#[serde(deny_unknown_fields)]
334pub struct CopyStep {
335  pub from: String,
336  pub to: String,
337  #[serde(default)]
338  pub required: bool,
339  #[serde(default)]
340  pub guards: Vec<String>,
341  /// "inline" → use [bootstrap.fallback.<key>] content if source missing.
342  /// "skip" → silently skip (default for non-required).
343  /// "abort" → fail bootstrap.
344  #[serde(default)]
345  pub fallback: Option<String>,
346}
347
348#[derive(Debug, Clone, Serialize, Deserialize)]
349#[serde(deny_unknown_fields)]
350pub struct Guard {
351  pub name: String,
352  #[serde(default)]
353  pub deny_patterns: Vec<String>,
354  /// "abort" (default) | "seed-from-example"
355  #[serde(default = "default_on_match")]
356  pub on_match: String,
357  #[serde(default)]
358  pub example_file: Option<String>,
359}
360
361fn default_on_match() -> String {
362  "abort".into()
363}
364
365#[derive(Debug, Clone, Serialize, Deserialize)]
366#[serde(deny_unknown_fields)]
367pub struct NoSymlink {
368  pub path: String,
369}
370
371#[derive(Debug, Clone, Serialize, Deserialize)]
372#[serde(deny_unknown_fields)]
373pub struct CommandStep {
374  pub name: String,
375  pub run: String,
376  /// `file_exists:<path>` only for now.
377  #[serde(default)]
378  pub when: Option<String>,
379  #[serde(default)]
380  pub env: HashMap<String, String>,
381}
382
383/// `[hooks]` lifecycle automation. Each array uses the same command
384/// shape as `[[bootstrap.command]]`, plus explicit failure handling.
385#[derive(Debug, Clone, Default, Serialize, Deserialize)]
386#[serde(deny_unknown_fields)]
387pub struct LifecycleHooksConfig {
388  #[serde(default)]
389  pub pre_create: Vec<HookStep>,
390  #[serde(default)]
391  pub post_create: Vec<HookStep>,
392  #[serde(default)]
393  pub pre_bootstrap: Vec<HookStep>,
394  #[serde(default)]
395  pub post_bootstrap: Vec<HookStep>,
396  #[serde(default)]
397  pub pre_remove: Vec<HookStep>,
398  #[serde(default)]
399  pub post_remove: Vec<HookStep>,
400}
401
402impl LifecycleHooksConfig {
403  pub fn has_any(&self) -> bool {
404    !self.pre_create.is_empty()
405      || !self.post_create.is_empty()
406      || !self.pre_bootstrap.is_empty()
407      || !self.post_bootstrap.is_empty()
408      || !self.pre_remove.is_empty()
409      || !self.post_remove.is_empty()
410  }
411}
412
413#[derive(Debug, Clone, Serialize, Deserialize)]
414#[serde(deny_unknown_fields)]
415pub struct HookStep {
416  pub name: String,
417  pub run: String,
418  #[serde(default)]
419  pub when: Option<String>,
420  #[serde(default)]
421  pub env: HashMap<String, String>,
422  #[serde(default)]
423  pub on_fail: HookOnFail,
424}
425
426impl From<CommandStep> for HookStep {
427  fn from(step: CommandStep) -> Self {
428    Self {
429      name: step.name,
430      run: step.run,
431      when: step.when,
432      env: step.env,
433      on_fail: HookOnFail::Abort,
434    }
435  }
436}
437
438#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
439#[serde(rename_all = "lowercase")]
440pub enum HookOnFail {
441  #[default]
442  Abort,
443  Warn,
444  Ignore,
445}
446
447#[derive(Debug, Clone, Serialize, Deserialize)]
448#[serde(deny_unknown_fields)]
449pub struct FallbackContent {
450  pub target: String,
451  pub content: String,
452}
453
454/// `[doctor]` table — knobs for `gwm doctor`. Currently exposes the trunk
455/// list used by the orphan-branch check; previously this was hardcoded to
456/// `["dev", "main"]` in `doctor.rs`, which silently no-op'd the filter on
457/// any repo using a different trunk convention (`master`, `trunk`,
458/// `release-1.x`, …). Default preserves the previous behaviour.
459#[derive(Debug, Clone, Serialize, Deserialize)]
460#[serde(deny_unknown_fields)]
461pub struct DoctorConfig {
462  /// Trunk branches the orphan-branch check treats as "merge destinations".
463  /// A gwm-style branch fully reachable from one of these is preserved per
464  /// CONTRIBUTING.md ("never delete the source branch after merge") and is
465  /// therefore not flagged as orphan. An empty list disables the filter
466  /// entirely (every unclaimed gwm-style branch becomes orphan).
467  #[serde(default = "default_trunks")]
468  pub trunks: Vec<String>,
469}
470
471impl Default for DoctorConfig {
472  fn default() -> Self {
473    Self {
474      trunks: default_trunks(),
475    }
476  }
477}
478
479fn default_trunks() -> Vec<String> {
480  vec!["dev".into(), "main".into()]
481}
482
483/// Which side the worktree-details sidebar sits on in the side-by-side
484/// TUI layout (issue #188). `Right` preserves the pre-#188 behaviour and
485/// is the default. In the stacked (narrow-terminal) layout the sidebar
486/// always sits at the bottom, so this preference only governs the
487/// side-by-side split. Toggled live with `H`; persisted here so the
488/// choice survives across launches.
489#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
490#[serde(rename_all = "lowercase")]
491pub enum SidebarPosition {
492  /// Sidebar on the left, worktree table on the right.
493  Left,
494  /// Sidebar on the right of the table — pre-#188 behaviour. Default.
495  #[default]
496  Right,
497}
498
499impl SidebarPosition {
500  /// Human-readable label for the status bar (`sidebar position: left`).
501  pub fn label(self) -> &'static str {
502    match self {
503      SidebarPosition::Left => "left",
504      SidebarPosition::Right => "right",
505    }
506  }
507
508  /// `true` when the sidebar should be drawn to the left of the table.
509  pub fn is_left(self) -> bool {
510    matches!(self, SidebarPosition::Left)
511  }
512}
513
514/// Where a macro command runs when fired from the TUI (#290).
515/// Serialised as snake_case strings in `[tui.macro1]` / `[tui.macro2]`
516/// (`open_in = "pty"` / `open_in = "mux_pane"`) — `snake_case`, not
517/// `lowercase`, so the documented `"mux_pane"` value deserialises (Codex
518/// review on PR #292: `lowercase` produced `"muxpane"`).
519#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
520#[serde(rename_all = "snake_case")]
521pub enum MacroOpenMode {
522  /// Open the command in an embedded PTY overlay (same as lazygit-pty /
523  /// terminal-pty). The TUI suspends until the command exits.
524  #[default]
525  Pty,
526  /// Open the command in a new pane of the running multiplexer (tmux /
527  /// Zellij / GNU Screen). Falls back to `Pty` when no multiplexer is
528  /// detected.
529  MuxPane,
530}
531
532/// `[tui.macro1]` / `[tui.macro2]` sub-table — a user-defined command
533/// that the `h` / `H` keys fire from inside the worktree TUI (#290).
534/// Absent → the key does nothing (no-op). Present → the command is run
535/// in the worktree's directory in the mode requested by `open_in`.
536#[derive(Debug, Clone, Serialize, Deserialize)]
537#[serde(deny_unknown_fields)]
538pub struct TuiMacroConfig {
539  /// Shell command to execute. Forwarded to the OS shell (`sh -c`).
540  pub command: String,
541  /// How the command is opened. Defaults to `pty`.
542  #[serde(default)]
543  pub open_in: MacroOpenMode,
544}
545
546/// `[tui]` table — runtime knobs for the worktree TUI. Currently exposes
547/// the safety countdown on the delete-confirm overlay (issue #30): when
548/// `delete_branch_on_remove` has been toggled ON, the modal forces the
549/// user to wait N seconds (visualised by a progress bar) before the
550/// destructive action actually fires. `0` disables the countdown and
551/// falls back to the classic single-keystroke confirm even when delete-
552/// branch is armed; the value is clamped to `5` at read time so a typo
553/// like `confirm_countdown_secs = 300` can never strand a destructive
554/// path behind a 300-second wait.
555#[derive(Debug, Clone, Serialize, Deserialize)]
556#[serde(deny_unknown_fields)]
557pub struct TuiConfig {
558  /// Safety countdown (in seconds) applied to the confirm overlay when
559  /// `delete_branch_on_remove` is ON. Accepts any non-negative integer;
560  /// values above [`Self::MAX_CONFIRM_COUNTDOWN_SECS`] are clamped on
561  /// read via [`Self::effective_confirm_countdown_secs`]. The field is
562  /// `u32` (rather than `u8`) so a typo like `confirm_countdown_secs = 300`
563  /// still round-trips through TOML deserialization and reaches the
564  /// clamp instead of erroring out at parse time.
565  #[serde(default = "default_confirm_countdown_secs")]
566  pub confirm_countdown_secs: u32,
567
568  /// Periodic worktree-list refresh interval in seconds. Default `60`
569  /// keeps Issue/PR table state reasonably fresh; `0` disables the
570  /// automatic refresh loop.
571  #[serde(default = "default_auto_refresh_secs")]
572  pub auto_refresh_secs: u64,
573
574  /// `[tui.open]` sub-table — drives the dispatch of the `o` key in the
575  /// list view. Default mode is `shell` (lazygit-like worktree-manager
576  /// workflow); pre-#73 behaviour (`open` / `xdg-open` / `explorer`) is
577  /// kept available under `mode = "finder"`.
578  #[serde(default)]
579  pub open: TuiOpenConfig,
580
581  /// Which side the worktree-details sidebar sits on in the side-by-side
582  /// layout (issue #188). Default `right` preserves pre-#188 behaviour;
583  /// `left` flips the split. Toggled live in the TUI with `H`. Ignored by
584  /// the stacked (narrow-terminal) layout, where the sidebar is always at
585  /// the bottom.
586  #[serde(default)]
587  pub sidebar_position: SidebarPosition,
588
589  /// `[tui.keys]` sub-table (issue #87) — user overrides for the
590  /// remappable keymap. Absent → keymap stays at the built-in
591  /// defaults. Present → every listed action *replaces* its default
592  /// binding set; actions left unmentioned keep their defaults. An
593  /// empty array (`down = []`) unbinds the action entirely.
594  #[serde(default)]
595  pub keys: TuiKeysConfig,
596
597  /// `[tui.macro1]` — user-defined command bound to `h` by default (#290).
598  /// Absent → the key does nothing.
599  #[serde(default)]
600  pub macro1: Option<TuiMacroConfig>,
601
602  /// `[tui.macro2]` — user-defined command bound to `H` by default (#290).
603  /// Absent → the key does nothing.
604  #[serde(default)]
605  pub macro2: Option<TuiMacroConfig>,
606}
607
608impl Default for TuiConfig {
609  fn default() -> Self {
610    Self {
611      confirm_countdown_secs: default_confirm_countdown_secs(),
612      auto_refresh_secs: default_auto_refresh_secs(),
613      open: TuiOpenConfig::default(),
614      sidebar_position: SidebarPosition::default(),
615      keys: TuiKeysConfig::default(),
616      macro1: None,
617      macro2: None,
618    }
619  }
620}
621
622/// `[tui.keys]` — user-facing override table for the TUI keymap.
623///
624/// Two kinds of entry live side by side, disambiguated by value type
625/// (issue #219):
626///
627/// - **array value** → a *global* `View::List` verb, e.g. `quit = ["q"]`.
628///   Resolved by [`Self::resolved_keymap`] into a
629///   [`crate::tui::keymap::Keymap`].
630/// - **table value** → a *contextual* modal sub-table, e.g.
631///   `[tui.keys.modal.confirm]` or `[tui.keys.modal.link.choose_target]`. Resolved by
632///   [`Self::resolved_modal_keymap`] into a
633///   [`crate::tui::modal_keymap::ModalKeymap`].
634///
635/// A few names exist in both worlds (`create`, `help`, `command_logs`,
636/// `link` are global actions *and* modal contexts). TOML forbids defining
637/// the same key twice, so a user picks one per file; the value type is
638/// what the walkers below key off. Resolution / validation runs at
639/// `Config::load_for_repo` time via [`Config::validate_tui_keys`] so a
640/// malformed override (unknown action / context / verb, parse error,
641/// per-context conflict, multi-stroke modal chord) is surfaced at load
642/// rather than as a silent no-op in the TUI. The raw table is preserved so
643/// `gwm tui keys` can show both the user's source and the resolved set.
644#[derive(Debug, Clone, Default, Serialize, Deserialize)]
645#[serde(transparent)]
646pub struct TuiKeysConfig {
647  pub raw: toml::Table,
648}
649
650impl TuiKeysConfig {
651  /// Resolve the **global** `View::List` keymap: the top-level array
652  /// entries of `[tui.keys]`. The `[tui.keys.modal]` sub-table (modal
653  /// contexts) is skipped here and handled by [`Self::resolved_modal_keymap`].
654  pub fn resolved_keymap(&self) -> Result<crate::tui::keymap::Keymap> {
655    use crate::tui::keymap::{Action, KeyStroke, Keymap};
656
657    let mut km = Keymap::defaults();
658    for (action_slug, value) in &self.raw {
659      // Modal contexts live under `[tui.keys.modal.<context>]` and are
660      // resolved by `resolved_modal_keymap`. The dedicated namespace (issue
661      // #219 review) keeps a global action and a same-named modal context
662      // (`create` / `help` / `command_logs` / `link`) from colliding at the
663      // `tui.keys.<name>` path during the layered merge — which previously
664      // replaced the global array with the modal table and silently dropped
665      // the user's global override.
666      if action_slug == TUI_KEYS_MODAL_NAMESPACE {
667        continue;
668      }
669      let chord_strings = match value {
670        toml::Value::Array(_) => as_chord_list(action_slug, value)?,
671        other => {
672          return Err(GwmError::Config(format!(
673            "tui.keys.{}: expected an array of chords; modal contexts go under [tui.keys.modal.<context>], got {}",
674            action_slug,
675            other.type_str()
676          )))
677        }
678      };
679      let action = Action::from_slug_compat(action_slug).ok_or_else(|| {
680        GwmError::Config(format!(
681          "tui.keys: unknown action {:?} (run `gwm tui keys` for the full list)",
682          action_slug
683        ))
684      })?;
685      let mut parsed = Vec::with_capacity(chord_strings.len());
686      for chord_str in &chord_strings {
687        let chord = KeyStroke::parse_chord(chord_str).map_err(|e| rewrap(&format!("tui.keys.{}", action_slug), e))?;
688        parsed.push(chord);
689      }
690      km.apply_override(action, parsed)
691        .map_err(|e| rewrap(&format!("tui.keys.{}", action_slug), e))?;
692    }
693    Ok(km)
694  }
695
696  /// Resolve the **contextual** modal keymap from the `[tui.keys.modal]`
697  /// sub-table, recursing into stages (`link.choose_target`, `config.edit`).
698  /// A missing `[tui.keys.modal]` table means no overrides — the built-in
699  /// defaults stand. The dedicated namespace (issue #219 review) keeps modal
700  /// contexts from colliding with same-named global actions during the
701  /// layered merge.
702  pub fn resolved_modal_keymap(&self) -> Result<crate::tui::modal_keymap::ModalKeymap> {
703    use crate::tui::modal_keymap::ModalKeymap;
704
705    let mut mk = ModalKeymap::defaults();
706    let Some(modal_val) = self.raw.get(TUI_KEYS_MODAL_NAMESPACE) else {
707      return Ok(mk);
708    };
709    let table = modal_val.as_table().ok_or_else(|| {
710      GwmError::Config(format!(
711        "tui.keys.modal: expected a table of modal contexts, got {}",
712        modal_val.type_str()
713      ))
714    })?;
715    for (ctx_key, value) in table {
716      match value {
717        toml::Value::Table(sub) => walk_modal_context(ctx_key, sub, &mut mk)?,
718        other => {
719          return Err(GwmError::Config(format!(
720            "tui.keys.modal.{}: expected a context table, got {}",
721            ctx_key,
722            other.type_str()
723          )))
724        }
725      }
726    }
727    Ok(mk)
728  }
729}
730
731/// Sub-table key under `[tui.keys]` that holds the contextual modal bindings
732/// (`[tui.keys.modal.<context>]`). See [`TuiKeysConfig::resolved_modal_keymap`].
733const TUI_KEYS_MODAL_NAMESPACE: &str = "modal";
734
735/// Extract a `["a", "b"]` chord list from a TOML array value, erroring on a
736/// non-string element. `coord` is the dotted `tui.keys.…` path for messages.
737fn as_chord_list(coord: &str, value: &toml::Value) -> Result<Vec<String>> {
738  let arr = value
739    .as_array()
740    .expect("as_chord_list called on a non-array — caller must match Value::Array first");
741  let mut out = Vec::with_capacity(arr.len());
742  for v in arr {
743    let s = v.as_str().ok_or_else(|| {
744      GwmError::Config(format!(
745        "tui.keys.{}: chord list must contain strings, got {}",
746        coord,
747        v.type_str()
748      ))
749    })?;
750    out.push(s.to_string());
751  }
752  Ok(out)
753}
754
755/// `true` when `path` is a non-leaf context *group* — i.e. some real
756/// context nests below it (`link` → `link.choose_target`). Used to give a
757/// precise "bind under a stage" error instead of "unknown context".
758fn is_modal_context_group(path: &str) -> bool {
759  let prefix = format!("{}.", path);
760  crate::tui::modal_keymap::KeyContext::all()
761    .iter()
762    .any(|c| c.config_path().starts_with(&prefix))
763}
764
765/// Walk one `[tui.keys.modal.<ctx_path>]` sub-table, applying every `verb = [keys]`
766/// entry to `mk` and recursing into nested stage sub-tables.
767fn walk_modal_context(
768  ctx_path: &str,
769  table: &toml::Table,
770  mk: &mut crate::tui::modal_keymap::ModalKeymap,
771) -> Result<()> {
772  use crate::tui::modal_keymap::{parse_single, KeyContext, ModalAction};
773
774  let ctx = KeyContext::from_config_path(ctx_path);
775  if ctx.is_none() && !is_modal_context_group(ctx_path) {
776    return Err(GwmError::Config(format!(
777      "tui.keys.modal.{}: unknown modal context (run `gwm tui keys` for the list)",
778      ctx_path
779    )));
780  }
781
782  for (key, value) in table {
783    match value {
784      toml::Value::Array(_) => {
785        let ctx = ctx.ok_or_else(|| {
786          GwmError::Config(format!(
787            "tui.keys.modal.{path}: {path:?} is a context group, not a leaf — bind under a stage (e.g. {path}.<stage>)",
788            path = ctx_path
789          ))
790        })?;
791        let coord = format!("modal.{}.{}", ctx_path, key);
792        let chords = as_chord_list(&coord, value)?;
793        let action = ModalAction::from_context_verb(ctx, key).ok_or_else(|| {
794          GwmError::Config(format!(
795            "tui.keys.modal.{}: unknown verb {:?} (run `gwm tui keys` for the list)",
796            ctx_path, key
797          ))
798        })?;
799        let mut parsed = Vec::with_capacity(chords.len());
800        for s in &chords {
801          parsed.push(parse_single(s).map_err(|e| rewrap(&coord, e))?);
802        }
803        mk.apply_override(action, parsed)
804          .map_err(|e| rewrap(&format!("tui.keys.modal.{}", ctx_path), e))?;
805      }
806      toml::Value::Table(sub) => {
807        let child = format!("{}.{}", ctx_path, key);
808        walk_modal_context(&child, sub, mk)?;
809      }
810      other => {
811        return Err(GwmError::Config(format!(
812          "tui.keys.modal.{}.{}: expected an array of keys or a sub-table, got {}",
813          ctx_path,
814          key,
815          other.type_str()
816        )))
817      }
818    }
819  }
820  Ok(())
821}
822
823/// Re-wrap a parser / keymap error so the user sees the `tui.keys.<coord>`
824/// coordinate rather than the bare `keymap:` prefix from the inner layer.
825fn rewrap(coord: &str, e: GwmError) -> GwmError {
826  let inner = match e {
827    GwmError::Config(msg) => msg,
828    other => other.to_string(),
829  };
830  GwmError::Config(format!("{}: {}", coord, inner))
831}
832
833/// `[theme]` block (issue #33) — role-based TUI colour scheme.
834///
835/// Two knobs:
836///
837/// - `preset` (optional string) — pick a built-in palette
838///   (`catppuccin`, `gruvbox`, `tokyo-night`). When absent, the
839///   resolved theme starts from [`crate::tui::theme::Theme::default`]
840///   (the pre-#33 hardcoded scheme).
841/// - Per-role keys (`focus`, `accent`, `branch`, …) — override the
842///   colour of a single role on top of the preset (or default).
843///   Recognised colours: named (`cyan`, `bright_blue`), indexed
844///   (`220`), or hex (`#89b4fa`).
845///
846/// Validation runs in `Config::load_for_repo` via
847/// [`Self::resolve`], so unknown presets, unknown roles, and bad
848/// colour values fail at load instead of silently picking the
849/// default colour at render time.
850#[derive(Debug, Clone, Default, Serialize, Deserialize)]
851pub struct ThemeConfig {
852  /// Optional preset name. `None` → start from the default scheme.
853  /// `Some("catppuccin")` → seed every role from that preset.
854  pub preset: Option<String>,
855  /// Per-role overrides. Keys must match an entry in
856  /// [`crate::tui::theme::Theme`]; values must parse via
857  /// [`crate::tui::theme::parse_color`].
858  #[serde(flatten)]
859  pub overrides: std::collections::BTreeMap<String, String>,
860}
861
862impl ThemeConfig {
863  /// Resolve this config into a [`crate::tui::theme::Theme`]:
864  ///
865  /// 1. Start from the preset if any (else default).
866  /// 2. Apply every per-role override on top.
867  ///
868  /// Returns `Err(GwmError::Config(_))` on unknown preset, unknown
869  /// role, or bad colour value.
870  pub fn resolve(&self) -> Result<crate::tui::theme::Theme> {
871    use crate::tui::theme::Theme;
872    let mut theme = match &self.preset {
873      Some(name) => Theme::preset(name).ok_or_else(|| {
874        let known = crate::tui::theme::preset_names().join(", ");
875        GwmError::Config(format!("theme.preset: unknown preset {:?} (known: {})", name, known))
876      })?,
877      None => Theme::default(),
878    };
879    for (role, value) in &self.overrides {
880      // `preset` lands in `overrides` via `#[serde(flatten)]` only if
881      // a user happens to also write `[theme] preset = "x"` (it
882      // doesn't — the dedicated field absorbs it first). Defensive
883      // guard anyway in case a future refactor moves the field.
884      if role == "preset" {
885        continue;
886      }
887      theme.apply_override(role, value)?;
888    }
889    Ok(theme)
890  }
891}
892
893/// `[tui.open]` — how the `o` key resolves the action on the selected
894/// worktree. Adds a configurable hook on top of the historical "reveal
895/// in OS file manager" so users with a worktree-heavy workflow can land
896/// in a shell or `$EDITOR` directly, sharing the spawn-and-restore
897/// lifecycle that `l: lazygit` already uses.
898#[derive(Debug, Clone, Default, Serialize, Deserialize)]
899#[serde(deny_unknown_fields)]
900pub struct TuiOpenConfig {
901  #[serde(default)]
902  pub mode: TuiOpenMode,
903  /// Override `$SHELL` when `mode = "shell"`. Falls back to `$SHELL`,
904  /// then `/bin/sh`. Empty TOML string reads as `None` so
905  /// `shell_cmd = ""` and an omitted key are observationally identical.
906  #[serde(default, deserialize_with = "deserialize_optional_non_empty")]
907  pub shell_cmd: Option<String>,
908  /// Override `$EDITOR` when `mode = "editor"`. Falls back to `$EDITOR`,
909  /// then `vi`. Same empty-string-as-unset convention as `shell_cmd`.
910  #[serde(default, deserialize_with = "deserialize_optional_non_empty")]
911  pub editor_cmd: Option<String>,
912}
913
914/// The three documented behaviours of the `o` key. Serialised in
915/// lowercase so `.gwm.toml` keys stay idiomatic (`mode = "shell"`); an
916/// unknown value is a hard config error surfaced by
917/// `Config::load_for_repo`, never silently coerced to a default.
918#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
919#[serde(rename_all = "lowercase")]
920pub enum TuiOpenMode {
921  /// Spawn an interactive shell with `cwd` set to the worktree
922  /// (lazygit-style suspend / spawn / restore). Default — matches the
923  /// "I want to do work in this worktree" intent of a worktree manager.
924  #[default]
925  Shell,
926  /// Spawn `$EDITOR <worktree-path>` and wait for it to exit. Useful
927  /// for drive-by edits without dropping into a full shell session.
928  Editor,
929  /// Pre-#73 behaviour: ask the OS to reveal the worktree directory
930  /// (`open` on macOS, `xdg-open` on Linux, `explorer` on Windows).
931  Finder,
932}
933
934/// Serde helper: treat an empty TOML string as `None`. Keeps
935/// `shell_cmd = ""` and an omitted key identical at the call site so
936/// the TUI never has to special-case the empty command.
937fn deserialize_optional_non_empty<'de, D>(d: D) -> std::result::Result<Option<String>, D::Error>
938where
939  D: serde::Deserializer<'de>,
940{
941  let opt = Option::<String>::deserialize(d)?;
942  Ok(opt.filter(|s| !s.is_empty()))
943}
944
945impl TuiConfig {
946  /// Documented range cap. Centralised so the TUI and the doctor share
947  /// the same clamp logic.
948  pub const MAX_CONFIRM_COUNTDOWN_SECS: u32 = 5;
949
950  /// Effective countdown value used by the TUI, clamped to
951  /// `[0, MAX_CONFIRM_COUNTDOWN_SECS]`. The raw field stays at the
952  /// user's value so a future doctor check can surface "your config
953  /// asked for X but we capped at 5".
954  pub fn effective_confirm_countdown_secs(&self) -> u32 {
955    self.confirm_countdown_secs.min(Self::MAX_CONFIRM_COUNTDOWN_SECS)
956  }
957}
958
959fn default_confirm_countdown_secs() -> u32 {
960  3
961}
962
963fn default_auto_refresh_secs() -> u64 {
964  60
965}
966
967/// Read a config file as a raw `toml::Value` (always a table at the
968/// document root). Kept separate from `toml::from_str::<Config>` so the
969/// two layers can be deep-merged at the value level before a single
970/// `deny_unknown_fields` deserialization runs on the result. Issue #190.
971fn read_config_value(path: &Path) -> Result<toml::Value> {
972  let raw = std::fs::read_to_string(path)?;
973  let val: toml::Value = toml::from_str(&raw)?;
974  Ok(val)
975}
976
977/// Deep-merge `over` onto `base`: two tables merge key-by-key
978/// recursively (so disjoint sections from both files coexist and a
979/// nested table override keeps the untouched sibling keys); for every
980/// other shape — scalars AND arrays — `over` wins wholesale. Arrays are
981/// intentionally replaced, never element-wise unioned, so a repo's
982/// `[[labels]]` fully supersedes the global set rather than producing a
983/// confusing concatenation. Issue #190.
984fn merge_toml(base: toml::Value, over: toml::Value) -> toml::Value {
985  match (base, over) {
986    (toml::Value::Table(mut b), toml::Value::Table(o)) => {
987      for (k, ov) in o {
988        let merged = match b.remove(&k) {
989          Some(bv) => merge_toml(bv, ov),
990          None => ov,
991        };
992        b.insert(k, merged);
993      }
994      toml::Value::Table(b)
995    }
996    (_, over) => over,
997  }
998}
999
1000/// Load a single top-level config section (e.g. `[exec]` / `[clean]`) from the
1001/// layered config (global `~/.config/gwm/config.toml` then repo `.gwm.toml`),
1002/// **tolerant of errors elsewhere** in the file but **strict on the section
1003/// itself**.
1004///
1005/// `gwm exec --profile` / `gwm clean` each consult exactly one section. An
1006/// unrelated problem — a stray top-level key, a semantic `[tui.keys]` error,
1007/// another section's shape — must not block them, so only the requested
1008/// subtree is deserialized; the rest is never validated. But the requested
1009/// section IS deserialized with its `deny_unknown_fields` / required-field
1010/// rules, so its OWN error still surfaces. That distinction matters for the
1011/// destructive `gwm clean`: a malformed `[clean.profiles.default]` must error
1012/// rather than silently revert to the built-in directory set (#324 review).
1013///
1014/// A missing section yields `T::default()`. A TOML *syntax* error (an
1015/// unreadable file) still surfaces — there is no section to read.
1016///
1017/// `repo_root` is `None` for a **bare** repo (no workdir, hence no repo
1018/// `.gwm.toml`): the repo layer is skipped, but the user-level GLOBAL config
1019/// is still read, so a global `[exec] jobs = N` applies even there (#324
1020/// review).
1021fn load_config_section<T>(repo_root: Option<&Path>, key: &str) -> Result<T>
1022where
1023  T: serde::de::DeserializeOwned + Default,
1024{
1025  load_config_section_layered(global_config_path().as_deref(), repo_root, key)
1026}
1027
1028/// Core of [`load_config_section`] with the global config path injected, so
1029/// the layering can be pinned by a test without touching the runner's real
1030/// `$HOME` / `$XDG_CONFIG_HOME`.
1031fn load_config_section_layered<T>(global: Option<&Path>, repo_root: Option<&Path>, key: &str) -> Result<T>
1032where
1033  T: serde::de::DeserializeOwned + Default,
1034{
1035  let global_val = match global {
1036    Some(p) if p.exists() => Some(read_config_value(p)?),
1037    _ => None,
1038  };
1039  let repo_val = match repo_root {
1040    Some(root) => {
1041      let repo_path = root.join(CONFIG_FILE);
1042      if repo_path.exists() {
1043        Some(read_config_value(&repo_path)?)
1044      } else {
1045        None
1046      }
1047    }
1048    None => None,
1049  };
1050  let merged = match (global_val, repo_val) {
1051    (None, None) => return Ok(T::default()),
1052    (Some(g), None) => g,
1053    (None, Some(r)) => r,
1054    (Some(g), Some(r)) => merge_toml(g, r),
1055  };
1056  match merged.get(key) {
1057    Some(section) => section
1058      .clone()
1059      .try_into()
1060      .map_err(|e| GwmError::Config(format!("invalid `[{key}]` config: {e}"))),
1061    None => Ok(T::default()),
1062  }
1063}
1064
1065/// Flatten a (possibly nested) TOML value into `key = display` rows,
1066/// dotted for tables and `name[i]` for arrays-of-tables, leaving scalar
1067/// leaves as-is. Shared by `gwm config list` (the CLI surface) and the
1068/// in-TUI Configuration panel (issue #232) so neither can drift from the
1069/// other's key shape. Pure — pushes onto `rows` in table-iteration order
1070/// (`toml::Value::Table` is a `BTreeMap`, so keys come out sorted).
1071pub(crate) fn flatten_value(prefix: &str, value: &toml::Value, rows: &mut Vec<(String, String)>) {
1072  match value {
1073    toml::Value::Table(table) => {
1074      for (key, value) in table {
1075        let next = if prefix.is_empty() {
1076          key.to_string()
1077        } else {
1078          format!("{}.{}", prefix, key)
1079        };
1080        flatten_value(&next, value, rows);
1081      }
1082    }
1083    toml::Value::Array(values) if values.iter().all(toml::Value::is_table) => {
1084      for (i, value) in values.iter().enumerate() {
1085        flatten_value(&format!("{}[{}]", prefix, i), value, rows);
1086      }
1087    }
1088    _ => rows.push((prefix.to_string(), format_list_value(value))),
1089  }
1090}
1091
1092/// Which configuration layer a resolved value came from (issue #232).
1093/// Ordered by precedence so the panel can colour the strongest source
1094/// distinctly: a repo `.gwm.toml` overrides the user-level global, which
1095/// overrides the built-in defaults.
1096#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1097pub enum ConfigSource {
1098  /// The value is a built-in default — set in neither config file.
1099  Default,
1100  /// The value comes from the user-level global config
1101  /// (`~/.config/gwm/config.toml`).
1102  User,
1103  /// The value comes from the repo's `.gwm.toml`.
1104  Repo,
1105}
1106
1107impl ConfigSource {
1108  /// Stable lowercase label for the panel's source column and tests.
1109  pub fn label(self) -> &'static str {
1110    match self {
1111      ConfigSource::Default => "default",
1112      ConfigSource::User => "user",
1113      ConfigSource::Repo => "repo",
1114    }
1115  }
1116}
1117
1118/// One resolved configuration row: the flattened `key`, its `value`
1119/// rendered exactly as `gwm config list` prints it, and the layer that
1120/// `source`d it. Consumed by the in-TUI Configuration panel (issue #232).
1121#[derive(Debug, Clone, PartialEq, Eq)]
1122pub struct ConfigRow {
1123  pub key: String,
1124  pub value: String,
1125  pub source: ConfigSource,
1126}
1127
1128/// Flatten the raw (un-defaulted) TOML at `path` into the set of keys it
1129/// literally declares — the membership probe behind source attribution.
1130/// An absent file contributes no keys (every value then comes from a
1131/// lower layer). Issue #232.
1132fn declared_keys(path: &Path) -> Result<std::collections::HashSet<String>> {
1133  if !path.exists() {
1134    return Ok(std::collections::HashSet::new());
1135  }
1136  let value = read_config_value(path)?;
1137  let mut rows = Vec::new();
1138  flatten_value("", &value, &mut rows);
1139  Ok(rows.into_iter().map(|(key, _)| key).collect())
1140}
1141
1142/// Resolve the effective configuration the way `gwm config list` does
1143/// (user-level global deep-merged under the repo `.gwm.toml`, defaults
1144/// filled), then attribute each flattened key to the layer that provided
1145/// it — repo over user over default. `global_path` is injected so the
1146/// attribution can be pinned by a test without touching the real
1147/// `$HOME` / `$XDG_CONFIG_HOME` (mirrors [`Config::load_layered`]).
1148/// Issue #232.
1149pub fn resolved_rows(repo_root: &Path, global_path: Option<&Path>) -> Result<Vec<ConfigRow>> {
1150  // The merged + defaulted config, serialised back to a value so the
1151  // key/value shape is identical to `gwm config list`.
1152  let cfg = Config::load_layered(repo_root, global_path)?;
1153  let value = toml::Value::try_from(cfg).map_err(|e| GwmError::Config(e.to_string()))?;
1154  let mut flat = Vec::new();
1155  flatten_value("", &value, &mut flat);
1156
1157  // Per-layer declared keys probe which layer owns each merged key. Both
1158  // layers and the merged value flow through the same `flatten_value`, so
1159  // a key present in a layer's raw file matches the merged key verbatim.
1160  let repo_keys = declared_keys(&repo_root.join(CONFIG_FILE))?;
1161  let user_keys = match global_path {
1162    Some(p) => declared_keys(p)?,
1163    None => std::collections::HashSet::new(),
1164  };
1165
1166  Ok(
1167    flat
1168      .into_iter()
1169      .map(|(key, value)| {
1170        let source = if repo_keys.contains(&key) {
1171          ConfigSource::Repo
1172        } else if user_keys.contains(&key) {
1173          ConfigSource::User
1174        } else {
1175          ConfigSource::Default
1176        };
1177        ConfigRow { key, value, source }
1178      })
1179      .collect(),
1180  )
1181}
1182
1183/// Render a single TOML scalar (or non-table aggregate) the way
1184/// `gwm config list` does: strings quoted, scalars bare, arrays/tables
1185/// via their `Display`. Shared with the Configuration panel (issue #232).
1186pub(crate) fn format_list_value(value: &toml::Value) -> String {
1187  match value {
1188    toml::Value::String(s) => format!("{:?}", s),
1189    toml::Value::Integer(i) => i.to_string(),
1190    toml::Value::Float(f) => f.to_string(),
1191    toml::Value::Boolean(b) => b.to_string(),
1192    toml::Value::Datetime(d) => d.to_string(),
1193    toml::Value::Array(_) | toml::Value::Table(_) => value.to_string(),
1194  }
1195}
1196
1197/// On-disk location of the user-level global config under a given
1198/// XDG config-home directory: `<config_home>/gwm/config.toml`. Pure
1199/// (no env / FS access) so the path contract is unit-testable. Issue
1200/// #190.
1201pub fn global_config_path_in(config_home: &Path) -> PathBuf {
1202  config_home.join("gwm").join("config.toml")
1203}
1204
1205/// Resolve the user-level global config path, honouring
1206/// `$XDG_CONFIG_HOME` first and falling back to `dirs::config_dir()` —
1207/// the same resolution order as `~/.config/gwm/aliases.toml` and the
1208/// trust ledger. Returns `None` on systems where neither resolves
1209/// (sandboxed CI / containers without `$HOME`), in which case loading
1210/// degrades to repo-only. Issue #190.
1211pub fn global_config_path() -> Option<PathBuf> {
1212  // Opt-out: `GWM_NO_GLOBAL_CONFIG=1` reports no global path, forcing
1213  // repo-only loading. `load_for_repo` reads the real user-level file,
1214  // so this keeps `cargo test` / CI deterministic on a machine that
1215  // happens to have a `~/.config/gwm/config.toml`, and lets a user pin
1216  // strictly repo-local config. Uses the same truthy parsing as the
1217  // other `GWM_*` flags (`GWM_ALLOW_BOOTSTRAP`). Issue #190.
1218  if crate::trust::env_truthy("GWM_NO_GLOBAL_CONFIG") {
1219    return None;
1220  }
1221  if let Ok(xdg) = std::env::var("XDG_CONFIG_HOME") {
1222    if !xdg.is_empty() {
1223      return Some(global_config_path_in(Path::new(&xdg)));
1224    }
1225  }
1226  dirs::config_dir().map(|p| global_config_path_in(&p))
1227}
1228
1229impl Config {
1230  /// Look for `.gwm.toml` in the given repo root, layered over the
1231  /// user-level global config at [`global_config_path`] (issue #190).
1232  /// Falls back to defaults when neither exists.
1233  pub fn load_for_repo(repo_root: &Path) -> Result<Self> {
1234    Self::load_layered(repo_root, global_config_path().as_deref())
1235  }
1236
1237  /// Load just the `[exec]` section (layered global → repo), tolerant of
1238  /// errors elsewhere in the config but strict on `[exec]` itself. Used by
1239  /// `gwm exec --profile` so an unrelated `.gwm.toml` problem doesn't block
1240  /// it. See [`load_config_section`].
1241  ///
1242  /// "Strict on itself" means EVERY `[exec.profiles.*]` is validated (not just
1243  /// the one the command selects), so `gwm exec --profile good` rejects the
1244  /// same file `Config::load_for_repo` / `gwm config validate` / doctor reject
1245  /// — a sibling profile's semantic error can't pass on the command path only.
1246  pub fn load_exec_config(repo_root: &Path) -> Result<ExecConfig> {
1247    let cfg: ExecConfig = load_config_section(Some(repo_root), "exec")?;
1248    for (name, p) in &cfg.profiles {
1249      crate::exec::validate_exec_profile_command(name, &p.command)?;
1250    }
1251    Ok(cfg)
1252  }
1253
1254  /// Read ONLY the `[exec] jobs` default (issue #324), without validating the
1255  /// `[exec.profiles.*]` semantics. Used by inline `gwm exec -- <cmd>` (no
1256  /// `--profile`, no `--jobs`) which needs the parallelism default but uses no
1257  /// profile — so a sibling profile's *semantic* issue must not block it. A
1258  /// shape error in `[exec]` (unknown field, wrong type) still surfaces.
1259  ///
1260  /// `repo_root` is `None` for a bare repo (no workdir): the repo `.gwm.toml`
1261  /// is skipped but the GLOBAL `[exec] jobs` still applies.
1262  pub fn load_exec_jobs_default(repo_root: Option<&Path>) -> Result<Option<u32>> {
1263    let cfg: ExecConfig = load_config_section(repo_root, "exec")?;
1264    Ok(cfg.jobs)
1265  }
1266
1267  /// Like [`Self::load_exec_jobs_default`] but with the global config path
1268  /// injected, so the global-vs-repo layering can be pinned by a test without
1269  /// touching the runner's real `$HOME` / `$XDG_CONFIG_HOME`.
1270  pub fn load_exec_jobs_default_layered(global: Option<&Path>, repo_root: Option<&Path>) -> Result<Option<u32>> {
1271    let cfg: ExecConfig = load_config_section_layered(global, repo_root, "exec")?;
1272    Ok(cfg.jobs)
1273  }
1274
1275  /// Load just the `[clean]` section (layered global → repo), tolerant of
1276  /// errors elsewhere but strict on `[clean]` itself. Used by `gwm clean` so
1277  /// an unrelated `.gwm.toml` problem doesn't block the built-in clean, while
1278  /// a malformed `[clean.profiles.default]` still errors rather than silently
1279  /// reverting to the built-in set before a destructive `--yes`. See
1280  /// [`load_config_section`].
1281  ///
1282  /// As with [`Self::load_exec_config`], EVERY `[clean.profiles.*]` is
1283  /// validated — a sibling profile that escapes the worktree can't slip
1284  /// through `gwm clean --profile good` while `gwm config validate` rejects it.
1285  ///
1286  /// `repo_root` is `None` for a bare repo (no workdir): the repo `.gwm.toml`
1287  /// is skipped but the GLOBAL `[clean]` section still applies (the built-ins
1288  /// are used when no `default` profile is defined).
1289  pub fn load_clean_config(repo_root: Option<&Path>) -> Result<CleanConfig> {
1290    let cfg: CleanConfig = load_config_section(repo_root, "clean")?;
1291    for (name, p) in &cfg.profiles {
1292      crate::clean::validate_clean_profile_dirs(name, &p.dirs)?;
1293    }
1294    Ok(cfg)
1295  }
1296
1297  /// Load the effective config by deep-merging the user-level global
1298  /// config (`global_path`, the base) under the repo's `.gwm.toml`
1299  /// (the override). Issue #190.
1300  ///
1301  /// Merge rule: the repo wins on conflicting scalars; tables merge
1302  /// key-by-key recursively; arrays are replaced wholesale by the repo
1303  /// when present. Validation runs on the merged result, so a bad
1304  /// value from either layer fails at load. When neither file exists
1305  /// the bare default is returned — identical to the pre-#190
1306  /// behaviour, which the absent-global case preserves byte-for-byte.
1307  ///
1308  /// `global_path` is injected (rather than resolved internally) so
1309  /// the merge contract can be pinned by a test without touching the
1310  /// runner's real `$HOME` / `$XDG_CONFIG_HOME`.
1311  pub fn load_layered(repo_root: &Path, global_path: Option<&Path>) -> Result<Self> {
1312    let cfg = Self::merge_layered(repo_root, global_path)?;
1313    cfg.validate_branch_types()?;
1314    cfg.validate_bootstrap_paths()?;
1315    cfg.validate_bootstrap_guards()?;
1316    cfg.validate_labels()?;
1317    cfg.validate_aliases()?;
1318    cfg.validate_tui_keys()?;
1319    cfg.validate_theme()?;
1320    cfg.validate_profiles()?;
1321    Ok(cfg)
1322  }
1323
1324  /// Reject semantically invalid `[exec.profiles.*]` / `[clean.profiles.*]`
1325  /// entries — an empty exec `command`, or a clean `dirs` entry that escapes
1326  /// the worktree (absolute, `..`, `.`/root, nested) — at config-load time, so
1327  /// `gwm config validate` / `gwm doctor` reject exactly what `gwm exec
1328  /// --profile` / `gwm clean` would (issue #324 review). The per-command
1329  /// resolvers share the same validators, so the two paths can't drift.
1330  pub(crate) fn validate_profiles(&self) -> Result<()> {
1331    for (name, p) in &self.exec.profiles {
1332      crate::exec::validate_exec_profile_command(name, &p.command)?;
1333    }
1334    for (name, p) in &self.clean.profiles {
1335      crate::clean::validate_clean_profile_dirs(name, &p.dirs)?;
1336    }
1337    Ok(())
1338  }
1339
1340  /// Build the effective (deep-merged) config from disk **without** running
1341  /// the semantic validators. Same merge rule as [`Self::load_layered`]; only
1342  /// the TOML structure must be sound (a parse / shape error still fails).
1343  ///
1344  /// `gwm doctor` uses this to re-check one section against the real on-disk
1345  /// config even when the lenient `repo_context_lenient` defaulted the whole
1346  /// config away after `load_for_repo` rejected it — otherwise a check would
1347  /// validate the default and mask the very error it promises to surface
1348  /// (issue #219 review).
1349  pub(crate) fn merge_layered(repo_root: &Path, global_path: Option<&Path>) -> Result<Self> {
1350    let repo_path = repo_root.join(CONFIG_FILE);
1351    let global_val = match global_path {
1352      Some(p) if p.exists() => Some(read_config_value(p)?),
1353      _ => None,
1354    };
1355    let repo_val = if repo_path.exists() {
1356      Some(read_config_value(&repo_path)?)
1357    } else {
1358      None
1359    };
1360
1361    Ok(match (global_val, repo_val) {
1362      (None, None) => Self::default(),
1363      (Some(g), None) => g.try_into()?,
1364      (None, Some(r)) => r.try_into()?,
1365      (Some(g), Some(r)) => merge_toml(g, r).try_into()?,
1366    })
1367  }
1368
1369  /// Reject `[tui.keys]` entries that name an unknown action, list a
1370  /// chord that does not parse, or create a conflict / prefix
1371  /// collision with another binding (issue #87). Delegates to
1372  /// [`TuiKeysConfig::resolved_keymap`] which does the full layering +
1373  /// validation in one pass — the resolved keymap is discarded here
1374  /// (it's rebuilt by the TUI at startup); the call is only kept for
1375  /// its error side-effects.
1376  pub(crate) fn validate_tui_keys(&self) -> Result<()> {
1377    // Global verbs (array entries) and contextual modal verbs (table
1378    // entries) are validated in one pass each; the resolved keymaps are
1379    // discarded — they're rebuilt by the TUI at startup.
1380    self.tui.keys.resolved_keymap().map(|_| ())?;
1381    self.tui.keys.resolved_modal_keymap().map(|_| ())
1382  }
1383
1384  /// Reject `[theme]` entries that name an unknown preset, unknown
1385  /// role, or unparsable colour value (issue #33). Delegates to
1386  /// [`ThemeConfig::resolve`] which does the full preset + override
1387  /// pass in one shot. The resolved theme is discarded here — the
1388  /// TUI rebuilds it at startup; the call is kept only for its
1389  /// error side-effects.
1390  pub(crate) fn validate_theme(&self) -> Result<()> {
1391    self.theme.resolve().map(|_| ())
1392  }
1393
1394  /// Reject `[aliases]` entries that shadow built-in subcommands, are
1395  /// empty, or contain shell pipeline metachars (issue #86). Delegates
1396  /// to [`crate::aliases::validate_aliases`] so the same rules apply
1397  /// symmetrically to the user-level `~/.config/gwm/aliases.toml`.
1398  pub(crate) fn validate_aliases(&self) -> Result<()> {
1399    crate::aliases::validate_aliases(&self.aliases, ".gwm.toml `[aliases]`")
1400  }
1401
1402  /// Reject `[[labels]]` entries whose `name` would be parsed as a flag
1403  /// by `gh label create` (or violate GitHub's naming rules). Delegates
1404  /// per-entry validation to [`crate::labels::validate_label_name`]; the
1405  /// error here prefixes the offending entry index so the user can
1406  /// locate the TOML coordinate without grepping the file (issue #100).
1407  ///
1408  /// The inner error is unwrapped from its `GwmError::Config` wrapping
1409  /// before being re-wrapped with the entry index — otherwise the
1410  /// `Display` impl reads `config error: labels[<i>]: config error:
1411  /// labels: …` with the prefix echoed twice, which is what the user
1412  /// actually sees on stderr.
1413  pub(crate) fn validate_labels(&self) -> Result<()> {
1414    for (i, l) in self.labels.iter().enumerate() {
1415      crate::labels::validate_label_name(&l.name).map_err(|e| {
1416        let inner = match e {
1417          GwmError::Config(msg) => msg,
1418          other => other.to_string(),
1419        };
1420        GwmError::Config(format!("labels[{}]: {}", i, inner))
1421      })?;
1422    }
1423    Ok(())
1424  }
1425
1426  /// Pre-compile every `[[bootstrap.guard]].deny_patterns` entry so a
1427  /// malformed regex surfaces at config load instead of being silently
1428  /// dropped at evaluation time (issue #96).
1429  ///
1430  /// Historically `bootstrap.rs::guard_match` wrapped `Regex::new(pat)`
1431  /// in `if let Ok(re) = …`, which made a guard fail-open whenever one
1432  /// of its patterns failed to compile: the bad pattern vanished and
1433  /// the surviving patterns evaluated against the file as if nothing
1434  /// was wrong. A refusal mechanism that silently refuses to refuse is
1435  /// strictly worse than no mechanism — the user reads "guard passed"
1436  /// and trusts a file that never went through the rule it was meant
1437  /// to be filtered by.
1438  ///
1439  /// The compiled regexes are deliberately discarded here: the goal
1440  /// of this validator is to fail fast at load time, and caching a
1441  /// `Vec<Regex>` on the `Guard` struct would force `#[serde(skip)]`
1442  /// gymnastics on a type that round-trips through TOML.
1443  ///
1444  /// **Trust boundary**: `Config::load_for_repo` is the primary
1445  /// chokepoint this validator protects. `bootstrap::guard_match`
1446  /// holds the matching defence-in-depth for `Config` values that
1447  /// bypass the loader (test fixtures, programmatic constructors,
1448  /// future APIs): a runtime `Regex::new` failure surfaces as a
1449  /// `StepStatus::Failed` step and refuses the copy, instead of
1450  /// silently dropping the pattern as the original #96 fail-open
1451  /// did.
1452  pub fn validate_bootstrap_guards(&self) -> Result<()> {
1453    for (gi, g) in self.bootstrap.guard.iter().enumerate() {
1454      for (pi, pat) in g.deny_patterns.iter().enumerate() {
1455        regex::Regex::new(pat).map_err(|e| {
1456          // Include the guard index AND the pattern index so a `.gwm.toml`
1457          // with five guards × five patterns surfaces "bootstrap.guard[3].
1458          // deny_patterns[1]" — the exact TOML coordinate — instead of
1459          // forcing the user to grep for the pattern content. Mirrors the
1460          // shape used by `validate_bootstrap_paths` (e.g.
1461          // "bootstrap.copy[0].to").
1462          GwmError::Config(format!(
1463            "bootstrap.guard[{}].deny_patterns[{}] '{}': invalid pattern {:?} — regex: {}",
1464            gi, pi, g.name, pat, e
1465          ))
1466        })?;
1467      }
1468    }
1469    Ok(())
1470  }
1471
1472  /// Reject `..` components and absolute paths in bootstrap path
1473  /// fields (issue #94). The runtime guard in `bootstrap::run_copies`
1474  /// is the last line of defence; this check surfaces violations with
1475  /// the TOML key in the error rather than failing mid-bootstrap.
1476  ///
1477  /// Three fields are validated:
1478  ///   - `bootstrap.copy[].to` — write target inside the worktree
1479  ///   - `bootstrap.guard[].example_file` — read source inside main repo
1480  ///   - `bootstrap.fallback.<key>.target` — declarative today, but a
1481  ///     `..` there still misrepresents intent and is rejected for
1482  ///     consistency with the other two
1483  ///
1484  /// `bootstrap.copy[].from` is intentionally NOT validated here: it
1485  /// is joined onto `ctx.main_repo` (the repo root that ships the
1486  /// config), so traversal there is bounded by who can edit the
1487  /// `.gwm.toml` itself — same trust boundary as for the rest of
1488  /// the file.
1489  ///
1490  /// **Trust-boundary note (revisit with #95)**: once the TOFU prompt
1491  /// on `.gwm.toml` lands, `.gwm.toml` may be sourced from a less
1492  /// trusted location (e.g. a freshly cloned hostile main repo
1493  /// during the first `gwm bootstrap`). At that point the `from`
1494  /// trust assumption no longer holds and this validator should be
1495  /// extended symmetrically — `check_relative_no_traversal` already
1496  /// accepts an arbitrary field label and is ready for it.
1497  pub(crate) fn validate_bootstrap_paths(&self) -> Result<()> {
1498    for (i, c) in self.bootstrap.copy.iter().enumerate() {
1499      check_relative_no_traversal(&c.to, &format!("bootstrap.copy[{}].to", i))?;
1500    }
1501    for (i, g) in self.bootstrap.guard.iter().enumerate() {
1502      if let Some(ex) = &g.example_file {
1503        check_relative_no_traversal(ex, &format!("bootstrap.guard[{}].example_file", i))?;
1504      }
1505    }
1506    for (key, fb) in &self.bootstrap.fallback {
1507      check_relative_no_traversal(&fb.target, &format!("bootstrap.fallback.{}.target", key))?;
1508    }
1509    Ok(())
1510  }
1511
1512  /// Validate `[[branch_types]]` entries on load so a malformed config
1513  /// surfaces a clear error at startup instead of failing downstream in
1514  /// `parse_branch` / git itself with a cryptic message. Rules:
1515  ///   - `name` must be non-empty
1516  ///   - `name` must match `^[a-z]+$` (the regex `parse_branch` uses
1517  ///     for the type segment of a gwm-style branch name)
1518  ///   - `name`s must be unique across the table — duplicates would
1519  ///     silently override each other under `serde`'s `Vec` decoding
1520  ///     and make the resolved list non-deterministic
1521  pub(crate) fn validate_branch_types(&self) -> Result<()> {
1522    let name_re = regex::Regex::new(r"^[a-z]+$").expect("static regex compiles");
1523    let mut seen: std::collections::HashSet<&str> = std::collections::HashSet::new();
1524    for entry in &self.branch_types {
1525      if entry.name.is_empty() {
1526        return Err(GwmError::Config(
1527          "branch_types: entry has empty `name`; use a lowercase ASCII alpha token (e.g. \"feat\")".into(),
1528        ));
1529      }
1530      if !name_re.is_match(&entry.name) {
1531        return Err(GwmError::Config(format!(
1532          "branch_types: invalid `name = \"{}\"`; must match ^[a-z]+$ to be a valid branch-prefix \
1533           (lowercase letters only, no digits, no dashes — git refs and `parse_branch` rely on this)",
1534          entry.name
1535        )));
1536      }
1537      if !seen.insert(entry.name.as_str()) {
1538        return Err(GwmError::Config(format!(
1539          "branch_types: duplicate entry for `name = \"{}\"` — each branch type must be declared at most once",
1540          entry.name
1541        )));
1542      }
1543    }
1544    Ok(())
1545  }
1546
1547  /// Write a default config to the given repo root.
1548  pub fn write_default(repo_root: &Path) -> Result<PathBuf> {
1549    Self::write_preset(repo_root, crate::presets::GENERIC_BODY)
1550  }
1551
1552  /// Write a `.gwm.toml` body (a built-in preset, see [`crate::presets`])
1553  /// to the repo root, refusing to clobber an existing file. Factored out
1554  /// of [`Self::write_default`] so `gwm init --preset <name>` seeds a
1555  /// stack-specific template through the same idempotency guard.
1556  pub fn write_preset(repo_root: &Path, body: &str) -> Result<PathBuf> {
1557    let target = repo_root.join(CONFIG_FILE);
1558    if target.exists() {
1559      return Err(GwmError::Config(format!("{} already exists", target.display())));
1560    }
1561    std::fs::write(&target, body)?;
1562    Ok(target)
1563  }
1564
1565  pub fn guard_by_name(&self, name: &str) -> Option<&Guard> {
1566    self.bootstrap.guard.iter().find(|g| g.name == name)
1567  }
1568
1569  /// Single lookup site for the allowed branch types. Returns the
1570  /// `[[branch_types]]` block from `.gwm.toml` when present, falling
1571  /// back to [`crate::naming::default_branch_types`] otherwise. Used
1572  /// by `BranchSpec::validate`, `gwm types`, the TUI create picker
1573  /// (and, future-pending, the pre-commit hook) so the list stays
1574  /// consistent across surfaces.
1575  pub fn resolved_branch_types(&self) -> ResolvedBranchTypes {
1576    if self.branch_types.is_empty() {
1577      ResolvedBranchTypes {
1578        types: crate::naming::default_branch_types(),
1579        source: BranchTypesSource::Default,
1580      }
1581    } else {
1582      ResolvedBranchTypes {
1583        types: self.branch_types.clone(),
1584        source: BranchTypesSource::Config,
1585      }
1586    }
1587  }
1588}
1589
1590/// `[git_tui]` table — drives the `l` keybinding in the TUI worktree list
1591/// (issue #75). Absent ⇒ legacy default `lazygit -p {path}` with
1592/// fullscreen=true, so no `.gwm.toml` change is required for repos that
1593/// were happy with the previous behaviour. Sharing `[`ReviewConfig`]`'s
1594/// shape (placeholder expansion + `fullscreen` flag) keeps the user's
1595/// mental model consistent across the two launcher keybindings.
1596#[derive(Debug, Clone, Default, Serialize, Deserialize)]
1597#[serde(deny_unknown_fields)]
1598pub struct GitTuiConfig {
1599  /// Shell line. Accepts the `{path}` placeholder. When `None`, the
1600  /// resolved launcher uses `lazygit -p {path}`.
1601  #[serde(default)]
1602  pub command: Option<String>,
1603  /// Whether gwm should suspend its own TUI before exec'ing the command.
1604  /// Defaults to `true` for TUI tools like lazygit / gitui / tig; set to
1605  /// `false` to launch e.g. a GUI editor that should run alongside gwm.
1606  #[serde(default)]
1607  pub fullscreen: Option<bool>,
1608}
1609
1610impl GitTuiConfig {
1611  /// Resolve to a concrete `(command, fullscreen)` pair. The default
1612  /// (no `[git_tui]` block in `.gwm.toml`) is `lazygit -p {path}` so the
1613  /// `l` keybinding behaves exactly as it did before issue #75 landed.
1614  pub fn resolved(&self) -> ResolvedLauncher {
1615    ResolvedLauncher {
1616      command: self.command.clone().unwrap_or_else(|| "lazygit -p {path}".into()),
1617      fullscreen: self.fullscreen.unwrap_or(true),
1618    }
1619  }
1620}
1621
1622/// `[review]` table — drives the `R` keybinding in the TUI worktree list
1623/// (issue #75). The user picks one of three forms:
1624///
1625/// - `command = "<shell line>"` — the primary contract; any CLI on $PATH
1626///   with any arguments. Placeholders `{base} {head} {path} {diff}` are
1627///   substituted before the shell line is split with `shell-words`.
1628/// - `tool = "<preset>"` — sugar for a built-in `(command, fullscreen)`
1629///   pair (see [`ReviewConfig::resolved`]).
1630/// - neither — the `R` key is inert and the status bar carries a hint.
1631///
1632/// When both are set, `command` wins (and the TUI surfaces a status-bar
1633/// hint at startup so the user notices their `tool` choice is shadowed).
1634#[derive(Debug, Clone, Serialize, Deserialize)]
1635#[serde(deny_unknown_fields)]
1636pub struct ReviewConfig {
1637  /// Shell line. Accepts `{base} {head} {path} {diff}` placeholders.
1638  #[serde(default)]
1639  pub command: Option<String>,
1640  /// Whether gwm should suspend its own TUI before exec'ing the command.
1641  /// Defaults to `false` so non-TUI tools (a linter, `gh pr view --web`)
1642  /// don't black-out the screen.
1643  #[serde(default)]
1644  pub fullscreen: Option<bool>,
1645  /// Preset name; one of `lumen`, `claude`, `codex`, `aider`, `gh`.
1646  /// Resolved to a `(command, fullscreen)` pair by [`ReviewConfig::resolved`].
1647  #[serde(default)]
1648  pub tool: Option<String>,
1649  /// Skip the shell-out when `git rev-list --count {base}..{head} == 0`.
1650  /// Default `true`.
1651  #[serde(default = "default_skip_when_no_changes")]
1652  pub skip_when_no_changes: bool,
1653  /// Optional pin for the review base ref. Slots into the base-
1654  /// resolution chain *after* `branch.<n>.merge` (upstream) and
1655  /// `branch.<n>.gwm-base`, and *before* the static `dev` / `main`
1656  /// fallback. Setting it overrides only the `dev` / `main` step —
1657  /// upstream and gwm-base still win when present. See
1658  /// [`crate::launcher::resolve_review_base`] for the canonical
1659  /// order.
1660  #[serde(default)]
1661  pub default_base: Option<String>,
1662}
1663
1664impl Default for ReviewConfig {
1665  fn default() -> Self {
1666    Self {
1667      command: None,
1668      fullscreen: None,
1669      tool: None,
1670      skip_when_no_changes: default_skip_when_no_changes(),
1671      default_base: None,
1672    }
1673  }
1674}
1675
1676fn default_skip_when_no_changes() -> bool {
1677  true
1678}
1679
1680/// Reject empty strings, absolute paths, Windows drive prefixes and
1681/// `..` traversal segments in bootstrap path fields (issue #94). The
1682/// field name is woven into the error message so the user can
1683/// pinpoint the offending TOML key. The wording stays neutral
1684/// ("base directory") because callers use this helper for both
1685/// `worktree`-relative (`copy.to`, `fallback.target`) and
1686/// `main_repo`-relative (`guard.example_file`) fields.
1687fn check_relative_no_traversal(value: &str, field: &str) -> Result<()> {
1688  if value.is_empty() {
1689    return Err(GwmError::Config(format!(
1690      "{}: empty path is not a valid bootstrap target",
1691      field
1692    )));
1693  }
1694  let p = Path::new(value);
1695  if p.is_absolute() {
1696    return Err(GwmError::Config(format!(
1697      "{}: {:?} is an absolute path — only relative paths under the base directory are allowed",
1698      field, value
1699    )));
1700  }
1701  // `Component::Prefix` covers Windows drive-relative paths like
1702  // `C:foo` which are NOT absolute (per `Path::is_absolute`) yet
1703  // make `PathBuf::join` drop the base. Unreachable on Unix, so
1704  // this is a defence-in-depth rejection for Windows targets.
1705  for comp in p.components() {
1706    match comp {
1707      std::path::Component::ParentDir => {
1708        return Err(GwmError::Config(format!(
1709          "{}: {:?} contains '..' traversal — only relative paths under the base directory are allowed",
1710          field, value
1711        )));
1712      }
1713      std::path::Component::Prefix(_) => {
1714        return Err(GwmError::Config(format!(
1715          "{}: {:?} contains a Windows drive prefix — only relative paths under the base directory are allowed",
1716          field, value
1717        )));
1718      }
1719      _ => {}
1720    }
1721  }
1722  Ok(())
1723}
1724
1725impl ReviewConfig {
1726  /// Resolve the user's choice to a concrete `(command, fullscreen)`
1727  /// pair, or `None` when neither `command` nor a recognised `tool` was
1728  /// set. The `command` field wins when both are present.
1729  pub fn resolved(&self) -> Option<ResolvedLauncher> {
1730    if let Some(cmd) = self.command.as_ref().filter(|s| !s.trim().is_empty()) {
1731      return Some(ResolvedLauncher {
1732        command: cmd.clone(),
1733        fullscreen: self.fullscreen.unwrap_or(false),
1734      });
1735    }
1736    let tool = self.tool.as_deref()?.trim();
1737    if tool.is_empty() {
1738      return None;
1739    }
1740    let (cmd, fullscreen_default) = review_tool_preset(tool)?;
1741    Some(ResolvedLauncher {
1742      command: cmd.into(),
1743      fullscreen: self.fullscreen.unwrap_or(fullscreen_default),
1744    })
1745  }
1746
1747  /// True when `command` and `tool` are both set — the TUI uses this to
1748  /// surface a one-shot warning ("your `tool = X` is shadowed by
1749  /// `command = Y`") on first render.
1750  pub fn has_shadowed_tool(&self) -> bool {
1751    self.command.as_ref().is_some_and(|s| !s.trim().is_empty())
1752      && self.tool.as_ref().is_some_and(|s| !s.trim().is_empty())
1753  }
1754}
1755
1756/// Built-in preset table for `[review].tool`. Returns `Some((command,
1757/// fullscreen))` for known tools, `None` otherwise.
1758///
1759/// The table is the canonical place to add new presets; it's exposed
1760/// (via [`ReviewConfig::resolved`]) so docs and `gwm doctor` can refer
1761/// to a single source of truth instead of duplicating the strings.
1762pub fn review_tool_preset(tool: &str) -> Option<(&'static str, bool)> {
1763  Some(match tool {
1764    "lumen" => ("lumen diff {base}..{head}", true),
1765    "claude" => ("claude --print 'review the diff {base}..{head}'", false),
1766    "codex" => ("codex review {base}..{head}", false),
1767    "aider" => ("aider --message 'review {base}..{head}'", true),
1768    "gh" => ("gh pr view --web", false),
1769    _ => return None,
1770  })
1771}
1772
1773/// Concrete `(command, fullscreen)` pair derived from a [`GitTuiConfig`]
1774/// or [`ReviewConfig`] by [`GitTuiConfig::resolved`] /
1775/// [`ReviewConfig::resolved`]. The launcher then expands placeholders
1776/// in `command` and decides whether to suspend the TUI based on
1777/// `fullscreen`.
1778#[derive(Debug, Clone, PartialEq, Eq)]
1779pub struct ResolvedLauncher {
1780  pub command: String,
1781  pub fullscreen: bool,
1782}
1783
1784/// Expand `{home}`, `{repo}`, `{repo_path}`, `{repo_parent}`, `{type}`,
1785/// `{issue}`, `{desc}` in a template string.
1786///
1787/// `{repo}` is the repo **name**; `{repo_path}` is the main repo's
1788/// absolute working directory and `{repo_parent}` its parent directory —
1789/// both resolved from `repo_path`. These two let a `base` be expressed
1790/// relative to the repo on disk (e.g. `{repo_parent}/worktrees`, matching
1791/// an editor's `../worktrees` convention). When `repo_path` is `None` the
1792/// disk-path tokens are left untouched rather than collapsed to empty.
1793pub fn expand_placeholders(
1794  template: &str,
1795  repo: &str,
1796  type_: Option<&str>,
1797  issue: Option<&str>,
1798  desc: Option<&str>,
1799  repo_path: Option<&Path>,
1800) -> Result<String> {
1801  let home = dirs::home_dir()
1802    .ok_or_else(|| GwmError::Config("cannot resolve $HOME".into()))?
1803    .to_string_lossy()
1804    .to_string();
1805  let mut out = template.replace("{home}", &home).replace("{repo}", repo);
1806  if let Some(t) = type_ {
1807    out = out.replace("{type}", t);
1808  }
1809  if let Some(i) = issue {
1810    out = out.replace("{issue}", i);
1811  }
1812  if let Some(d) = desc {
1813    out = out.replace("{desc}", d);
1814  }
1815  if let Some(p) = repo_path {
1816    out = out.replace("{repo_path}", &p.to_string_lossy());
1817    if let Some(parent) = p.parent() {
1818      out = out.replace("{repo_parent}", &parent.to_string_lossy());
1819    }
1820  }
1821  // Tilde expansion in case the template starts with ~/...
1822  let expanded = shellexpand::tilde(&out).to_string();
1823  Ok(expanded)
1824}