tmux-sessionizer 0.6.1

Tmux Sessionizer is a cli tool to fuzzy find all git repositories in a list of specified folders and open them as a new tmux session. Also, for git worktrees, this tool opens all checked out worktrees as new windows.
Documentation
use std::{
    collections::HashMap,
    path::{Path, PathBuf},
};

use error_stack::ResultExt;

use crate::{
    configs::{Config, VcsProviders},
    dirty_paths::DirtyUtf8Path,
    error::TmsError,
    repos::{find_repos, find_submodules, LazyRepoProvider},
    tmux::Tmux,
    Result,
};

pub struct Session {
    pub name: String,
    pub session_type: SessionType,
}

pub enum SessionType {
    Git(LazyRepoProvider),
    Bookmark(PathBuf),
}

impl Session {
    pub fn new(name: String, session_type: SessionType) -> Self {
        Session { name, session_type }
    }

    pub fn path(&self) -> &Path {
        match &self.session_type {
            SessionType::Git(provider) => &provider.path,
            SessionType::Bookmark(path) => path,
        }
    }

    pub fn switch_to(&self, tmux: &Tmux, config: &Config) -> Result<()> {
        match &self.session_type {
            SessionType::Git(repo) => self.switch_to_repo_session(repo, tmux, config),
            SessionType::Bookmark(path) => self.switch_to_bookmark_session(tmux, path, config),
        }
    }

    fn switch_to_repo_session(
        &self,
        repo: &LazyRepoProvider,
        tmux: &Tmux,
        config: &Config,
    ) -> Result<()> {
        let repo = repo.resolve()?;
        let path = if repo.is_bare() {
            repo.path().to_path_buf().to_string()?
        } else {
            repo.work_dir()
                .expect("bare repositories should all have parent directories")
                .canonicalize()
                .change_context(TmsError::IoError)?
                .to_string()?
        };
        let session_name = self.name.replace('.', "_");

        if !tmux.session_exists(&session_name) {
            tmux.new_session(Some(&session_name), Some(&path));
            tmux.set_up_tmux_env(repo, &session_name)?;
            tmux.run_session_create_script(self.path(), &session_name, config)?;
        }

        tmux.switch_to_session(&session_name);

        Ok(())
    }

    fn switch_to_bookmark_session(&self, tmux: &Tmux, path: &Path, config: &Config) -> Result<()> {
        let session_name = self.name.replace('.', "_");

        if !tmux.session_exists(&session_name) {
            tmux.new_session(Some(&session_name), path.to_str());
            tmux.run_session_create_script(path, &session_name, config)?;
        }

        tmux.switch_to_session(&session_name);

        Ok(())
    }
}

pub trait SessionContainer {
    fn find_session(&self, name: &str) -> Option<&Session>;
    fn insert_session(&mut self, name: String, repo: Session);
    fn list(&self) -> Vec<String>;
}

impl SessionContainer for HashMap<String, Session> {
    fn find_session(&self, name: &str) -> Option<&Session> {
        self.get(name)
    }

    fn insert_session(&mut self, name: String, session: Session) {
        self.insert(name, session);
    }

    fn list(&self) -> Vec<String> {
        let mut list: Vec<String> = self.keys().map(|s| s.to_owned()).collect();
        list.sort();

        list
    }
}

pub fn create_sessions(config: &Config) -> Result<impl SessionContainer> {
    let mut sessions = find_repos(config)?;
    sessions = append_bookmarks(config, sessions)?;

    let sessions = generate_session_container(sessions, config)?;

    Ok(sessions)
}

fn generate_session_container(
    mut sessions: HashMap<String, Vec<Session>>,
    config: &Config,
) -> Result<impl SessionContainer> {
    let mut ret = HashMap::new();

    for list in sessions.values_mut() {
        if list.len() == 1 {
            let session = list.pop().unwrap();
            insert_session(&mut ret, session, config)?;
        } else {
            let deduplicated = deduplicate_sessions(list);

            for session in deduplicated {
                insert_session(&mut ret, session, config)?;
            }
        }
    }

    Ok(ret)
}

fn insert_session(
    sessions: &mut impl SessionContainer,
    session: Session,
    config: &Config,
) -> Result<()> {
    let visible_name = if config.display_full_path == Some(true) {
        session.path().display().to_string()
    } else {
        session.name.clone()
    };
    if let SessionType::Git(repo) = &session.session_type {
        if matches!(
            (config.search_submodules, repo.provider),
            (Some(true), VcsProviders::Git),
        ) {
            if let Ok(Some(submodules)) = repo.resolve().and_then(|repo| repo.submodules()) {
                find_submodules(submodules, &visible_name, sessions, config)?;
            }
        }
    }
    sessions.insert_session(visible_name, session);
    Ok(())
}

fn deduplicate_sessions(duplicate_sessions: &mut Vec<Session>) -> Vec<Session> {
    let mut depth = 1;
    let mut deduplicated = Vec::new();
    while let Some(current_session) = duplicate_sessions.pop() {
        let mut equal = true;
        let current_path = current_session.path();
        let mut current_depth = 1;

        while equal {
            equal = false;
            if let Some(current_str) = current_path.iter().rev().nth(current_depth) {
                for session in &mut *duplicate_sessions {
                    if let Some(str) = session.path().iter().rev().nth(current_depth) {
                        if str == current_str {
                            current_depth += 1;
                            equal = true;
                            break;
                        }
                    }
                }
            }
        }

        deduplicated.push(current_session);
        depth = depth.max(current_depth);
    }

    for session in &mut deduplicated {
        session.name = {
            let mut count = depth + 1;
            let mut iterator = session.path().iter().rev();
            let mut str = String::new();

            while count > 0 {
                if let Some(dir) = iterator.next() {
                    if str.is_empty() {
                        str = dir.to_string_lossy().to_string();
                    } else {
                        str = format!("{}/{}", dir.to_string_lossy(), str);
                    }
                    count -= 1;
                } else {
                    count = 0;
                }
            }

            str
        };
    }

    deduplicated
}

fn append_bookmarks(
    config: &Config,
    mut sessions: HashMap<String, Vec<Session>>,
) -> Result<HashMap<String, Vec<Session>>> {
    let bookmarks = config.bookmark_paths();

    for path in bookmarks {
        let session_name = path
            .file_name()
            .expect("The file name doesn't end in `..`")
            .to_string()?;
        let session = Session::new(session_name, SessionType::Bookmark(path));
        if let Some(list) = sessions.get_mut(&session.name) {
            list.push(session);
        } else {
            sessions.insert(session.name.clone(), vec![session]);
        }
    }

    Ok(sessions)
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn verify_session_name_deduplication() {
        let mut test_sessions = vec![
            Session::new(
                "test".into(),
                SessionType::Bookmark("/search/path/to/proj1/test".into()),
            ),
            Session::new(
                "test".into(),
                SessionType::Bookmark("/search/path/to/proj2/test".into()),
            ),
            Session::new(
                "test".into(),
                SessionType::Bookmark("/other/path/to/projects/proj2/test".into()),
            ),
        ];

        let deduplicated = deduplicate_sessions(&mut test_sessions);

        assert_eq!(deduplicated[0].name, "projects/proj2/test");
        assert_eq!(deduplicated[1].name, "to/proj2/test");
        assert_eq!(deduplicated[2].name, "to/proj1/test");
    }
}