assay-cli 3.26.0

Policy-as-code gate for MCP agent tool calls, with verifiable evidence and Linux kernel enforcement.
use crate::cli::args::BundleCreateArgs;
use std::path::{Path, PathBuf};

pub(super) fn select_source_root(
    args: &BundleCreateArgs,
    cwd: &Path,
) -> anyhow::Result<(PathBuf, String)> {
    if let Some(from) = &args.from {
        let p = absolutize(from, cwd);
        if !p.exists() {
            anyhow::bail!("--from path does not exist: {}", p.display());
        }
        if p.is_file() {
            let parent = p
                .parent()
                .map(std::path::Path::to_path_buf)
                .unwrap_or_else(|| cwd.to_path_buf());
            return Ok((parent, "explicit-from".to_string()));
        }
        return Ok((p, "explicit-from".to_string()));
    }
    if let Some(run_id) = &args.run_id {
        for rel in &[
            format!(".assay/{}", run_id),
            format!(".assay/run_{}", run_id),
            format!(".assay/runs/{}", run_id),
        ] {
            let p = cwd.join(rel);
            if p.exists() {
                return Ok((p, "run-id".to_string()));
            }
        }
        anyhow::bail!(
            "--run-id was provided but no matching path exists under .assay for id {}",
            run_id
        );
    }
    if let Some(latest) = find_latest_run_json(cwd)? {
        let parent = latest
            .parent()
            .map(std::path::Path::to_path_buf)
            .unwrap_or_else(|| cwd.to_path_buf());
        return Ok((parent, "mtime-latest".to_string()));
    }
    Ok((cwd.to_path_buf(), "cwd-fallback".to_string()))
}

pub(super) fn find_run_json(source_root: &Path, from: Option<&PathBuf>) -> Option<PathBuf> {
    if let Some(from) = from {
        if from.is_file() && from.file_name().and_then(|x| x.to_str()) == Some("run.json") {
            return Some(from.clone());
        }
    }
    find_first_existing(
        source_root,
        &[PathBuf::from("run.json"), PathBuf::from(".assay/run.json")],
    )
}

pub(super) fn find_summary_json(source_root: &Path) -> Option<PathBuf> {
    find_first_existing(
        source_root,
        &[
            PathBuf::from("summary.json"),
            PathBuf::from(".assay/summary.json"),
        ],
    )
}

pub(super) fn select_config_path(args: &BundleCreateArgs, source_root: &Path) -> Option<PathBuf> {
    if let Some(cfg) = &args.config {
        return Some(cfg.clone());
    }
    find_first_existing(
        source_root,
        &[PathBuf::from("eval.yaml"), PathBuf::from("assay.yaml")],
    )
}

pub(super) fn select_trace_path(args: &BundleCreateArgs, source_root: &Path) -> Option<PathBuf> {
    if let Some(t) = &args.trace_file {
        return Some(t.clone());
    }
    find_first_existing(
        source_root,
        &[
            PathBuf::from("trace.jsonl"),
            PathBuf::from("traces/ci.jsonl"),
            PathBuf::from("traces/run.jsonl"),
            PathBuf::from("traces/trace.jsonl"),
        ],
    )
}

pub(super) fn collect_files_recursive(root: &Path) -> anyhow::Result<Vec<PathBuf>> {
    let mut out = Vec::new();
    collect_files_recursive_inner(root, &mut out)?;
    Ok(out)
}

pub(super) fn cassette_dirs(source_root: &Path) -> Vec<PathBuf> {
    let mut dirs = vec![
        source_root.join("cassettes"),
        source_root.join(".assay/cassettes"),
        source_root.join(".assay/vcr"),
    ];
    if let Ok(vcr_dir) = std::env::var("ASSAY_VCR_DIR") {
        let p = PathBuf::from(vcr_dir);
        if p.is_absolute() {
            dirs.push(p);
        } else {
            dirs.push(source_root.join(p));
        }
    }
    dirs
}

fn find_latest_run_json(root: &Path) -> anyhow::Result<Option<PathBuf>> {
    let candidates = collect_named_files(root, "run.json", 6)?;
    let mut best: Option<(std::time::SystemTime, PathBuf)> = None;
    for p in candidates {
        let meta = match std::fs::metadata(&p) {
            Ok(m) => m,
            Err(_) => continue,
        };
        let mtime = match meta.modified() {
            Ok(t) => t,
            Err(_) => continue,
        };
        match &best {
            Some((bt, _)) if &mtime <= bt => {}
            _ => best = Some((mtime, p)),
        }
    }
    Ok(best.map(|(_, p)| p))
}

pub(super) fn find_first_existing(source_root: &Path, candidates: &[PathBuf]) -> Option<PathBuf> {
    for c in candidates {
        let p = if c.is_absolute() {
            c.clone()
        } else {
            source_root.join(c)
        };
        if p.exists() && p.is_file() {
            return Some(p);
        }
    }
    None
}

fn collect_named_files(
    root: &Path,
    needle: &str,
    max_depth: usize,
) -> anyhow::Result<Vec<PathBuf>> {
    let mut out = Vec::new();
    collect_named_files_inner(root, needle, max_depth, 0, &mut out)?;
    Ok(out)
}

fn collect_named_files_inner(
    dir: &Path,
    needle: &str,
    max_depth: usize,
    depth: usize,
    out: &mut Vec<PathBuf>,
) -> anyhow::Result<()> {
    if depth > max_depth {
        return Ok(());
    }
    for entry in std::fs::read_dir(dir)? {
        let entry = entry?;
        let p = entry.path();
        let ft = entry.file_type()?;
        if ft.is_dir() {
            let name = p.file_name().and_then(|s| s.to_str()).unwrap_or("");
            if should_skip_recursive_dir(name) {
                continue;
            }
            collect_named_files_inner(&p, needle, max_depth, depth + 1, out)?;
        } else if ft.is_file() && p.file_name().and_then(|s| s.to_str()) == Some(needle) {
            out.push(p);
        }
    }
    Ok(())
}

fn collect_files_recursive_inner(dir: &Path, out: &mut Vec<PathBuf>) -> anyhow::Result<()> {
    for entry in std::fs::read_dir(dir)? {
        let entry = entry?;
        let path = entry.path();
        let ft = entry.file_type()?;
        if ft.is_dir() {
            let name = path.file_name().and_then(|s| s.to_str()).unwrap_or("");
            if should_skip_recursive_dir(name) {
                continue;
            }
            collect_files_recursive_inner(&path, out)?;
        } else if ft.is_file() {
            out.push(path);
        }
    }
    Ok(())
}

fn should_skip_recursive_dir(name: &str) -> bool {
    matches!(
        name,
        ".git" | "target" | "node_modules" | ".venv" | "venv" | "__pycache__" | "dist" | "build"
    )
}

fn absolutize(path: &Path, cwd: &Path) -> PathBuf {
    if path.is_absolute() {
        path.to_path_buf()
    } else {
        cwd.join(path)
    }
}