Skip to main content

gwm/
naming.rs

1use crate::config::{expand_placeholders, BranchType, WorktreeConfig};
2use crate::error::{GwmError, Result};
3use regex::Regex;
4use std::sync::LazyLock;
5
6/// Compile-time literal regexes lifted to module statics so each branch
7/// validation / parse runs at ~50ns instead of recompiling the pattern
8/// per call (issue #97). `LazyLock::new` defers the work until the
9/// first access; `expect` is acceptable here because the input is a
10/// hard-coded literal — a regex-compile failure would be a developer
11/// bug caught by the test suite at first use, not a user-facing error
12/// path the CLAUDE.md "no unwrap on user paths" rule targets.
13///
14/// `ISSUE_RE` pins the digits-only contract on issue numbers (no
15/// scientific notation, no hex, no leading zeros stripped). `DESC_RE`
16/// matches the post-`kebab` shape — leading alphanumeric, then a tail
17/// of alphanumeric / dash.
18///
19/// There is deliberately no `BRANCH_RE` here any more (issue #417): the
20/// parser is compiled from `worktree.branch_pattern` by [`BranchParser`],
21/// so the shape gwm reads is the shape gwm writes, by construction.
22static ISSUE_RE: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"^\d+$").expect("static ISSUE_RE compiles"));
23static DESC_RE: LazyLock<Regex> =
24  LazyLock::new(|| Regex::new(r"^[a-z0-9][a-z0-9-]*$").expect("static DESC_RE compiles"));
25
26/// Charset each capturing token contributes to the compiled regex.
27///
28/// These mirror the validators the *formatter* side enforces, so the parser
29/// accepts exactly the strings [`BranchSpec::branch_name`] can emit and
30/// nothing more: `{issue}` is `ISSUE_RE`, `{desc}` is `DESC_RE`, and `{type}`
31/// is the `^[a-z]+$` that `validate_branch_types` pins on every configured
32/// name.
33///
34/// `{type}` is deliberately *not* an alternation of the repo's configured
35/// types, which issue #417 proposed. It would narrow the parser to branches
36/// the repo can create *today*, so a branch created before a type was retired
37/// from `.gwm.toml` would stop being recognised as gwm's — a regression on a
38/// name the previous release read fine. Nothing needs the narrowing either:
39/// once adjacent placeholders are refused, `[a-z]+` splits every pattern the
40/// alternation splits (measured across the whole documented pattern table),
41/// and the one consumer that genuinely requires a *configured* type — the TUI
42/// rename — checks the resolved list itself and says so precisely.
43const TYPE_GROUP: &str = r"(?P<type>[a-z]+)";
44const ISSUE_GROUP: &str = r"(?P<issue>\d+)";
45const DESC_GROUP: &str = r"(?P<desc>[a-z0-9][a-z0-9-]*)";
46
47/// Every token [`crate::config::expand_placeholders`] substitutes on the branch
48/// path, paired with the capture group it compiles to — `None` for the two the
49/// formatter resolves to fixed text before a branch name exists.
50///
51/// `{repo_path}` / `{repo_parent}` are absent on purpose: `BranchSpec::branch_name`
52/// passes no `repo_path`, so the formatter leaves them verbatim and so does the
53/// compiler. `tests/naming_tests.rs` reads the token list back out of
54/// `expand_placeholders` and fails if the two drift apart.
55type Token = (&'static str, Option<(&'static str, &'static str)>);
56const TOKENS: [Token; 5] = [
57  ("{type}", Some(("type", TYPE_GROUP))),
58  ("{issue}", Some(("issue", ISSUE_GROUP))),
59  ("{desc}", Some(("desc", DESC_GROUP))),
60  ("{repo}", None),
61  ("{home}", None),
62];
63
64/// The three segments a branch name carries, in the order constants are
65/// resolved: strictest oracle first, so a token that could serve two segments
66/// goes to the one that can be sure about it.
67const SEGMENTS: [&str; 3] = ["type", "issue", "desc"];
68
69/// Which of the three editable segments the given patterns ask the user to
70/// supply, in the order the patterns write them (issue #418).
71///
72/// These are the tokens [`crate::config::expand_placeholders`] fills from a
73/// value the user typed, as opposed to the ones gwm resolves from the repo and
74/// the environment (`{repo}`, `{home}`, `{repo_path}`, `{repo_parent}`). So this
75/// is exactly the field set the TUI create form has to present: a pattern that
76/// writes no issue number must not ask for one, and asking anyway is not merely
77/// noise — [`BranchSpec::validate_against`] then refuses to submit until the
78/// field is filled with a number the patterns discard.
79///
80/// **Order is the pattern's, not the canonical triple's.** `{desc}-{issue}` is a
81/// legitimate convention, and a form whose Tab order disagreed with the name
82/// being written would read backwards.
83///
84/// Pass every pattern the triple feeds. `base` is one of them:
85/// [`BranchSpec::worktree_path`] expands `{type}` / `{issue}` / `{desc}` in it,
86/// so a segment only `base` carries still names a real directory on disk and
87/// still has to be collected.
88///
89/// De-duplicated on first occurrence, which cannot be inherited from
90/// [`BranchParser::compile`]'s stricter contract: the compiler refuses a
91/// repeated capturing token, but `expand_placeholders` is a chain of
92/// `str::replace` and substitutes every occurrence quite happily.
93///
94/// Reads the pattern as written, which is only correct because
95/// `expand_placeholders` is single-pass (#494): an expansion is a value, so a
96/// repo named `api-{type}` contributes the text `api-{type}` and not a `{type}`
97/// placeholder. While the formatter re-substituted its own output, #418 had to
98/// resolve `{home}` / `{repo}` here first to stay in step with it; removing the
99/// root removed the need, and leaving that resolution in would now invent a
100/// field for a token the formatter writes literally.
101pub fn editable_segments(patterns: &[&str]) -> Vec<&'static str> {
102  let mut out: Vec<&'static str> = Vec::new();
103  for pattern in patterns {
104    let mut hits: Vec<(usize, &'static str)> = SEGMENTS
105      .into_iter()
106      .filter_map(|segment| pattern.find(&format!("{{{}}}", segment)).map(|at| (at, segment)))
107      .collect();
108    hits.sort_by_key(|(at, _)| *at);
109    for (_, segment) in hits {
110      if !out.contains(&segment) {
111        out.push(segment);
112      }
113    }
114  }
115  out
116}
117
118/// Where a placeholder stood, in the literal text [`literal_constants`] reads.
119///
120/// Two jobs. It **separates**: the literals in `1{type}2-{desc}` are not one
121/// token, and fusing them made the recovery freeze the issue number `12`, which
122/// no branch the pattern writes contains. And it **positions**: a literal
123/// before `{issue}` can only be the type, one after it only the description,
124/// which is how `feat/#{issue}-fix` recovers both even though `feat` and `fix`
125/// are each a configured branch type.
126///
127/// Every marker is outside all three charsets, so no candidate spans one.
128fn segment_marker(segment: &str) -> char {
129  match segment {
130    "type" => '\u{1}',
131    "issue" => '\u{2}',
132    _ => '\u{3}',
133  }
134}
135
136/// Where `{repo}` / `{home}` stood. Their expansion is real text in the branch
137/// name but nobody authored it, so it separates the literals around it without
138/// positioning anything.
139const OPAQUE_MARKER: char = '\u{4}';
140
141/// Built-in branch types — the fallback when `.gwm.toml` carries no
142/// `[[branch_types]]` block. Kept as a `&[(&str, &str)]` const so the
143/// static string table stays compile-time and zero-alloc; the runtime
144/// view is materialised on demand via [`default_branch_types`].
145pub const BRANCH_TYPES: &[(&str, &str)] = &[
146  ("feat", "New feature implementation"),
147  ("fix", "Bug fix"),
148  ("hotfix", "Critical production bug fix"),
149  ("docs", "Documentation changes"),
150  ("test", "Test additions or modifications"),
151  ("refactor", "Code restructuring"),
152  ("chore", "Maintenance tasks"),
153  ("perf", "Performance improvements"),
154  ("ci", "CI/CD configuration"),
155  ("build", "Build system changes"),
156];
157
158/// Runtime view of [`BRANCH_TYPES`] as a `Vec<BranchType>`. Used by
159/// [`crate::config::Config::resolved_branch_types`] when no override
160/// is configured, and by [`BranchSpec::validate`] / [`BranchSpec::new`]
161/// to keep the legacy "no config = built-in defaults" contract.
162pub fn default_branch_types() -> Vec<BranchType> {
163  BRANCH_TYPES
164    .iter()
165    .map(|(name, desc)| BranchType {
166      name: (*name).into(),
167      description: (*desc).into(),
168    })
169    .collect()
170}
171
172#[derive(Debug, Clone)]
173pub struct BranchSpec {
174  pub type_: String,
175  pub issue: String,
176  pub desc: String,
177}
178
179impl BranchSpec {
180  /// Construct a [`BranchSpec`] validated against the built-in branch
181  /// types. Kept for callers (tests, internal helpers) that don't have
182  /// a [`crate::config::Config`] in scope; production code paths
183  /// (`gwm create`, TUI create) should use [`Self::new_with_types`]
184  /// with the resolved list so per-repo overrides are honoured.
185  pub fn new(type_: impl Into<String>, issue: impl Into<String>, desc: impl Into<String>) -> Result<Self> {
186    Self::new_with_types(type_, issue, desc, &default_branch_types())
187  }
188
189  /// Construct a [`BranchSpec`] validated against the supplied list of
190  /// allowed branch types — typically the output of
191  /// [`crate::config::Config::resolved_branch_types`].
192  pub fn new_with_types(
193    type_: impl Into<String>,
194    issue: impl Into<String>,
195    desc: impl Into<String>,
196    allowed: &[BranchType],
197  ) -> Result<Self> {
198    let s = Self {
199      type_: type_.into(),
200      issue: issue.into(),
201      desc: kebab(&desc.into()),
202    };
203    s.validate_against(allowed)?;
204    Ok(s)
205  }
206
207  /// As [`Self::new_with_types`], but validating only the segments in
208  /// `required` (issue #418).
209  ///
210  /// `required` is [`editable_segments`] over the repo's patterns: a segment no
211  /// pattern carries is discarded by [`crate::config::expand_placeholders`],
212  /// so demanding a value for it makes the value mandatory *and* thrown away.
213  /// That is what left the TUI create form unusable on a `{type}/{desc}` repo.
214  pub fn new_with_required(
215    type_: impl Into<String>,
216    issue: impl Into<String>,
217    desc: impl Into<String>,
218    allowed: &[BranchType],
219    required: &[&str],
220  ) -> Result<Self> {
221    let s = Self {
222      type_: type_.into(),
223      issue: issue.into(),
224      desc: kebab(&desc.into()),
225    };
226    s.validate_with_required(allowed, required)?;
227    Ok(s)
228  }
229
230  /// Validate against the built-in branch types. Convenience wrapper
231  /// around [`Self::validate_against`] for legacy call sites.
232  pub fn validate(&self) -> Result<()> {
233    self.validate_against(&default_branch_types())
234  }
235
236  /// Validate against the supplied list of allowed branch types. The
237  /// error message produced when the type is rejected enumerates the
238  /// allowed names so the TUI status bar / CLI stderr always shows the
239  /// repo-local truth (built-in or `.gwm.toml`-driven).
240  pub fn validate_against(&self, allowed: &[BranchType]) -> Result<()> {
241    self.validate_with_required(allowed, &SEGMENTS)
242  }
243
244  /// As [`Self::validate_against`], but checking only the segments in
245  /// `required` (issue #418). A segment the repo's patterns do not carry is
246  /// never written anywhere, so there is nothing to validate about it — and
247  /// refusing an empty one would refuse a form the user has filled completely.
248  ///
249  /// Additive: `validate_against` passes all three, so every existing caller
250  /// (the whole CLI surface included) keeps the same contract.
251  pub fn validate_with_required(&self, allowed: &[BranchType], required: &[&str]) -> Result<()> {
252    if required.contains(&"type") && !allowed.iter().any(|t| t.name == self.type_) {
253      let names = allowed.iter().map(|t| t.name.as_str()).collect::<Vec<_>>().join(", ");
254      return Err(GwmError::InvalidBranchType {
255        got: self.type_.clone(),
256        allowed: names,
257      });
258    }
259    if required.contains(&"issue") && !ISSUE_RE.is_match(&self.issue) {
260      return Err(GwmError::InvalidIssue(self.issue.clone()));
261    }
262    if required.contains(&"desc") && !DESC_RE.is_match(&self.desc) {
263      return Err(GwmError::InvalidDescription(self.desc.clone()));
264    }
265    Ok(())
266  }
267
268  pub fn branch_name(&self, cfg: &WorktreeConfig, repo: &str) -> Result<String> {
269    expand_placeholders(
270      &cfg.branch_pattern,
271      repo,
272      Some(&self.type_),
273      Some(&self.issue),
274      Some(&self.desc),
275      None,
276    )
277  }
278
279  pub fn worktree_dirname(&self, cfg: &WorktreeConfig, repo: &str) -> Result<String> {
280    expand_placeholders(
281      &cfg.path_pattern,
282      repo,
283      Some(&self.type_),
284      Some(&self.issue),
285      Some(&self.desc),
286      None,
287    )
288  }
289
290  /// Resolve the absolute worktree path for this spec. `repo_path` is the
291  /// main repo's working directory on disk — it feeds the `{repo_path}` /
292  /// `{repo_parent}` placeholders so a `base` like `{repo_parent}/worktrees`
293  /// can be expressed relative to the repo (matching, e.g., an editor's
294  /// `../worktrees` convention).
295  pub fn worktree_path(
296    &self,
297    cfg: &WorktreeConfig,
298    repo: &str,
299    repo_path: &std::path::Path,
300  ) -> Result<std::path::PathBuf> {
301    let base = expand_placeholders(
302      &cfg.base,
303      repo,
304      Some(&self.type_),
305      Some(&self.issue),
306      Some(&self.desc),
307      Some(repo_path),
308    )?;
309    let dir = self.worktree_dirname(cfg, repo)?;
310    Ok(std::path::PathBuf::from(base).join(dir))
311  }
312}
313
314/// How a worktree got its name (issue #416).
315///
316/// [`Self::Structured`] is the canonical `<type>/#<issue>-<desc>` triple that
317/// `branch_pattern` / `path_pattern` expand. [`Self::Freeform`] is a name the
318/// user simply chose — `gwm create --name spike-redis` — and it deliberately
319/// escapes the convention: no branch type, no issue, no `DESC_RE`.
320///
321/// The patterns do not apply to a free-form name, because they are written in
322/// terms of `{type}` / `{issue}` / `{desc}` and it has none of them. `base`
323/// still applies, so a free-form worktree lands beside the structured ones
324/// rather than somewhere else — but only for the placeholders it documents
325/// (`{home}` / `{repo}` / `{repo_path}` / `{repo_parent}`). The structured
326/// path also feeds `{type}` / `{issue}` / `{desc}` through `base`; a `base`
327/// written with one of those is refused here rather than expanded literally.
328/// Max bytes in a single path component. `NAME_MAX` is 255 on every
329/// filesystem gwm targets; git's own ref check is silent about length,
330/// so the worktree directory is the binding constraint.
331///
332/// Public because the TUI create form has to stop typing at exactly this
333/// number: a form that stopped short would silently truncate a name the
334/// validator would have accepted, and submit a different branch than the
335/// one that was typed.
336pub const MAX_DIR_COMPONENT_BYTES: usize = 255;
337
338/// Bytes git tacks onto the ref's **final** component while creating it:
339/// `refs/heads/<name>.lock` has to exist before `refs/heads/<name>` does.
340/// Earlier components are plain directories and carry no suffix.
341const GIT_REF_LOCK_SUFFIX_BYTES: usize = ".lock".len();
342
343/// The `base` placeholders only the structured triple can supply. A
344/// free-form name has no value for any of them, and `expand_placeholders`
345/// leaves an unfed placeholder literal, so a `base` written with one of
346/// these has to be refused rather than turned into a directory called
347/// `{type}`.
348const STRUCTURED_BASE_PLACEHOLDERS: [&str; 3] = ["{type}", "{issue}", "{desc}"];
349
350/// The Win32 path characters `Branch::name_is_valid` does *not* already
351/// refuse. Windows forbids nine in a path component (`< > : " / \ | ? *`);
352/// measured against the oracle, it already rejects `: \ ? *`, and `/` is the
353/// ref separator [`WorktreeName::worktree_dirname`] flattens to `-`. These
354/// four are the residual, and flattening leaves each of them untouched, so
355/// they reach the directory name verbatim.
356const WINDOWS_FORBIDDEN_CHARS: [char; 4] = ['<', '>', '"', '|'];
357
358/// The MS-DOS device names, reserved by Win32 in *every* directory rather
359/// than only at a volume root. Taken verbatim from [Naming Files, Paths, and
360/// Namespaces][win32]: `COM0` and `LPT0` are deliberately absent (they are
361/// not on that list), while the ISO 8859-1 superscripts `¹ ² ³` are on it,
362/// because Windows reads them as digits in a device name.
363///
364/// [win32]: https://learn.microsoft.com/en-us/windows/win32/fileio/naming-a-file
365const WINDOWS_RESERVED_STEMS: [&str; 28] = [
366  "CON", "PRN", "AUX", "NUL", //
367  "COM1", "COM2", "COM3", "COM4", "COM5", "COM6", "COM7", "COM8", "COM9", "COM¹", "COM²", "COM³", //
368  "LPT1", "LPT2", "LPT3", "LPT4", "LPT5", "LPT6", "LPT7", "LPT8", "LPT9", "LPT¹", "LPT²", "LPT³",
369];
370
371/// Whether `segment` is a reserved Win32 device name.
372///
373/// The comparison is on the stem, everything before the **first** `.`: Win32
374/// documents `NUL.txt` and `NUL.tar.gz` as both equivalent to `NUL`. Taking
375/// the first rather than testing every dot-separated piece is what keeps
376/// `x.CON` legal, which it is.
377///
378/// Case-insensitive, since Windows path comparison is. `eq_ignore_ascii_case`
379/// is enough even for the superscript entries: `¹ ² ³` have no case variants,
380/// so their bytes compare equal either way.
381fn is_windows_reserved_segment(segment: &str) -> bool {
382  let stem = segment.split('.').next().unwrap_or(segment);
383  WINDOWS_RESERVED_STEMS
384    .iter()
385    .any(|reserved| stem.eq_ignore_ascii_case(reserved))
386}
387
388#[derive(Debug, Clone)]
389pub enum WorktreeName {
390  Structured(BranchSpec),
391  /// Already validated by [`WorktreeName::freeform`] — constructing this
392  /// variant directly bypasses the ref/path checks.
393  Freeform(String),
394}
395
396impl WorktreeName {
397  /// Validate a user-supplied free-form name.
398  ///
399  /// The bar is deliberately low. `Spike_Redis`, `2026.07.27` and
400  /// `réécriture` are all fine — refusing them would defeat the point of the
401  /// flag. The rules are enumerated from the three things the name has to be
402  /// at once, rather than accreted one reviewer example at a time:
403  ///
404  /// 1. **A git branch.** Delegated to libgit2's own
405  ///    [`git2::Branch::name_is_valid`] — the branch-level oracle, not the
406  ///    reference-level one, because they disagree: `refs/heads/HEAD` is a
407  ///    syntactically valid *reference* name while `HEAD` is not a usable
408  ///    *branch* name. The authority on what git accepts is git.
409  /// 2. **A single filesystem path component** — the worktree directory. A
410  ///    branch name is a *path* of components, so the two have different
411  ///    limits: bounded at [`MAX_DIR_COMPONENT_BYTES`], and no `.` / `..`.
412  /// 3. **A literal value in placeholder expansion.** Two of the three
413  ///    expanders substitute in one pass and can no longer rewrite a value
414  ///    from the inside — `lifecycle::expand` since the hook-injection patch,
415  ///    `config::expand_placeholders` since #494. The third,
416  ///    `launcher::expand`, still chains `str::replace` over its own output,
417  ///    and it substitutes the worktree **path** before `{base}` / `{head}`,
418  ///    so a brace reaching that path is re-substituted inside an already
419  ///    shell-quoted value. So this rule is not merely defensive: it is what
420  ///    keeps a chosen name out of that surface.
421  ///
422  /// Plus one rule that belongs to none of them: no leading `-`, which is a
423  /// CLI-ergonomics rule (git accepts it).
424  ///
425  /// Consumer (2) has a second half, added by #475: the directory has to be
426  /// hostable on **Windows** too, which forbids `< > " |` and the reserved
427  /// device names ([`WINDOWS_FORBIDDEN_CHARS`], [`WINDOWS_RESERVED_STEMS`]).
428  /// The rule is unconditional rather than `#[cfg(windows)]`: a branch
429  /// travels to a teammate's machine through the forge, so a name no Windows
430  /// checkout can host is a cross-platform hazard, and gating it would leave
431  /// a rule only one CI runner ever exercises. The residual list is what
432  /// libgit2 does not already cover, measured against the oracle rather than
433  /// copied wholesale from the Win32 page.
434  ///
435  /// The name is validated exactly as typed. Trimming would accept
436  /// `--name " spike"` and create `spike` instead — a different branch from
437  /// the one that was asked for, which the "the name becomes the branch"
438  /// contract does not allow.
439  pub fn freeform(input: &str) -> Result<Self> {
440    let name = input;
441    let reject = |reason: &str| {
442      Err(GwmError::InvalidWorktreeName {
443        name: input.to_string(),
444        reason: reason.to_string(),
445      })
446    };
447
448    if name.is_empty() {
449      return reject("empty");
450    }
451    // Checked before the ref oracle so the message points at the real
452    // problem: git happily accepts `..` inside a longer component, but a
453    // worktree directory named `..` would escape the base directory.
454    if name.split('/').any(|part| part == "." || part == "..") {
455      return reject("`.` and `..` are not usable as a directory name");
456    }
457    if name.contains('\0') {
458      return reject("contains a NUL byte");
459    }
460    // Not a git rule: libgit2 accepts `-x` as a branch name quite happily
461    // (verified — the oracle below lets it through). It is an *ergonomics*
462    // rule. A leading `-` makes the name unusable as an argument to every
463    // command that would take it back: `gwm remove -x`, `git branch -d -x`,
464    // `cd -x` all read it as a flag.
465    if name.starts_with('-') {
466      return reject("a leading `-` makes the name unusable as a command argument");
467    }
468    // Consumer (3). The reason originally written here has expired:
469    // `lifecycle::expand` used to replace `{branch}` first and `{type}` /
470    // `{issue}` / `{desc}` / `{repo}` after, so a hook asking for `{branch}`
471    // on `spike-{issue}` received `spike-`. That expander has been single-pass
472    // since the hook-injection patch and `config::expand_placeholders` is
473    // since #494.
474    //
475    // The rule stays, and not merely as a belt: `launcher::expand` still
476    // chains `str::replace` over its own output, substituting the worktree
477    // path before `{base}` / `{head}`. `kebab` strips braces out of a
478    // structured description, so this check is the only thing keeping them out
479    // of a name that becomes a directory. (`worktree.base` can still put one
480    // there, which is config rather than a chosen name — a separate surface.)
481    //
482    // Worth stating rather than leaving as folklore: a rejection justified by
483    // a cause that has expired is how the next reader removes it for the wrong
484    // reason, and this one nearly was.
485    if name.contains('{') || name.contains('}') {
486      return reject("`{` and `}` would be re-substituted when a lifecycle hook expands its placeholders");
487    }
488    // A ref is a *path* of components, a worktree directory is a single one,
489    // so `a×130/b×130` is a legal ref and an illegal directory name. Without
490    // this check `worktree::add` creates the branch, then fails on the
491    // directory and leaves the branch orphaned. `/` → `-` is 1:1, so the
492    // flattened dirname has exactly the name's byte length.
493    if name.len() > MAX_DIR_COMPONENT_BYTES {
494      return reject(&format!(
495        "{} bytes long — a worktree directory is a single path component, capped at {}",
496        name.len(),
497        MAX_DIR_COMPONENT_BYTES
498      ));
499    }
500    // The ref side of the same limit, five bytes tighter on the final
501    // component only: git creates `refs/heads/<name>.lock` first, and
502    // `Branch::name_is_valid` checks syntax, never length. Measured: a
503    // 250-byte final segment creates, 251 fails — after `pre_create` hooks
504    // have run. Earlier segments are plain directories, so they keep the
505    // full budget; capping them too would refuse names git accepts.
506    let last = name.rsplit('/').next().unwrap_or(name);
507    if last.len() + GIT_REF_LOCK_SUFFIX_BYTES > MAX_DIR_COMPONENT_BYTES {
508      return reject(&format!(
509        "its last segment is {} bytes — git writes `refs/heads/<name>.lock` first, leaving {} for it",
510        last.len(),
511        MAX_DIR_COMPONENT_BYTES - GIT_REF_LOCK_SUFFIX_BYTES
512      ));
513    }
514
515    // Consumer (1). The branch-level oracle, not `Reference::is_valid_name`
516    // on `refs/heads/<name>`: that one accepts `HEAD`, which `git branch`
517    // refuses and which would collide with the HEAD pseudo-ref.
518    if !git2::Branch::name_is_valid(name).unwrap_or(false) {
519      return reject(
520        "not a valid git branch name — git rejects spaces, `~ ^ : ? * [ \\`, `@{`, `..`, \
521         leading/trailing `/`, a trailing `.`, a `.lock` suffix and `HEAD`",
522      );
523    }
524
525    // Consumer (2) again, for the platform this is not compiled on. Runs
526    // after the oracle so that a name failing both rules gets git's message,
527    // which is the more specific one; by construction everything reaching
528    // here is a name git already accepted.
529    if let Some(ch) = name.chars().find(|c| WINDOWS_FORBIDDEN_CHARS.contains(c)) {
530      return reject(&format!(
531        "`{}` cannot appear in a directory name on Windows — the branch would be \
532         unusable on a teammate's machine even though git accepts it",
533        ch
534      ));
535    }
536    // Per ref segment, not just on the flattened directory name: a loose ref
537    // is a file at `.git/refs/heads/<name>`, so every `/`-separated segment
538    // is a path component there too. `spike/CON` flattens to the perfectly
539    // legal directory `spike-CON` and would still be an unwritable ref.
540    for segment in name.split('/') {
541      // Win32: "Do not end a file or directory name with a space or a
542      // period." Git covers the space in every position, but its own
543      // trailing-period rule applies to the *whole* name, so an inner
544      // segment slips through it: `foo./bar` and `a./b./c` are measured
545      // to pass both `Branch::name_is_valid` and `git check-ref-format`.
546      if segment.ends_with('.') {
547        return reject(&format!(
548          "`{}` ends with `.`, which Windows refuses as a directory name — git only \
549           applies that rule to the last segment of a branch",
550          segment
551        ));
552      }
553      if is_windows_reserved_segment(segment) {
554        return reject(&format!(
555          "`{}` is a reserved device name on Windows (`CON`, `PRN`, `AUX`, `NUL`, \
556           `COM1`-`COM9`, `LPT1`-`LPT9`, with or without an extension) — no path \
557           component may be one",
558          segment
559        ));
560      }
561    }
562
563    Ok(Self::Freeform(name.to_string()))
564  }
565
566  /// The branch this worktree gets. Structured names expand
567  /// `branch_pattern`; free-form names are the branch.
568  pub fn branch_name(&self, cfg: &WorktreeConfig, repo: &str) -> Result<String> {
569    match self {
570      Self::Structured(spec) => spec.branch_name(cfg, repo),
571      Self::Freeform(name) => Ok(name.clone()),
572    }
573  }
574
575  /// The worktree directory name. A branch may carry `/`; a directory is a
576  /// single path component, so it flattens to `-` — the same relationship
577  /// the default `branch_pattern` / `path_pattern` pair already has
578  /// (`feat/#42-x` on disk is `feat-42-x`).
579  pub fn worktree_dirname(&self, cfg: &WorktreeConfig, repo: &str) -> Result<String> {
580    match self {
581      Self::Structured(spec) => spec.worktree_dirname(cfg, repo),
582      Self::Freeform(name) => Ok(name.replace('/', "-")),
583    }
584  }
585
586  /// Absolute worktree path: `base` (expanded) joined with the directory
587  /// name. `base` applies in both modes.
588  pub fn worktree_path(
589    &self,
590    cfg: &WorktreeConfig,
591    repo: &str,
592    repo_path: &std::path::Path,
593  ) -> Result<std::path::PathBuf> {
594    match self {
595      Self::Structured(spec) => spec.worktree_path(cfg, repo, repo_path),
596      Self::Freeform(_) => {
597        if let Some(ph) = STRUCTURED_BASE_PLACEHOLDERS.iter().find(|ph| cfg.base.contains(**ph)) {
598          return Err(GwmError::Config(format!(
599            "worktree.base `{}` uses `{}`, which a free-form name has no value for \
600             (it would be left literal in the path) — drop it from base, or create \
601             the worktree with <type> <issue> <desc>",
602            cfg.base, ph
603          )));
604        }
605        let base = expand_placeholders(&cfg.base, repo, None, None, None, Some(repo_path))?;
606        Ok(std::path::PathBuf::from(base).join(self.worktree_dirname(cfg, repo)?))
607      }
608    }
609  }
610}
611
612/// The reader half of `worktree.branch_pattern` (issue #417).
613///
614/// [`BranchSpec::branch_name`] writes a branch by expanding the pattern;
615/// this compiles the *same* pattern into the regex that reads it back. One
616/// source of truth, so a repo that customises `branch_pattern` keeps issue
617/// auto-linking, gitmoji, `gwm pr` placeholders, lifecycle hook placeholders
618/// and the TUI rename instead of losing them silently.
619///
620/// The compiler mirrors the exact [`expand_placeholders`] call the formatter
621/// makes — `(pattern, repo, Some(type), Some(issue), Some(desc), None)`:
622///
623/// - `{type}` / `{issue}` / `{desc}` become named capture groups.
624/// - `{repo}` and `{home}` become escaped literals, because the formatter
625///   substitutes them too and their values are fixed at parse time.
626/// - **Everything else is an escaped literal**, including `{repo_path}` /
627///   `{repo_parent}`. That is not an oversight: the formatter passes `None`
628///   for `repo_path`, so those tokens survive into the branch name verbatim
629///   and the parser has to expect them verbatim to round-trip.
630///
631/// The `shellexpand::tilde` pass the formatter ends with is *not* mirrored, so
632/// a pattern starting with `~` does not round-trip. That is reported rather
633/// than hidden: [`branch_pattern_warning`] probes the compiled parser and
634/// names the loss.
635/// A pattern may also **freeze** a segment instead of writing it from a
636/// placeholder: `feat/#{issue}-{desc}` hardcodes the type, `{type}/#1-{desc}`
637/// hardcodes the issue number. Those are recovered as constants (see
638/// [`BranchParser::constants`]), because the previous release read them out of
639/// the branch name and dropping them would take gitmoji or auto-linking away
640/// from a repo that had them working.
641#[derive(Debug, Clone)]
642pub struct BranchParser {
643  re: Regex,
644  /// Segments the pattern freezes as literal text, in [`SEGMENTS`] order.
645  /// Never overlaps the regex's capture groups: a segment is either written
646  /// by a placeholder or frozen by a literal, never both.
647  constants: Vec<(&'static str, String)>,
648}
649
650impl BranchParser {
651  /// Compile `pattern` into a parser.
652  ///
653  /// `repo` must be the real repo name ([`crate::worktree::repo_name`]) and
654  /// `types` the repo's [`crate::config::Config::resolved_branch_types`] —
655  /// both feed the compiled regex, so a stand-in produces a parser that reads
656  /// a different repo's branches.
657  ///
658  /// Two patterns are refused rather than compiled into a parser that reads
659  /// back the wrong thing:
660  ///
661  /// 1. **A boundary between two capturing tokens that can move.** The
662  ///    question is never "is there a separator" but "can the split between
663  ///    these two land in more than one place", and [`boundary_can_shift`]
664  ///    answers it from the charsets. Adjacent placeholders are only refused
665  ///    when their alphabets overlap: `{issue}{desc}` reads `42` + `123-x`
666  ///    back as `4212` + `3-x`, but `{type}{issue}` is safe, because `[a-z]+`
667  ///    stops at the first digit and `\d+` at the first letter. A separator is
668  ///    not required to sit outside the left token's charset either —
669  ///    `{desc}-{issue}` round-trips, since `\d+` can never contain the `-`
670  ///    that would have to reappear after the shift.
671  /// 2. **The same capturing token twice.** The formatter's `str::replace`
672  ///    substitutes every occurrence, so `{desc}-{desc}` writes `foo-foo`;
673  ///    reading that back needs a backreference, which this engine has not
674  ///    got, and a second group of the same name will not compile.
675  ///
676  /// The rule as a whole is pinned by
677  /// `tests/naming_tests.rs::the_ambiguity_rule_accepts_exactly_the_patterns_that_round_trip`,
678  /// which enumerates the pattern space rather than sampling it: three
679  /// separate review findings landed here, and every one of them was a pattern
680  /// the hand-picked examples did not cover.
681  pub fn compile(pattern: &str, repo: &str, types: &[BranchType]) -> Result<Self> {
682    let mut re = String::from("^");
683    let mut seen: Vec<&str> = Vec::new();
684    // Literal text the *pattern author* wrote, kept for constant recovery.
685    // Deliberately not fed by `{repo}` / `{home}`: the user wrote `feat` in
686    // `feat/#{issue}-{desc}` and meant it, but nobody chose the repo's name
687    // for this purpose, and a repo that happens to be called `docs` must not
688    // turn `{repo}/{issue}-{desc}` into a docs-typed branch.
689    let mut authored = String::new();
690    // The last capture emitted, paired with every literal character emitted
691    // since — the separator the next capture would sit behind. An empty string
692    // means the two are adjacent, which a `{repo}` expanding to nothing
693    // achieves just as surely as writing them side by side, so the state
694    // follows the emitted *text* and not the token kind.
695    let mut pending: Option<(&'static str, String)> = None;
696    let mut rest = pattern;
697
698    while !rest.is_empty() {
699      // Find tokens the way `expand_placeholders` does — by searching for each
700      // one, not by scanning for `{`. The two disagree the moment a brace sits
701      // next to a placeholder: `str::replace` sees `{type}` at offset 1 of
702      // `{{type}` and writes `{feat`, whereas a `{`-scanner takes `{{type}` for
703      // one unknown token and demands that text back verbatim. Everything the
704      // formatter leaves alone — an unknown `{foo}`, an unbalanced brace — is
705      // literal here for the same reason.
706      let Some((at, token, group)) = TOKENS
707        .iter()
708        .filter_map(|(token, group)| rest.find(token).map(|at| (at, *token, *group)))
709        .min_by_key(|(at, ..)| *at)
710      else {
711        authored.push_str(rest);
712        push_literal(&mut re, rest, &mut pending);
713        break;
714      };
715      if at > 0 {
716        authored.push_str(&rest[..at]);
717        push_literal(&mut re, &rest[..at], &mut pending);
718      }
719      rest = &rest[at + token.len()..];
720
721      match group {
722        Some((name, group)) => {
723          // Before the boundary check, so `{desc}-{desc}` is diagnosed as the
724          // repeat it is rather than as a separator it could swallow.
725          if seen.contains(&name) {
726            return Err(GwmError::Config(format!(
727              "worktree.branch_pattern `{}` uses `{{{}}}` more than once; every occurrence expands \
728               to the same value, which cannot be read back",
729              sanitise_for_terminal(pattern),
730              name
731            )));
732          }
733          // The split between the previous capture and this one has to be the
734          // only one a branch name admits. Refusing here rather than reporting
735          // it later is the point: a mis-split is deterministic, so every probe
736          // agrees with it and the pattern would be declared valid while
737          // auto-linking targeted the wrong issue.
738          if let Some((left, sep)) = pending.as_ref() {
739            if boundary_can_shift(left, sep, name) {
740              return Err(GwmError::Config(if sep.is_empty() {
741                format!(
742                  "worktree.branch_pattern `{}` puts `{{{}}}` straight after `{{{}}}` with nothing \
743                   between them and both can hold the same characters, so a branch it writes cannot \
744                   be read back unambiguously — separate them with a literal (`-`, `_`, `/`, …)",
745                  sanitise_for_terminal(pattern),
746                  name,
747                  left
748                )
749              } else {
750                format!(
751                  "worktree.branch_pattern `{}` separates `{{{}}}` from `{{{}}}` with `{}`, which \
752                   could be read as part of either, so a branch it writes splits at the wrong place \
753                   — separate them with a character neither can contain (`/`, `_`, `#`, `.`, …)",
754                  sanitise_for_terminal(pattern),
755                  left,
756                  name,
757                  sanitise_for_terminal(sep)
758                )
759              }));
760            }
761          }
762          seen.push(name);
763          re.push_str(group);
764          authored.push(segment_marker(name));
765          pending = Some((name, String::new()));
766        }
767        // `{repo}` / `{home}`: resolved by the formatter, so fixed text by the
768        // time a branch name exists. `{home}` is looked up lazily, since a
769        // pattern that does not use it must not fail to compile on a machine
770        // with no resolvable `$HOME`.
771        //
772        // Both break the literal run they sit in: they put real text between
773        // the literals either side, so those literals are not one token.
774        None => {
775          let text = if token == "{home}" {
776            dirs::home_dir()
777              .ok_or_else(|| GwmError::Config("cannot resolve $HOME".into()))?
778              .to_string_lossy()
779              .to_string()
780          } else {
781            repo.to_string()
782          };
783          authored.push(OPAQUE_MARKER);
784          push_literal(&mut re, &text, &mut pending);
785        }
786      }
787    }
788
789    re.push('$');
790    let re = Regex::new(&re).map_err(|e| {
791      GwmError::Config(format!(
792        "worktree.branch_pattern `{}` does not compile into a parser: {}",
793        sanitise_for_terminal(pattern),
794        e
795      ))
796    })?;
797    let constants = literal_constants(&authored, &seen, types);
798    let parser = Self { re, constants };
799    parser.mirrors_formatter(pattern, repo)?;
800    Ok(parser)
801  }
802
803  /// Check the parser just built against the formatter it is derived from.
804  ///
805  /// "The compiler mirrors `expand_placeholders`" was argued rather than
806  /// checked, and review found it wrong three times: a brace before a
807  /// placeholder, an expansion carrying another token, a token formed across
808  /// an expansion boundary. Each fix closed one instance and the next pass
809  /// found the next. So the property is verified instead — one probe branch
810  /// through the real formatter, read back with this parser — and a pattern
811  /// the two disagree on is refused rather than compiled into a parser that
812  /// recognises none of the branches it creates.
813  ///
814  /// Two of those three classes are gone since #494 made the formatter
815  /// single-pass: an expansion is a value now, so it cannot carry a token and
816  /// cannot form one against the text around it. The patterns that used to be
817  /// refused for it compile and round-trip. The check stays because its value
818  /// was never the list of known cases — it is what turns the next unknown one
819  /// into a refusal instead of a parser that reads nothing.
820  ///
821  /// Two deliberate exclusions:
822  ///
823  /// - **A `~` prefix.** `expand_placeholders` ends with `shellexpand::tilde`,
824  ///   which no parser can undo. That divergence is known, documented, and
825  ///   reported in full by [`branch_pattern_warning`]'s probe; refusing it
826  ///   here would replace a verdict that names every affected feature with a
827  ///   bare compile error.
828  /// - **A pattern the formatter itself cannot expand.** It fails at
829  ///   `gwm create` with its own error, so refusing to build a parser for it
830  ///   adds nothing.
831  fn mirrors_formatter(&self, pattern: &str, repo: &str) -> Result<()> {
832    if pattern.starts_with('~') {
833      return Ok(());
834    }
835    const PROBE: (&str, &str, &str) = ("feat", "42", "probe");
836    let Ok(written) = expand_placeholders(pattern, repo, Some(PROBE.0), Some(PROBE.1), Some(PROBE.2), None) else {
837      return Ok(());
838    };
839    // Only the segments this parser captures: one the pattern freezes comes
840    // back as its literal by design, and one it omits comes back empty.
841    let agrees = self.parse(&written).is_some_and(|spec| {
842      SEGMENTS.iter().all(|segment| {
843        !self.re.capture_names().flatten().any(|name| name == *segment)
844          || match *segment {
845            "type" => spec.type_ == PROBE.0,
846            "issue" => spec.issue == PROBE.1,
847            _ => spec.desc == PROBE.2,
848          }
849      })
850    });
851    if agrees {
852      return Ok(());
853    }
854    Err(GwmError::Config(format!(
855      "worktree.branch_pattern `{}` writes `{}`, which the parser derived from it does not read \
856       back, so gwm would recognise none of the branches this pattern creates",
857      sanitise_for_terminal(pattern),
858      sanitise_for_terminal(&written)
859    )))
860  }
861
862  /// The parser for a repo's effective config. The single lookup site that
863  /// pairs `worktree.branch_pattern` with `resolved_branch_types`, so no
864  /// caller has to remember they belong together.
865  ///
866  /// A pattern that cannot be compiled yields a parser that reads nothing
867  /// rather than one that reads the *default* shape: falling back to the
868  /// default is exactly the format/parse divergence this issue removes, and
869  /// it would put the wrong issue number on a branch instead of none. The
870  /// loud report belongs to `gwm doctor` / `gwm config validate`, which call
871  /// [`branch_pattern_warning`].
872  pub fn from_config(config: &crate::config::Config, repo: &str) -> Self {
873    let types = config.resolved_branch_types().types;
874    Self::compile(&config.worktree.branch_pattern, repo, &types).unwrap_or_else(|_| Self::inert())
875  }
876
877  /// The parser for whatever repo `repo` points at, loading its effective
878  /// config (repo layer over global, same as every other runtime read).
879  ///
880  /// For call sites that hold a repo handle but no [`crate::config::Config`].
881  /// Prefer [`Self::from_config`] when a config is already in hand, and hoist
882  /// this out of loops — it reads `.gwm.toml` and compiles a regex, so once
883  /// per listing rather than once per branch.
884  ///
885  /// A config that fails to load falls back to the built-in pattern. That is
886  /// the pre-#417 behaviour and it is the right one here: a `.gwm.toml` gwm
887  /// cannot read is a `.gwm.toml` gwm could not have created a worktree from
888  /// either, and it has its own diagnostic in `gwm doctor`.
889  pub fn for_repo(repo: &git2::Repository) -> Self {
890    let config = repo
891      .workdir()
892      .and_then(|wd| crate::config::Config::load_for_repo(wd).ok())
893      .unwrap_or_default();
894    Self::from_config(&config, &crate::worktree::repo_name(repo))
895  }
896
897  /// The built-in `{type}/#{issue}-{desc}` parser, for the entry points that
898  /// genuinely have no repo to read a config from — `gwm commit-prefix
899  /// --branch <name>` run outside a checkout is the only one.
900  pub fn builtin() -> &'static Self {
901    static BUILTIN: LazyLock<BranchParser> = LazyLock::new(|| {
902      BranchParser::compile(&crate::config::default_branch_pattern(), "", &default_branch_types())
903        .expect("the default branch_pattern compiles")
904    });
905    &BUILTIN
906  }
907
908  /// Does this parser recover `segment` (`type` / `issue` / `desc`) at all?
909  ///
910  /// True when the pattern writes it from a placeholder *or* freezes it as a
911  /// literal. False means no branch name the pattern produces can carry it: a
912  /// permanent absence rather than a parse that goes wrong.
913  pub fn reads_segment(&self, segment: &str) -> bool {
914    self.captures_segment(segment) || self.constants.iter().any(|(name, _)| *name == segment)
915  }
916
917  /// Does the pattern **write** `segment` from a placeholder, as opposed to
918  /// freezing it as literal text or omitting it?
919  ///
920  /// The distinction is what makes a value authoritative: a captured segment
921  /// carries whatever `gwm create` was given, a frozen one carries what the
922  /// pattern's author wrote whatever anyone asked for. See [`worktree_spec`].
923  pub fn captures_segment(&self, segment: &str) -> bool {
924    self.re.capture_names().flatten().any(|name| name == segment)
925  }
926
927  /// The segments this pattern freezes as literal text, `(segment, value)`.
928  ///
929  /// Disclosure, not decoration: `gwm doctor` names these on its OK line, so a
930  /// pattern that quietly pins every branch to one issue number says so rather
931  /// than looking like it read one out of the branch.
932  pub fn constants(&self) -> &[(&'static str, String)] {
933    &self.constants
934  }
935
936  /// A parser that matches nothing. `\z\A` can never match: it demands the
937  /// end of the haystack before its start.
938  fn inert() -> Self {
939    Self {
940      re: Regex::new(r"\z\A").expect("static inert regex compiles"),
941      constants: Vec::new(),
942    }
943  }
944
945  /// Recover the segments from a branch name, or `None` when the name was not
946  /// written by this pattern.
947  ///
948  /// A segment the pattern neither writes nor freezes comes back empty rather
949  /// than blocking the parse: `{type}/{desc}` carries no issue number, and
950  /// reporting the type and desc it *does* carry beats reporting nothing.
951  /// Callers that need a segment check it — see `gwm commit-prefix`, which is
952  /// defined in terms of the type and the issue and says so when either is
953  /// missing.
954  pub fn parse(&self, branch: &str) -> Option<BranchSpec> {
955    let cap = self.re.captures(branch)?;
956    let seg = |name: &str| {
957      cap
958        .name(name)
959        .map(|m| m.as_str().to_string())
960        .or_else(|| {
961          self
962            .constants
963            .iter()
964            .find(|(seg, _)| *seg == name)
965            .map(|(_, value)| value.clone())
966        })
967        .unwrap_or_default()
968    };
969    Some(BranchSpec {
970      type_: seg("type"),
971      issue: seg("issue"),
972      desc: seg("desc"),
973    })
974  }
975}
976
977/// Read the triple a worktree carries, from its branch **and** its directory
978/// name (issue #478).
979///
980/// `worktree.branch_pattern` and `worktree.path_pattern` need not carry the
981/// same segments, and when they do not, neither name holds the whole triple.
982/// Under `branch_pattern = "feat/#{issue}-{desc}"` with the default
983/// `path_pattern`, `gwm create fix 42 x` writes the branch `feat/#42-x` and
984/// the directory `fix-42-x`: the branch has no `{type}` to put `fix` into, so
985/// the directory is the only place that value still exists.
986///
987/// The branch wins for every segment it **writes from a placeholder** — it is
988/// the worktree's identity, and a directory renamed by hand or created under
989/// an older `path_pattern` must not rewrite it. The directory is consulted
990/// only for the rest: a segment the branch freezes as a literal, or omits.
991///
992/// `dirname` is the worktree's directory name, not its full path — the same
993/// thing [`BranchSpec::worktree_dirname`] produces. `None`, an empty name, a
994/// name `path_pattern` does not match, or a `path_pattern` that cannot be
995/// compiled all leave the branch's own reading untouched; a broken
996/// `path_pattern` has its own diagnostic and must not take the rename away.
997///
998/// `None` only when the branch itself does not parse — a free-form worktree
999/// (#416), which no triple describes.
1000pub fn worktree_spec(
1001  config: &crate::config::Config,
1002  repo: &str,
1003  branch: &str,
1004  dirname: Option<&str>,
1005) -> Option<BranchSpec> {
1006  let branch_parser = BranchParser::from_config(config, repo);
1007  let mut spec = branch_parser.parse(branch)?;
1008
1009  let types = config.resolved_branch_types().types;
1010  let from_path = dirname
1011    .filter(|name| !name.is_empty())
1012    .zip(BranchParser::compile(&config.worktree.path_pattern, repo, &types).ok())
1013    .and_then(|(name, parser)| parser.parse(name).map(|read| (parser, read)));
1014  let Some((path_parser, read)) = from_path else {
1015    return Some(spec);
1016  };
1017
1018  for segment in SEGMENTS {
1019    if branch_parser.captures_segment(segment) || !path_parser.captures_segment(segment) {
1020      continue;
1021    }
1022    let value = match segment {
1023      "type" => &read.type_,
1024      "issue" => &read.issue,
1025      _ => &read.desc,
1026    };
1027    if value.is_empty() {
1028      continue;
1029    }
1030    match segment {
1031      "type" => spec.type_ = value.clone(),
1032      "issue" => spec.issue = value.clone(),
1033      _ => spec.desc = value.clone(),
1034    }
1035  }
1036  Some(spec)
1037}
1038
1039/// Append fixed text to the regex under construction, escaping it, and record
1040/// it as part of the separator behind the capture still open — but only when
1041/// the text is actually non-empty. `{repo}` in a repo whose name failed to
1042/// resolve contributes nothing, and pretending it separated two groups would
1043/// let an ambiguous pattern through.
1044fn push_literal(re: &mut String, text: &str, pending: &mut Option<(&'static str, String)>) {
1045  if text.is_empty() {
1046    return;
1047  }
1048  // A separator may arrive in several chunks (`{issue}` `-` `{repo}` `-`
1049  // `{desc}`), so it accumulates rather than being decided by the first one.
1050  if let Some((_, sep)) = pending.as_mut() {
1051    sep.push_str(text);
1052  }
1053  re.push_str(&regex::escape(text));
1054}
1055
1056/// Can the split between two consecutive captures land in more than one place?
1057///
1058/// `left` and `right` are the captures and `sep` the literal text the pattern
1059/// puts between them, empty when they are adjacent. The formatter writes
1060/// `left · sep · right`; this asks whether some branch name it produces also
1061/// reads as a *different* pair.
1062///
1063/// With no separator the answer is yes as soon as one character can both end
1064/// `left` and start `right`: that character crosses the split. `{type}{issue}`
1065/// is therefore safe — `[a-z]+` stops at the first digit and `\d+` at the first
1066/// letter — while `{issue}{desc}` is not.
1067///
1068/// With a separator, moving the split `d` characters to the right means `left`
1069/// swallows `sep[..d]`, the separator then has to match `d` characters further
1070/// along — which requires `sep` to repeat with period `d` — and `right` has to
1071/// supply the `d` characters of separator that are no longer covered. All three
1072/// have to hold at once, which is what keeps `{type}-{issue}9-{desc}` legal
1073/// (`\d+` can eat the `9` but never the `-` that would have to follow it) while
1074/// `{type}-{issue}9{desc}` is refused.
1075///
1076/// Known ceiling: the last test approximates "what `right` can supply" by its
1077/// charset, which is exact only while the displaced separator is no longer than
1078/// the value beside it. No combination of the three segment charsets makes a
1079/// longer one reachable, and the enumeration in `tests/naming_tests.rs` is what
1080/// checks that claim rather than this comment.
1081fn boundary_can_shift(left: &str, sep: &str, right: &str) -> bool {
1082  let sep: Vec<char> = sep.chars().collect();
1083  if sep.is_empty() {
1084    return WITNESSES
1085      .iter()
1086      .any(|&c| segment_accepts(left, c) && segment_starts(right, c));
1087  }
1088  (1..=sep.len()).any(|d| {
1089    sep[..d].iter().all(|&c| segment_accepts(left, c))
1090      && sep[..sep.len() - d] == sep[d..]
1091      && segment_can_start_with(right, &sep[sep.len() - d..])
1092  })
1093}
1094
1095/// One character per class the three capture groups are built from. Every
1096/// charset is a union of these, so testing them decides any "can both sides
1097/// hold the same character" question without walking Unicode.
1098const WITNESSES: [char; 3] = ['a', '0', '-'];
1099
1100/// Can `segment` *begin* with `c`? Only `desc` differs from
1101/// [`segment_accepts`]: its tail allows the `-` its first character cannot be.
1102fn segment_starts(segment: &str, c: char) -> bool {
1103  match segment {
1104    "desc" => c.is_ascii_lowercase() || c.is_ascii_digit(),
1105    _ => segment_accepts(segment, c),
1106  }
1107}
1108
1109/// Can `segment` begin with the whole of `prefix`?
1110fn segment_can_start_with(segment: &str, prefix: &[char]) -> bool {
1111  match prefix.split_first() {
1112    None => true,
1113    Some((first, rest)) => segment_starts(segment, *first) && rest.iter().all(|&c| segment_accepts(segment, c)),
1114  }
1115}
1116
1117/// Can `segment` contain `c`? Mirrors the charsets the capture groups use, so
1118/// the boundary check asks about the same characters the regex would match.
1119fn segment_accepts(segment: &str, c: char) -> bool {
1120  match segment {
1121    "type" => c.is_ascii_lowercase(),
1122    "issue" => c.is_ascii_digit(),
1123    _ => c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-',
1124  }
1125}
1126
1127/// Recover the segments a pattern freezes as literal text instead of writing
1128/// from a placeholder.
1129///
1130/// `feat/#{issue}-{desc}` has no `{type}`, yet every branch it writes *is* a
1131/// `feat` branch, and the release before this one read that back because its
1132/// hardcoded regex happened to have a group in that position. Dropping it here
1133/// would take gitmoji, `[pr_template.by_type]` and `gwm commit-prefix` away
1134/// from a repo where they work today, so the literal is recovered on purpose.
1135///
1136/// `authored` is the literal text **the user wrote in the pattern**, with a
1137/// marker where each placeholder and each `{repo}` / `{home}` stood. The
1138/// expansions themselves are never in it: nobody picked the repo's name for
1139/// this, and a repo that happens to be called `docs` must not turn
1140/// `{repo}/{issue}-{desc}` into a docs-typed branch.
1141///
1142/// Recovery is **positional first, then oracle**, in that order — the reverse
1143/// lost both segments of `feat/#{issue}-fix`, where `feat` and `fix` are each
1144/// a configured branch type and neither is globally unique:
1145///
1146/// 1. A segment can only be recovered from the stretch of `authored` between
1147///    the placeholders it sits between. `{type}` before `{issue}`, `{desc}`
1148///    after it — so in `feat/#{issue}-fix` the type can only come from
1149///    `feat/#` and the description only from `-fix`.
1150/// 2. Within that stretch, candidates are the maximal runs of the segment's
1151///    own charset, and each is put to its own oracle: a branch type is an
1152///    exact match against the repo's configured list, an issue number is all
1153///    digits, a description is whatever `DESC_RE` accepts.
1154/// 3. A segment is recovered iff **every** maximal reading of the pattern
1155///    names it with the **same value**. Readings that disagree are a genuine
1156///    ambiguity — `feat/fix-{issue}-{desc}` names two configured types before
1157///    the issue — and inventing one would be worse than reporting none.
1158///
1159/// Rule 3 is stated per segment on purpose, and counting readings instead was
1160/// wrong twice. `feat/feat/#{issue}-{desc}` has two readings that both say
1161/// `feat`, which is one answer and not a coin toss; `feat/#{issue}-fix/done`
1162/// has two that disagree about the description while agreeing about the type,
1163/// and one ambiguous segment must not take an unanimous one down with it.
1164///
1165/// Segments are resolved in `type`, `issue`, `desc` order and each claim
1166/// advances a cursor, so `feat/#1-fixed`, which freezes all three in one
1167/// stretch, hands `feat` to the type, `1` to the issue and `fixed` to the
1168/// description rather than letting the loose `desc` oracle take the lot.
1169fn literal_constants(authored: &str, captured: &[&str], types: &[BranchType]) -> Vec<(&'static str, String)> {
1170  let missing: Vec<(usize, &'static str)> = SEGMENTS
1171    .iter()
1172    .enumerate()
1173    .filter(|(_, segment)| !captured.contains(segment))
1174    .map(|(rank, segment)| (rank, *segment))
1175    .collect();
1176
1177  let all = assignments(authored, &missing, types, 0);
1178  let best = all.iter().map(Vec::len).max().unwrap_or(0);
1179  let top: Vec<Vec<(&'static str, String)>> = all.into_iter().filter(|a| a.len() == best).collect();
1180
1181  // Unanimity, segment by segment. Keeping the first reading and dropping
1182  // whatever the others contradict is the whole rule: a segment survives when
1183  // every reading names it the same way, so the readings can disagree about
1184  // one segment without costing the others. `first` is already in `type`,
1185  // `issue`, `desc` order, which is the order callers expect back.
1186  let Some((first, rest)) = top.split_first() else {
1187    return Vec::new();
1188  };
1189  first
1190    .iter()
1191    .filter(|(segment, value)| {
1192      rest
1193        .iter()
1194        .all(|reading| reading.iter().any(|(s, v)| s == segment && v == value))
1195    })
1196    .cloned()
1197    .collect()
1198}
1199
1200/// Every way the missing segments could be read out of `authored`, in order.
1201///
1202/// Each segment may take a candidate from its own stretch or be skipped, and a
1203/// taken candidate moves the cursor so the next segment reads what is left.
1204/// Enumerating rather than deciding greedily is what separates the two cases
1205/// that look alike from the outside: `feat/#1-fix` freezes all three segments
1206/// and `fix` is a configured branch type, but only one reading assigns all
1207/// three (`fix` as the type leaves no digits for the issue), while
1208/// `feat/fix-{issue}-{desc}` has two readings of equal size and is therefore
1209/// genuinely ambiguous.
1210fn assignments(
1211  authored: &str,
1212  missing: &[(usize, &'static str)],
1213  types: &[BranchType],
1214  cursor: usize,
1215) -> Vec<Vec<(&'static str, String)>> {
1216  let Some(((rank, segment), rest)) = missing.split_first().map(|(head, tail)| (*head, tail)) else {
1217    return vec![Vec::new()];
1218  };
1219  // Skipping is always allowed: `feat/#{desc}` freezes a type and no issue
1220  // number, and losing the type because the issue has nowhere to come from
1221  // would be the regression this whole function exists to prevent.
1222  let mut out = assignments(authored, rest, types, cursor);
1223
1224  let (lower, upper) = segment_region(authored, rank);
1225  let lower = lower.max(cursor);
1226  if lower < upper {
1227    for (end, value) in charset_runs(&authored[lower..upper], segment) {
1228      let plausible = match segment {
1229        "type" => types.iter().any(|t| t.name == value),
1230        "issue" => ISSUE_RE.is_match(&value),
1231        _ => DESC_RE.is_match(&value),
1232      };
1233      if !plausible {
1234        continue;
1235      }
1236      for tail in assignments(authored, rest, types, lower + end) {
1237        let mut whole = vec![(segment, value.clone())];
1238        whole.extend(tail);
1239        out.push(whole);
1240      }
1241    }
1242  }
1243  out
1244}
1245
1246/// The stretch of `authored` a segment could have been written into: after
1247/// every placeholder that canonically precedes it, before the first that
1248/// follows. A placeholder the pattern does not carry leaves no marker, so it
1249/// bounds nothing.
1250fn segment_region(authored: &str, rank: usize) -> (usize, usize) {
1251  let lower = SEGMENTS[..rank]
1252    .iter()
1253    .filter_map(|earlier| {
1254      let marker = segment_marker(earlier);
1255      authored.find(marker).map(|at| at + marker.len_utf8())
1256    })
1257    .max()
1258    .unwrap_or(0);
1259  let upper = SEGMENTS[rank + 1..]
1260    .iter()
1261    .filter_map(|later| authored.find(segment_marker(later)))
1262    .min()
1263    .unwrap_or(authored.len());
1264  (lower, upper)
1265}
1266
1267/// Every maximal run of `segment`'s charset in `text`, as `(end offset, value)`.
1268///
1269/// A description's charset spans the `-` its first character cannot be, so a
1270/// run is trimmed of leading dashes rather than split on them: `-fixed--desc`
1271/// is one description, and so is `-fixed-`, both of which `DESC_RE` and the
1272/// 1.5.0 parser accept. Splitting on the dash dropped the first and truncated
1273/// the second.
1274fn charset_runs(text: &str, segment: &str) -> Vec<(usize, String)> {
1275  let mut out: Vec<(usize, String)> = Vec::new();
1276  let mut run: Option<usize> = None;
1277  for (at, c) in text.char_indices().chain(std::iter::once((text.len(), '\0'))) {
1278    if at < text.len() && segment_accepts(segment, c) {
1279      run.get_or_insert(at);
1280      continue;
1281    }
1282    if let Some(start) = run.take() {
1283      let value = text[start..at].trim_start_matches('-');
1284      if !value.is_empty() {
1285        out.push((at, value.to_string()));
1286      }
1287    }
1288  }
1289  out
1290}
1291
1292/// What each branch segment feeds, shared by both shapes of the warning.
1293///
1294/// Every segment feeds the TUI rename and `cmd_pr`'s template context on top
1295/// of its own headline consumer — naming only the headline would under-report
1296/// exactly what this warning promises to name. Two boundaries keep the claim
1297/// honest:
1298///
1299/// - `issue` is specifically *issue* linking. PR/MR detection goes through
1300///   `Forge::find_pr_for_branch`, which takes the whole branch name and never
1301///   parses it, so it keeps working whatever the pattern.
1302/// - hook placeholders break on the **remove / bootstrap** paths only. Those
1303///   rebuild the context with `HookContext::for_worktree`, which re-parses the
1304///   branch. `gwm create` uses `HookContext::for_create` and passes the
1305///   original `BranchSpec` straight through, so its own hooks keep the right
1306///   `type` / `issue` / `desc` however unreadable the pattern is.
1307fn segment_consumers(segment: &str) -> &'static str {
1308  match segment {
1309    "type" => "gitmoji / `gwm commit-prefix`, `[pr_template.by_type]` selection, remove/bootstrap hook placeholders and the TUI rename",
1310    "issue" => "issue auto-linking from the branch name, `gwm pr` body placeholders, remove/bootstrap hook placeholders and the TUI rename",
1311    _ => "`gwm pr` body placeholders, remove/bootstrap hook placeholders and the TUI rename",
1312  }
1313}
1314
1315/// How a segment's consumers fail when the pattern never writes it. Distinct
1316/// from the mis-parse verbs: nothing here reads the *wrong* value, there is
1317/// simply no value to read.
1318fn segment_absent_verb(segment: &str) -> &'static str {
1319  match segment {
1320    "type" => "have no branch type to work from",
1321    "issue" => "have no issue number to work from",
1322    _ => "have no description to work from",
1323  }
1324}
1325
1326/// Does `worktree.branch_pattern` survive a format/parse round-trip?
1327///
1328/// Issue #415 introduced this as damage assessment: the parser was hardcoded,
1329/// so most customised patterns broke, and the warning's job was to name which
1330/// features died. Issue #417 derived the parser from the pattern, which fixes
1331/// the cause. What is left for this predicate is the residue — the two things
1332/// a derived parser still cannot recover:
1333///
1334/// 1. **A pattern that cannot be compiled at all** (adjacent tokens, a
1335///    repeated token). [`BranchParser::from_config`] falls back to reading
1336///    nothing rather than to the built-in shape, deliberately; this is the
1337///    loud half of that silence.
1338/// 2. **A segment the pattern does not carry.** `{type}/{desc}` writes no
1339///    issue number, so nothing can read one back, and issue auto-linking is
1340///    genuinely inactive on that repo. Same for a pattern that hardcodes the
1341///    type: it is a legitimate convention, and stating what it costs is the
1342///    whole point of #415.
1343///
1344/// The check is an actual probe, never a comparison against the default
1345/// string: "differs from the default" and "breaks the parser" were never the
1346/// same set, and since #417 they barely overlap. `{type}-{issue}-{desc}`,
1347/// `{type}_{issue}_{desc}` and `{desc}-{issue}` are all customised and all
1348/// round-trip.
1349///
1350/// Returns the user-facing warning naming what actually breaks, or `None`
1351/// when the pattern round-trips. This is the single predicate both
1352/// `gwm doctor` and `gwm config validate` consume.
1353///
1354/// `repo` must be the real repo name ([`crate::worktree::repo_name`]) and
1355/// `types` the repo's [`crate::config::Config::resolved_branch_types`]: both
1356/// feed the compiled parser, so a stand-in returns a verdict about a
1357/// different repo's branches.
1358///
1359/// **Invariant: this function reports what it observed, and never
1360/// generalises.** Every message is phrased over "the N branch shapes probed".
1361/// The probe set is *classes worth probing*, not an exhaustive space; a class
1362/// it misses can only make the counts smaller, never make the statement
1363/// false. Since the parser is now derived, the probe's remaining job is to
1364/// catch what the compiler does not mirror — the `shellexpand::tilde` pass
1365/// the formatter ends with is the known one.
1366///
1367/// - `type` — every configured branch type. Finite, so this one *is*
1368///   exhaustive, and a type gwm would refuse to create is excluded.
1369/// - `issue` — `ISSUE_RE` is `\d+`: single-digit and multi-digit.
1370/// - `desc` — `DESC_RE` is `[a-z0-9][a-z0-9-]*`: a plain word, one carrying
1371///   the `-` it allows, one all-digits, and one that starts with digits and
1372///   then carries a `-`. Those last two are what tell an ambiguous adjacency
1373///   apart from a merely unusual separator.
1374pub fn branch_pattern_warning(pattern: &str, repo: &str, types: &[BranchType]) -> Option<String> {
1375  const ISSUES: [&str; 2] = ["7", "42"];
1376  const DESCS: [&str; 4] = ["probe", "probe-desc", "123", "123-probe"];
1377
1378  // A pattern that cannot be compiled reads *nothing* (see
1379  // `BranchParser::from_config`), so this is the loud half of that silence
1380  // rather than a separate class of problem. The compile error already names
1381  // the pattern and the reason.
1382  let parser = match BranchParser::compile(pattern, repo, types) {
1383    Ok(p) => p,
1384    Err(e) => return Some(format!("{}", e)),
1385  };
1386
1387  // A segment the pattern cannot supply at all is a different report from a
1388  // segment that reads back wrong, and conflating them was misleading in both
1389  // directions: saying "N of the shapes probed read back the wrong type" both
1390  // over-quantifies a permanent absence and hides the one-line fix. Ask the
1391  // compiled parser rather than re-scanning the pattern string, so the verdict
1392  // comes from the artefact that does the reading — and so a segment the
1393  // pattern *freezes* as a literal counts as supplied, because it is.
1394  let missing: Vec<&str> = SEGMENTS.into_iter().filter(|seg| !parser.reads_segment(seg)).collect();
1395
1396  let (mut unparseable, mut parsed, mut lossy) = (None::<String>, 0usize, 0usize);
1397  let (mut bad_type, mut bad_issue, mut bad_desc) = (false, false, false);
1398  let mut probes = 0usize;
1399
1400  // Probe only types `gwm create` would actually accept. `merge_layered`
1401  // deserialises `[[branch_types]]` without running `validate_branch_types`,
1402  // so the effective list can carry a name like `Feat` that the config
1403  // validator rejects outright — probing it would report the *default*
1404  // pattern as broken, because `BRANCH_RE`'s `[a-z]+` cannot match it. The
1405  // invalid config is reported by its own check; this one stays quiet
1406  // rather than blaming the pattern for it.
1407  let usable = types
1408    .iter()
1409    .map(|t| t.name.as_str())
1410    .filter(|n| !n.is_empty() && n.chars().all(|c| c.is_ascii_lowercase()));
1411
1412  for type_ in usable {
1413    for issue in ISSUES {
1414      for desc in DESCS {
1415        // A pattern that does not expand at all is a different, *loud*
1416        // failure: `gwm create` errors on it outright. Not our business.
1417        let formatted = expand_placeholders(pattern, repo, Some(type_), Some(issue), Some(desc), None).ok()?;
1418        probes += 1;
1419        match parser.parse(&formatted) {
1420          None => {
1421            unparseable.get_or_insert(formatted);
1422          }
1423          Some(back) => {
1424            parsed += 1;
1425            // A segment the pattern omits is reported by `missing`; counting
1426            // it here too would tally the same loss twice, in the shape that
1427            // describes it least well.
1428            let (t, i, d) = (
1429              !missing.contains(&"type") && back.type_ != type_,
1430              !missing.contains(&"issue") && back.issue != issue,
1431              !missing.contains(&"desc") && back.desc != desc,
1432            );
1433            // `lossy` counts probes, the flags accumulate across them. The
1434            // distinction matters: the flags say *which* segments can come
1435            // back wrong somewhere, `lossy` says *how many* shapes they came
1436            // back wrong on. Reporting the flags as if they held for every
1437            // parsed probe is the over-claim this counter exists to stop —
1438            // `{desc}/#{issue}-{type}` has probes that round-trip perfectly
1439            // alongside probes that swap two segments.
1440            lossy += usize::from(t || i || d);
1441            bad_type |= t;
1442            bad_issue |= i;
1443            bad_desc |= d;
1444          }
1445        }
1446      }
1447    }
1448  }
1449
1450  // No configured types at all would leave the verdict unprobed — say
1451  // nothing rather than guess. `resolved_branch_types` never yields this
1452  // (it falls back to the built-ins), so it is a guard, not a path.
1453  if probes == 0 {
1454    return None;
1455  }
1456
1457  let unparsed = probes - parsed;
1458  if missing.is_empty() && unparsed == 0 && lossy == 0 {
1459    return None;
1460  }
1461
1462  // Counts are always scoped to the shapes probed. There is no branch that
1463  // says "every branch created with this pattern", because that is precisely
1464  // the claim the probe set cannot support. The `missing` part is the one
1465  // exception, and it earns it: a placeholder the pattern does not contain
1466  // is absent from every name it will ever write, no probing required.
1467  let mut parts: Vec<String> = Vec::new();
1468  if !missing.is_empty() {
1469    let tokens = missing.iter().map(|s| format!("`{{{}}}`", s)).collect::<Vec<_>>();
1470    let losses = missing
1471      .iter()
1472      .map(|seg| format!("{} {}", segment_consumers(seg), segment_absent_verb(seg)))
1473      .collect::<Vec<_>>();
1474    parts.push(format!(
1475      "it carries no {}, so {} — write {} into the pattern to get them back",
1476      tokens.join(" and "),
1477      losses.join("; "),
1478      tokens.join(" and ")
1479    ));
1480  }
1481  if let Some(example) = unparseable {
1482    parts.push(format!(
1483      "{} of the {} branch shapes probed match nothing at all (e.g. `{}`), so issue auto-linking from the branch name, gitmoji / `gwm commit-prefix`, `gwm pr` template selection and placeholders, remove/bootstrap hook placeholders, the TUI rename and the branch-convention check are inactive on those (PR/MR detection is unaffected — it queries the forge with the full branch name)",
1484      unparsed,
1485      probes,
1486      sanitise_for_terminal(&example)
1487    ));
1488  }
1489  if lossy > 0 {
1490    let mut broken: Vec<String> = Vec::new();
1491    for (flag, seg, verb) in [
1492      (bad_type, "type", "read the wrong branch type"),
1493      (bad_issue, "issue", "target the wrong issue"),
1494      (bad_desc, "desc", "see the wrong description"),
1495    ] {
1496      if flag {
1497        broken.push(format!("`{}`, so {} {}", seg, segment_consumers(seg), verb));
1498      }
1499    }
1500    parts.push(format!(
1501      "{} of the {} branch shapes probed parse but read back {}",
1502      lossy,
1503      probes,
1504      broken.join("; ")
1505    ));
1506  }
1507
1508  Some(format!(
1509    "worktree.branch_pattern `{}` does not round-trip: {}",
1510    sanitise_for_terminal(pattern),
1511    parts.join("; and ")
1512  ))
1513}
1514
1515/// Neutralise control characters, and the display-reordering characters
1516/// [`is_display_reordering`] names, before echoing a config-supplied value on
1517/// a **single row**.
1518///
1519/// Config values come from a repo's `.gwm.toml`, and the commands that quote
1520/// them (`gwm config get` / `list`, `gwm types`, `gwm aliases list`,
1521/// `gwm doctor`, `gwm config validate`, `gwm commit-prefix`, and the TOFU
1522/// prompt itself) do not go through the trust gate, because inspecting a repo
1523/// you have not vetted is meant to be the safe thing to do. Echoing the raw
1524/// value would hand an untrusted `.gwm.toml` a terminal escape channel (an
1525/// OSC 52 clipboard write, a title rewrite, cursor games). Same idiom as
1526/// [`crate::tui::wt_tree::sanitize_name`]: replace, don't strip, so the value
1527/// stays recognisable and its length is not silently altered.
1528///
1529/// Line breaks are neutralised too, deliberately: a value that could emit a
1530/// `\n` could forge an extra row in a report the user reads as a list of
1531/// facts. Use [`sanitise_block_for_terminal`] for output that is *meant* to
1532/// span rows.
1533///
1534/// Public because every site that echoes a config-supplied string has to use
1535/// it, `main` included; a second copy would be a second thing to forget.
1536pub fn sanitise_for_terminal(s: &str) -> String {
1537  s.chars()
1538    .map(|c| {
1539      if c.is_control() || is_display_reordering(c) {
1540        '?'
1541      } else {
1542        c
1543      }
1544    })
1545    .collect()
1546}
1547
1548/// The characters carrying the Unicode `Bidi_Control` property (issue #502).
1549///
1550/// They are `Cf` (format), not `Cc` (control), so [`char::is_control`] does
1551/// not match them — which is the whole reason they need naming here. They
1552/// reorder how a terminal *renders* the text around them, so a value carrying
1553/// one can display a benign-looking command while the bytes that execute
1554/// differ. That defeats the same "what you read is what is there" guarantee
1555/// the control-character replacement exists to provide, so they are
1556/// neutralised the same way and at the same sinks.
1557///
1558/// The set is `Bidi_Control` exactly, not a subset: the overrides, embeddings
1559/// and isolates, **plus** the three implicit marks. The marks earn their place
1560/// on their bidi class rather than on being formatting characters. `U+061C` is
1561/// class `AL` and `U+200F` is `R`, so either one is a strong right-to-left
1562/// character inside otherwise left-to-right text, and UAX #9's weak and
1563/// neutral rules then reorder the digits and punctuation around it: an
1564/// argument or a URL can render in an order the bytes do not have. `U+200E`
1565/// is class `L` and does the same in a value whose paragraph direction is
1566/// right-to-left. Aligning on the named property rather than on a hand-picked
1567/// list is also what makes this reviewable.
1568///
1569/// Visible to the crate because `aliases::validate_aliases` needs the same set
1570/// at the one boundary the sinks cannot reach: an alias expansion becomes argv
1571/// before clap parses it, so it is refused there rather than cleaned here,
1572/// exactly as the control characters are.
1573pub(crate) fn is_display_reordering(c: char) -> bool {
1574  matches!(c, '\u{061C}' | '\u{200E}' | '\u{200F}' | '\u{202A}'..='\u{202E}' | '\u{2066}'..='\u{2069}')
1575}
1576
1577/// Neutralise control characters in output whose **shape is rows**: a `toml`
1578/// parse diagnostic with its caret-under-the-column snippet, or the raw
1579/// `.gwm.toml` body the TOFU prompt shows on `show` (issue #473).
1580///
1581/// Keeps `\n` and `\t`, which carry the layout, and replaces everything else
1582/// including `\r` (which returns the cursor to column zero and lets a value
1583/// overwrite the line it was printed on) and the display-reordering characters
1584/// [`is_display_reordering`] names. Sanitising these is not "breaking raw": an
1585/// escape sequence inside the body defeats the very inspection the `show` view
1586/// exists to provide.
1587/// Neutralise a **diagnostic** headed for stderr: block-sanitise it, then
1588/// indent every line after the first (issue #473).
1589///
1590/// The indent is the security part, not cosmetics. A diagnostic is one string
1591/// that mixes layout `toml` generated with values it decoded out of the repo's
1592/// file, and no classification can separate them: the same message carries a
1593/// caret-under-the-column snippet AND an ``unknown field `<key>` `` naming a
1594/// key the repo chose. Measured, a key written as `"bad\nerror: forged"`
1595/// printed a second line at column zero reading exactly like a statement from
1596/// gwm.
1597///
1598/// Owning the left margin makes that impossible without classifying anything:
1599/// only the first line starts at column zero, and `\r` is already replaced, so
1600/// nothing the config emits can get back there. The snippet stays readable,
1601/// which is the whole reason the line breaks survive at all.
1602pub fn sanitise_diagnostic_for_terminal(s: &str) -> String {
1603  let cleaned = sanitise_block_for_terminal(s);
1604  let mut out = String::with_capacity(cleaned.len());
1605  for (i, line) in cleaned.split('\n').enumerate() {
1606    if i > 0 {
1607      out.push_str("\n  ");
1608    }
1609    out.push_str(line);
1610  }
1611  out
1612}
1613
1614pub fn sanitise_block_for_terminal(s: &str) -> String {
1615  // A CRLF pair is a line ending, so it normalises to `\n` rather than losing
1616  // its `\r` to a `?`. Windows writes config files, ledgers and process output
1617  // that way, and marking every one of their line ends as suspicious would
1618  // make the neutralisation itself look like the corruption.
1619  //
1620  // A LONE `\r` is not a line ending: it returns the cursor to column zero and
1621  // lets what follows overwrite the line already printed, which is the one
1622  // thing the margin rule exists to prevent. That one still goes.
1623  let normalised = s.replace("\r\n", "\n");
1624  normalised
1625    .chars()
1626    .map(|c| {
1627      if (c.is_control() && c != '\n' && c != '\t') || is_display_reordering(c) {
1628        '?'
1629      } else {
1630        c
1631      }
1632    })
1633    .collect()
1634}
1635
1636pub fn kebab(input: &str) -> String {
1637  // Lowercase, then collapse every non-alphanumeric run into a single `-`.
1638  let lower = input.to_lowercase();
1639  let mut out = String::with_capacity(lower.len());
1640  let mut prev_dash = false;
1641  for c in lower.chars() {
1642    if c.is_ascii_alphanumeric() {
1643      out.push(c);
1644      prev_dash = false;
1645    } else if !prev_dash && !out.is_empty() {
1646      out.push('-');
1647      prev_dash = true;
1648    }
1649  }
1650  out.trim_matches('-').to_string()
1651}