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}