use crate::rules::{EvaluationContext, RuleEngine, RuleViolation, Severity, TaskType};
use serde::{Deserialize, Serialize};
use serde_json::Value;
use std::fmt;
use std::path::PathBuf;
#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum VerificationStatus {
Passed,
PassedWithWarnings,
Blocked,
Failed,
}
impl VerificationStatus {
pub fn label(&self) -> &'static str {
match self {
Self::Passed => "passed",
Self::PassedWithWarnings => "passed with warnings",
Self::Blocked => "blocked",
Self::Failed => "failed",
}
}
}
impl fmt::Display for VerificationStatus {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
let s = match self {
Self::Passed => "passed",
Self::PassedWithWarnings => "passed_with_warnings",
Self::Blocked => "blocked",
Self::Failed => "failed",
};
write!(f, "{}", s)
}
}
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)]
pub struct EvidenceItem {
pub source: String,
pub description: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub content_preview: Option<String>,
#[serde(default)]
pub verified: bool,
}
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Default)]
pub struct TestsStatus {
pub tests_run: bool,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub tests_passed: Option<u32>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub tests_failed: Option<u32>,
#[serde(default)]
pub tests_added: Vec<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub coverage_delta: Option<f64>,
}
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)]
pub struct StaticAnalysisResult {
pub tool: String,
pub issues_found: u32,
pub critical_issues: u32,
pub warnings: u32,
#[serde(default)]
pub details: Vec<String>,
}
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)]
pub struct ToolCallRecord {
pub tool_name: String,
pub args_summary: String,
pub result_preview: String,
pub timestamp: String,
}
#[derive(Serialize, Deserialize, Debug, Clone, Default)]
pub struct VerificationContext {
pub task_description: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub task_type: Option<TaskType>,
#[serde(default)]
pub changed_files: Vec<String>,
#[serde(default)]
pub tool_calls: Vec<ToolCallRecord>,
#[serde(default)]
pub evidence_gathered: Vec<EvidenceItem>,
#[serde(default)]
pub tests_added: Vec<String>,
#[serde(default)]
pub dependencies_changed: Vec<(String, String)>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub git_diff: Option<String>,
}
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)]
pub struct VerificationReport {
pub status: VerificationStatus,
pub task_description: String,
pub rule_violations: Vec<RuleViolation>,
pub evidence_used: Vec<EvidenceItem>,
pub missing_evidence: Vec<String>,
pub recommendations: Vec<String>,
pub confidence: f64,
pub confidence_level: String,
pub files_changed: Vec<String>,
pub tests_status: TestsStatus,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub static_analysis_status: Option<StaticAnalysisResult>,
pub timestamp: String,
}
#[derive(Debug, Clone)]
pub struct Verifier {
pub rule_engine: RuleEngine,
pub workdir: PathBuf,
}
impl Verifier {
pub fn new(workdir: &str) -> Self {
Self {
rule_engine: RuleEngine::load_defaults(),
workdir: PathBuf::from(workdir),
}
}
pub fn with_rules(workdir: &str, rule_engine: RuleEngine) -> Self {
Self {
rule_engine,
workdir: PathBuf::from(workdir),
}
}
pub fn verify(&self, ctx: &VerificationContext) -> VerificationReport {
let tools_called: Vec<String> =
ctx.tool_calls.iter().map(|c| c.tool_name.clone()).collect();
let eval_ctx = EvaluationContext {
task_type: ctx.task_type.clone(),
changed_files: ctx.changed_files.clone(),
tools_called: tools_called.clone(),
tests_added: ctx.tests_added.clone(),
tests_run: tools_called
.iter()
.any(|t| t == "bash" || t == "run_command"),
dependencies_added: ctx
.dependencies_changed
.iter()
.filter(|(_, action)| action == "added")
.map(|(name, _)| name.clone())
.collect(),
dependencies_removed: ctx
.dependencies_changed
.iter()
.filter(|(_, action)| action == "removed")
.map(|(name, _)| name.clone())
.collect(),
migration_type: None,
has_rollback_plan: false,
has_changelog_entry: ctx.changed_files.iter().any(|f| {
f.to_lowercase().contains("changelog") || f.to_lowercase().contains("release_notes")
}),
security_review_done: false,
custom_facts: Default::default(),
};
let rule_violations = self.rule_engine.evaluate(&eval_ctx);
let tests_status = self.check_tests(
&ctx.changed_files,
&ctx.tests_added,
eval_ctx.tests_run,
&ctx.tool_calls,
);
let static_analysis_status = self.run_static_analysis(&ctx.changed_files);
let mut missing_evidence = Vec::new();
if ctx.evidence_gathered.is_empty() && ctx.task_type == Some(TaskType::DecisionSupport) {
missing_evidence.push(
"No evidence items gathered for decision support recommendation.".to_string(),
);
}
for violation in &rule_violations {
if let Some(ref act) = violation.suggested_action {
missing_evidence.push(format!("Rule [{}]: {}", violation.rule_id, act));
}
}
let mut report = VerificationReport {
status: VerificationStatus::Passed,
task_description: ctx.task_description.clone(),
rule_violations: rule_violations.clone(),
evidence_used: ctx.evidence_gathered.clone(),
missing_evidence,
recommendations: Vec::new(),
confidence: 1.0,
confidence_level: "high".to_string(),
files_changed: ctx.changed_files.clone(),
tests_status,
static_analysis_status,
timestamp: crate::knowledge::chrono_now_iso(),
};
report.confidence = self.compute_confidence(&report);
report.confidence_level = match report.confidence {
c if c >= 0.8 => "high".to_string(),
c if c >= 0.5 => "medium".to_string(),
c if c >= 0.2 => "low".to_string(),
_ => "blocked".to_string(),
};
if rule_violations
.iter()
.any(|v| v.severity == Severity::Critical || v.severity == Severity::High)
{
report.status = VerificationStatus::Blocked;
} else if !rule_violations.is_empty() || !report.missing_evidence.is_empty() {
report.status = VerificationStatus::PassedWithWarnings;
} else {
report.status = VerificationStatus::Passed;
}
report
}
pub fn compute_confidence(&self, report: &VerificationReport) -> f64 {
let mut score = 1.0;
for v in &report.rule_violations {
match v.severity {
Severity::Critical => return 0.0,
Severity::High => score -= 0.2,
Severity::Medium => score -= 0.1,
Severity::Low => score -= 0.05,
Severity::Info => {}
}
}
score -= (report.missing_evidence.len() as f64) * 0.05;
score.clamp(0.0, 1.0)
}
pub fn check_tests(
&self,
_changed_files: &[String],
tests_added: &[String],
tests_run: bool,
tool_calls: &[ToolCallRecord],
) -> TestsStatus {
let counts = tool_calls
.iter()
.find_map(|c| parse_test_counts(&c.result_preview));
let (tests_passed, tests_failed) = match counts {
Some((p, f)) => (Some(p), Some(f)),
None => (None, None),
};
TestsStatus {
tests_run,
tests_passed,
tests_failed,
tests_added: tests_added.to_vec(),
coverage_delta: None,
}
}
pub fn run_static_analysis(&self, _files: &[String]) -> Option<StaticAnalysisResult> {
None
}
pub fn format_report(report: &VerificationReport) -> String {
let mut out = format!("## Verification: {}\n\n", report.status.label());
out.push_str(&format!("- **Task**: {}\n", report.task_description));
out.push_str(&format!(
"- **Confidence**: {:.2} ({})\n",
report.confidence, report.confidence_level
));
out.push_str(&format!(
"- **Files changed**: {}\n",
report.files_changed.len()
));
out.push_str(&format!(
"- **Tests run**: {}\n\n",
if report.tests_status.tests_run {
"yes"
} else {
"no"
}
));
if !report.rule_violations.is_empty() {
out.push_str("### Rule Violations\n");
for v in &report.rule_violations {
out.push_str(&format!(
"- **[{}]** `{}`: {}\n",
v.severity.to_string().to_uppercase(),
v.rule_id,
v.message
));
}
out.push('\n');
}
if !report.missing_evidence.is_empty() {
out.push_str("### Missing Evidence\n");
for m in &report.missing_evidence {
out.push_str(&format!("- {}\n", m));
}
out.push('\n');
}
out
}
pub fn format_report_json(report: &VerificationReport) -> Value {
serde_json::to_value(report).unwrap_or(Value::Null)
}
pub fn format_decision_memo(report: &VerificationReport) -> String {
let mut out = String::from("# Decision Memo\n\n");
out.push_str(&format!(
"**Confidence**: {:.2} ({})\n\n",
report.confidence,
report.confidence_level.to_uppercase()
));
out.push_str("## Evidence Inspected\n");
if report.evidence_used.is_empty() {
out.push_str("- *No explicit evidence recorded.*\n");
} else {
for e in &report.evidence_used {
out.push_str(&format!("- **{}**: {}\n", e.source, e.description));
}
}
out.push('\n');
if !report.rule_violations.is_empty() {
out.push_str("## Applicable Constraints & Violations\n");
for v in &report.rule_violations {
out.push_str(&format!(
"- **[{}]** `{}`: {}\n",
v.severity.to_string().to_uppercase(),
v.rule_id,
v.message
));
}
out.push('\n');
}
out
}
}
fn parse_test_counts(output: &str) -> Option<(u32, u32)> {
let passed = count_before(output, " passed")?;
let failed = count_before(output, " failed").unwrap_or(0);
Some((passed, failed))
}
fn count_before(text: &str, marker: &str) -> Option<u32> {
let prefix = &text[..text.find(marker)?];
let digits: Vec<char> = prefix
.chars()
.rev()
.take_while(|c| c.is_ascii_digit())
.collect();
digits.iter().rev().collect::<String>().parse::<u32>().ok()
}
#[cfg(test)]
mod tests {
use super::*;
use crate::rules::TaskType;
#[test]
fn test_verifier_execution() {
let verifier = Verifier::new("/tmp");
let ctx = VerificationContext {
task_description: "Fix a bug without tests".to_string(),
task_type: Some(TaskType::BugFix),
..Default::default()
};
let report = verifier.verify(&ctx);
assert!(report
.rule_violations
.iter()
.any(|v| v.rule_id == "bug_fix_requires_regression_test"));
assert!(report.confidence < 1.0);
assert_eq!(report.timestamp.len(), 20);
assert!(report.timestamp.ends_with('Z'));
}
fn bash_call(preview: &str) -> ToolCallRecord {
ToolCallRecord {
tool_name: "bash".to_string(),
args_summary: "cargo test".to_string(),
result_preview: preview.to_string(),
timestamp: String::new(),
}
}
#[test]
fn check_tests_never_fabricates_counts() {
let verifier = Verifier::new("/tmp");
let calls = vec![bash_call("Compiling harness v0.1.0\nFinished dev")];
let status = verifier.check_tests(&[], &[], true, &calls);
assert!(status.tests_run);
assert_eq!(status.tests_passed, None);
assert_eq!(status.tests_failed, None);
}
#[test]
fn check_tests_parses_cargo_summary() {
let verifier = Verifier::new("/tmp");
let calls = vec![bash_call(
"test result: ok. 12 passed; 1 failed; 0 ignored; 0 measured",
)];
let status = verifier.check_tests(&[], &[], true, &calls);
assert_eq!(status.tests_passed, Some(12));
assert_eq!(status.tests_failed, Some(1));
}
#[test]
fn parse_test_counts_handles_pytest_and_prose() {
assert_eq!(
parse_test_counts("===== 7 passed in 0.42s ====="),
Some((7, 0))
);
assert_eq!(parse_test_counts("all checks passed"), None);
assert_eq!(parse_test_counts(""), None);
}
}