use std::path::Path;
use anyhow::{Context, Result};
use clap::Args;
use serde::Serialize;
const WINDOW_LINES: usize = 5;
#[derive(Args, Debug)]
#[command(
long_about = "Deterministic cross-reference check for /mati-enrich Stage 3 Round 2.\n\
Returns JSON: {verified, file, line, quote_match, pattern_match, reason}.\n\
Exit 0 iff verified=true."
)]
pub struct VerifyEvidenceArgs {
#[arg(long)]
pub file: String,
#[arg(long)]
pub line: String,
#[arg(long)]
pub quote: String,
#[arg(long)]
pub pattern: Option<String>,
}
#[derive(Serialize)]
struct VerifyResult<'a> {
verified: bool,
file: &'a str,
line: usize,
quote_match: bool,
pattern_match: Option<bool>,
reason: Option<String>,
}
pub async fn run(args: VerifyEvidenceArgs) -> Result<()> {
let line_num = parse_line_arg(&args.line)
.with_context(|| format!("invalid --line value: {}", args.line))?;
let window = read_window(&args.file, line_num, WINDOW_LINES)
.with_context(|| format!("failed to read window from {}", args.file))?;
let quote_match = !args.quote.is_empty() && window.contains(&args.quote);
let pattern_match = args.pattern.as_ref().map(|p| window.contains(p));
let verified = quote_match && pattern_match.unwrap_or(true);
let reason = if verified {
None
} else if !quote_match {
Some(format!(
"quote not found in {} lines {}..={}",
args.file,
line_num.saturating_sub(WINDOW_LINES),
line_num + WINDOW_LINES,
))
} else if matches!(pattern_match, Some(false)) {
Some(format!(
"pattern not found in {} lines {}..={} (quote matched but rule generalizes beyond visible scope)",
args.file,
line_num.saturating_sub(WINDOW_LINES),
line_num + WINDOW_LINES,
))
} else {
None
};
let result = VerifyResult {
verified,
file: &args.file,
line: line_num,
quote_match,
pattern_match,
reason,
};
println!("{}", serde_json::to_string(&result)?);
if !verified {
std::process::exit(1);
}
Ok(())
}
fn parse_line_arg(raw: &str) -> Result<usize> {
let stripped = raw
.strip_prefix('L')
.or_else(|| raw.strip_prefix('l'))
.unwrap_or(raw);
let n: usize = stripped
.parse()
.with_context(|| format!("expected positive integer, got {raw:?}"))?;
if n == 0 {
anyhow::bail!("line must be 1-based, got 0");
}
Ok(n)
}
fn read_window(file: &str, line: usize, radius: usize) -> Result<String> {
let path = Path::new(file);
let content =
std::fs::read_to_string(path).with_context(|| format!("failed to read {file}"))?;
let lines: Vec<&str> = content.lines().collect();
let start = line.saturating_sub(radius + 1); let end = (line + radius).min(lines.len());
if start >= lines.len() {
return Ok(String::new());
}
Ok(lines[start..end].join("\n"))
}
#[cfg(test)]
mod tests {
use super::*;
use std::io::Write;
use tempfile::NamedTempFile;
fn write_tmp(content: &str) -> NamedTempFile {
let mut f = NamedTempFile::new().unwrap();
f.write_all(content.as_bytes()).unwrap();
f
}
#[test]
fn parse_line_strips_l_prefix() {
assert_eq!(parse_line_arg("L42").unwrap(), 42);
assert_eq!(parse_line_arg("l42").unwrap(), 42);
assert_eq!(parse_line_arg("42").unwrap(), 42);
}
#[test]
fn parse_line_rejects_zero() {
assert!(parse_line_arg("0").is_err());
assert!(parse_line_arg("L0").is_err());
}
#[test]
fn parse_line_rejects_non_numeric() {
assert!(parse_line_arg("abc").is_err());
assert!(parse_line_arg("L").is_err());
assert!(parse_line_arg("").is_err());
}
#[test]
fn window_returns_lines_around_target() {
let content = (1..=20)
.map(|n| format!("line {n}"))
.collect::<Vec<_>>()
.join("\n");
let f = write_tmp(&content);
let path = f.path().to_str().unwrap();
let window = read_window(path, 10, 2).unwrap();
assert!(window.contains("line 8"));
assert!(window.contains("line 12"));
assert!(!window.contains("line 7"));
assert!(!window.contains("line 13"));
}
#[test]
fn window_clamps_at_file_start() {
let content = "a\nb\nc\nd\ne\n";
let f = write_tmp(content);
let path = f.path().to_str().unwrap();
let window = read_window(path, 1, 5).unwrap();
assert!(window.contains('a'));
assert!(window.contains('e'));
}
#[test]
fn window_clamps_at_file_end() {
let content = "a\nb\nc\nd\ne\n";
let f = write_tmp(content);
let path = f.path().to_str().unwrap();
let window = read_window(path, 5, 5).unwrap();
assert!(window.contains('a'));
assert!(window.contains('e'));
}
#[test]
fn window_past_eof_returns_empty() {
let content = "a\nb\nc\n";
let f = write_tmp(content);
let path = f.path().to_str().unwrap();
let window = read_window(path, 100, 5).unwrap();
assert!(window.is_empty());
}
}