wsx-core 0.16.2

Library crate for wsx: worktree, tmux, git, hooks, config, model primitives. Ratatui-free; consumable by wsx binary and external orchestrators (e.g. auwsx).
Documentation
use std::collections::{HashMap, HashSet};
use std::path::{Path, PathBuf};

use serde::Serialize;

/// Foreground process class for a tmux session, classified by `tmux::monitor`.
/// "Running" (Active state) is decided downstream in `session_state` — this
/// enum stays a raw input.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Default)]
pub enum ForegroundKind {
    #[default]
    Unknown,
    Shell,
    PassiveViewer,
    Runtime,
    Agent,
    InteractiveApp,
}

#[derive(Debug, Clone, Serialize)]
pub struct WorkspaceState {
    pub projects: Vec<Project>,
}

#[derive(Debug, Clone, Serialize)]
pub struct Project {
    pub name: String,
    pub path: PathBuf,
    pub default_branch: String,
    pub worktrees: Vec<WorktreeInfo>,
    #[serde(skip)]
    pub config: Option<ProjectConfig>,
    #[serde(skip)]
    pub expanded: bool,
    #[serde(skip)]
    pub missing: bool,
}

#[derive(Debug, Clone, Default)]
pub struct ProjectConfig {
    pub post_create: Option<String>,
    pub copy_includes: Vec<String>,
    pub copy_excludes: Vec<String>,
}

#[derive(Debug, Clone, Serialize)]
pub struct SessionInfo {
    pub name: String,         // full tmux session name
    pub display_name: String, // shown in UI (strips wt_slug prefix)
    pub has_activity: bool,   // tmux bell/alert flag
    #[serde(skip)]
    pub pane_capture: Option<String>,
    #[serde(skip)]
    pub last_activity: Option<std::time::Instant>,
    pub foreground: ForegroundKind, // raw process classification — see tmux::monitor
    #[serde(skip)]
    pub is_running_wsx: bool, // foreground process is wsx — suppresses capture preview
    #[serde(skip)]
    pub muted: bool, // user silenced — no activity updates, shown as ⊘
}

#[derive(Debug, Clone, PartialEq, Serialize)]
pub enum FetchFailReason {
    Auth,    // "Authentication failed", "Permission denied", "could not read Username"
    Timeout, // killed after 10s
    Network, // generic / other failure
}

#[derive(Debug, Clone, Serialize)]
pub struct WorktreeInfo {
    pub name: String,
    pub branch: String,
    pub path: PathBuf,
    pub is_main: bool,
    pub alias: Option<String>,
    pub sessions: Vec<SessionInfo>,
    #[serde(skip)]
    pub expanded: bool,
    pub git_info: Option<GitInfo>,
    pub fetch_failed: bool,
    pub fetch_fail_count: u32,
    pub fetch_fail_reason: Option<FetchFailReason>,
    #[serde(skip)]
    pub last_fetched: Option<std::time::Instant>,
    #[serde(skip)]
    pub git_info_fetched_at: Option<std::time::Instant>,
}

impl Project {
    /// Maps branch name -> list of tmux session names for all worktrees.
    pub fn branch_session_names(&self) -> HashMap<String, Vec<String>> {
        self.worktrees
            .iter()
            .map(|wt| {
                let sessions = wt.sessions.iter().map(|s| s.name.clone()).collect();
                (wt.branch.clone(), sessions)
            })
            .collect()
    }
}

impl WorktreeInfo {
    pub fn display_name(&self) -> &str {
        self.alias.as_deref().unwrap_or(&self.name)
    }

    pub fn session_slug(&self, project_name: &str) -> String {
        canonical_session_slug(project_name, &self.path)
    }

    pub fn session_names(&self) -> Vec<String> {
        self.sessions.iter().map(|s| s.name.clone()).collect()
    }
}

fn sanitize_slug(raw: &str) -> String {
    raw.replace(|c: char| !c.is_alphanumeric() && c != '-' && c != '_', "-")
}

