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