use crate::mcp::read;
use crate::mcp::types::{ToolError, error_class};
use std::fs::File;
use std::path::{Path, PathBuf};
use std::time::Duration;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Profile {
Quick,
Deep,
}
impl Profile {
pub fn parse(value: Option<&str>) -> Result<Self, ToolError> {
match value {
None | Some("quick") => Ok(Profile::Quick),
Some("deep") => Ok(Profile::Deep),
Some(other) => Err(ToolError::new(
error_class::RUN_FAILED,
format!("unknown profile '{other}'; expected 'quick' or 'deep'"),
)),
}
}
fn cli_flag(self) -> &'static str {
match self {
Profile::Quick => "--quick",
Profile::Deep => "--deep",
}
}
fn as_str(self) -> &'static str {
match self {
Profile::Quick => "quick",
Profile::Deep => "deep",
}
}
}
const QUICK_BUDGET: Duration = Duration::from_secs(60);
const DEFAULT_BASES: &[&str] = &["develop", "main", "master"];
fn short_head(repo: &Path) -> String {
crate::git::git_cmd()
.args(["rev-parse", "--short", "HEAD"])
.current_dir(repo)
.output()
.ok()
.filter(|o| o.status.success())
.map(|o| String::from_utf8_lossy(&o.stdout).trim().to_string())
.unwrap_or_default()
}
fn allocate_run_dir(repo_name: &str, branch_key: &str) -> Result<(PathBuf, String), ToolError> {
let repo_runs_root = crate::config::prview_home().join("runs").join(repo_name);
let stamp = chrono::Local::now().format("%Y%m%d-%H%M%S").to_string();
allocate_run_dir_in(&repo_runs_root, branch_key, &stamp)
}
fn allocate_run_dir_in(
repo_runs_root: &Path,
branch_key: &str,
stamp: &str,
) -> Result<(PathBuf, String), ToolError> {
let base = repo_runs_root.join(branch_key);
std::fs::create_dir_all(&base).map_err(|e| {
ToolError::new(
error_class::RUN_FAILED,
format!("failed to create runs dir {}: {e}", base.display()),
)
})?;
let mut suffix = 2u32;
let mut run_id = stamp.to_string();
loop {
if !run_id_taken_in_repo(repo_runs_root, &run_id) {
let run_dir = base.join(&run_id);
match std::fs::create_dir(&run_dir) {
Ok(()) => return Ok((run_dir, run_id)),
Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => {}
Err(e) => {
return Err(ToolError::new(
error_class::RUN_FAILED,
format!("failed to create run dir {}: {e}", run_dir.display()),
));
}
}
}
run_id = format!("{stamp}-{suffix}");
suffix += 1;
if suffix > 10_000 {
return Err(ToolError::new(
error_class::RUN_FAILED,
"exhausted run-id suffixes allocating a run directory".to_string(),
));
}
}
}
fn run_id_taken_in_repo(repo_runs_root: &Path, run_id: &str) -> bool {
let Ok(read) = std::fs::read_dir(repo_runs_root) else {
return false;
};
for branch in read.flatten() {
let bp = branch.path();
if bp.is_dir() && bp.join(run_id).exists() {
return true;
}
}
false
}
fn active_run(repo_name: &str, branch_key: &str) -> Option<String> {
let base = crate::config::prview_home()
.join("runs")
.join(repo_name)
.join(branch_key);
let read = std::fs::read_dir(&base).ok()?;
for entry in read.flatten() {
let dir = entry.path();
if dir.is_dir()
&& matches!(read::run_status(&dir), read::RunStatus::Running { .. })
&& let Some(id) = dir.file_name().and_then(|n| n.to_str())
{
return Some(id.to_string());
}
}
None
}
fn write_marker(run_dir: &Path, marker: &read::RunningMarker) {
if let Ok(text) = serde_json::to_string_pretty(marker) {
let _ = std::fs::write(read::running_marker_path(run_dir), text);
}
}
fn intended_bases(base: &Option<String>) -> Vec<String> {
match base {
Some(b) => vec![b.clone()],
None => DEFAULT_BASES.iter().map(|s| s.to_string()).collect(),
}
}
fn positional_args(repo: &Path, base: &Option<String>) -> Vec<String> {
match base {
Some(b) => {
let branch =
crate::config::current_branch_name(repo).unwrap_or_else(|| "HEAD".to_string());
vec![branch, b.clone()]
}
None => Vec::new(),
}
}
fn stdio_files(run_dir: &Path) -> Result<(File, File), ToolError> {
let out = File::create(run_dir.join("run.log")).map_err(|e| {
ToolError::new(
error_class::RUN_FAILED,
format!("cannot create run.log: {e}"),
)
})?;
let err = File::create(run_dir.join("run.stderr.log")).map_err(|e| {
ToolError::new(
error_class::RUN_FAILED,
format!("cannot create run.stderr.log: {e}"),
)
})?;
Ok((out, err))
}
fn stderr_tail(run_dir: &Path) -> String {
let text = std::fs::read_to_string(run_dir.join("run.stderr.log")).unwrap_or_default();
let tail: Vec<&str> = text.lines().rev().take(20).collect();
tail.into_iter().rev().collect::<Vec<_>>().join("\n")
}
pub async fn start(
repo: &Path,
base: Option<String>,
profile: Profile,
) -> Result<serde_json::Value, ToolError> {
let repo_name = crate::config::repo_name_from_root(repo);
let branch_key = crate::config::storage_branch_key(repo);
if let Some(active_run_id) = active_run(&repo_name, &branch_key) {
return Err(ToolError::with_extra(
error_class::STORAGE_LOCKED,
"another review is already running for this repo branch",
serde_json::json!({
"active_run_id": active_run_id,
"retry_after_ms": 5000,
}),
));
}
let (run_dir, run_id) = allocate_run_dir(&repo_name, &branch_key)?;
let commit = short_head(repo);
let (out_file, err_file) = stdio_files(&run_dir)?;
let mut args: Vec<String> = vec![
"--output-dir".to_string(),
run_dir.to_string_lossy().to_string(),
profile.cli_flag().to_string(),
];
args.extend(positional_args(repo, &base));
match profile {
Profile::Quick => {
let mut cmd = tokio::process::Command::new(std::env::current_exe().map_err(|e| {
ToolError::new(error_class::RUN_FAILED, format!("current_exe failed: {e}"))
})?);
cmd.current_dir(repo)
.args(&args)
.stdout(std::process::Stdio::from(out_file))
.stderr(std::process::Stdio::from(err_file));
crate::proc::harden(&mut cmd);
let mut child = cmd.spawn().map_err(|e| {
ToolError::new(error_class::RUN_FAILED, format!("spawn prview failed: {e}"))
})?;
let child_pid = child.id();
write_marker(
&run_dir,
&read::RunningMarker {
pid: child_pid.unwrap_or(0),
started_at: chrono::Local::now().to_rfc3339(),
profile: profile.as_str().to_string(),
commit: commit.clone(),
base_used: intended_bases(&base),
},
);
match tokio::time::timeout(QUICK_BUDGET, child.wait()).await {
Err(_) => {
#[cfg(unix)]
if let Some(pid) = child_pid {
crate::proc::sigkill_process_group(pid);
}
let _ = child.start_kill();
Err(ToolError::with_extra(
error_class::RUN_TIMEOUT,
"quick review exceeded the 60s budget",
serde_json::json!({ "run_id": run_id }),
))
}
Ok(Err(e)) => Err(ToolError::new(
error_class::RUN_FAILED,
format!("failed to wait on prview: {e}"),
)),
Ok(Ok(_status)) => {
if read::run_status(&run_dir) != read::RunStatus::Completed {
return Err(ToolError::with_extra(
error_class::RUN_FAILED,
"prview produced no completed pack",
serde_json::json!({
"run_id": run_id,
"stderr_tail": stderr_tail(&run_dir),
}),
));
}
let _ = std::fs::remove_file(read::running_marker_path(&run_dir));
Ok(completed_body(&run_dir, &run_id, &commit))
}
}
}
Profile::Deep => {
let pid = spawn_detached(repo, &args, out_file, err_file)?;
write_marker(
&run_dir,
&read::RunningMarker {
pid,
started_at: chrono::Local::now().to_rfc3339(),
profile: profile.as_str().to_string(),
commit: commit.clone(),
base_used: intended_bases(&base),
},
);
Ok(serde_json::json!({
"run_id": run_id,
"status": "running",
"commit": commit,
"base_used": intended_bases(&base),
}))
}
}
}
fn spawn_detached(
repo: &Path,
args: &[String],
out_file: File,
err_file: File,
) -> Result<u32, ToolError> {
let mut cmd = std::process::Command::new(std::env::current_exe().map_err(|e| {
ToolError::new(error_class::RUN_FAILED, format!("current_exe failed: {e}"))
})?);
cmd.current_dir(repo)
.args(args)
.stdin(std::process::Stdio::null())
.stdout(std::process::Stdio::from(out_file))
.stderr(std::process::Stdio::from(err_file));
#[cfg(unix)]
{
use std::os::unix::process::CommandExt;
cmd.process_group(0);
}
let child = cmd.spawn().map_err(|e| {
ToolError::new(
error_class::RUN_FAILED,
format!("spawn detached prview failed: {e}"),
)
})?;
Ok(child.id())
}
fn completed_body(run_dir: &Path, run_id: &str, commit: &str) -> serde_json::Value {
let decision = read::read_decision(run_dir);
let (verdict, merge_rec, allow_merge, base_used, blocking, caveats) = match &decision {
Ok(d) => (
d.verdict.clone(),
d.merge_recommendation.clone(),
d.allow_merge,
d.base_used.clone(),
d.blocking_issues.clone(),
d.caveats.clone(),
),
Err(_) => (
"UNKNOWN".to_string(),
"review_required".to_string(),
false,
read::read_bases(run_dir),
Vec::new(),
Vec::new(),
),
};
let (checks_passed, checks_failed, files_changed) = run_stats(run_id, run_dir);
let mut artifact_paths = serde_json::json!({
"pack": run_dir.to_string_lossy(),
"merge_gate": "00_summary/MERGE_GATE.json",
});
if run_dir
.join("30_context")
.join("INLINE_FINDINGS.sarif")
.exists()
{
artifact_paths["sarif"] = serde_json::json!("30_context/INLINE_FINDINGS.sarif");
}
if run_dir.join("report.json").exists() {
artifact_paths["report"] = serde_json::json!("report.json");
}
serde_json::json!({
"run_id": run_id,
"status": "completed",
"commit": commit,
"base_used": base_used,
"verdict": verdict,
"merge_recommendation": merge_rec,
"allow_merge": allow_merge,
"blocking_issues": blocking,
"caveats": caveats,
"gates": read::read_gates(run_dir),
"artifact_paths": artifact_paths,
"stats": {
"checks_passed": checks_passed,
"checks_failed": checks_failed,
"files_changed": files_changed,
},
})
}
fn run_stats(run_id: &str, run_dir: &Path) -> (usize, usize, usize) {
let index = crate::storage::RunIndex::load();
if let Some(e) = index
.entries()
.iter()
.find(|e| e.id == run_id && e.path == run_dir)
{
(e.checks_passed, e.checks_failed, e.files_changed)
} else {
(0, 0, 0)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn profile_parse_defaults_quick_and_rejects_unknown() {
assert_eq!(Profile::parse(None).unwrap(), Profile::Quick);
assert_eq!(Profile::parse(Some("quick")).unwrap(), Profile::Quick);
assert_eq!(Profile::parse(Some("deep")).unwrap(), Profile::Deep);
let err = Profile::parse(Some("turbo")).unwrap_err();
assert_eq!(err.class, error_class::RUN_FAILED);
}
#[test]
fn intended_bases_uses_explicit_then_defaults() {
assert_eq!(intended_bases(&Some("dev".to_string())), vec!["dev"]);
assert_eq!(intended_bases(&None), vec!["develop", "main", "master"]);
}
#[test]
fn allocate_run_dir_is_exclusive_within_branch() {
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path();
let stamp = "20260701-120000";
let (dir1, id1) = allocate_run_dir_in(root, "main", stamp).unwrap();
let (dir2, id2) = allocate_run_dir_in(root, "main", stamp).unwrap();
assert_eq!(id1, stamp);
assert_eq!(id2, "20260701-120000-2");
assert_ne!(dir1, dir2);
assert!(dir1.is_dir() && dir2.is_dir());
}
#[test]
fn allocate_run_dir_is_globally_unique_across_branches() {
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path();
let stamp = "20260701-120000";
let (existing, existing_id) = allocate_run_dir_in(root, "feature", stamp).unwrap();
let (fresh, fresh_id) = allocate_run_dir_in(root, "main", stamp).unwrap();
assert_eq!(existing_id, stamp);
assert_eq!(fresh_id, "20260701-120000-2");
assert_ne!(existing_id, fresh_id);
assert!(existing.is_dir() && fresh.is_dir());
}
#[test]
fn completed_body_advertises_root_report_json() {
let tmp = tempfile::tempdir().unwrap();
let run_dir = tmp.path();
let summary = run_dir.join("00_summary");
std::fs::create_dir_all(&summary).unwrap();
std::fs::write(
summary.join("MERGE_GATE.json"),
serde_json::to_string(&serde_json::json!({
"bases": ["main"],
"decision": {
"merge_recommendation": "approve",
"verdict": "APPROVE",
"allow_merge": true
}
}))
.unwrap(),
)
.unwrap();
std::fs::write(run_dir.join("report.json"), "{}").unwrap();
let body = completed_body(run_dir, "20260701-120000", "abc1234");
assert_eq!(
body["artifact_paths"]["report"],
serde_json::json!("report.json")
);
}
}