fn legacy_branch_slug(branch: &str) -> String {
    sanitize_slug(&branch.replace('/', "-"))
}

pub fn canonical_session_slug(project_name: &str, worktree_path: &Path) -> String {
    let dir_name = worktree_path
        .file_name()
        .map(|n| n.to_string_lossy().to_string())
        .unwrap_or_else(|| project_name.to_string());
    let proj_prefix = format!("{}-", project_name);
    let short_name = dir_name.strip_prefix(&proj_prefix).unwrap_or(&dir_name);
    sanitize_slug(short_name)
}

pub fn session_display_name_from_tmux(
    tmux_name: &str,
    project_name: &str,
    worktree_path: &Path,
    branch: &str,
    alias: Option<&str>,
) -> String {
    let canonical = format!(
        "{}-{}-",
        project_name,
        canonical_session_slug(project_name, worktree_path)
    );
    if let Some(rest) = tmux_name.strip_prefix(&canonical) {
        return rest.to_string();
    }

    // Backward compatibility: older builds prefixed by branch/alias slug.
    let legacy_branch = format!("{}-{}-", project_name, legacy_branch_slug(branch));
    if let Some(rest) = tmux_name.strip_prefix(&legacy_branch) {
        return rest.to_string();
    }

    if let Some(alias) = alias {
        let legacy_alias = format!("{}-{}-", project_name, sanitize_slug(alias));
        if let Some(rest) = tmux_name.strip_prefix(&legacy_alias) {
            return rest.to_string();
        }
    }

    // Last-resort compatibility for historical `{project}-{any_slug}-{display}` names.
    if let Some(rest) = tmux_name.strip_prefix(&format!("{}-", project_name)) {
        if let Some((_, display)) = rest.split_once('-') {
            return display.to_string();
        }
    }

    tmux_name.to_string()
}

#[cfg(test)]
mod tests {
    use super::{canonical_session_slug, session_display_name_from_tmux};
    use std::path::Path;

    #[test]
    fn canonical_slug_uses_worktree_dir_for_main() {
        let slug = canonical_session_slug("wsx", Path::new("/tmp/wsx"));
        assert_eq!(slug, "wsx");
    }

    #[test]
    fn canonical_slug_strips_project_prefix_for_worktrees() {
        let slug = canonical_session_slug("wsx", Path::new("/tmp/wsx-feature-auth"));
        assert_eq!(slug, "feature-auth");
    }

    #[test]
    fn display_name_parses_canonical_prefix() {
        let display = session_display_name_from_tmux(
            "wsx-wsx-agent",
            "wsx",
            Path::new("/tmp/wsx"),
            "main",
            None,
        );
        assert_eq!(display, "agent");
    }

    #[test]
    fn display_name_parses_legacy_branch_prefix() {
        let display = session_display_name_from_tmux(
            "wsx-main-agent",
            "wsx",
            Path::new("/tmp/wsx"),
            "main",
            None,
        );
        assert_eq!(display, "agent");
    }

    #[test]
    fn display_name_parses_legacy_alias_prefix() {
        let display = session_display_name_from_tmux(
            "wsx-auth-agent",
            "wsx",
            Path::new("/tmp/wsx-feature-auth"),
            "feature/auth",
            Some("auth"),
        );
        assert_eq!(display, "agent");
    }

    #[test]
    fn display_name_falls_back_to_project_slug_pattern() {
        let display = session_display_name_from_tmux(
            "wsx-oldslug-agent",
            "wsx",
            Path::new("/tmp/wsx-feature-auth"),
            "feature/auth",
            None,
        );
        assert_eq!(display, "agent");
    }
}

#[derive(Debug, Clone, PartialEq, Serialize)]
pub struct GitInfo {
    pub recent_commits: Vec<CommitSummary>,
    pub modified_files: Vec<String>,
    pub ahead: usize,
    pub behind: usize,
    pub remote_branch: Option<String>,
}

#[derive(Debug, Clone, PartialEq, Serialize)]
pub struct CommitSummary {
    pub hash: String,
    pub message: String,
}

