smux-cli 0.1.10

Small Rust CLI for tmux session selection and creation
Documentation
use std::ffi::OsStr;
use std::path::{Path, PathBuf};

use anyhow::{Context, Result, bail};

pub fn command_available(command: &str) -> bool {
    which::which(command).is_ok()
}

pub fn inside_tmux() -> bool {
    std::env::var_os("TMUX").is_some()
}

pub fn normalize_path(path: &Path) -> Result<PathBuf> {
    let expanded = expand_tilde_path(path);
    expanded
        .canonicalize()
        .with_context(|| format!("failed to resolve path {}", expanded.display()))
}

pub fn expand_and_normalize_path(path: &Path) -> Result<PathBuf> {
    let expanded = expand_tilde_path(path);
    expanded
        .canonicalize()
        .with_context(|| format!("failed to resolve path {}", expanded.display()))
}

pub fn expand_and_absolutize_path(path: &Path) -> Result<PathBuf> {
    let expanded = expand_tilde_path(path);

    if expanded.exists() {
        return expanded
            .canonicalize()
            .with_context(|| format!("failed to resolve path {}", expanded.display()));
    }

    if expanded.is_absolute() {
        Ok(expanded)
    } else {
        let current_dir =
            std::env::current_dir().context("failed to resolve current working directory")?;
        Ok(current_dir.join(expanded))
    }
}

pub fn session_name_from_path(path: &Path) -> Result<String> {
    let basename = path
        .file_name()
        .and_then(OsStr::to_str)
        .context("path does not have a valid terminal directory name")?;

    let sanitized = sanitize_session_name(basename);

    if sanitized.is_empty() {
        bail!(
            "could not derive a valid tmux session name from {}",
            path.display()
        );
    }

    Ok(sanitized)
}

pub fn validated_session_name(value: &str) -> Result<String> {
    let sanitized = sanitize_session_name(value);

    if sanitized.is_empty() {
        bail!("session name resolved to an empty value");
    }

    Ok(sanitized)
}

pub fn validated_project_name(value: &str) -> Result<String> {
    let trimmed = value.trim().trim_end_matches(".toml");

    if trimmed.is_empty() {
        bail!("project name resolved to an empty value");
    }

    if trimmed == "." || trimmed == ".." {
        bail!("project name must not be . or ..");
    }

    if trimmed.contains(std::path::MAIN_SEPARATOR)
        || trimmed.contains('/')
        || trimmed.contains('\\')
    {
        bail!("project name must not contain path separators");
    }

    Ok(trimmed.to_owned())
}

pub fn sanitize_session_name(value: &str) -> String {
    value
        .trim()
        .chars()
        .map(|character| match character {
            ' ' | ':' | '.' | '\t' | '\n' | '\r' => '_',
            character if is_tmux_safe(character) => character,
            _ => '_',
        })
        .collect::<String>()
        .trim_matches('_')
        .to_owned()
}

pub fn path_to_string(path: &Path) -> Result<String> {
    path.to_str()
        .map(ToOwned::to_owned)
        .context("path was not valid utf-8")
}

pub fn path_to_config_string(path: &Path) -> Result<String> {
    let path = path_to_string(path)?;
    if let Ok(home) = std::env::var("HOME") {
        if path == home {
            return Ok("~".to_owned());
        }

        if let Some(stripped) = path.strip_prefix(&(home.clone() + "/")) {
            return Ok(format!("~/{stripped}"));
        }
    }

    Ok(path)
}

pub fn expand_tilde_path(path: &Path) -> PathBuf {
    let text = path.to_string_lossy();
    if !text.starts_with("~/") && text != "~" {
        return path.to_path_buf();
    }

    let Some(home) = std::env::var_os("HOME") else {
        return path.to_path_buf();
    };

    if text == "~" {
        return PathBuf::from(home);
    }

    let suffix = text.trim_start_matches("~/");
    PathBuf::from(home).join(suffix)
}

fn is_tmux_safe(character: char) -> bool {
    character.is_ascii_alphanumeric() || matches!(character, '_' | '-')
}

#[cfg(test)]
mod tests {
    use super::{
        expand_tilde_path, path_to_config_string, sanitize_session_name, validated_project_name,
        validated_session_name,
    };
    use std::path::Path;
    use std::sync::Mutex;

    static HOME_ENV_LOCK: Mutex<()> = Mutex::new(());

    #[test]
    fn expands_tilde_paths() {
        let _guard = HOME_ENV_LOCK.lock().expect("home env lock should work");
        unsafe {
            std::env::set_var("HOME", "/Users/stefan");
        }

        let path = expand_tilde_path(Path::new("~/code"));
        assert!(path.is_absolute());

        unsafe {
            std::env::remove_var("HOME");
        }
    }

    #[test]
    fn strips_invalid_session_characters() {
        assert_eq!(sanitize_session_name(" hello/world "), "hello_world");
    }

    #[test]
    fn rejects_empty_session_names() {
        assert!(validated_session_name("...").is_err());
    }

    #[test]
    fn rejects_project_names_with_path_separators() {
        assert!(validated_project_name("foo/bar").is_err());
    }

    #[test]
    fn strips_toml_suffix_from_project_name() {
        assert_eq!(
            validated_project_name("example.toml").expect("project name should validate"),
            "example"
        );
    }

    #[test]
    fn collapses_home_for_config_paths() {
        let _guard = HOME_ENV_LOCK.lock().expect("home env lock should work");
        unsafe {
            std::env::set_var("HOME", "/Users/stefan");
        }

        let home = std::env::var("HOME").expect("HOME should be set");
        let path = Path::new(&home).join("code").join("smux");
        assert_eq!(
            path_to_config_string(&path).expect("path should render"),
            "~/code/smux"
        );

        unsafe {
            std::env::remove_var("HOME");
        }
    }
}