treeflow 0.2.1

CLI tool for simplified Git worktree management to speed up switching contexts when working collaboratively.
Documentation
#[cfg(test)]
mod utils;

#[cfg(test)]
mod project_tests {
    use std::path::{Path, PathBuf};
    use predicates::boolean::PredicateBooleanExt;
    use tempdir::TempDir;
    use test_case::test_case;
    use treeflow::utils::Return;
    use crate::utils::treeflow_command::TreeflowCommand;

    // Project cases
    #[test_case("project", "worktrees", "project", "worktrees", false; "project - Basic")]
    #[test_case("project", "worktrees", "project", "worktrees", true; "project - Relative 2")]
    #[test_case("project", "worktrees", "project/",  "worktrees", false; "Project - Trailing slash")]
    #[test_case("project", "worktrees", "project/subdir/../", "worktrees", false; "Project - Relative")]
    // Worktree cases
    #[test_case("project", "worktrees", "project", "worktrees", false; "Worktree - Basic")]
    #[test_case("project", "worktrees", "project", "worktrees", true; "Worktree - Relative 3")]
    #[test_case("project", "worktrees", "project", "worktrees/", false; "Worktree - Trailing slash")]
    #[test_case("project", "worktrees", "project", "worktrees/subdir/../", false; "Worktree - Relative")]
    fn project_add_updates_config(expected_repo: &str, expected_worktree: &str, repo: &str, worktree: &str, relative: bool) {
        let config_dir = TempDir::new("config_dir").expect("should be able to create temp config dir");
        let current_dir = TempDir::new("current_dir").expect("should be able to create temp current dir");
        let current_path = current_dir.path();
        let repository_path = current_path.join(repo);
        let worktrees_path = current_path.join(worktree);

        TreeflowCommand::new(config_dir.path().to_path_buf())
            .project_add(if relative { Path::new(repo) } else { repository_path.as_path() }, Some(if relative { Path::new(worktree) } else { worktrees_path.as_path() }))
            .current_dir(current_path)
            .cmd()
            .assert()
            .success()
            .stdout(Return::Null {}.print());

        TreeflowCommand::new(config_dir.path().to_path_buf())
            .config()
            .cmd()
            .assert()
            .success()
            .stdout(format!(
                "work_types = []\n\n[[projects]]\nrepository = \"{0}\"\nworktrees = \"{1}\"\n",
                current_path.join(expected_repo).display(),
                current_path.join(expected_worktree).display()
            ));
    }

    #[test_case(false, "project", false, "project"; "Matching absolute paths")]
    #[test_case(true, "project", true, "project"; "Matching relative paths")]
    #[test_case(false, "project", true, "project"; "Matching absolute / relative paths")]
    #[test_case(true, "project", false, "project"; "Matching relative / absolute paths")]
    #[test_case(false, ".", true, "."; "absolute / relative current dir")]
    #[test_case(true, ".", false, "."; "relative / absolute current dir")]
    #[test_case(true, "project/../.", false, "."; "Add path traversal")]
    #[test_case(true, ".", false, "project/../."; "Remove path traversal")]
    fn project_remove(add_relative: bool, add_path: &str, remove_relative: bool, remove_path: &str) {
        let config_dir = TempDir::new("config_dir").expect("should be able to create temp config dir");
        let current_dir = TempDir::new("current_dir").expect("should be able to create temp current dir");

        let add_path_buf = match add_relative {
            true =>  PathBuf::from(add_path),
            false => current_dir.path().join(add_path)
        };
        let remove_path_buf = match remove_relative {
            true =>  PathBuf::from(remove_path),
            false => current_dir.path().join(remove_path)
        };

        let worktrees_path = current_dir.path().join("worktrees");

        // Add a project first
        TreeflowCommand::new(config_dir.path().to_path_buf())
            .project_add(&add_path_buf, Some(&worktrees_path))
            .current_dir(current_dir.path())
            .cmd()
            .assert()
            .success();

        // Remove the project
        TreeflowCommand::new(config_dir.path().to_path_buf())
            .project_remove(&remove_path_buf)
            .current_dir(current_dir.path())
            .cmd()
            .assert()
            .success()
            .stdout(Return::Null { }.print());

        // Verify project is removed
        TreeflowCommand::new(config_dir.path().to_path_buf())
            .project_list()
            .cmd()
            .assert()
            .success()
            .stdout("<<No projects configured>>\n");
    }

    #[test]
    fn remove_projects_updates_list() {
        let config_dir = TempDir::new("config_dir").expect("Temp dir");
        let current_dir = TempDir::new("current_dir").expect("Temp dir");
        let repo1_path = current_dir.path().join("repo1");
        let repo2_path = current_dir.path().join("repo2");

        let worktrees1_path = current_dir.path().join("worktrees1");
        let worktrees2_path = current_dir.path().join("worktrees2");

        // Create directories for repositories
        std::fs::create_dir_all(&repo1_path).expect("Failed to create repo1 dir");
        std::fs::create_dir_all(&repo2_path).expect("Failed to create repo2 dir");

        // Add multiple projects
        TreeflowCommand::new(config_dir.path().to_path_buf())
            .project_add(&repo1_path, Some(&worktrees1_path))
            .current_dir(current_dir.path())
            .cmd()
            .assert()
            .success();

        TreeflowCommand::new(config_dir.path().to_path_buf())
            .project_add(&repo2_path, Some(&worktrees2_path))
            .current_dir(current_dir.path())
            .cmd()
            .assert()
            .success();

        // Remove one project
        TreeflowCommand::new(config_dir.path().to_path_buf())
            .project_remove(&repo2_path)
            .current_dir(current_dir.path())
            .cmd()
            .assert()
            .success();

        // Verify only remaining project is listed
        TreeflowCommand::new(config_dir.path().to_path_buf())
            .project_list()
            .cmd()
            .assert()
            .success()
            .stdout(format!("{}\n", repo1_path.display()));
    }

