use std::fs::{self, OpenOptions};
use std::io::{BufRead, BufReader, Write};
use std::process::Command;
use serde::{Deserialize, Serialize};
use crate::project::CheckmateProject;
use crate::runner::TestSuiteResult;
fn default_run_type() -> String {
"test".to_string()
}
#[derive(Debug, Serialize, Deserialize, Clone)]
pub struct RunRecord {
pub id: String,
pub timestamp: String,
#[serde(default = "default_run_type")]
pub run_type: String,
pub git: Option<GitContext>,
pub summary: RunSummary,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub diff_summary: Option<DiffSummary>,
pub spec_file: Option<String>,
}
#[derive(Debug, Serialize, Deserialize, Clone)]
pub struct GitContext {
pub commit: String,
pub branch: String,
pub dirty: bool,
pub message: Option<String>,
}
#[derive(Debug, Serialize, Deserialize, Clone)]
pub struct RunSummary {
pub total: usize,
pub passed: usize,
pub failed: usize,
pub errors: usize,
pub duration_ms: u64,
}
#[derive(Debug, Serialize, Deserialize, Clone)]
pub struct DiffSummary {
pub additions: usize,
pub removals: usize,
pub value_changes: usize,
pub type_changes: usize,
}
impl RunRecord {
pub fn generate_id() -> String {
format!("cm-run-{}", nanoid::nanoid!(4))
}
pub fn from_suite_result(result: &TestSuiteResult, spec_file: Option<&str>) -> Self {
let summary = RunSummary {
total: result.summary.total,
passed: result.summary.passed,
failed: result.summary.failed,
errors: result.summary.errors,
duration_ms: result.duration_ms,
};
Self {
id: Self::generate_id(),
timestamp: chrono_lite_timestamp(),
run_type: "test".to_string(),
git: GitContext::capture(),
summary,
diff_summary: None,
spec_file: spec_file.map(String::from),
}
}
pub fn from_diff_result(
result: &TestSuiteResult,
diff_summary: DiffSummary,
spec_file: Option<&str>,
) -> Self {
let summary = RunSummary {
total: result.summary.total,
passed: result.summary.passed,
failed: result.summary.failed,
errors: result.summary.errors,
duration_ms: result.duration_ms,
};
Self {
id: Self::generate_id(),
timestamp: chrono_lite_timestamp(),
run_type: "diff".to_string(),
git: GitContext::capture(),
summary,
diff_summary: Some(diff_summary),
spec_file: spec_file.map(String::from),
}
}
}
impl GitContext {
pub fn capture() -> Option<Self> {
let status = Command::new("git")
.args(["rev-parse", "--git-dir"])
.output()
.ok()?;
if !status.status.success() {
return None;
}
let commit = Command::new("git")
.args(["rev-parse", "--short", "HEAD"])
.output()
.ok()
.and_then(|o| {
if o.status.success() {
Some(String::from_utf8_lossy(&o.stdout).trim().to_string())
} else {
None
}
})?;
let branch = Command::new("git")
.args(["branch", "--show-current"])
.output()
.ok()
.map(|o| String::from_utf8_lossy(&o.stdout).trim().to_string())
.unwrap_or_default();
let dirty = Command::new("git")
.args(["status", "--porcelain"])
.output()
.ok()
.map(|o| !o.stdout.is_empty())
.unwrap_or(false);
let message = Command::new("git")
.args(["log", "-1", "--format=%s"])
.output()
.ok()
.and_then(|o| {
if o.status.success() {
let msg = String::from_utf8_lossy(&o.stdout).trim().to_string();
if msg.is_empty() { None } else { Some(msg) }
} else {
None
}
});
Some(GitContext {
commit,
branch,
dirty,
message,
})
}
}
pub fn save_run(project: &CheckmateProject, record: &RunRecord) -> Result<(), std::io::Error> {
let runs_file = project.runs_file();
if let Some(parent) = runs_file.parent() {
fs::create_dir_all(parent)?;
}
let mut file = OpenOptions::new()
.create(true)
.append(true)
.open(&runs_file)?;
let json = serde_json::to_string(record)
.map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e))?;
writeln!(file, "{}", json)?;
Ok(())
}
pub fn load_history(project: &CheckmateProject) -> Result<Vec<RunRecord>, std::io::Error> {
let runs_file = project.runs_file();
if !runs_file.exists() {
return Ok(Vec::new());
}
let file = fs::File::open(&runs_file)?;
let reader = BufReader::new(file);
let mut records = Vec::new();
for line in reader.lines() {
let line = line?;
if line.trim().is_empty() {
continue;
}
if let Ok(record) = serde_json::from_str::<RunRecord>(&line) {
records.push(record);
}
}
Ok(records)
}
pub fn load_history_filtered(
project: &CheckmateProject,
commit: Option<&str>,
spec: Option<&str>,
limit: usize,
) -> Result<Vec<RunRecord>, std::io::Error> {
let mut records = load_history(project)?;
if let Some(commit_filter) = commit {
records.retain(|r| {
r.git.as_ref()
.map(|g| g.commit.starts_with(commit_filter))
.unwrap_or(false)
});
}
if let Some(spec_filter) = spec {
records.retain(|r| {
r.spec_file.as_ref()
.map(|s| s.contains(spec_filter))
.unwrap_or(false)
});
}
records.reverse();
records.truncate(limit);
Ok(records)
}
pub fn find_run(project: &CheckmateProject, run_id: &str) -> Result<Option<RunRecord>, std::io::Error> {
let records = load_history(project)?;
Ok(records.into_iter().find(|r| r.id == run_id))
}
fn chrono_lite_timestamp() -> String {
use std::time::{SystemTime, UNIX_EPOCH};
let duration = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap_or_default();
let secs = duration.as_secs();
let days_since_epoch = secs / 86400;
let time_of_day = secs % 86400;
let (year, month, day) = days_to_ymd(days_since_epoch as i64);
let hours = time_of_day / 3600;
let minutes = (time_of_day % 3600) / 60;
let seconds = time_of_day % 60;
format!(
"{:04}-{:02}-{:02}T{:02}:{:02}:{:02}Z",
year, month, day, hours, minutes, seconds
)
}
fn days_to_ymd(days: i64) -> (i32, u32, u32) {
let mut remaining_days = days;
let mut year = 1970i32;
loop {
let days_in_year = if is_leap_year(year) { 366 } else { 365 };
if remaining_days < days_in_year {
break;
}
remaining_days -= days_in_year;
year += 1;
}
let days_in_months: [i64; 12] = if is_leap_year(year) {
[31, 29, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31]
} else {
[31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31]
};
let mut month = 1u32;
for days_in_month in days_in_months.iter() {
if remaining_days < *days_in_month {
break;
}
remaining_days -= days_in_month;
month += 1;
}
let day = remaining_days as u32 + 1;
(year, month, day)
}
fn is_leap_year(year: i32) -> bool {
(year % 4 == 0 && year % 100 != 0) || (year % 400 == 0)
}
pub fn format_run_short(record: &RunRecord) -> String {
let status = if record.summary.failed > 0 || record.summary.errors > 0 {
"✗"
} else {
"✓"
};
let git_info = record.git.as_ref()
.map(|g| {
let dirty_marker = if g.dirty { "*" } else { "" };
format!(" @ {}{}", g.commit, dirty_marker)
})
.unwrap_or_default();
let type_tag = if record.run_type == "diff" { " [diff]" } else { "" };
format!(
"{} {} {} {}/{} passed{}{}",
status,
record.id,
&record.timestamp[..10],
record.summary.passed,
record.summary.total,
type_tag,
git_info
)
}
pub fn format_run_detail(record: &RunRecord) -> String {
let mut out = String::new();
out.push_str(&format!("Run: {}\n", record.id));
out.push_str(&format!("Type: {}\n", record.run_type));
out.push_str(&format!("Time: {}\n", record.timestamp));
if let Some(ref spec) = record.spec_file {
out.push_str(&format!("Spec: {}\n", spec));
}
out.push_str(&format!(
"Results: {}/{} passed, {} failed, {} errors ({}ms)\n",
record.summary.passed,
record.summary.total,
record.summary.failed,
record.summary.errors,
record.summary.duration_ms
));
if let Some(ref ds) = record.diff_summary {
out.push_str(&format!(
"\nDiff Summary: +{} -{} ~{} value, ~{} type\n",
ds.additions, ds.removals, ds.value_changes, ds.type_changes
));
}
if let Some(ref git) = record.git {
out.push_str("\nGit Context:\n");
out.push_str(&format!(" Commit: {}\n", git.commit));
if !git.branch.is_empty() {
out.push_str(&format!(" Branch: {}\n", git.branch));
}
out.push_str(&format!(" Dirty: {}\n", if git.dirty { "yes" } else { "no" }));
if let Some(ref msg) = git.message {
out.push_str(&format!(" Message: {}\n", msg));
}
}
out
}
pub fn run_history(
commit: Option<&str>,
spec: Option<&str>,
limit: usize,
) -> Result<(), Box<dyn std::error::Error>> {
let project = CheckmateProject::discover().ok_or_else(|| {
"No .checkmate/ found. Run 'cm init' first."
})?;
let records = load_history_filtered(&project, commit, spec, limit)?;
if records.is_empty() {
println!("No runs recorded yet.");
println!("Run 'cm test run' or 'cm diff run' to record history.");
return Ok(());
}
println!("Run History (most recent first):\n");
for record in &records {
println!("{}", format_run_short(record));
}
if records.len() == limit {
println!("\n(showing {} most recent, use -n to see more)", limit);
}
Ok(())
}
pub fn run_show(run_id: &str) -> Result<(), Box<dyn std::error::Error>> {
let project = CheckmateProject::discover().ok_or_else(|| {
"No .checkmate/ found. Run 'cm init' first."
})?;
let record = find_run(&project, run_id)?;
match record {
Some(r) => {
println!("{}", format_run_detail(&r));
}
None => {
eprintln!("Run '{}' not found.", run_id);
eprintln!("Use 'cm history' to see available runs.");
std::process::exit(1);
}
}
Ok(())
}