Skip to main content

smux/
util.rs

1use std::ffi::OsStr;
2use std::path::{Path, PathBuf};
3
4use anyhow::{Context, Result, bail};
5
6pub fn command_available(command: &str) -> bool {
7    which::which(command).is_ok()
8}
9
10pub fn inside_tmux() -> bool {
11    std::env::var_os("TMUX").is_some()
12}
13
14pub fn normalize_path(path: &Path) -> Result<PathBuf> {
15    let expanded = expand_tilde_path(path);
16    expanded
17        .canonicalize()
18        .with_context(|| format!("failed to resolve path {}", expanded.display()))
19}
20
21pub fn expand_and_normalize_path(path: &Path) -> Result<PathBuf> {
22    let expanded = expand_tilde_path(path);
23    expanded
24        .canonicalize()
25        .with_context(|| format!("failed to resolve path {}", expanded.display()))
26}
27
28pub fn expand_and_absolutize_path(path: &Path) -> Result<PathBuf> {
29    let expanded = expand_tilde_path(path);
30
31    if expanded.exists() {
32        return expanded
33            .canonicalize()
34            .with_context(|| format!("failed to resolve path {}", expanded.display()));
35    }
36
37    if expanded.is_absolute() {
38        Ok(expanded)
39    } else {
40        let current_dir =
41            std::env::current_dir().context("failed to resolve current working directory")?;
42        Ok(current_dir.join(expanded))
43    }
44}
45
46pub fn session_name_from_path(path: &Path) -> Result<String> {
47    let basename = path
48        .file_name()
49        .and_then(OsStr::to_str)
50        .context("path does not have a valid terminal directory name")?;
51
52    let sanitized = sanitize_session_name(basename);
53
54    if sanitized.is_empty() {
55        bail!(
56            "could not derive a valid tmux session name from {}",
57            path.display()
58        );
59    }
60
61    Ok(sanitized)
62}
63
64pub fn validated_session_name(value: &str) -> Result<String> {
65    let sanitized = sanitize_session_name(value);
66
67    if sanitized.is_empty() {
68        bail!("session name resolved to an empty value");
69    }
70
71    Ok(sanitized)
72}
73
74pub fn validated_project_name(value: &str) -> Result<String> {
75    let trimmed = value.trim().trim_end_matches(".toml");
76
77    if trimmed.is_empty() {
78        bail!("project name resolved to an empty value");
79    }
80
81    if trimmed == "." || trimmed == ".." {
82        bail!("project name must not be . or ..");
83    }
84
85    if trimmed.contains(std::path::MAIN_SEPARATOR)
86        || trimmed.contains('/')
87        || trimmed.contains('\\')
88    {
89        bail!("project name must not contain path separators");
90    }
91
92    Ok(trimmed.to_owned())
93}
94
95pub fn sanitize_session_name(value: &str) -> String {
96    value
97        .trim()
98        .chars()
99        .map(|character| match character {
100            ' ' | ':' | '.' | '\t' | '\n' | '\r' => '_',
101            character if is_tmux_safe(character) => character,
102            _ => '_',
103        })
104        .collect::<String>()
105        .trim_matches('_')
106        .to_owned()
107}
108
109pub fn path_to_string(path: &Path) -> Result<String> {
110    path.to_str()
111        .map(ToOwned::to_owned)
112        .context("path was not valid utf-8")
113}
114
115pub fn path_to_config_string(path: &Path) -> Result<String> {
116    let path = path_to_string(path)?;
117    if let Ok(home) = std::env::var("HOME") {
118        if path == home {
119            return Ok("~".to_owned());
120        }
121
122        if let Some(stripped) = path.strip_prefix(&(home.clone() + "/")) {
123            return Ok(format!("~/{stripped}"));
124        }
125    }
126
127    Ok(path)
128}
129
130pub fn expand_tilde_path(path: &Path) -> PathBuf {
131    let text = path.to_string_lossy();
132    if !text.starts_with("~/") && text != "~" {
133        return path.to_path_buf();
134    }
135
136    let Some(home) = std::env::var_os("HOME") else {
137        return path.to_path_buf();
138    };
139
140    if text == "~" {
141        return PathBuf::from(home);
142    }
143
144    let suffix = text.trim_start_matches("~/");
145    PathBuf::from(home).join(suffix)
146}
147
148fn is_tmux_safe(character: char) -> bool {
149    character.is_ascii_alphanumeric() || matches!(character, '_' | '-')
150}
151
152#[cfg(test)]
153mod tests {
154    use super::{
155        expand_tilde_path, path_to_config_string, sanitize_session_name, validated_project_name,
156        validated_session_name,
157    };
158    use std::path::Path;
159    use std::sync::Mutex;
160
161    static HOME_ENV_LOCK: Mutex<()> = Mutex::new(());
162
163    #[test]
164    fn expands_tilde_paths() {
165        let _guard = HOME_ENV_LOCK.lock().expect("home env lock should work");
166        unsafe {
167            std::env::set_var("HOME", "/Users/stefan");
168        }
169
170        let path = expand_tilde_path(Path::new("~/code"));
171        assert!(path.is_absolute());
172
173        unsafe {
174            std::env::remove_var("HOME");
175        }
176    }
177
178    #[test]
179    fn strips_invalid_session_characters() {
180        assert_eq!(sanitize_session_name(" hello/world "), "hello_world");
181    }
182
183    #[test]
184    fn rejects_empty_session_names() {
185        assert!(validated_session_name("...").is_err());
186    }
187
188    #[test]
189    fn rejects_project_names_with_path_separators() {
190        assert!(validated_project_name("foo/bar").is_err());
191    }
192
193    #[test]
194    fn strips_toml_suffix_from_project_name() {
195        assert_eq!(
196            validated_project_name("example.toml").expect("project name should validate"),
197            "example"
198        );
199    }
200
201    #[test]
202    fn collapses_home_for_config_paths() {
203        let _guard = HOME_ENV_LOCK.lock().expect("home env lock should work");
204        unsafe {
205            std::env::set_var("HOME", "/Users/stefan");
206        }
207
208        let home = std::env::var("HOME").expect("HOME should be set");
209        let path = Path::new(&home).join("code").join("smux");
210        assert_eq!(
211            path_to_config_string(&path).expect("path should render"),
212            "~/code/smux"
213        );
214
215        unsafe {
216            std::env::remove_var("HOME");
217        }
218    }
219}