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}