Skip to main content

seshat_cli/
completions.rs

1//! `seshat completions` — print a shell completion script to stdout.
2//!
3//! When invoked without an explicit `<shell>` argument, the target is
4//! auto-detected from the `$SHELL` environment variable (basename of the
5//! login shell). On Windows we fall back to PowerShell when `$SHELL` is
6//! unset. If detection fails we return [`CliError::InvalidArgument`] with
7//! a friendly hint listing the supported shells.
8
9use std::io::{self, Write};
10
11use clap::CommandFactory;
12use clap_complete::{Shell, generate};
13
14use crate::args::Cli;
15use crate::error::CliError;
16
17/// The binary name embedded in generated completion scripts.
18///
19/// Pinned as a literal so a future rename of the clap `Cli` `name`
20/// attribute (or invocation via a wrapper that changes argv[0]) cannot
21/// silently produce completions registered against the wrong command.
22const COMPLETION_BIN_NAME: &str = "seshat";
23
24/// Print the completion script for `shell` (or the auto-detected current
25/// shell) to stdout.
26///
27/// Treats `BrokenPipe` (e.g. `seshat completions bash | head`) as a
28/// successful early termination — the consumer got what it needed and
29/// closed the pipe; propagating that as a failure would only confuse
30/// rc-file users.
31pub fn run_completions(shell: Option<Shell>) -> Result<(), CliError> {
32    let shell = match shell {
33        Some(s) => s,
34        None => detect_shell()?,
35    };
36
37    let mut cmd = Cli::command();
38    let stdout = io::stdout();
39    let mut handle = stdout.lock();
40    generate(shell, &mut cmd, COMPLETION_BIN_NAME, &mut handle);
41
42    // Flush explicitly so trailing bytes hit the descriptor before
43    // process exit. Map BrokenPipe to Ok — a downstream `head` closing
44    // its read end is a normal exit, not a CLI failure.
45    match handle.flush() {
46        Ok(()) => Ok(()),
47        Err(e) if e.kind() == io::ErrorKind::BrokenPipe => Ok(()),
48        Err(e) => Err(CliError::Io(e)),
49    }
50}
51
52/// Auto-detect the running shell from environment.
53///
54/// Checks `$SHELL` first (POSIX login-shell convention) and maps the
55/// basename to a [`Shell`] variant. Treats an empty / whitespace-only
56/// `$SHELL` as if it were unset. On Windows, falls back to
57/// [`Shell::PowerShell`] only when `$SHELL` is genuinely unset — a
58/// `$SHELL` that's set but unparseable is an error worth surfacing
59/// instead of masking with the platform default.
60fn detect_shell() -> Result<Shell, CliError> {
61    let raw_set = std::env::var("SHELL").ok();
62    // Trim whitespace and CR (CRLF env files leave a trailing `\r` on
63    // POSIX systems). Normalise empty-after-trim to "unset" semantics.
64    let raw = raw_set
65        .as_deref()
66        .map(|s| s.trim().trim_end_matches('\r'))
67        .filter(|s| !s.is_empty());
68
69    if let Some(raw) = raw {
70        if let Some(name) = shell_basename(raw) {
71            if let Some(shell) = map_shell_name(name) {
72                return Ok(shell);
73            }
74            return Err(CliError::InvalidArgument(format!(
75                "could not auto-detect shell from $SHELL={raw:?} (basename {name:?}). \
76                 Pass one explicitly: bash | zsh | fish | powershell | elvish"
77            )));
78        }
79        // `$SHELL` is set but parsing failed (no basename). Surface the
80        // raw value so the user can see what we choked on.
81        return Err(CliError::InvalidArgument(format!(
82            "could not auto-detect shell from $SHELL={raw:?} (no basename). \
83             Pass one explicitly: bash | zsh | fish | powershell | elvish"
84        )));
85    }
86
87    if cfg!(windows) {
88        return Ok(Shell::PowerShell);
89    }
90
91    Err(CliError::InvalidArgument(
92        "could not auto-detect shell ($SHELL is unset). \
93         Pass one explicitly: bash | zsh | fish | powershell | elvish"
94            .to_owned(),
95    ))
96}
97
98/// Extract the executable basename from a shell path, stripping any
99/// trailing `.exe` so that `C:\Program Files\PowerShell\7\pwsh.exe` maps
100/// to `pwsh`. Some login shells are recorded as a wrapper invocation
101/// (`/usr/bin/script /bin/zsh ...`) — fall back to the last
102/// whitespace-separated token before path-splitting.
103fn shell_basename(path: &str) -> Option<&str> {
104    // Wrapper invocations: take the last whitespace-separated token.
105    let last_token = path
106        .split_ascii_whitespace()
107        .next_back()
108        .filter(|s| !s.is_empty())?;
109    let name = last_token
110        .rsplit(['/', '\\'])
111        .next()
112        .filter(|s| !s.is_empty())?;
113    // Case-insensitive `.exe` strip — Windows filesystems are
114    // case-insensitive so `PWSH.EXE` / `pwsh.Exe` must round-trip.
115    let trimmed = if name.len() >= 4 && name[name.len() - 4..].eq_ignore_ascii_case(".exe") {
116        &name[..name.len() - 4]
117    } else {
118        name
119    };
120    Some(trimmed)
121}
122
123/// Map a shell basename (`bash`, `zsh`, `pwsh`, ...) to the [`Shell`]
124/// variant clap_complete understands.
125///
126/// Note: `sh` is intentionally *not* mapped to [`Shell::Bash`]. On
127/// Debian/Ubuntu `/bin/sh` is `dash`, on Alpine and BusyBox systems
128/// it's `ash`; emitting bash completion (which uses `compgen` /
129/// `COMPREPLY`) into those shells silently fails when the script is
130/// sourced. A user whose `$SHELL` is genuinely `/bin/sh` should pass
131/// the desired target explicitly.
132fn map_shell_name(name: &str) -> Option<Shell> {
133    match name.to_ascii_lowercase().as_str() {
134        "bash" => Some(Shell::Bash),
135        "zsh" => Some(Shell::Zsh),
136        "fish" => Some(Shell::Fish),
137        "elvish" => Some(Shell::Elvish),
138        "pwsh" | "powershell" => Some(Shell::PowerShell),
139        _ => None,
140    }
141}
142
143#[cfg(test)]
144mod tests {
145    use super::*;
146
147    #[test]
148    fn shell_basename_strips_unix_path() {
149        assert_eq!(shell_basename("/bin/zsh"), Some("zsh"));
150        assert_eq!(shell_basename("/usr/local/bin/fish"), Some("fish"));
151    }
152
153    #[test]
154    fn shell_basename_strips_windows_path_and_exe() {
155        assert_eq!(
156            shell_basename(r"C:\Program Files\PowerShell\7\pwsh.exe"),
157            Some("pwsh"),
158        );
159    }
160
161    #[test]
162    fn shell_basename_strips_uppercase_exe() {
163        // Windows filesystems are case-insensitive; .EXE / .Exe must
164        // round-trip through the strip, otherwise the lowercased name
165        // ("pwsh.exe") never matches map_shell_name's keys.
166        assert_eq!(
167            shell_basename(r"C:\WINDOWS\System32\PWSH.EXE"),
168            Some("PWSH"),
169        );
170        assert_eq!(shell_basename(r"C:\bin\Bash.Exe"), Some("Bash"));
171    }
172
173    #[test]
174    fn shell_basename_handles_bare_name() {
175        assert_eq!(shell_basename("zsh"), Some("zsh"));
176    }
177
178    #[test]
179    fn shell_basename_handles_wrapper_invocation() {
180        // Some logins record the shell as a wrapper invocation
181        // (`script(1)` capturing a session, `env`, `nice`, etc.).
182        // Take the last whitespace-separated token before path-splitting.
183        assert_eq!(shell_basename("/usr/bin/script /bin/zsh"), Some("zsh"));
184        assert_eq!(shell_basename("nice -n 19 /usr/bin/fish"), Some("fish"));
185    }
186
187    #[test]
188    fn shell_basename_rejects_empty_and_trailing_separator() {
189        assert_eq!(shell_basename(""), None);
190        assert_eq!(shell_basename("/bin/"), None);
191        assert_eq!(shell_basename("   "), None);
192    }
193
194    #[test]
195    fn map_shell_name_known_shells() {
196        assert_eq!(map_shell_name("bash"), Some(Shell::Bash));
197        assert_eq!(map_shell_name("zsh"), Some(Shell::Zsh));
198        assert_eq!(map_shell_name("fish"), Some(Shell::Fish));
199        assert_eq!(map_shell_name("elvish"), Some(Shell::Elvish));
200        assert_eq!(map_shell_name("pwsh"), Some(Shell::PowerShell));
201        assert_eq!(map_shell_name("powershell"), Some(Shell::PowerShell));
202        assert_eq!(map_shell_name("PowerShell"), Some(Shell::PowerShell));
203    }
204
205    #[test]
206    fn map_shell_name_unknown_shell() {
207        assert_eq!(map_shell_name("nu"), None);
208        assert_eq!(map_shell_name("xonsh"), None);
209        assert_eq!(map_shell_name(""), None);
210    }
211
212    #[test]
213    fn map_shell_name_does_not_assume_sh_is_bash() {
214        // `/bin/sh` is dash on Debian, ash on Alpine — emitting bash
215        // completion would fail when sourced. Force the user to choose.
216        assert_eq!(map_shell_name("sh"), None);
217        assert_eq!(map_shell_name("dash"), None);
218        assert_eq!(map_shell_name("ash"), None);
219        assert_eq!(map_shell_name("ksh"), None);
220    }
221}