/// Flat tree entry for rendering and 3-level navigation.
#[derive(Debug, Clone, PartialEq)]
pub enum FlatEntry {
    Project {
        idx: usize,
    },
    Worktree {
        project_idx: usize,
        worktree_idx: usize,
    },
    Session {
        project_idx: usize,
        worktree_idx: usize,
        session_idx: usize,
    },
}

/// Flatten workspace into visible tree entries based on expand state.
#[allow(dead_code)]
pub fn flatten_tree(workspace: &WorkspaceState) -> Vec<FlatEntry> {
    let mut result = Vec::new();
    for (pi, project) in workspace.projects.iter().enumerate() {
        result.push(FlatEntry::Project { idx: pi });
        if project.expanded {
            for (wi, wt) in project.worktrees.iter().enumerate() {
                result.push(FlatEntry::Worktree {
                    project_idx: pi,
                    worktree_idx: wi,
                });
                if wt.expanded {
                    for (si, _) in wt.sessions.iter().enumerate() {
                        result.push(FlatEntry::Session {
                            project_idx: pi,
                            worktree_idx: wi,
                            session_idx: si,
                        });
                    }
                }
            }
        }
    }
    result
}

/// Like `flatten_tree` but skips projects whose index is not in `visible`.
pub fn flatten_tree_filtered(
    workspace: &WorkspaceState,
    visible: &HashSet<usize>,
) -> Vec<FlatEntry> {
    let mut result = Vec::new();
    for (pi, project) in workspace.projects.iter().enumerate() {
        if !visible.contains(&pi) {
            continue;
        }
        result.push(FlatEntry::Project { idx: pi });
        if project.expanded {
            for (wi, wt) in project.worktrees.iter().enumerate() {
                result.push(FlatEntry::Worktree {
                    project_idx: pi,
                    worktree_idx: wi,
                });
                if wt.expanded {
                    for (si, _) in wt.sessions.iter().enumerate() {
                        result.push(FlatEntry::Session {
                            project_idx: pi,
                            worktree_idx: wi,
                            session_idx: si,
                        });
                    }
                }
            }
        }
    }
    result
}

/// What is currently focused.
#[derive(Debug, Clone, PartialEq)]
pub enum Selection {
    Project(usize),
    Worktree(usize, usize),
    Session(usize, usize, usize),
    None,
}

impl WorkspaceState {
    pub fn empty() -> Self {
        Self {
            projects: Vec::new(),
        }
    }

    pub fn worktree(&self, pi: usize, wi: usize) -> Option<&WorktreeInfo> {
        self.projects.get(pi)?.worktrees.get(wi)
    }

    pub fn worktree_mut(&mut self, pi: usize, wi: usize) -> Option<&mut WorktreeInfo> {
        self.projects.get_mut(pi)?.worktrees.get_mut(wi)
    }

    pub fn session(&self, pi: usize, wi: usize, si: usize) -> Option<&SessionInfo> {
        self.projects.get(pi)?.worktrees.get(wi)?.sessions.get(si)
    }

    pub fn session_mut(&mut self, pi: usize, wi: usize, si: usize) -> Option<&mut SessionInfo> {
        self.projects
            .get_mut(pi)?
            .worktrees
            .get_mut(wi)?
            .sessions
            .get_mut(si)
    }

    /// Resolve flat index to Selection using a pre-computed flat slice.
    pub fn get_selection(&self, flat_idx: usize, flat: &[FlatEntry]) -> Selection {
        match flat.get(flat_idx) {
            Some(FlatEntry::Project { idx }) => Selection::Project(*idx),
            Some(FlatEntry::Worktree {
                project_idx,
                worktree_idx,
            }) => Selection::Worktree(*project_idx, *worktree_idx),
            Some(FlatEntry::Session {
                project_idx,
                worktree_idx,
                session_idx,
            }) => Selection::Session(*project_idx, *worktree_idx, *session_idx),
            None => Selection::None,
        }
    }
}