smux-cli 0.1.10

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

use crate::config::FolderSearchSettings;
use crate::util;

const SKIPPED_DIRECTORY_NAMES: &[&str] = &[
    "node_modules",
    "target",
    "vendor",
    "dist",
    "build",
    ".git",
    ".direnv",
    ".cache",
];
const SKIPPED_ROOT_CHILD_NAMES: &[&str] = &["Library"];

#[derive(Debug, Clone, Default, Eq, PartialEq)]
pub struct FolderSearchResult {
    pub directories: Vec<String>,
    pub warnings: Vec<FolderSearchWarning>,
}

#[derive(Debug, Clone, Eq, PartialEq)]
pub struct FolderSearchWarning {
    pub root: String,
    pub message: String,
}

pub fn list_directories(settings: &FolderSearchSettings) -> FolderSearchResult {
    let mut result = FolderSearchResult::default();
    let mut seen = HashSet::new();

    for root in &settings.roots {
        let expanded = util::expand_tilde_path(Path::new(root));
        let Ok(root_path) = expanded.canonicalize() else {
            result.warnings.push(FolderSearchWarning {
                root: root.clone(),
                message: format!(
                    "failed to resolve folder search root {}",
                    expanded.display()
                ),
            });
            continue;
        };

        walk_directory(
            root,
            &root_path,
            0,
            settings.max_depth,
            settings.include_hidden,
            &mut seen,
            &mut result,
        );
    }

    result.directories.sort();
    result
}

fn walk_directory(
    root_label: &str,
    directory: &Path,
    depth: usize,
    max_depth: usize,
    include_hidden: bool,
    seen: &mut HashSet<PathBuf>,
    result: &mut FolderSearchResult,
) {
    if !seen.insert(directory.to_path_buf()) {
        return;
    }

    if let Ok(path) = util::path_to_string(directory) {
        result.directories.push(path);
    }

    if depth >= max_depth {
        return;
    }

    let entries = match fs::read_dir(directory) {
        Ok(entries) => entries,
        Err(error) => {
            result.warnings.push(FolderSearchWarning {
                root: root_label.to_owned(),
                message: format!("failed to read {}: {error}", directory.display()),
            });
            return;
        }
    };

    for entry in entries.flatten() {
        let file_type = match entry.file_type() {
            Ok(file_type) => file_type,
            Err(_) => continue,
        };
        if !file_type.is_dir() {
            continue;
        }

        let path = entry.path();
        if should_skip_child(&path, depth, include_hidden) {
            continue;
        }

        let Ok(path) = path.canonicalize() else {
            continue;
        };
        walk_directory(
            root_label,
            &path,
            depth + 1,
            max_depth,
            include_hidden,
            seen,
            result,
        );
    }
}

fn should_skip_child(path: &Path, parent_depth: usize, include_hidden: bool) -> bool {
    let Some(name) = path.file_name().and_then(|name| name.to_str()) else {
        return false;
    };

    (!include_hidden && name.starts_with('.'))
        || SKIPPED_DIRECTORY_NAMES.contains(&name)
        || (parent_depth == 0 && SKIPPED_ROOT_CHILD_NAMES.contains(&name))
}

#[cfg(test)]
mod tests {
    use std::fs;

    use crate::config::FolderSearchSettings;

    #[test]
    fn finds_directories_under_configured_roots() {
        let tempdir = tempfile::tempdir().expect("tempdir should be created");
        fs::create_dir(tempdir.path().join("app")).expect("app dir should be created");
        fs::create_dir_all(tempdir.path().join("app").join("src"))
            .expect("nested dir should be created");

        let result = super::list_directories(&FolderSearchSettings {
            roots: vec![tempdir.path().display().to_string()],
            max_depth: 1,
            include_hidden: false,
        });

        assert!(result.warnings.is_empty());
        assert!(
            result
                .directories
                .contains(&tempdir.path().canonicalize().unwrap().display().to_string())
        );
        assert!(
            result.directories.contains(
                &tempdir
                    .path()
                    .join("app")
                    .canonicalize()
                    .unwrap()
                    .display()
                    .to_string()
            )
        );
        assert!(
            !result.directories.contains(
                &tempdir
                    .path()
                    .join("app")
                    .join("src")
                    .canonicalize()
                    .unwrap()
                    .display()
                    .to_string()
            )
        );
    }

    #[test]
    fn skips_hidden_directories_by_default() {
        let tempdir = tempfile::tempdir().expect("tempdir should be created");
        fs::create_dir(tempdir.path().join(".hidden")).expect("hidden dir should be created");

        let result = super::list_directories(&FolderSearchSettings {
            roots: vec![tempdir.path().display().to_string()],
            max_depth: 1,
            include_hidden: false,
        });

        assert!(
            !result.directories.contains(
                &tempdir
                    .path()
                    .join(".hidden")
                    .canonicalize()
                    .unwrap()
                    .display()
                    .to_string()
            )
        );
    }

    #[test]
    fn skips_common_heavy_directories() {
        let tempdir = tempfile::tempdir().expect("tempdir should be created");
        fs::create_dir(tempdir.path().join("target")).expect("target dir should be created");

        let result = super::list_directories(&FolderSearchSettings {
            roots: vec![tempdir.path().display().to_string()],
            max_depth: 1,
            include_hidden: true,
        });

        assert!(
            !result.directories.contains(
                &tempdir
                    .path()
                    .join("target")
                    .canonicalize()
                    .unwrap()
                    .display()
                    .to_string()
            )
        );
    }

    #[test]
    fn reports_missing_roots_without_failing() {
        let tempdir = tempfile::tempdir().expect("tempdir should be created");
        let missing = tempdir.path().join("missing");

        let result = super::list_directories(&FolderSearchSettings {
            roots: vec![missing.display().to_string()],
            max_depth: 1,
            include_hidden: false,
        });

        assert!(result.directories.is_empty());
        assert_eq!(result.warnings.len(), 1);
    }
}