repoverse 0.1.1

Multi-repo workspace tool: keep many git repos in sync and roll changes up across dependency boundaries
//! Per-repo task resolution (setup / lint / test).
//! Precedence: explicit override → CI mapping → repo-local hook →
//! autodetect → none (skip with warning).

use crate::ci;
use crate::config::Project;
use anyhow::Result;
use std::path::Path;

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Kind {
    Setup,
    Lint,
    Test,
}

impl Kind {
    pub fn as_str(self) -> &'static str {
        match self {
            Kind::Setup => "setup",
            Kind::Lint => "lint",
            Kind::Test => "test",
        }
    }
}

#[derive(Debug, Clone)]
pub struct Resolved {
    pub script: String,
    pub source: &'static str,
    pub caveats: Vec<String>,
}

/// Resolve how to run `kind` for `project` rooted at `dir`.
pub fn resolve(project: &Project, dir: &Path, kind: Kind) -> Result<Option<Resolved>> {
    // 1. explicit override
    let explicit = match kind {
        Kind::Setup => &project.setup,
        Kind::Lint => &project.lint,
        Kind::Test => &project.test,
    };
    if let Some(s) = explicit {
        return Ok(Some(Resolved {
            script: s.clone(),
            source: "override",
            caveats: vec![],
        }));
    }

    // 2. CI mapping
    if let Some(cimap) = &project.ci {
        let job = match kind {
            Kind::Setup => cimap.setup_job.as_ref(),
            Kind::Lint => cimap.lint_job.as_ref(),
            Kind::Test => cimap.test_job.as_ref(),
        };
        if let Some(job) = job {
            let wf = dir.join(".github/workflows").join(&cimap.workflow);
            if wf.is_file() {
                if let Some(ex) = ci::extract_job(&wf, job)? {
                    if !ex.script.is_empty() {
                        return Ok(Some(Resolved {
                            script: ex.script,
                            source: "ci",
                            caveats: ex.caveats,
                        }));
                    }
                }
            }
        }
    }

    // 3. repo-local hook
    let hook = dir.join(".repoverse").join(kind.as_str());
    if hook.is_file() {
        return Ok(Some(Resolved {
            script: format!("./.repoverse/{}", kind.as_str()),
            source: "hook",
            caveats: vec![],
        }));
    }

    // 4. autodetect
    if let Some(script) = autodetect(dir, kind) {
        return Ok(Some(Resolved {
            script,
            source: "autodetect",
            caveats: vec![],
        }));
    }

    // 5. none
    Ok(None)
}

fn autodetect(dir: &Path, kind: Kind) -> Option<String> {
    let has = |f: &str| dir.join(f).exists();
    match kind {
        Kind::Setup => {
            if has("Cargo.toml") {
                Some("cargo fetch".into())
            } else if has("package-lock.json") {
                Some("npm ci".into())
            } else if has("pnpm-lock.yaml") {
                Some("pnpm install --frozen-lockfile".into())
            } else if has("uv.lock") {
                Some("uv sync".into())
            } else if has("go.mod") {
                Some("go mod download".into())
            } else if has("requirements.txt") {
                Some("pip install -r requirements.txt".into())
            } else {
                None
            }
        }
        Kind::Lint => {
            if has("Cargo.toml") {
                Some("cargo clippy --all-targets -- -D warnings".into())
            } else if has("package.json") {
                Some("npm run lint --if-present".into())
            } else {
                None
            }
        }
        Kind::Test => {
            if has("Cargo.toml") {
                Some("cargo test".into())
            } else if has("package.json") {
                Some("npm test".into())
            } else if has("go.mod") {
                Some("go test ./...".into())
            } else if has("pytest.ini") || has("pyproject.toml") {
                Some("pytest -q".into())
            } else {
                None
            }
        }
    }
}

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

    fn proj() -> Project {
        serde_yaml::from_str("{ name: acme/x, path: x }").unwrap()
    }

    #[test]
    fn override_wins() {
        let mut p = proj();
        p.test = Some("make t".into());
        let d = tempdir().unwrap();
        let r = resolve(&p, d.path(), Kind::Test).unwrap().unwrap();
        assert_eq!(r.source, "override");
        assert_eq!(r.script, "make t");
    }

    #[test]
    fn autodetects_cargo() {
        let d = tempdir().unwrap();
        std::fs::write(d.path().join("Cargo.toml"), "").unwrap();
        let r = resolve(&proj(), d.path(), Kind::Test).unwrap().unwrap();
        assert_eq!(r.source, "autodetect");
        assert_eq!(r.script, "cargo test");
    }

    #[test]
    fn none_when_nothing() {
        let d = tempdir().unwrap();
        assert!(resolve(&proj(), d.path(), Kind::Lint).unwrap().is_none());
    }

    #[test]
    fn hook_beats_autodetect() {
        let d = tempdir().unwrap();
        std::fs::write(d.path().join("Cargo.toml"), "").unwrap();
        std::fs::create_dir_all(d.path().join(".repoverse")).unwrap();
        std::fs::write(d.path().join(".repoverse/test"), "#!/bin/sh\n").unwrap();
        let r = resolve(&proj(), d.path(), Kind::Test).unwrap().unwrap();
        assert_eq!(r.source, "hook");
    }
}