atuin_common/
shell.rs

1use std::{ffi::OsStr, path::Path, process::Command};
2
3use serde::Serialize;
4use sysinfo::{Process, System, get_current_pid};
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 from_env() -> Shell {
66        std::env::var("ATUIN_SHELL").map_or(Shell::Unknown, |shell| {
67            Shell::from_string(shell.trim().to_lowercase())
68        })
69    }
70
71    pub fn config_file(&self) -> Option<std::path::PathBuf> {
72        let mut path = if let Some(base) = directories::BaseDirs::new() {
73            base.home_dir().to_owned()
74        } else {
75            return None;
76        };
77
78        // TODO: handle all shells
79        match self {
80            Shell::Bash => path.push(".bashrc"),
81            Shell::Zsh => path.push(".zshrc"),
82            Shell::Fish => path.push(".config/fish/config.fish"),
83
84            _ => return None,
85        };
86
87        Some(path)
88    }
89
90    /// Best-effort attempt to determine the default shell
91    /// This implementation will be different across different platforms
92    /// Caller should ensure to handle Shell::Unknown correctly
93    pub fn default_shell() -> Result<Shell, ShellError> {
94        let sys = System::name().unwrap_or("".to_string()).to_lowercase();
95
96        // TODO: Support Linux
97        // I'm pretty sure we can use /etc/passwd there, though there will probably be some issues
98        let path = if sys.contains("darwin") {
99            // This works in my testing so far
100            Shell::Sh.run_interactive([
101                "dscl localhost -read \"/Local/Default/Users/$USER\" shell | awk '{print $2}'",
102            ])?
103        } else if cfg!(windows) {
104            return Ok(Shell::Powershell);
105        } else {
106            Shell::Sh.run_interactive(["getent passwd $LOGNAME | cut -d: -f7"])?
107        };
108
109        let path = Path::new(path.trim());
110        let shell = path.file_name();
111
112        if shell.is_none() {
113            return Err(ShellError::NotSupported);
114        }
115
116        Ok(Shell::from_string(
117            shell.unwrap().to_string_lossy().to_string(),
118        ))
119    }
120
121    pub fn from_string(name: String) -> Shell {
122        match name.as_str() {
123            "bash" => Shell::Bash,
124            "fish" => Shell::Fish,
125            "zsh" => Shell::Zsh,
126            "xonsh" => Shell::Xonsh,
127            "nu" => Shell::Nu,
128            "sh" => Shell::Sh,
129            "powershell" => Shell::Powershell,
130
131            _ => Shell::Unknown,
132        }
133    }
134
135    /// Returns true if the shell is posix-like
136    /// Note that while fish is not posix compliant, it behaves well enough for our current
137    /// featureset that this does not matter.
138    pub fn is_posixish(&self) -> bool {
139        matches!(self, Shell::Bash | Shell::Fish | Shell::Zsh)
140    }
141
142    pub fn run_interactive<I, S>(&self, args: I) -> Result<String, ShellError>
143    where
144        I: IntoIterator<Item = S>,
145        S: AsRef<OsStr>,
146    {
147        let shell = self.to_string();
148        let output = if self == &Self::Powershell {
149            Command::new(shell)
150                .args(args)
151                .output()
152                .map_err(|e| ShellError::ExecError(e.to_string()))?
153        } else {
154            Command::new(shell)
155                .arg("-ic")
156                .args(args)
157                .output()
158                .map_err(|e| ShellError::ExecError(e.to_string()))?
159        };
160
161        Ok(String::from_utf8(output.stdout).unwrap())
162    }
163}
164
165pub fn shell_name(parent: Option<&Process>) -> String {
166    let sys = System::new_all();
167
168    let parent = if let Some(parent) = parent {
169        parent
170    } else {
171        let process = sys
172            .process(get_current_pid().expect("Failed to get current PID"))
173            .expect("Process with current pid does not exist");
174
175        sys.process(process.parent().expect("Atuin running with no parent!"))
176            .expect("Process with parent pid does not exist")
177    };
178
179    let shell = parent.name().trim().to_lowercase();
180    let shell = shell.strip_prefix('-').unwrap_or(&shell);
181
182    shell.to_string()
183}