claude-profiles 0.7.0

Launch claude with only the plugins, skills, and MCP servers a profile defines
use crate::coord::state::{RunDir, RunState, Status, WorkerState};
use crate::coord::worktree;
use crate::fs_paths::Paths;
use anyhow::{bail, Result};
use std::fmt;
use std::path::Path;

#[derive(Debug, PartialEq)]
pub enum Kept {
    Uncommitted(String),
    Unmerged(String),
    InUse(String),
}

impl fmt::Display for Kept {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        match self {
            Kept::Uncommitted(p) => write!(f, "kept (uncommitted changes): {p}"),
            Kept::Unmerged(b) => write!(f, "kept (unmerged branch): {b}"),
            Kept::InUse(id) => write!(f, "skipped (in use): {id}"),
        }
    }
}

const ORDER: [(Status, &str); 7] = [
    (Status::Queued, "queued"),
    (Status::Starting, "starting"),
    (Status::Working, "working"),
    (Status::Blocked, "blocked"),
    (Status::Done, "done"),
    (Status::Failed, "failed"),
    (Status::Cancelled, "cancelled"),
];

pub fn run(paths: &Paths, clean_runs: bool, run_id: Option<&str>) -> Result<i32> {
    if clean_runs {
        for item in clean(paths, run_id)? {
            println!("{item}");
        }
        return Ok(0);
    }
    if run_id.is_some() {
        bail!("a run id needs --clean");
    }
    let mut runs = Vec::new();
    for dir in list_dirs(paths) {
        let loaded = dir.load_run().and_then(|r| Ok((r, dir.workers()?, dir.is_live())));
        match loaded {
            Ok(pair) => runs.push(pair),
            Err(e) => eprintln!("warning: skipping {}: {e}", dir.root.display()),
        }
    }
    println!("{}", format_runs(&runs));
    Ok(0)
}

fn list_dirs(paths: &Paths) -> Vec<RunDir> {
    let Ok(entries) = std::fs::read_dir(paths.runs_dir()) else {
        return Vec::new();
    };
    let mut dirs: Vec<RunDir> = entries
        .flatten()
        .filter(|e| e.path().join("run.json").is_file())
        .map(|e| RunDir { root: e.path() })
        .collect();
    dirs.sort_by(|a, b| a.root.cmp(&b.root));
    dirs
}

fn counts(workers: &[WorkerState]) -> String {
    if workers.is_empty() {
        return "0 workers".to_string();
    }
    let parts: Vec<String> = ORDER
        .iter()
        .map(|(s, name)| (name, workers.iter().filter(|w| w.status == *s).count()))
        .filter(|(_, n)| *n > 0)
        .map(|(name, n)| format!("{name}: {n}"))
        .collect();
    format!("{} workers ({})", workers.len(), parts.join(", "))
}

pub fn format_runs(runs: &[(RunState, Vec<WorkerState>, bool)]) -> String {
    if runs.is_empty() {
        return "No runs.".to_string();
    }
    let mut sorted: Vec<&(RunState, Vec<WorkerState>, bool)> = runs.iter().collect();
    sorted.sort_by(|a, b| b.0.created_at.cmp(&a.0.created_at).then_with(|| b.0.run_id.cmp(&a.0.run_id)));
    sorted
        .iter()
        .map(|(r, w, live)| {
            let active = if *live { " (active)" } else { "" };
            format!("{}  {}  {}{active}", r.run_id, r.cwd.display(), counts(w))
        })
        .collect::<Vec<_>>()
        .join("\n")
}

fn drop_branch(repo: &Path, branch: &str, kept: &mut Vec<Kept>) -> Result<bool> {
    if !worktree::branch_exists(repo, branch) {
        return Ok(true);
    }
    if worktree::is_merged(repo, branch)? {
        worktree::delete_branch(repo, branch)?;
        Ok(true)
    } else {
        kept.push(Kept::Unmerged(branch.to_string()));
        Ok(false)
    }
}

fn clean_run(dir: &RunDir, run: &RunState, kept: &mut Vec<Kept>) -> Result<()> {
    let workers = match dir.workers() {
        Ok(w) => w,
        Err(e) => {
            eprintln!("warning: skipping {}: {e}", dir.root.display());
            return Ok(());
        }
    };
    let mut run_kept = false;
    for mut w in workers {
        let Some(repo) = run.repo_root.as_deref() else {
            continue;
        };
        let (Some(wt), Some(branch)) = (w.worktree.clone(), w.branch.clone()) else {
            continue;
        };
        if wt.exists() {
            if worktree::has_uncommitted(&wt)? {
                kept.push(Kept::Uncommitted(wt.display().to_string()));
                run_kept = true;
                continue;
            }
            worktree::remove_worktree(repo, &wt)?;
        }
        worktree::prune(repo)?;
        w.worktree = None;
        if drop_branch(repo, &branch, kept)? {
            w.branch = None;
        } else {
            run_kept = true;
        }
        dir.save_worker(&w)?;
    }
    if !run_kept {
        std::fs::remove_dir_all(&dir.root)?;
    }
    Ok(())
}

