use crate::config::{
branch_storage_key, current_branch_name, find_repo_root_from, prview_home, repo_name_from_root,
};
use anyhow::{Context, Result, anyhow, bail};
use serde::{Deserialize, Serialize};
use std::collections::{HashMap, HashSet};
use std::fs;
use std::io::{BufRead, ErrorKind, Write};
use std::path::{Path, PathBuf};
#[derive(Serialize, Deserialize, Clone, Debug)]
pub struct RunEntry {
pub id: String,
pub repo: String,
pub branch: String,
pub commit: String,
pub path: PathBuf,
pub created_at: String,
pub quality_pass: bool,
pub merge_status: String,
pub policy_mode: String,
pub checks_passed: usize,
pub checks_failed: usize,
pub files_changed: usize,
pub size_bytes: u64,
pub has_dashboard: bool,
}
pub struct RetentionPolicy {
pub max_runs_per_branch: usize,
pub max_runs_per_repo: usize,
pub max_total_bytes: u64,
}
impl Default for RetentionPolicy {
fn default() -> Self {
Self {
max_runs_per_branch: 20,
max_runs_per_repo: 200,
max_total_bytes: 5 * 1024 * 1024 * 1024, }
}
}
pub struct RunIndex {
entries: Vec<RunEntry>,
}
fn index_path() -> PathBuf {
prview_home().join("index.jsonl")
}
fn lock_path() -> PathBuf {
prview_home().join("index.jsonl.lock")
}
fn resolve_explicit_index_path(path: &Path) -> Result<PathBuf> {
let parent = path.parent().ok_or_else(|| {
anyhow!(
"Index path must include a parent directory: {}",
path.display()
)
})?;
let file_name = path
.file_name()
.ok_or_else(|| anyhow!("Index path must include a file name: {}", path.display()))?;
crate::paths::resolve_file_name_within(parent, file_name)
}
impl RunIndex {
pub fn load() -> Self {
let path = index_path();
let entries = match fs::File::open(&path) {
Ok(file) => {
let reader = std::io::BufReader::new(file);
reader
.lines()
.map_while(Result::ok)
.filter(|line| !line.trim().is_empty())
.filter_map(|line| serde_json::from_str::<RunEntry>(&line).ok())
.collect()
}
Err(_) => Vec::new(),
};
Self { entries }
}
pub fn load_from(path: &Path) -> Self {
let resolved = match resolve_explicit_index_path(path) {
Ok(path) => path,
Err(_) => {
return Self {
entries: Vec::new(),
};
}
};
let entries = match fs::File::open(&resolved) {
Ok(file) => {
let reader = std::io::BufReader::new(file);
reader
.lines()
.map_while(Result::ok)
.filter(|line| !line.trim().is_empty())
.filter_map(|line| serde_json::from_str::<RunEntry>(&line).ok())
.collect()
}
Err(_) => Vec::new(),
};
Self { entries }
}
pub fn save_to(&self, path: &Path) -> Result<()> {
let resolved = resolve_explicit_index_path(path)?;
if let Some(parent) = resolved.parent() {
fs::create_dir_all(parent)?;
}
let tmp = resolved.with_extension("jsonl.tmp");
{
let mut f = fs::File::create(&tmp)
.with_context(|| format!("Cannot create {}", tmp.display()))?;
for entry in &self.entries {
let line = serde_json::to_string(entry)?;
writeln!(f, "{}", line)?;
}
f.flush()?;
}
fs::rename(&tmp, &resolved)?;
Ok(())
}
pub fn save(&self) -> Result<()> {
let path = resolve_explicit_index_path(&index_path())?;
if let Some(parent) = path.parent() {
fs::create_dir_all(parent)?;
}
let tmp = path.with_extension("jsonl.tmp");
{
let mut f = fs::File::create(&tmp)
.with_context(|| format!("Cannot create {}", tmp.display()))?;
for entry in &self.entries {
let line = serde_json::to_string(entry)?;
writeln!(f, "{}", line)?;
}
f.flush()?;
}
fs::rename(&tmp, &path)?;
Ok(())
}
pub fn append(&mut self, entry: RunEntry) {
self.entries.push(entry);
}
pub fn entries(&self) -> &[RunEntry] {
&self.entries
}
pub fn list_for_repo(&self, repo: &str) -> Vec<&RunEntry> {
self.entries.iter().filter(|e| e.repo == repo).collect()
}
pub fn list_for_branch(&self, repo: &str, branch: &str) -> Vec<&RunEntry> {
let branch_keys = branch_lookup_keys(branch);
self.entries
.iter()
.filter(|e| e.repo == repo && branch_keys.contains(&e.branch))
.collect()
}
pub fn latest(&self, repo: &str, branch: &str) -> Option<&RunEntry> {
let branch_keys = branch_lookup_keys(branch);
self.entries
.iter()
.rev()
.find(|e| e.repo == repo && branch_keys.contains(&e.branch))
}
pub fn remove_stale(&mut self) {
self.entries.retain(|e| e.path.is_dir());
}
pub fn rebuild() -> Self {
let runs_dir = prview_home().join("runs");
let mut entries = Vec::new();
if !runs_dir.is_dir() {
return Self { entries };
}
let repos = read_subdirs(&runs_dir);
for repo_dir in repos {
let repo_name = dir_name(&repo_dir);
let branches = read_subdirs(&repo_dir);
for branch_dir in branches {
let branch_name = dir_name(&branch_dir);
let runs = read_subdirs(&branch_dir);
for run_dir in runs {
let id = dir_name(&run_dir);
if id == "latest" {
continue;
}
if let Some(entry) = entry_from_disk(&run_dir, &id, &repo_name, &branch_name) {
entries.push(entry);
}
}
}
}
entries.sort_by(|a, b| a.created_at.cmp(&b.created_at));
Self { entries }
}
pub fn prune(&mut self, policy: &RetentionPolicy, current_run: &Path) -> Vec<PathBuf> {
let mut protected: HashSet<PathBuf> = HashSet::new();
protected.insert(current_run.to_path_buf());
for entry in &self.entries {
if let Some(parent) = entry.path.parent() {
let latest = parent.join("latest");
if let Ok(target) = fs::read_link(&latest) {
let resolved = parent.join(target);
protected.insert(resolved);
}
}
}
let mut to_remove: HashSet<PathBuf> = HashSet::new();
let mut branch_groups: HashMap<(String, String), Vec<usize>> = HashMap::new();
for (i, e) in self.entries.iter().enumerate() {
branch_groups
.entry((e.repo.clone(), e.branch.clone()))
.or_default()
.push(i);
}
for indices in branch_groups.values() {
if indices.len() > policy.max_runs_per_branch {
let mut excess = indices.len() - policy.max_runs_per_branch;
for &idx in indices {
if excess == 0 {
break;
}
let path = &self.entries[idx].path;
if !protected.contains(path) {
to_remove.insert(path.clone());
excess -= 1;
}
}
}
}
let mut repo_groups: HashMap<String, Vec<usize>> = HashMap::new();
for (i, e) in self.entries.iter().enumerate() {
if !to_remove.contains(&e.path) {
repo_groups.entry(e.repo.clone()).or_default().push(i);
}
}
for indices in repo_groups.values() {
if indices.len() > policy.max_runs_per_repo {
let mut excess = indices.len() - policy.max_runs_per_repo;
for &idx in indices {
if excess == 0 {
break;
}
let path = &self.entries[idx].path;
if !protected.contains(path) {
to_remove.insert(path.clone());
excess -= 1;
}
}
}
}
let remaining: Vec<usize> = self
.entries
.iter()
.enumerate()
.filter(|(_, e)| !to_remove.contains(&e.path))
.map(|(i, _)| i)
.collect();
let total_size: u64 = remaining.iter().map(|&i| self.entries[i].size_bytes).sum();
if total_size > policy.max_total_bytes {
let mut freed: u64 = 0;
let needed = total_size - policy.max_total_bytes;
for &idx in &remaining {
if freed >= needed {
break;
}
let path = &self.entries[idx].path;
if !protected.contains(path) {
freed += self.entries[idx].size_bytes;
to_remove.insert(path.clone());
}
}
}
self.entries.retain(|e| !to_remove.contains(&e.path));
to_remove.into_iter().collect()
}
}
#[derive(Debug)]
pub struct LockGuard {
path: PathBuf,
token: String,
}
impl Drop for LockGuard {
fn drop(&mut self) {
if let Ok(content) = fs::read_to_string(&self.path)
&& content.trim() == self.token
{
let _ = fs::remove_file(&self.path);
}
}
}
pub fn acquire_lock() -> Result<LockGuard> {
acquire_lock_at(&lock_path())
}
pub fn acquire_lock_at(path: &Path) -> Result<LockGuard> {
let path = path.to_path_buf();
if let Some(parent) = path.parent() {
fs::create_dir_all(parent)?;
}
let token = format!(
"{}:{}",
std::process::id(),
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_nanos())
.unwrap_or(0)
);
for _ in 0..3 {
match fs::OpenOptions::new()
.write(true)
.create_new(true)
.open(&path)
{
Ok(mut file) => {
file.write_all(token.as_bytes())?;
file.flush()?;
return Ok(LockGuard { path, token });
}
Err(err) if err.kind() == ErrorKind::AlreadyExists => {
let content = fs::read_to_string(&path).unwrap_or_default();
if lock_is_stale(&content) {
let claim = path.with_file_name(format!(
"{}.claim.{}",
path.file_name()
.and_then(|n| n.to_str())
.unwrap_or("index.jsonl.lock"),
token.replace(':', "-")
));
match fs::rename(&path, &claim) {
Ok(()) => {
let moved = fs::read_to_string(&claim).unwrap_or_default();
if moved.trim() == content.trim() {
let _ = fs::remove_file(&claim);
continue;
}
let _ = fs::rename(&claim, &path);
}
Err(rename_err) if rename_err.kind() == ErrorKind::NotFound => continue,
Err(rename_err) => {
return Err(rename_err).with_context(|| {
format!("Failed to claim stale lock {}", path.display())
});
}
}
}
bail!(
"Index lock held by another live process ({}). If this is stale, delete {}",
content.trim(),
path.display()
);
}
Err(err) => {
return Err(err)
.with_context(|| format!("Failed to create lock {}", path.display()));
}
}
}
bail!(
"Failed to acquire index lock after clearing stale lock races at {}",
path.display()
);
}
fn lock_is_stale(content: &str) -> bool {
let pid = content
.trim()
.split(':')
.next()
.and_then(|part| part.parse::<u32>().ok());
match pid {
Some(pid) => !is_process_alive(pid),
None => true,
}
}
pub(crate) fn is_process_alive(pid: u32) -> bool {
let rc = unsafe { libc::kill(pid as libc::pid_t, 0) };
if rc == 0 {
return true;
}
matches!(
std::io::Error::last_os_error().raw_os_error(),
Some(libc::EPERM)
)
}
fn read_subdirs(dir: &Path) -> Vec<PathBuf> {
fs::read_dir(dir)
.ok()
.map(|rd| {
rd.filter_map(|e| e.ok())
.filter(|e| e.path().is_dir())
.map(|e| e.path())
.collect()
})
.unwrap_or_default()
}
fn dir_name(p: &Path) -> String {
p.file_name()
.and_then(|n| n.to_str())
.unwrap_or("unknown")
.to_string()
}
fn branch_lookup_keys(branch: &str) -> Vec<String> {
let primary = branch_storage_key(branch);
let legacy = branch.replace('/', "-");
if primary == legacy {
vec![primary]
} else {
vec![primary, legacy]
}
}
fn entry_from_disk(run_dir: &Path, id: &str, repo: &str, branch: &str) -> Option<RunEntry> {
let report_path = run_dir.join("report.json");
let size = dir_size(run_dir);
let has_dashboard = run_dir.join("dashboard.html").exists();
if let Ok(data) = fs::read_to_string(&report_path)
&& let Ok(val) = serde_json::from_str::<serde_json::Value>(&data)
{
let commit = val
.pointer("/meta/range/target/commit")
.and_then(|v| v.as_str())
.unwrap_or("")
.to_string();
let quality_pass = val
.pointer("/gate/quality_pass")
.and_then(|v| v.as_bool())
.unwrap_or(false);
let merge_status = match val
.pointer("/gate/merge_recommendation")
.and_then(|v| v.as_str())
{
Some("approve") => "ALLOW",
Some("review_required") => "HOLD",
Some("block") => "BLOCK",
_ => val
.pointer("/gate/verdict")
.and_then(|v| v.as_str())
.map(|verdict| match verdict {
"PASS" | "ALLOW" => "ALLOW",
"HOLD" | "CONDITIONAL" => "HOLD",
_ => "BLOCK",
})
.unwrap_or("BLOCK"),
};
let policy_mode = val
.pointer("/gate/policy_mode")
.and_then(|v| v.as_str())
.unwrap_or("shadow")
.to_string();
let checks_passed = val
.pointer("/checks")
.and_then(|v| v.as_array())
.map(|arr| {
arr.iter()
.filter(|c| c.get("status").and_then(|s| s.as_str()) == Some("PASS"))
.count()
})
.unwrap_or(0);
let checks_failed = val
.pointer("/checks")
.and_then(|v| v.as_array())
.map(|arr| {
arr.iter()
.filter(|c| {
matches!(
c.get("status").and_then(|s| s.as_str()),
Some("FAIL") | Some("ERROR")
)
})
.count()
})
.unwrap_or(0);
let files_changed = val
.pointer("/diff/stats/files_changed")
.or_else(|| val.pointer("/diff/files_changed"))
.and_then(|v| v.as_u64())
.unwrap_or(0) as usize;
let created_at = val
.pointer("/meta/generated_at")
.and_then(|v| v.as_str())
.unwrap_or("")
.to_string();
return Some(RunEntry {
id: id.to_string(),
repo: repo.to_string(),
branch: branch.to_string(),
commit: crate::git::short_sha(&commit).to_string(),
path: run_dir.to_path_buf(),
created_at,
quality_pass,
merge_status: merge_status.to_string(),
policy_mode,
checks_passed,
checks_failed,
files_changed,
size_bytes: size,
has_dashboard,
});
}
let created_at = id_to_iso(id).unwrap_or_default();
Some(RunEntry {
id: id.to_string(),
repo: repo.to_string(),
branch: branch.to_string(),
commit: String::new(),
path: run_dir.to_path_buf(),
created_at,
quality_pass: false,
merge_status: "N/A".to_string(),
policy_mode: "shadow".to_string(),
checks_passed: 0,
checks_failed: 0,
files_changed: 0,
size_bytes: size,
has_dashboard,
})
}
fn dir_size(dir: &Path) -> u64 {
walkdir::WalkDir::new(dir)
.into_iter()
.filter_map(|e| e.ok())
.filter(|e| e.file_type().is_file())
.filter_map(|e| e.metadata().ok())
.map(|m| m.len())
.sum()
}
fn id_to_iso(id: &str) -> Option<String> {
if id.len() >= 15 {
let dt = chrono::NaiveDateTime::parse_from_str(id, "%Y%m%d-%H%M%S").ok()?;
Some(dt.format("%Y-%m-%dT%H:%M:%S").to_string())
} else {
None
}
}
pub fn format_size(bytes: u64) -> String {
if bytes >= 1_073_741_824 {
format!("{:.1}G", bytes as f64 / 1_073_741_824.0)
} else if bytes >= 1_048_576 {
format!("{:.1}M", bytes as f64 / 1_048_576.0)
} else if bytes >= 1024 {
format!("{}K", bytes / 1024)
} else {
format!("{}B", bytes)
}
}
pub struct RunsOpts {
pub all: bool,
pub branch: Option<String>,
pub status: Option<String>,
pub json: bool,
pub rebuild: bool,
}
pub fn run_runs_command(opts: &RunsOpts) -> Result<()> {
let index = if opts.rebuild {
eprintln!("Rebuilding index from disk...");
let _lock = acquire_lock()?;
let idx = RunIndex::rebuild();
idx.save()?;
eprintln!("Rebuilt index with {} entries", idx.entries().len());
idx
} else {
match acquire_lock() {
Ok(_lock) => {
let mut idx = RunIndex::load();
let before = idx.entries().len();
idx.remove_stale();
if idx.entries().len() < before {
idx.save()?;
}
idx
}
Err(_) => RunIndex::load(),
}
};
let mut entries: Vec<&RunEntry> = if opts.all {
index.entries().iter().collect()
} else {
let repo_name = detect_current_repo_name();
if let Some(ref branch) = opts.branch {
index.list_for_branch(&repo_name, branch)
} else {
index.list_for_repo(&repo_name)
}
};
if let Some(ref status) = opts.status {
let want_pass = status.to_lowercase() == "pass" || status.to_lowercase() == "ok";
entries.retain(|e| e.quality_pass == want_pass);
}
if opts.json {
let json = serde_json::to_string_pretty(&entries)?;
if let Err(err) = write_json_stdout(&json)
&& err.kind() != std::io::ErrorKind::BrokenPipe
{
return Err(err.into());
}
return Ok(());
}
if entries.is_empty() {
println!("No runs found.");
return Ok(());
}
print_runs_table(&entries);
Ok(())
}
fn write_json_stdout(json: &str) -> std::io::Result<()> {
use std::io::Write;
let stdout = std::io::stdout();
let mut handle = stdout.lock();
writeln!(handle, "{json}")
}
fn print_runs_table(entries: &[&RunEntry]) {
use std::collections::BTreeMap;
let mut groups: BTreeMap<(String, String), Vec<&RunEntry>> = BTreeMap::new();
for e in entries {
groups
.entry((e.repo.clone(), e.branch.clone()))
.or_default()
.push(e);
}
for ((repo, branch), runs) in &groups {
println!("{} / {}", repo, branch);
for run in runs.iter().rev() {
let status = if run.quality_pass {
"\u{2713}"
} else {
"\u{2717}"
};
let status_label = if run.quality_pass { "PASS" } else { "FAIL" };
let checks_total = run.checks_passed + run.checks_failed;
println!(
" {} {} {} {}/{} checks {} files {}",
run.id,
status,
status_label,
run.checks_passed,
checks_total,
run.files_changed,
format_size(run.size_bytes),
);
}
println!();
}
}
fn detect_current_repo_name() -> String {
std::env::current_dir()
.ok()
.and_then(|cwd| find_repo_root_from(&cwd).ok())
.map(|repo_root| repo_name_from_root(&repo_root))
.or_else(|| {
std::env::current_dir()
.ok()
.and_then(|p| p.file_name().map(|n| n.to_string_lossy().to_string()))
})
.unwrap_or_else(|| "unknown".to_string())
}
pub struct OpenOpts {
pub run_id: Option<String>,
pub dir_only: bool,
}
pub fn run_open_command(opts: &OpenOpts) -> Result<()> {
let index = RunIndex::load();
let repo_name = detect_current_repo_name();
let branch_name = detect_current_branch();
let entry = if let Some(ref run_id) = opts.run_id {
index
.entries()
.iter()
.find(|e| e.id == *run_id && e.repo == repo_name)
.or_else(|| {
index.entries().iter().find(|e| e.id == *run_id)
})
} else {
index.latest(&repo_name, &branch_name)
};
let entry = match entry {
Some(e) => e,
None => {
if let Some(ref id) = opts.run_id {
bail!("Run '{}' not found in index", id);
}
bail!(
"No runs found for {} / {}. Run `prview` first or try `prview runs --all`",
repo_name,
branch_name
);
}
};
let dashboard = entry.path.join("dashboard.html");
if opts.dir_only {
println!("{}", entry.path.display());
return Ok(());
}
if !dashboard.exists() {
bail!(
"Dashboard not found at {}. Use `prview open --dir` for the directory path.",
dashboard.display()
);
}
#[cfg(target_os = "macos")]
{
let status = std::process::Command::new("open")
.arg(&dashboard)
.status()
.context("Failed to open dashboard")?;
ensure_opener_succeeded("open", &dashboard, status)?;
}
#[cfg(not(target_os = "macos"))]
{
let status = std::process::Command::new("xdg-open")
.arg(&dashboard)
.status()
.context("Failed to open dashboard")?;
ensure_opener_succeeded("xdg-open", &dashboard, status)?;
}
Ok(())
}
fn ensure_opener_succeeded(
opener: &str,
dashboard: &Path,
status: std::process::ExitStatus,
) -> Result<()> {
if status.success() {
return Ok(());
}
match status.code() {
Some(code) => bail!(
"{} exited with status {} while opening {}",
opener,
code,
dashboard.display()
),
None => bail!(
"{} terminated before opening {}",
opener,
dashboard.display()
),
}
}
fn detect_current_branch() -> String {
std::env::current_dir()
.ok()
.and_then(|cwd| find_repo_root_from(&cwd).ok())
.and_then(|repo_root| current_branch_name(&repo_root))
.unwrap_or_else(|| "HEAD".to_string())
}
pub fn register_and_prune(
out_dir: &Path,
entry: RunEntry,
emit_human_stdout: bool,
) -> Result<usize> {
let _lock = acquire_lock()?;
let mut index = RunIndex::load();
index.append(entry);
let pruned = index.prune(&RetentionPolicy::default(), out_dir);
let pruned_count = pruned.len();
for path in &pruned {
if path.is_dir() {
let _ = fs::remove_dir_all(path);
}
}
index.save()?;
if emit_human_stdout && pruned_count > 0 {
use colored::Colorize;
println!(
" {} Pruned {} old run{}",
"\u{267b}".green(),
pruned_count,
if pruned_count == 1 { "" } else { "s" },
);
}
Ok(pruned_count)
}
#[cfg(test)]
mod tests {
use super::*;
use std::fs;
use std::path::Path;
#[cfg(unix)]
fn exit_status(code: i32) -> std::process::ExitStatus {
use std::os::unix::process::ExitStatusExt;
std::process::ExitStatus::from_raw(code << 8)
}
#[cfg(windows)]
fn exit_status(code: u32) -> std::process::ExitStatus {
use std::os::windows::process::ExitStatusExt;
std::process::ExitStatus::from_raw(code)
}
fn make_entry(id: &str, repo: &str, branch: &str, size: u64) -> RunEntry {
RunEntry {
id: id.to_string(),
repo: repo.to_string(),
branch: branch.to_string(),
commit: "abc1234".to_string(),
path: PathBuf::from(format!("/tmp/test-runs/{}/{}/{}", repo, branch, id)),
created_at: format!("2026-01-01T00:00:{}Z", id),
quality_pass: true,
merge_status: "ALLOW".to_string(),
policy_mode: "shadow".to_string(),
checks_passed: 3,
checks_failed: 0,
files_changed: 10,
size_bytes: size,
has_dashboard: true,
}
}
#[test]
fn test_load_save_roundtrip() {
let tmp = tempfile::tempdir().unwrap();
let idx_path = tmp.path().join("index.jsonl");
let mut index = RunIndex { entries: vec![] };
index.append(make_entry("001", "myrepo", "main", 1000));
index.append(make_entry("002", "myrepo", "feat-x", 2000));
index.save_to(&idx_path).unwrap();
assert!(idx_path.exists());
let loaded = RunIndex::load_from(&idx_path);
assert_eq!(loaded.entries().len(), 2);
assert_eq!(loaded.entries()[0].id, "001");
assert_eq!(loaded.entries()[1].id, "002");
}
#[test]
fn test_list_for_repo_and_branch() {
let mut index = RunIndex { entries: vec![] };
index.append(make_entry("001", "repo-a", "main", 1000));
index.append(make_entry("002", "repo-a", "feat", 1000));
index.append(make_entry("003", "repo-b", "main", 1000));
assert_eq!(index.list_for_repo("repo-a").len(), 2);
assert_eq!(index.list_for_branch("repo-a", "main").len(), 1);
assert_eq!(index.list_for_branch("repo-b", "main").len(), 1);
assert_eq!(index.list_for_branch("repo-c", "main").len(), 0);
}
#[test]
fn test_latest() {
let mut index = RunIndex { entries: vec![] };
index.append(make_entry("001", "repo", "main", 1000));
index.append(make_entry("002", "repo", "main", 2000));
let latest = index.latest("repo", "main").unwrap();
assert_eq!(latest.id, "002");
}
#[test]
fn test_prune_per_branch() {
let tmp = tempfile::tempdir().unwrap();
let current = tmp.path().join("current");
fs::create_dir_all(¤t).unwrap();
let mut index = RunIndex { entries: vec![] };
let policy = RetentionPolicy {
max_runs_per_branch: 3,
max_runs_per_repo: 100,
max_total_bytes: u64::MAX,
};
for i in 0..5 {
let id = format!("{:03}", i);
let dir = tmp.path().join(format!("run-{}", i));
fs::create_dir_all(&dir).unwrap();
let mut entry = make_entry(&id, "repo", "main", 1000);
entry.path = dir;
index.append(entry);
}
let pruned = index.prune(&policy, ¤t);
assert_eq!(pruned.len(), 2);
assert_eq!(index.entries().len(), 3);
assert_eq!(index.entries()[0].id, "002");
}
#[test]
fn test_prune_protects_current_run() {
let tmp = tempfile::tempdir().unwrap();
let mut index = RunIndex { entries: vec![] };
let policy = RetentionPolicy {
max_runs_per_branch: 1,
max_runs_per_repo: 100,
max_total_bytes: u64::MAX,
};
let current = tmp.path().join("run-0");
fs::create_dir_all(¤t).unwrap();
let mut entry0 = make_entry("000", "repo", "main", 1000);
entry0.path = current.clone();
index.append(entry0);
let dir1 = tmp.path().join("run-1");
fs::create_dir_all(&dir1).unwrap();
let mut entry1 = make_entry("001", "repo", "main", 1000);
entry1.path = dir1;
index.append(entry1);
let pruned = index.prune(&policy, ¤t);
assert_eq!(pruned.len(), 1);
assert!(index.entries().iter().any(|e| e.path == current));
}
#[test]
fn test_prune_global_size() {
let tmp = tempfile::tempdir().unwrap();
let current = tmp.path().join("current");
fs::create_dir_all(¤t).unwrap();
let mut index = RunIndex { entries: vec![] };
let policy = RetentionPolicy {
max_runs_per_branch: 100,
max_runs_per_repo: 100,
max_total_bytes: 2500, };
for i in 0..3 {
let dir = tmp.path().join(format!("run-{}", i));
fs::create_dir_all(&dir).unwrap();
let mut entry = make_entry(&format!("{:03}", i), "repo", "main", 1000);
entry.path = dir;
index.append(entry);
}
let pruned = index.prune(&policy, ¤t);
assert_eq!(pruned.len(), 1);
}
#[test]
fn test_remove_stale() {
let tmp = tempfile::tempdir().unwrap();
let existing = tmp.path().join("existing");
fs::create_dir_all(&existing).unwrap();
let mut index = RunIndex { entries: vec![] };
let mut e1 = make_entry("001", "repo", "main", 1000);
e1.path = existing;
let mut e2 = make_entry("002", "repo", "main", 1000);
e2.path = PathBuf::from("/nonexistent/path/run-002");
index.append(e1);
index.append(e2);
index.remove_stale();
assert_eq!(index.entries().len(), 1);
assert_eq!(index.entries()[0].id, "001");
}
#[test]
fn test_format_size() {
assert_eq!(format_size(500), "500B");
assert_eq!(format_size(1024), "1K");
assert_eq!(format_size(965_100), "942K");
assert_eq!(format_size(1_200_000), "1.1M");
assert_eq!(format_size(5_368_709_120), "5.0G");
}
#[test]
fn test_id_to_iso() {
assert_eq!(
id_to_iso("20260305-022829"),
Some("2026-03-05T02:28:29".to_string())
);
assert_eq!(id_to_iso("short"), None);
}
#[test]
fn test_entry_from_disk_reads_diff_stats_files_changed() {
let tmp = tempfile::tempdir().unwrap();
let run_dir = tmp.path().join("20260305-022829");
fs::create_dir_all(&run_dir).unwrap();
fs::write(
run_dir.join("report.json"),
r#"{
"meta":{"generated_at":"2026-03-05T02:28:29Z","range":{"target":{"commit":"abcdef1234567"}}},
"gate":{"quality_pass":true,"allow_merge":true,"policy_mode":"warn"},
"checks":[{"status":"PASS"},{"status":"FAIL"}],
"diff":{"stats":{"files_changed":7}}
}"#,
)
.unwrap();
let entry = entry_from_disk(&run_dir, "20260305-022829", "repo", "main").unwrap();
assert_eq!(entry.files_changed, 7);
assert_eq!(entry.checks_passed, 1);
assert_eq!(entry.checks_failed, 1);
}
#[test]
fn test_entry_from_disk_counts_only_failed_and_errored_as_failed() {
let tmp = tempfile::tempdir().unwrap();
let run_dir = tmp.path().join("20260305-022829");
fs::create_dir_all(&run_dir).unwrap();
fs::write(
run_dir.join("report.json"),
r#"{
"meta":{"generated_at":"2026-03-05T02:28:29Z","range":{"target":{"commit":"abcdef1234567"}}},
"gate":{"quality_pass":false,"allow_merge":false,"policy_mode":"warn"},
"checks":[{"status":"PASS"},{"status":"WARN"},{"status":"SKIP"},{"status":"FAIL"},{"status":"ERROR"}],
"diff":{"stats":{"files_changed":1}}
}"#,
)
.unwrap();
let entry = entry_from_disk(&run_dir, "20260305-022829", "repo", "main").unwrap();
assert_eq!(entry.checks_passed, 1, "only PASS counts as passed");
assert_eq!(
entry.checks_failed, 2,
"only FAIL and ERROR count as failed; WARN/SKIP must not inflate it"
);
}
#[test]
fn test_acquire_lock_rejects_live_owner_and_cleans_up_on_drop() {
let tmp = tempfile::tempdir().unwrap();
let lock_file = tmp.path().join("index.jsonl.lock");
let guard = acquire_lock_at(&lock_file).unwrap();
let second = acquire_lock_at(&lock_file).unwrap_err();
assert!(second.to_string().contains("another live process"));
drop(guard);
assert!(!lock_file.exists());
}
#[test]
fn test_acquire_lock_replaces_stale_dead_pid_lock() {
let tmp = tempfile::tempdir().unwrap();
let lock_file = tmp.path().join("index.jsonl.lock");
fs::create_dir_all(tmp.path()).unwrap();
fs::write(&lock_file, "999999:1").unwrap();
let guard = acquire_lock_at(&lock_file).unwrap();
let content = fs::read_to_string(&lock_file).unwrap();
assert_eq!(content.trim(), guard.token);
drop(guard);
}
#[test]
fn test_acquire_lock_claims_stale_lock_without_leaving_artifacts() {
let tmp = tempfile::tempdir().unwrap();
let lock_file = tmp.path().join("index.jsonl.lock");
fs::write(&lock_file, "999999:1").unwrap();
let guard = acquire_lock_at(&lock_file).unwrap();
let leftovers: Vec<String> = fs::read_dir(tmp.path())
.unwrap()
.filter_map(|e| e.ok())
.map(|e| e.file_name().to_string_lossy().into_owned())
.filter(|name| name.contains(".claim."))
.collect();
assert!(
leftovers.is_empty(),
"stale-lock claim must leave no temp artifacts, found: {leftovers:?}"
);
assert!(lock_file.exists(), "our live lock must be held");
drop(guard);
assert!(!lock_file.exists());
}
#[test]
fn test_list_for_branch_matches_legacy_branch_keys() {
let mut index = RunIndex { entries: vec![] };
index.append(make_entry("001", "repo-a", "feature-user-auth", 1000));
let matches = index.list_for_branch("repo-a", "feature/user-auth");
assert_eq!(matches.len(), 1);
assert_eq!(matches[0].branch, "feature-user-auth");
}
#[test]
fn test_ensure_opener_succeeded_accepts_zero_exit() {
let dashboard = Path::new("/tmp/dashboard.html");
ensure_opener_succeeded("open", dashboard, exit_status(0)).expect("zero exit succeeds");
}
#[test]
fn test_ensure_opener_succeeded_rejects_non_zero_exit() {
let dashboard = Path::new("/tmp/dashboard.html");
let err = ensure_opener_succeeded("open", dashboard, exit_status(7)).unwrap_err();
let msg = err.to_string();
assert!(msg.contains("open exited with status 7"));
assert!(msg.contains("/tmp/dashboard.html"));
}
static ENV_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
fn with_prview_home<R>(f: impl FnOnce(&Path) -> R) -> R {
let _guard = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
let home = tempfile::tempdir().unwrap();
let prev = std::env::var("PRVIEW_HOME").ok();
unsafe { std::env::set_var("PRVIEW_HOME", home.path()) };
let result = f(home.path());
match prev {
Some(v) => unsafe { std::env::set_var("PRVIEW_HOME", v) },
None => unsafe { std::env::remove_var("PRVIEW_HOME") },
}
result
}
fn runs_opts_all_json() -> RunsOpts {
RunsOpts {
all: true,
branch: None,
status: None,
json: true,
rebuild: false,
}
}
fn live_entry(home: &Path, id: &str) -> RunEntry {
let dir = home.join("runs/myrepo/main").join(id);
fs::create_dir_all(&dir).unwrap();
let mut e = make_entry(id, "myrepo", "main", 100);
e.path = dir;
e
}
fn stale_entry(home: &Path, id: &str) -> RunEntry {
let mut e = make_entry(id, "myrepo", "main", 100);
e.path = home.join("runs/myrepo/main").join(id);
e
}
#[test]
fn run_runs_command_cleanup_removes_stale_and_keeps_live() {
with_prview_home(|home| {
let mut index = RunIndex { entries: vec![] };
index.append(live_entry(home, "live-001"));
index.append(stale_entry(home, "stale-002"));
index.save().unwrap();
run_runs_command(&runs_opts_all_json()).expect("runs command");
let reloaded = RunIndex::load();
let ids: Vec<&str> = reloaded.entries().iter().map(|e| e.id.as_str()).collect();
assert!(
ids.contains(&"live-001"),
"live entry must survive: {ids:?}"
);
assert!(
!ids.contains(&"stale-002"),
"stale entry must be pruned: {ids:?}"
);
});
}
#[test]
fn run_runs_command_skips_cleanup_write_when_lock_is_held() {
with_prview_home(|home| {
let mut index = RunIndex { entries: vec![] };
index.append(live_entry(home, "live-001"));
index.append(stale_entry(home, "stale-002"));
index.save().unwrap();
let _held = acquire_lock().expect("acquire lock");
run_runs_command(&runs_opts_all_json()).expect("runs command stays read-only");
let reloaded = RunIndex::load();
let ids: Vec<&str> = reloaded.entries().iter().map(|e| e.id.as_str()).collect();
assert!(
ids.contains(&"stale-002"),
"cleanup must be gated on the lock; stale entry must remain: {ids:?}"
);
});
}
}