Skip to main content

gwm/
aliases.rs

1//! CLI aliases — `[aliases]` in `.gwm.toml` plus a user-level fallback
2//! at `~/.config/gwm/aliases.toml` (issue #86).
3//!
4//! `git config` ships with `[alias]`; `gwm` mirrors the shape. Aliases
5//! are string-substitution: `gwm <alias>` is expanded to argv tokens
6//! before clap parses the command. Three resolution levels coexist:
7//!
8//!   1. **Built-in** — every `visible_alias` declared on a clap
9//!      subcommand (`cd → path` from issue #67, `s → switch` from
10//!      issue #43). Always wins, can never be shadowed by user config.
11//!   2. **Repo (`.gwm.toml`)** — declared under `[aliases]`. Follows
12//!      the repo across machines.
13//!   3. **User (`~/.config/gwm/aliases.toml`)** — same `[aliases]`
14//!      block; survives a machine reinstall but is invisible to the
15//!      rest of the team. Repo aliases win on name collision.
16//!
17//! ## Why expansion happens before clap parses
18//!
19//! Aliases must turn into argv tokens BEFORE clap reaches the
20//! subcommand slot — otherwise clap rejects an unknown subcommand
21//! before we get a chance to substitute it. The flow is:
22//!
23//! ```text
24//!   main() → aliases::load() → aliases::expand_argv() → Cli::parse(expanded)
25//! ```
26//!
27//! This shape mirrors what `git` does with `[alias]` — the dispatcher
28//! sees the expanded form, never the alias name.
29//!
30//! ## What aliases CAN'T do
31//!
32//! - **No shell pipelines** — `wip = "create feat 0 wip && lazygit"`
33//!   is rejected at load. Shell metachars (`&&`, `||`, `|`, `;`,
34//!   backticks) cannot be honoured by an argv-substitution path that
35//!   hands off to clap. Use a shell alias if that's what you need.
36//! - **No recursion** — `wip = "ll"` followed by `ll = "list --
37//!   format names"` expands once, then dispatches. Matches git's
38//!   behaviour and keeps the resolution loop linear.
39//! - **No shadowing of built-in subcommands** — `[aliases] list =
40//!   "create feat 0 wip"` is a hard config error. The check uses the
41//!   compile-time clap CommandFactory, so adding a new subcommand
42//!   automatically extends the shadow gate.
43
44use crate::error::{GwmError, Result};
45use clap::CommandFactory;
46use std::collections::BTreeMap;
47use std::path::{Path, PathBuf};
48
49/// One entry in the built-in alias snapshot. `name` is the clap
50/// `visible_alias` (e.g. `cd`); `expansion` is the canonical
51/// subcommand it points at (e.g. `path`). Static `&'static str` so the
52/// snapshot lives in `BUILT_IN_ALIASES` as a `const` slice (no heap
53/// allocation, no lazy init).
54#[derive(Debug, Clone, Copy, PartialEq, Eq)]
55pub struct AliasEntry {
56  pub name: &'static str,
57  pub expansion: &'static str,
58}
59
60/// Built-in aliases — every `#[command(visible_alias = "…")]` declared
61/// on the clap `Command` enum. Must stay in lockstep with `src/cli.rs`;
62/// a regression test in `tests/aliases_tests.rs` pins the contract.
63///
64/// The list is short by design — clap visible aliases are the only
65/// "built-ins" gwm exposes. They are reachable as bare argv tokens
66/// (`gwm cd foo`, `gwm s`) so the shadow check has to know about them
67/// to refuse user aliases of the same name.
68pub const BUILT_IN_ALIASES: &[AliasEntry] = &[
69  AliasEntry {
70    name: "cd",
71    expansion: "path",
72  },
73  AliasEntry {
74    name: "s",
75    expansion: "switch",
76  },
77];
78
79/// Resolved alias chain — built-in + repo + user, in lookup-priority
80/// order. Built by [`load`] and consumed by [`expand_argv`] (for the
81/// pre-clap expansion) and by `gwm aliases list` (for the user-facing
82/// summary).
83#[derive(Debug, Clone)]
84pub struct ResolvedAliases {
85  /// Built-in clap `visible_alias` set — always wins over user
86  /// declarations. Static slice cloned into a `Vec` so callers can
87  /// extend it cheaply (e.g. for tests).
88  pub built_in: Vec<AliasEntry>,
89  /// Repo-level aliases from `.gwm.toml`. Wins over `user` on name
90  /// collision. `BTreeMap` so iteration order is deterministic
91  /// (alphabetical), matching the output of `gwm aliases list`.
92  pub repo: BTreeMap<String, String>,
93  /// User-level aliases from `~/.config/gwm/aliases.toml`. Lowest
94  /// precedence — overridden by both `built_in` and `repo`.
95  pub user: BTreeMap<String, String>,
96}
97
98impl ResolvedAliases {
99  /// Look up `name` honouring the resolution chain: built-in first,
100  /// then repo, then user. Returns the raw expansion string (to be
101  /// tokenised by `shell_words::split` at call time).
102  ///
103  /// Built-in entries are checked first because they must be
104  /// impossible to shadow — even if a malformed `ResolvedAliases` is
105  /// constructed by hand (tests, future APIs) with a `list` entry in
106  /// `user`, `expand_argv` MUST treat `list` as the built-in
107  /// subcommand and skip the substitution.
108  fn lookup(&self, name: &str) -> Option<String> {
109    if BUILT_IN_SUBCOMMANDS.contains(&name) {
110      // Built-in subcommand names ARE the strongest binding — never
111      // expand them, regardless of what `repo`/`user` say. This is
112      // the defence-in-depth complement to the shadow check in
113      // `load`.
114      return None;
115    }
116    if let Some(e) = self.built_in.iter().find(|e| e.name == name) {
117      return Some(e.expansion.to_string());
118    }
119    if let Some(v) = self.repo.get(name) {
120      return Some(v.clone());
121    }
122    if let Some(v) = self.user.get(name) {
123      return Some(v.clone());
124    }
125    None
126  }
127}
128
129/// Load and validate the alias chain. `repo_root` is the repo root
130/// (where `.gwm.toml` lives) — `None` skips the repo step entirely
131/// (used by `aliases load` outside a git repo). `user_path` is the
132/// user-level file path — `None` falls back to the default
133/// `~/.config/gwm/aliases.toml`; an explicit path is honoured even if
134/// it doesn't exist (returns empty user map).
135///
136/// Production entry point: the repo step merges the **real** user-level
137/// global config (`global_config_path()`) underneath `.gwm.toml`, exactly
138/// like `Config::load_for_repo`. Tests that must not read the runner's
139/// real `~/.config/gwm/` should drive [`load_layered`] instead, which
140/// takes both the global and user paths explicitly (issue #194).
141///
142/// Errors:
143///   - `GwmError::Config` when a TOML parse fails, an alias shadows a
144///     built-in subcommand, an alias value contains shell pipeline
145///     metachars (`&&`, `||`, `|`, `;`, backticks), or an alias value
146///     is empty.
147///   - `GwmError::Io` if the file exists but can't be read.
148///   - `GwmError::TomlParse` propagates the underlying serde error.
149pub fn load(repo_root: Option<&Path>, user_path: Option<&Path>) -> Result<ResolvedAliases> {
150  // Resolve the two real-world fallbacks here, then delegate to the
151  // injectable seam so runtime behaviour is unchanged: the repo step sees
152  // the real global config, the user step sees `~/.config/gwm/aliases.toml`
153  // when no explicit path was given.
154  let global = crate::config::global_config_path();
155  let resolved_user_path = user_path.map(PathBuf::from).or_else(default_user_path);
156  load_layered(repo_root, global.as_deref(), resolved_user_path.as_deref())
157}
158
159/// Injectable variant of [`load`] with **no** hidden environment reads
160/// (issue #194). The repo step layers `global_path` underneath the repo's
161/// `.gwm.toml` via [`crate::config::Config::load_layered`]; `user_path` is
162/// taken literally (no `default_user_path` fallback). Passing `None` for
163/// both yields a fully hermetic, repo-only resolution — the seam tests use
164/// so they never depend on the runner's real `~/.config/gwm/`. Mirrors the
165/// `Config::load_for_repo` / `Config::load_layered` pair added in #190.
166pub fn load_layered(
167  repo_root: Option<&Path>,
168  global_path: Option<&Path>,
169  user_path: Option<&Path>,
170) -> Result<ResolvedAliases> {
171  let built_in = BUILT_IN_ALIASES.to_vec();
172
173  // Repo: read `.gwm.toml`'s `[aliases]` block via `Config::load_layered`
174  // (injected global), which already validates the same shadow /
175  // shell-pipeline rules via its dedicated `validate_aliases` method.
176  let repo = match repo_root {
177    Some(root) => {
178      let cfg = crate::config::Config::load_layered(root, global_path)?;
179      cfg.aliases
180    }
181    None => BTreeMap::new(),
182  };
183
184  // User: same shape, validated through the standalone helper so the
185  // file path appears in the error message. `user_path` is literal here.
186  let user = match user_path {
187    Some(path) if path.exists() => {
188      let raw = std::fs::read_to_string(path)?;
189      let file: AliasesFile = toml::from_str(&raw)?;
190      let map = file.aliases;
191      validate_aliases(&map, &format!("{} `[aliases]`", path.display()))?;
192      map
193    }
194    _ => BTreeMap::new(),
195  };
196
197  Ok(ResolvedAliases { built_in, repo, user })
198}
199
200/// Expand `argv` in place: replace the first non-flag token in
201/// `argv[1..]` with its alias expansion (if any). Single-pass — never
202/// recurses, never expands a token that maps to a built-in subcommand
203/// (defence-in-depth on top of `load`'s shadow check).
204///
205/// `argv[0]` (the binary name) is preserved unchanged. Trailing
206/// arguments after the alias slot are appended after the expansion —
207/// `gwm wip --no-bootstrap` with `wip = "create feat 0 wip"` becomes
208/// `gwm create feat 0 wip --no-bootstrap`.
209///
210/// Tokenisation uses `shell_words::split` (POSIX shell quoting). A
211/// malformed value (unbalanced quotes) returns the original argv
212/// unchanged — the load-time validation should already have caught
213/// shell metachars, so reaching this branch means the user
214/// hand-edited the config to something pathological. We refuse to
215/// dispatch a partial substitution and let clap report the unknown
216/// subcommand verbatim.
217pub fn expand_argv(argv: Vec<String>, aliases: &ResolvedAliases) -> Vec<String> {
218  if argv.len() < 2 {
219    return argv;
220  }
221  // Find the first non-flag token starting at index 1. Anything
222  // starting with `-` is a global flag (clap parses `--allow-bootstrap`
223  // anywhere); we look past it to land on the subcommand slot.
224  let Some(alias_idx) = argv
225    .iter()
226    .enumerate()
227    .skip(1)
228    .find_map(|(i, tok)| if tok.starts_with('-') { None } else { Some(i) })
229  else {
230    return argv;
231  };
232
233  let alias_name = &argv[alias_idx];
234  let Some(expansion) = aliases.lookup(alias_name) else {
235    return argv;
236  };
237
238  let Ok(expanded_tokens) = shell_words::split(&expansion) else {
239    // Pathological case: load() should have rejected this. Refuse
240    // partial substitution and let clap surface the unknown subcommand
241    // verbatim — the user sees "error: unrecognized subcommand" with
242    // the original alias name, not a half-expanded mess.
243    return argv;
244  };
245
246  let mut out = Vec::with_capacity(argv.len() + expanded_tokens.len());
247  out.extend_from_slice(&argv[..alias_idx]);
248  out.extend(expanded_tokens);
249  out.extend_from_slice(&argv[alias_idx + 1..]);
250  out
251}
252
253/// `OsString` counterpart of [`expand_argv`] — accepts the raw
254/// `std::env::args_os()` slice without forcing a UTF-8 round-trip on
255/// every token.
256///
257/// Why this matters: `std::env::args()` panics on the first non-UTF-8
258/// argv entry (Linux/macOS allow arbitrary bytes in argv). Clap parses
259/// `OsString` natively via `args_os`, and the panic in `main` was a
260/// regression vs. that default. We mirror the `expand_argv` logic on
261/// `OsString` and only attempt UTF-8 conversion on the alias-slot
262/// token — if it is not valid UTF-8 it cannot match an alias name
263/// (alias keys are `String` by construction in `ResolvedAliases`), so
264/// the argv is returned unchanged and clap surfaces the unknown
265/// subcommand verbatim.
266///
267/// Flag detection is byte-level: a leading `b'-'` is unambiguous in
268/// every valid argv encoding (the byte is ASCII, so it cannot appear
269/// mid-UTF-8-sequence), which means we can scan past flags without
270/// decoding them.
271pub fn expand_argv_os(argv: Vec<std::ffi::OsString>, aliases: &ResolvedAliases) -> Vec<std::ffi::OsString> {
272  if argv.len() < 2 {
273    return argv;
274  }
275  // First non-flag token from index 1. Use the underlying bytes for
276  // the leading-dash check so we don't reject argv with a non-UTF-8
277  // tail; on Unix this is `as_bytes`, on Windows the encoded form is
278  // WTF-8 and the same ASCII-byte invariant holds for the leading
279  // dash check we do here.
280  let alias_idx = argv.iter().enumerate().skip(1).find_map(|(i, tok)| {
281    let is_flag = first_byte_is_dash(tok);
282    if is_flag {
283      None
284    } else {
285      Some(i)
286    }
287  });
288  let Some(alias_idx) = alias_idx else {
289    return argv;
290  };
291
292  // Only valid UTF-8 can match an alias key. Non-UTF-8 tokens cannot
293  // be alias names (the key type is `String`), so we return the argv
294  // unchanged and let clap surface the unknown subcommand verbatim.
295  let Some(alias_name) = argv[alias_idx].to_str() else {
296    return argv;
297  };
298
299  let Some(expansion) = aliases.lookup(alias_name) else {
300    return argv;
301  };
302
303  let Ok(expanded_tokens) = shell_words::split(&expansion) else {
304    // Pathological case: load() should have rejected this. See
305    // `expand_argv` for the rationale — refuse partial substitution.
306    return argv;
307  };
308
309  let mut out = Vec::with_capacity(argv.len() + expanded_tokens.len());
310  out.extend_from_slice(&argv[..alias_idx]);
311  out.extend(expanded_tokens.into_iter().map(std::ffi::OsString::from));
312  out.extend_from_slice(&argv[alias_idx + 1..]);
313  out
314}
315
316/// Inspect the first byte of an `OsStr` to decide whether the token
317/// is a flag (leading `-`). The byte is examined in the platform's
318/// native argv encoding — ASCII bytes survive both UTF-8 (Unix) and
319/// WTF-8 (Windows) round-trips intact, so a simple `as_encoded_bytes`
320/// check is correct on both targets.
321fn first_byte_is_dash(token: &std::ffi::OsStr) -> bool {
322  // `OsStr::as_encoded_bytes` is stable since 1.74 and exposes the
323  // platform-native encoding. We only check the first byte against
324  // ASCII `-` which is identical in UTF-8 and WTF-8, so this is safe
325  // without ever decoding the rest of the token.
326  token.as_encoded_bytes().first() == Some(&b'-')
327}
328
329/// Default location of the user-level alias file, resolved the same way as
330/// the global config (`~/.config/gwm/config.toml`, issues #372/#374): an
331/// explicit `$XDG_CONFIG_HOME` wins outright, otherwise the first existing of
332/// the documented `~/.config/gwm/aliases.toml` then the platform config dir
333/// (`Application Support` on macOS, `%APPDATA%` on Windows); the canonical
334/// `~/.config` path when neither exists. Returns `None` on systems where no
335/// home resolves (sandboxed CI, containers without `$HOME`). Delegates to the
336/// shared [`crate::config::resolve_gwm_config_file`] so the alias and config
337/// resolvers can't drift.
338///
339/// `var_os`, not `var`: a non-UTF-8 `$XDG_CONFIG_HOME` (legal on Unix) must
340/// still win outright rather than be dropped and masked by `~/.config`.
341fn default_user_path() -> Option<PathBuf> {
342  let xdg = std::env::var_os("XDG_CONFIG_HOME").filter(|s| !s.is_empty());
343  let home = dirs::home_dir();
344  let platform = dirs::config_dir();
345  crate::config::resolve_gwm_config_file(
346    "aliases.toml",
347    xdg.as_deref().map(Path::new),
348    home.as_deref(),
349    platform.as_deref(),
350    |p| p.exists(),
351  )
352}
353
354/// Internal shape of the user-level alias file. Mirrors the
355/// `[aliases]` block in `.gwm.toml` so the user can copy-paste
356/// between the two without remembering whether the key prefix
357/// differs.
358#[derive(Debug, Default, serde::Deserialize)]
359struct AliasesFile {
360  #[serde(default)]
361  aliases: BTreeMap<String, String>,
362}
363
364/// Built-in subcommand names. Resolved from the clap `Command` factory
365/// at call time — adding a new subcommand to `cli::Command` extends
366/// this set automatically. Memoised inside `validate_aliases` per
367/// call; the slice form here is just for the const-time lookup in
368/// `ResolvedAliases::lookup` (subcommands hard-coded so the lookup
369/// path doesn't need to allocate). Adding a new subcommand requires
370/// adding its name here AND `tests/aliases_tests.rs` will catch a
371/// miss via the canary test.
372const BUILT_IN_SUBCOMMANDS: &[&str] = &[
373  "init",
374  "list",
375  "create",
376  "remove",
377  "path",
378  "bootstrap",
379  "prune",
380  "doctor",
381  "types",
382  "completions",
383  "shell-init",
384  "switch",
385  "tmux",
386  "zellij",
387  "link",
388  "unlink",
389  "open",
390  "status",
391  "labels",
392  "milestones",
393  "trust",
394  "aliases",
395  "help",
396];
397
398/// Validate a user-supplied alias map. Used by both
399/// [`crate::config::Config::validate_aliases`] (repo-level) and the
400/// user-level loader, so the rules stay symmetric.
401///
402/// `source_label` is woven into the error message ("`.gwm.toml`
403/// `[aliases]`" vs `"/home/x/.config/gwm/aliases.toml [aliases]"`)
404/// so the user knows which file to edit.
405///
406/// Rules enforced (matching the issue contract):
407///
408///   1. Alias name must NOT shadow a built-in subcommand or a
409///      built-in visible alias. The check uses the runtime clap
410///      `CommandFactory` so it's always in sync with `src/cli.rs`.
411///   2. Alias value must NOT be empty after trimming.
412///   3. Alias value must NOT contain shell pipeline metachars:
413///      `&&`, `||`, `|`, `;`, backticks. These would silently lose
414///      semantics under argv substitution — the user must reach for
415///      a shell alias instead.
416pub fn validate_aliases(map: &BTreeMap<String, String>, source_label: &str) -> Result<()> {
417  // Pull the built-in subcommand + alias names directly from clap so
418  // adding a new subcommand to `cli::Command` automatically extends
419  // the shadow check.
420  let cmd = crate::cli::Cli::command();
421  let mut built_ins: std::collections::HashSet<String> = std::collections::HashSet::new();
422  for sub in cmd.get_subcommands() {
423    built_ins.insert(sub.get_name().to_string());
424    for alias in sub.get_visible_aliases() {
425      built_ins.insert(alias.to_string());
426    }
427    for alias in sub.get_all_aliases() {
428      built_ins.insert(alias.to_string());
429    }
430  }
431  // Always include the canonical subcommand list — keeps the gate
432  // honest when `validate_aliases` is called before `Cli::command()`
433  // has registered new subcommands (e.g. from a test that mutates
434  // the command tree).
435  for name in BUILT_IN_SUBCOMMANDS {
436    built_ins.insert((*name).to_string());
437  }
438
439  for (name, value) in map {
440    if built_ins.contains(name) {
441      return Err(GwmError::Config(format!(
442        "{}: alias '{}' would shadow a built-in subcommand or alias — pick a different name",
443        source_label, name
444      )));
445    }
446    let trimmed = value.trim();
447    if trimmed.is_empty() {
448      return Err(GwmError::Config(format!(
449        "{}: alias '{}' has an empty value — provide a subcommand to expand to",
450        source_label, name
451      )));
452    }
453    // Shell pipeline metachar gate. The list is conservative — we
454    // refuse anything that LOOKS like a pipeline so the failure
455    // mode is "user reads the error" rather than "user sees a
456    // half-honoured alias do mysterious things".
457    for pat in SHELL_METACHARS {
458      if trimmed.contains(pat) {
459        return Err(GwmError::Config(format!(
460          "{}: alias '{}' = {:?} contains shell metachar {:?} — \
461           gwm aliases are argv substitution only (no pipelines); use a shell alias instead",
462          source_label, name, value, pat
463        )));
464      }
465    }
466    // Issue #473. An alias expansion becomes argv BEFORE clap parses it, and
467    // clap prints its own error and exits without passing through the stderr
468    // sink in `main`. Clap strips the ASCII controls from the token it quotes,
469    // but not the C1 range: measured, `\u{9b}` (CSI) and `\u{85}` (NEL) came
470    // out of `gwm <alias>` intact.
471    //
472    // Rejected at the boundary rather than cleaned at the sink, because argv
473    // has more than one downstream and clap's is the one we do not own. No
474    // legitimate expansion needs a control character; the name and value are
475    // quoted with `{:?}` so the report cannot replay what it is refusing.
476    // Issue #502: the `Bidi_Control` characters ride the same path for the same
477    // reason. They are `Cf`, so the test above does not see them, and measured,
478    // clap quotes `nope<U+061C>abc` back with the character intact, which makes
479    // the token it names render in an order it does not have.
480    if let Some(bad) = value
481      .chars()
482      .find(|c| c.is_control() || crate::naming::is_display_reordering(*c))
483    {
484      return Err(GwmError::Config(format!(
485        "{}: alias '{}' = {:?} contains the character {:?}; \
486         an expansion becomes argv, and a control or display-reordering character \
487         there is a terminal escape rather than an argument",
488        source_label, name, value, bad
489      )));
490    }
491  }
492  Ok(())
493}
494
495/// Forbidden shell metachars in alias values. The list intentionally
496/// stays short — anything that even hints at "shell pipeline" gets
497/// rejected. A user trying to do `path | pbcopy` hits the gate and
498/// reads the error pointing at shell aliases as the right tool.
499const SHELL_METACHARS: &[&str] = &["&&", "||", "|", ";", "`"];