pub fn clean(paths: &Paths, run_id: Option<&str>) -> Result<Vec<Kept>> {
    let dirs = match run_id {
        Some(id) => match RunDir::open(paths, id) {
            Ok(d) => vec![d],
            Err(_) => bail!("unknown run '{id}'"),
        },
        None => list_dirs(paths),
    };
    let mut kept = Vec::new();
    for dir in dirs {
        match dir.load_run() {
            Ok(run) if dir.is_live() => kept.push(Kept::InUse(run.run_id)),
            Ok(run) => clean_run(&dir, &run, &mut kept)?,
            Err(e) => eprintln!("warning: skipping {}: {e}", dir.root.display()),
        }
    }
    Ok(kept)
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::coord::state::BackendKind;
    use std::path::{Path, PathBuf};

    fn git_in(dir: &Path, args: &[&str]) {
        let out = crate::exe::command("git")
            .args(["-c", "user.name=t", "-c", "user.email=t@t", "-c", "init.defaultBranch=main", "-c", "commit.gpgsign=false"])
            .args(args)
            .current_dir(dir)
            .output()
            .unwrap();
        assert!(out.status.success(), "{}", String::from_utf8_lossy(&out.stderr));
    }

    fn run_state(id: &str, repo: &Path, created_at: u64) -> RunState {
        RunState {
            run_id: id.into(),
            repo_root: Some(repo.to_path_buf()),
            cwd: repo.to_path_buf(),
            permission_flags: vec![],
            backend: BackendKind::Headless,
            max_workers: 4,
            coordinator_pane: None,
            created_at,
        }
    }

    fn worker(id: &str, status: Status) -> WorkerState {
        WorkerState {
            id: id.into(),
            name: id.into(),
            profiles: vec![],
            task: "t".into(),
            status,
            status_detail: None,
            cwd: PathBuf::from("/x"),
            worktree: None,
            branch: None,
            base: None,
            session_id: "s".into(),
            pane_id: None,
            pid: None,
            created_at: 1,
            updated_at: 1,
            idle_since: None,
        }
    }

    #[test]
    fn format_runs_counts_statuses() {
        let r = run_state("100-abc", Path::new("/repo"), 100);
        let ws = vec![worker("a", Status::Done), worker("b", Status::Working), worker("c", Status::Done)];
        let out = format_runs(&[(r.clone(), ws, true), (run_state("200-def", Path::new("/other"), 200), vec![], false)]);
        assert_eq!(
            out,
            "200-def  /other  0 workers\n100-abc  /repo  3 workers (working: 1, done: 2) (active)"
        );
    }

    #[test]
    fn format_runs_empty() {
        assert_eq!(format_runs(&[]), "No runs.");
    }

    struct Fixture {
        _tmp: tempfile::TempDir,
        repo: PathBuf,
        paths: Paths,
    }

    fn fixture() -> Fixture {
        let tmp = tempfile::tempdir().unwrap();
        let repo = tmp.path().join("repo");
        std::fs::create_dir_all(&repo).unwrap();
        git_in(&repo, &["init"]);
        std::fs::write(repo.join("a.txt"), "a").unwrap();
        git_in(&repo, &["add", "."]);
        git_in(&repo, &["commit", "-m", "init"]);
        let paths = Paths::from_home(tmp.path().join("home"));
        Fixture { _tmp: tmp, repo, paths }
    }

    fn add_worker(f: &Fixture, dir: &RunDir, id: &str, status: Status) -> WorkerState {
        let c = worktree::create(&f.paths, &f.repo, &f.repo, id, None).unwrap();
        let mut w = worker(id, status);
        w.worktree = Some(c.path);
        w.branch = Some(c.branch);
        dir.save_worker(&w).unwrap();
        w
    }

    #[test]
    fn clean_removes_finished_and_keeps_dirty() {
        let f = fixture();
        let dir = RunDir::create(&f.paths, &run_state("1-aaa", &f.repo, 1)).unwrap();
        let clean_w = add_worker(&f, &dir, "clean", Status::Done);
        let dirty_w = add_worker(&f, &dir, "dirty", Status::Failed);
        let dirty_path = dirty_w.worktree.clone().unwrap();
        std::fs::write(dirty_path.join("new.txt"), "x").unwrap();
        let kept = clean(&f.paths, None).unwrap();
        assert_eq!(kept, vec![Kept::Uncommitted(dirty_path.display().to_string())]);
        assert!(!clean_w.worktree.unwrap().exists());
        assert!(!worktree::branch_exists(&f.repo, "cp/clean"));
        assert!(dirty_path.exists());
        assert!(dir.root.exists());
        let reloaded = dir.load_worker("clean").unwrap();
        assert_eq!(reloaded.worktree, None);
        assert_eq!(reloaded.branch, None);
        assert!(dir.load_worker("dirty").unwrap().worktree.is_some());
    }

    #[test]
    fn clean_removes_run_dir_when_all_finished() {
        let f = fixture();
        let dir = RunDir::create(&f.paths, &run_state("1-aaa", &f.repo, 1)).unwrap();
        add_worker(&f, &dir, "w", Status::Done);
        assert!(clean(&f.paths, Some("1-aaa")).unwrap().is_empty());
        assert!(!dir.root.exists());
        assert!(!worktree::branch_exists(&f.repo, "cp/w"));
    }

    #[test]
    fn clean_skips_live_run() {
        let f = fixture();
        let dir = RunDir::create(&f.paths, &run_state("1-aaa", &f.repo, 1)).unwrap();
        let active = add_worker(&f, &dir, "act", Status::Working);
        let fin = add_worker(&f, &dir, "fin", Status::Done);
        let _lock = dir.hold_lock().unwrap();
        let kept = clean(&f.paths, None).unwrap();
        assert_eq!(kept, vec![Kept::InUse("1-aaa".into())]);
        assert_eq!(kept[0].to_string(), "skipped (in use): 1-aaa");
        assert!(active.worktree.unwrap().exists());
        assert!(fin.worktree.unwrap().exists());
        assert!(worktree::branch_exists(&f.repo, "cp/fin"));
        assert!(dir.root.exists());
    }

    #[test]
    fn clean_treats_unfinished_workers_of_dead_run_as_finished() {
        let f = fixture();
        let dir = RunDir::create(&f.paths, &run_state("1-aaa", &f.repo, 1)).unwrap();
        let active = add_worker(&f, &dir, "act", Status::Working);
        add_worker(&f, &dir, "fin", Status::Done);
        assert!(clean(&f.paths, None).unwrap().is_empty());
        assert!(!active.worktree.unwrap().exists());
        assert!(!worktree::branch_exists(&f.repo, "cp/act"));
        assert!(!worktree::branch_exists(&f.repo, "cp/fin"));
        assert!(!dir.root.exists());
    }

    #[test]
    fn clean_deletes_branch_when_worktree_missing() {
        let f = fixture();
        let dir = RunDir::create(&f.paths, &run_state("1-aaa", &f.repo, 1)).unwrap();
        let w = add_worker(&f, &dir, "gone", Status::Done);
        std::fs::remove_dir_all(w.worktree.unwrap()).unwrap();
        clean(&f.paths, None).unwrap();
        assert!(!worktree::branch_exists(&f.repo, "cp/gone"));
    }

    #[test]
    fn clean_keeps_unmerged_branch() {
        let f = fixture();
        let dir = RunDir::create(&f.paths, &run_state("1-aaa", &f.repo, 1)).unwrap();
        let w = add_worker(&f, &dir, "ahead", Status::Done);
        let wt = w.worktree.unwrap();
        std::fs::write(wt.join("x.txt"), "x").unwrap();
        git_in(&wt, &["add", "."]);
        git_in(&wt, &["commit", "-m", "x"]);
        let kept = clean(&f.paths, None).unwrap();
        assert_eq!(kept, vec![Kept::Unmerged("cp/ahead".into())]);
        assert_eq!(kept[0].to_string(), "kept (unmerged branch): cp/ahead");
        assert!(!wt.exists());
        assert!(worktree::branch_exists(&f.repo, "cp/ahead"));
        assert!(dir.root.exists());
        let reloaded = dir.load_worker("ahead").unwrap();
        assert_eq!(reloaded.worktree, None);
        assert_eq!(reloaded.branch.as_deref(), Some("cp/ahead"));
    }

    #[test]
    fn clean_skips_run_with_corrupt_worker() {
        let f = fixture();
        let bad = RunDir::create(&f.paths, &run_state("1-bad", &f.repo, 1)).unwrap();
        add_worker(&f, &bad, "w", Status::Done);
        std::fs::write(bad.worker_dir("w").join("worker.json"), "{").unwrap();
        let good = RunDir::create(&f.paths, &run_state("2-good", &f.repo, 2)).unwrap();
        add_worker(&f, &good, "g", Status::Done);
        assert!(clean(&f.paths, None).unwrap().is_empty());
        assert!(bad.root.exists());
        assert!(!good.root.exists());
    }

    #[test]
    fn clean_unknown_run_errors() {
        let f = fixture();
        let err = clean(&f.paths, Some("nope")).unwrap_err();
        assert_eq!(err.to_string(), "unknown run 'nope'");
    }
}