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] = &["&&", "||", "|", ";", "`"];