tmux-workspace 0.1.0-alpha.16

Build tmux workspaces from tmuxp-style YAML using libtmux (alpha)
Documentation
use std::collections::BTreeSet;
use std::path::{Path, PathBuf};

use serde_json::{Value, json};

use super::{CliError, Result, document};

const EXTENSIONS: [&str; 3] = ["yaml", "yml", "json"];

pub(super) fn home() -> PathBuf {
    std::env::var_os("HOME").map_or_else(|| PathBuf::from("."), PathBuf::from)
}

/// The variables `text` names as `$NAME` or `${NAME...}`, for a shell to
/// expand. A braced form counts by its leading name, as in `${NAME:-default}`.
pub(super) fn referenced(text: &str) -> impl Iterator<Item = &str> {
    text.match_indices('$').filter_map(|(index, _)| {
        let tail = &text[index + 1..];
        let tail = tail.strip_prefix('{').unwrap_or(tail);
        let end = tail
            .find(|c: char| !c.is_ascii_alphanumeric() && c != '_')
            .unwrap_or(tail.len());
        let name = &tail[..end];
        name.starts_with(|c: char| c.is_ascii_alphabetic() || c == '_')
            .then_some(name)
    })
}

pub(super) fn expand(text: &str) -> String {
    let mut text = if text == "~" {
        home().to_string_lossy().into_owned()
    } else if let Some(tail) = text.strip_prefix("~/") {
        home().join(tail).to_string_lossy().into_owned()
    } else {
        text.to_owned()
    };
    let mut output = String::new();
    while let Some(index) = text.find('$') {
        output.push_str(&text[..index]);
        let tail = &text[index + 1..];
        let (name, consumed) = if let Some(tail) = tail.strip_prefix('{') {
            if let Some(end) = tail.find('}') {
                (&tail[..end], end + 2)
            } else {
                ("", 0)
            }
        } else {
            let end = tail
                .find(|c: char| !c.is_ascii_alphanumeric() && c != '_')
                .unwrap_or(tail.len());
            (&tail[..end], end)
        };
        if name.is_empty() {
            output.push('$');
            text = tail.to_owned();
            continue;
        }
        if let Ok(value) = std::env::var(name) {
            output.push_str(&value);
        } else {
            output.push_str(&text[index..index + 1 + consumed]);
        }
        text = tail[consumed..].to_owned();
    }
    output.push_str(&text);
    output
}

pub(super) fn masked(path: &Path) -> String {
    path.strip_prefix(home()).map_or_else(
        |_| path.to_string_lossy().into_owned(),
        |tail| {
            if tail.as_os_str().is_empty() {
                "~".into()
            } else {
                format!("~/{}", tail.display())
            }
        },
    )
}

/// Where a new workspace belongs: the first directory `load <name>` searches
/// that exists, else `$TMUXP_CONFIGDIR` when set, else
/// `$XDG_CONFIG_HOME/tmuxp`. Saving there creates it.
pub(super) fn workspace_dir() -> PathBuf {
    let dirs = global_dirs();
    dirs.iter()
        .find(|path| path.is_dir())
        .or_else(|| dirs.first())
        .cloned()
        .unwrap_or_else(|| home().join(".tmuxp"))
}

pub(super) fn global_dirs() -> Vec<PathBuf> {
    let mut dirs = Vec::new();
    if let Some(path) = std::env::var_os("TMUXP_CONFIGDIR").filter(|v| !v.is_empty()) {
        dirs.push(PathBuf::from(expand(&path.to_string_lossy())));
    }
    dirs.push(
        std::env::var_os("XDG_CONFIG_HOME")
            .filter(|v| !v.is_empty())
            .map_or_else(|| home().join(".config"), PathBuf::from)
            .join("tmuxp"),
    );
    dirs.push(home().join(".tmuxp"));
    let mut seen = BTreeSet::new();
    dirs.retain(|path| seen.insert(path.clone()));
    dirs
}

pub(super) fn global_metadata() -> Result<Vec<Value>> {
    let mut active = false;
    global_dirs().into_iter().map(|path| {
        let exists = path.is_dir();
        let selected = exists && !active;
        active |= exists;
        let count = if exists { std::fs::read_dir(&path)?.filter_map(std::result::Result::ok).filter(|entry| {
            let name = entry.file_name();
            !name.to_string_lossy().starts_with('.') && entry.path().extension().is_some_and(|ext| EXTENSIONS.contains(&ext.to_string_lossy().to_ascii_lowercase().as_str()))
        }).count() } else { 0 };
        let source = if std::env::var_os("TMUXP_CONFIGDIR").is_some_and(|raw| Path::new(&expand(&raw.to_string_lossy())) == path) { "$TMUXP_CONFIGDIR" }
        else if path == home().join(".tmuxp") { "Legacy" }
        else if std::env::var_os("XDG_CONFIG_HOME").is_some() { "$XDG_CONFIG_HOME/tmuxp" } else { "XDG default" };
        Ok(json!({"path":masked(&path),"source":source,"exists":exists,"workspace_count":count,"active":selected}))
    }).collect()
}

