use anyhow::{Context, Result};
use clap::Parser;
use serde::Serialize;
use std::path::PathBuf;
use std::time::{SystemTime, UNIX_EPOCH};
#[derive(Parser, Debug)]
#[command(
name = "cargo-impact log-miss",
about = "Record a missed finding for heuristic tuning"
)]
pub struct LogMissArgs {
#[arg(long)]
pub finding_id: String,
#[arg(long)]
pub what_broke: String,
#[arg(long)]
pub manifest_dir: Option<PathBuf>,
}
#[derive(Debug, Serialize)]
struct MissRecord<'a> {
timestamp: u64,
tool_version: &'a str,
finding_id: &'a str,
what_broke: &'a str,
git_head: String,
}
pub fn run(args: &LogMissArgs) -> Result<()> {
let root = match &args.manifest_dir {
Some(p) => p.clone(),
None => std::env::current_dir().context("reading current directory")?,
};
let dir = root.join("target").join("ai-tools-cache").join("impact");
std::fs::create_dir_all(&dir).with_context(|| format!("creating {}", dir.display()))?;
let path = dir.join("misses.jsonl");
let record = MissRecord {
timestamp: current_unix_secs(),
tool_version: env!("CARGO_PKG_VERSION"),
finding_id: &args.finding_id,
what_broke: &args.what_broke,
git_head: git_head(&root).unwrap_or_else(|| "unknown".to_string()),
};
let line = serde_json::to_string(&record)?;
use std::io::Write;
let mut file = std::fs::OpenOptions::new()
.create(true)
.append(true)
.open(&path)
.with_context(|| format!("opening {}", path.display()))?;
writeln!(file, "{line}").with_context(|| format!("writing {}", path.display()))?;
file.flush()?;
eprintln!(
"cargo-impact: logged miss to {}\n\
({} records total — free dataset for future heuristic tuning)",
path.display(),
count_lines(&path).unwrap_or(0)
);
Ok(())
}
fn current_unix_secs() -> u64 {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_secs())
.unwrap_or(0)
}
fn git_head(root: &std::path::Path) -> Option<String> {
let output = std::process::Command::new("git")
.arg("-C")
.arg(root)
.args(["rev-parse", "HEAD"])
.output()
.ok()?;
if !output.status.success() {
return None;
}
Some(String::from_utf8_lossy(&output.stdout).trim().to_string())
}
fn count_lines(path: &std::path::Path) -> std::io::Result<usize> {
use std::io::BufRead;
let file = std::fs::File::open(path)?;
Ok(std::io::BufReader::new(file).lines().count())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn creates_cache_directory_and_writes_record() {
let dir = tempfile::TempDir::new().unwrap();
let args = LogMissArgs {
finding_id: "f-abcd1234".into(),
what_broke: "missed test api_smoke".into(),
manifest_dir: Some(dir.path().to_path_buf()),
};
run(&args).unwrap();
let log = dir.path().join("target/ai-tools-cache/impact/misses.jsonl");
assert!(log.exists(), "expected log file at {}", log.display());
let content = std::fs::read_to_string(&log).unwrap();
assert!(content.contains("f-abcd1234"));
assert!(content.contains("missed test api_smoke"));
assert!(content.ends_with('\n'));
}
#[test]
fn appends_multiple_records() {
let dir = tempfile::TempDir::new().unwrap();
for (id, msg) in [("f-1", "first"), ("f-2", "second"), ("f-3", "third")] {
run(&LogMissArgs {
finding_id: id.into(),
what_broke: msg.into(),
manifest_dir: Some(dir.path().to_path_buf()),
})
.unwrap();
}
let log = dir.path().join("target/ai-tools-cache/impact/misses.jsonl");
let content = std::fs::read_to_string(&log).unwrap();
let lines: Vec<_> = content.lines().collect();
assert_eq!(lines.len(), 3);
assert!(lines[0].contains("first"));
assert!(lines[1].contains("second"));
assert!(lines[2].contains("third"));
}
#[test]
fn each_line_is_valid_standalone_json() {
let dir = tempfile::TempDir::new().unwrap();
run(&LogMissArgs {
finding_id: "f-check".into(),
what_broke: "contains \"quotes\" and a\nnewline".into(),
manifest_dir: Some(dir.path().to_path_buf()),
})
.unwrap();
let log = dir.path().join("target/ai-tools-cache/impact/misses.jsonl");
let content = std::fs::read_to_string(&log).unwrap();
for line in content.lines().filter(|l| !l.is_empty()) {
let _: serde_json::Value =
serde_json::from_str(line).expect("each jsonl row must parse as JSON");
}
}
}