    #[test]
    fn list_empty_projects() {
        let config_dir = TempDir::new("config_dir").expect("Temp dir");

        // List projects when none are configured
        TreeflowCommand::new(config_dir.path().to_path_buf())
            .project_list()
            .cmd()
            .assert()
            .success()
            .stdout("<<No projects configured>>\n");
    }
    #[test]
    fn list_multiple_projects() {
        let config_dir = TempDir::new("config_dir").expect("Temp dir");
        let current_dir = TempDir::new("current_dir").expect("Temp dir");
        let repo1_path = current_dir.path().join("repo1");
        let repo2_path = current_dir.path().join("repo2");
        let repo3_path = current_dir.path().join("repo3");

        let worktrees1_path = current_dir.path().join("worktrees1");
        let worktrees2_path = current_dir.path().join("worktrees2");
        let worktrees3_path = current_dir.path().join("worktrees3");

        // Create directories for repositories
        std::fs::create_dir_all(&repo1_path).expect("Failed to create repo1 dir");
        std::fs::create_dir_all(&repo2_path).expect("Failed to create repo2 dir");
        std::fs::create_dir_all(&repo3_path).expect("Failed to create repo3 dir");

        // Add multiple projects
        TreeflowCommand::new(config_dir.path().to_path_buf())
            .project_add(&repo1_path, Some(&worktrees1_path))
            .current_dir(current_dir.path())
            .cmd()
            .assert()
            .success();

        TreeflowCommand::new(config_dir.path().to_path_buf())
            .project_add(&repo2_path, Some(&worktrees2_path))
            .current_dir(current_dir.path())
            .cmd()
            .assert()
            .success();

        TreeflowCommand::new(config_dir.path().to_path_buf())
            .project_add(&repo3_path, Some(&worktrees3_path))
            .current_dir(current_dir.path())
            .cmd()
            .assert()
            .success();

        // Verify all appear in list in the order they were added
        TreeflowCommand::new(config_dir.path().to_path_buf())
            .project_list()
            .cmd()
            .assert()
            .success()
            .stdout(format!("{}\n{}\n{}\n", repo1_path.display(), repo2_path.display(), repo3_path.display()));
    }

    #[test]
    fn project_add_duplicate_path_fails() {
        let config_dir = TempDir::new("config_dir").expect("should be able to create temp config dir");
        let current_dir = TempDir::new("current_dir").expect("should be able to create temp current dir");
        let repository_path = current_dir.path().to_path_buf();
        let worktrees_path = current_dir.path().join("worktrees");

        // Add project for the first time - should succeed
        TreeflowCommand::new(config_dir.path().to_path_buf())
            .project_add(&repository_path, Some(&worktrees_path))
            .current_dir(current_dir.path())
            .cmd()
            .assert()
            .success();

        // Try to add the same project again - should fail
        TreeflowCommand::new(config_dir.path().to_path_buf())
            .project_add(&repository_path, Some(&worktrees_path))
            .current_dir(current_dir.path())
            .cmd()
            .assert()
            .failure()
            .stderr(predicates::str::contains("already exists").or(predicates::str::contains("duplicate")));

        // Verify project list contains only one instance
        TreeflowCommand::new(config_dir.path().to_path_buf())
            .project_list()
            .cmd()
            .assert()
            .success()
            .stdout(format!("{}\n", repository_path.display()));
    }

    #[test]
    fn project_add_duplicate_worktrees_path_fails() {
        let config_dir = TempDir::new("config_dir").expect("should be able to create temp config dir");
        let current_dir = TempDir::new("current_dir").expect("should be able to create temp current dir");
        let repo1_path = current_dir.path().join("repo1");
        let repo2_path = current_dir.path().join("repo2");
        let worktrees_path = current_dir.path().join("worktrees");

        // Create directories for repositories
        std::fs::create_dir_all(&repo1_path).expect("Failed to create repo1 dir");
        std::fs::create_dir_all(&repo2_path).expect("Failed to create repo2 dir");

        // Add first project with the worktrees path - should succeed
        TreeflowCommand::new(config_dir.path().to_path_buf())
            .project_add(&repo1_path, Some(&worktrees_path))
            .current_dir(current_dir.path())
            .cmd()
            .assert()
            .success();

        // Try to add another project with the same worktrees path - should fail
        TreeflowCommand::new(config_dir.path().to_path_buf())
            .project_add(&repo2_path, Some(&worktrees_path))
            .current_dir(current_dir.path())
            .cmd()
            .assert()
            .failure()
            .stderr(predicates::str::contains("already exists").or(predicates::str::contains("duplicate")));

        // Verify project list contains only the first project
        TreeflowCommand::new(config_dir.path().to_path_buf())
            .project_list()
            .cmd()
            .assert()
            .success()
            .stdout(format!("{}\n", repo1_path.display()));
    }
}