fn project(directory: &Path) -> Option<PathBuf> {
    EXTENSIONS
        .into_iter()
        .map(|ext| directory.join(format!(".tmuxp.{ext}")))
        .find(|p| p.is_file())
}

fn candidate(path: &Path) -> Option<PathBuf> {
    if path.is_file() {
        return Some(path.to_owned());
    }
    if path.is_dir() {
        return project(path);
    }
    EXTENSIONS
        .into_iter()
        .map(|ext| path.with_extension(ext))
        .find(|p| p.is_file())
}

/// A name with no directory and no extension. tmuxp looks one up only in the
/// workspace directory, so `load dev` means the same session from anywhere,
/// and a `dev.yaml` that happens to sit in the current directory -- or an
/// earlier import's output beside a tmuxinator project -- cannot stand in.
fn is_pure_name(name: &str) -> bool {
    let path = Path::new(name);
    !matches!(name, "" | "." | "..") && !name.contains('/') && path.extension().is_none()
}

pub(super) fn resolve(name: &str, importer: Option<&str>) -> Result<PathBuf> {
    let expanded = expand(name);
    let path = PathBuf::from(&expanded);
    let pure = is_pure_name(&expanded);
    if !pure {
        if let Some(found) = candidate(&path) {
            return Ok(found.canonicalize()?);
        }
    }
    if path.components().count() == 1 {
        let directory = match importer {
            Some("teamocil") => home().join(".teamocil"),
            Some("tmuxinator") => std::env::var("TMUXINATOR_CONFIG").map_or_else(
                |_| home().join(".tmuxinator"),
                |value| PathBuf::from(expand(&value)),
            ),
            _ => global_dirs()
                .into_iter()
                .find(|p| p.is_dir())
                .unwrap_or_else(|| home().join(".tmuxp")),
        };
        if let Some(found) = candidate(&directory.join(&path)) {
            return Ok(found.canonicalize()?);
        }
        if pure && candidate(&path).is_some() {
            return Err(CliError::new(
                "workspace_not_found",
                format!(
                    "{name:?} is not in {}; to use the one in this directory, write ./{name}",
                    masked(&directory)
                ),
            ));
        }
    }
    Err(CliError::new(
        "workspace_not_found",
        format!("workspace source {name:?} was not found"),
    ))
}

pub(super) fn paths() -> Result<Vec<(PathBuf, &'static str)>> {
    let mut paths = Vec::new();
    let cwd = std::env::current_dir()?;
    for directory in cwd.ancestors() {
        if let Some(path) = project(directory) {
            paths.push((path, "local"));
        }
        if directory == home() {
            break;
        }
    }
    if let Some(directory) = global_dirs().into_iter().find(|p| p.is_dir()) {
        let mut entries =
            std::fs::read_dir(directory)?.collect::<std::result::Result<Vec<_>, _>>()?;
        entries.sort_by_key(std::fs::DirEntry::file_name);
        for entry in entries {
            let path = entry.path();
            if path.is_file()
                && path
                    .extension()
                    .and_then(|s| s.to_str())
                    .is_some_and(|s| EXTENSIONS.contains(&s))
            {
                paths.push((path, "global"));
            }
        }
    }
    let mut seen = BTreeSet::new();
    paths.retain(|(path, _)| seen.insert(path.clone()));
    Ok(paths)
}

pub(super) fn records(full: bool) -> Result<Vec<Value>> {
    paths()?.into_iter().map(|(path, source)| {
        let metadata = path.metadata()?;
        let parsed = document::read(&path);
        let mut record = json!({
            "name":path.file_stem().unwrap_or_default().to_string_lossy(),
            "path":masked(&path), "format": if path.extension().is_some_and(|ext| ext == "json") {"json"} else {"yaml"},
            "size":metadata.len(), "mtime":metadata.modified().ok().and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok()).map(|d| d.as_secs().to_string()),
            "session_name":parsed.as_ref().ok().and_then(|v| v.get("session_name")).cloned().unwrap_or(Value::Null), "source":source,
        });
        match parsed {
            Ok(value) if full => { record["config"] = value; }
            Err(error) => { record["error"] = json!(error.message); }
            _ => {}
        }
        Ok(record)
    }).collect()
}