atuin_common/
shell.rs

1use std::{ffi::OsStr, path::Path, process::Command};
2
3use serde::Serialize;
4use sysinfo::{get_current_pid, Process, System};
5use thiserror::Error;
6
7#[derive(PartialEq)]
8pub enum Shell {
9    Sh,
10    Bash,
11    Fish,
12    Zsh,
13    Xonsh,
14    Nu,
15    Powershell,
16
17    Unknown,
18}
19
20impl std::fmt::Display for Shell {
21    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
22        let shell = match self {
23            Shell::Bash => "bash",
24            Shell::Fish => "fish",
25            Shell::Zsh => "zsh",
26            Shell::Nu => "nu",
27            Shell::Xonsh => "xonsh",
28            Shell::Sh => "sh",
29            Shell::Powershell => "powershell",
30
31            Shell::Unknown => "unknown",
32        };
33
34        write!(f, "{}", shell)
35    }
36}
37
38#[derive(Debug, Error, Serialize)]
39pub enum ShellError {
40    #[error("shell not supported")]
41    NotSupported,
42
43    #[error("failed to execute shell command: {0}")]
44    ExecError(String),
45}
46
47impl Shell {
48    pub fn current() -> Shell {
49        let sys = System::new_all();
50
51        let process = sys
52            .process(get_current_pid().expect("Failed to get current PID"))
53            .expect("Process with current pid does not exist");
54
55        let parent = sys
56            .process(process.parent().expect("Atuin running with no parent!"))
57            .expect("Process with parent pid does not exist");
58
59        let shell = parent.name().trim().to_lowercase();
60        let shell = shell.strip_prefix('-').unwrap_or(&shell);
61
62        Shell::from_string(shell.to_string())
63    }
64
65    pub fn config_file(&self) -> Option<std::path::PathBuf> {
66        let mut path = if let Some(base) = directories::BaseDirs::new() {
67            base.home_dir().to_owned()
68        } else {
69            return None;
70        };
71
72        // TODO: handle all shells
73        match self {
74            Shell::Bash => path.push(".bashrc"),
75            Shell::Zsh => path.push(".zshrc"),
76            Shell::Fish => path.push(".config/fish/config.fish"),
77
78            _ => return None,
79        };
80
81        Some(path)
82    }
83
84    /// Best-effort attempt to determine the default shell
85    /// This implementation will be different across different platforms
86    /// Caller should ensure to handle Shell::Unknown correctly
87    pub fn default_shell() -> Result<Shell, ShellError> {
88        let sys = System::name().unwrap_or("".to_string()).to_lowercase();
89
90        // TODO: Support Linux
91        // I'm pretty sure we can use /etc/passwd there, though there will probably be some issues
92        let path = if sys.contains("darwin") {
93            // This works in my testing so far
94            Shell::Sh.run_interactive([
95                "dscl localhost -read \"/Local/Default/Users/$USER\" shell | awk '{print $2}'",
96            ])?
97        } else if cfg!(windows) {
98            return Ok(Shell::Powershell);
99        } else {
100            Shell::Sh.run_interactive(["getent passwd $LOGNAME | cut -d: -f7"])?
101        };
102
103        let path = Path::new(path.trim());
104        let shell = path.file_name();
105
106        if shell.is_none() {
107            return Err(ShellError::NotSupported);
108        }
109
110        Ok(Shell::from_string(
111            shell.unwrap().to_string_lossy().to_string(),
112        ))
113    }
114
115    pub fn from_string(name: String) -> Shell {
116        match name.as_str() {
117            "bash" => Shell::Bash,
118            "fish" => Shell::Fish,
119            "zsh" => Shell::Zsh,
120            "xonsh" => Shell::Xonsh,
121            "nu" => Shell::Nu,
122            "sh" => Shell::Sh,
123            "powershell" => Shell::Powershell,
124
125            _ => Shell::Unknown,
126        }
127    }
128
129    /// Returns true if the shell is posix-like
130    /// Note that while fish is not posix compliant, it behaves well enough for our current
131    /// featureset that this does not matter.
132    pub fn is_posixish(&self) -> bool {
133        matches!(self, Shell::Bash | Shell::Fish | Shell::Zsh)
134    }
135
136    pub fn run_interactive<I, S>(&self, args: I) -> Result<String, ShellError>
137    where
138        I: IntoIterator<Item = S>,
139        S: AsRef<OsStr>,
140    {
141        let shell = self.to_string();
142        let output = if self == &Self::Powershell {
143            Command::new(shell)
144                .args(args)
145                .output()
146                .map_err(|e| ShellError::ExecError(e.to_string()))?
147        } else {
148            Command::new(shell)
149                .arg("-ic")
150                .args(args)
151                .output()
152                .map_err(|e| ShellError::ExecError(e.to_string()))?
153        };
154
155        Ok(String::from_utf8(output.stdout).unwrap())
156    }
157}
158
159pub fn shell_name(parent: Option<&Process>) -> String {
160    let sys = System::new_all();
161
162    let parent = if let Some(parent) = parent {
163        parent
164    } else {
165        let process = sys
166            .process(get_current_pid().expect("Failed to get current PID"))
167            .expect("Process with current pid does not exist");
168
169        sys.process(process.parent().expect("Atuin running with no parent!"))
170            .expect("Process with parent pid does not exist")
171    };
172
173    let shell = parent.name().trim().to_lowercase();
174    let shell = shell.strip_prefix('-').unwrap_or(&shell);
175
176    shell.to_string()
177}