use std::collections::HashMap;
use std::fs;
use std::path::{Path, PathBuf};
use serde::{Deserialize, Serialize};
use super::{Finding, FindingSeverity};
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SpecClaim {
pub id: String,
pub title: String,
pub line: usize,
pub section_path: Vec<String>,
pub implementations: Vec<CodeLocation>,
pub findings: Vec<String>,
pub status: ClaimStatus,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CodeLocation {
pub file: PathBuf,
pub line: usize,
pub context: String,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum ClaimStatus {
Verified,
Warning,
Failed,
Pending,
}
impl std::fmt::Display for ClaimStatus {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::Verified => write!(f, "✓ Verified"),
Self::Warning => write!(f, "⚠️ Warning"),
Self::Failed => write!(f, "✗ Failed"),
Self::Pending => write!(f, "○ Pending"),
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ParsedSpec {
pub path: PathBuf,
pub claims: Vec<SpecClaim>,
pub original_content: String,
}
impl ParsedSpec {
pub fn parse(spec_path: &Path) -> Result<Self, String> {
let content = fs::read_to_string(spec_path)
.map_err(|e| format!("Failed to read spec file: {}", e))?;
let claims = parse_claims(&content);
Ok(Self { path: spec_path.to_path_buf(), claims, original_content: content })
}
pub fn claims_for_section(&self, section: &str) -> Vec<&SpecClaim> {
self.claims
.iter()
.filter(|c| {
c.section_path.iter().any(|s| s.contains(section))
|| c.id.contains(section)
|| c.title.contains(section)
})
.collect()
}
pub fn update_with_findings(
&mut self,
findings: &[(String, Vec<Finding>)], ) -> Result<String, String> {
let mut updated = remove_existing_status_blocks(&self.original_content);
for (claim_id, claim_findings) in findings {
if let Some(claim) = self.claims.iter_mut().find(|c| c.id == *claim_id) {
claim.status = if claim_findings.is_empty() {
ClaimStatus::Verified
} else if claim_findings.iter().any(|f| {
matches!(f.severity, FindingSeverity::Critical | FindingSeverity::High)
}) {
ClaimStatus::Failed
} else {
ClaimStatus::Warning
};
let status_block = generate_status_block(claim, claim_findings);
if let Some(insert_pos) = find_claim_end(&updated, &claim.id) {
updated.insert_str(insert_pos, &status_block);
}
}
}
Ok(updated)
}
pub fn write_updated(&self, updated_content: &str) -> Result<(), String> {
let backup_path = self.path.with_extension("md.bak");
fs::copy(&self.path, &backup_path)
.map_err(|e| format!("Failed to create backup: {}", e))?;
fs::write(&self.path, updated_content)
.map_err(|e| format!("Failed to write spec: {}", e))?;
Ok(())
}
}
fn parse_claims(content: &str) -> Vec<SpecClaim> {
let mut claims = Vec::new();
let mut current_sections: Vec<String> = Vec::new();
for (line_num, line) in content.lines().enumerate() {
let line_num = line_num + 1;
let trimmed = line.trim();
if let Some(section) = trimmed.strip_prefix("## ") {
current_sections.clear();
current_sections.push(section.to_string());
} else if let Some(subsection) = trimmed.strip_prefix("### ") {
current_sections.truncate(1);
current_sections.push(subsection.to_string());
}
if trimmed.starts_with("### ") {
if let Some((id, title)) = parse_claim_header(trimmed) {
claims.push(SpecClaim {
id,
title,
line: line_num,
section_path: current_sections.clone(),
implementations: Vec::new(),
findings: Vec::new(),
status: ClaimStatus::Pending,
});
}
}
}
claims
}
fn parse_claim_header(header: &str) -> Option<(String, String)> {
let text = header.trim_start_matches('#').trim();
let colon_pos = text.find(':')?;
let potential_id = &text[..colon_pos];
let title = text[colon_pos + 1..].trim();
let dash_pos = potential_id.find('-')?;
let prefix = &potential_id[..dash_pos];
let suffix = &potential_id[dash_pos + 1..];
if prefix.is_empty() || prefix.len() > 4 || !prefix.chars().all(|c| c.is_ascii_uppercase()) {
return None;
}
if suffix.is_empty() || suffix.len() > 4 || !suffix.chars().all(|c| c.is_ascii_digit()) {
return None;
}
Some((potential_id.to_string(), title.to_string()))
}
fn generate_status_block(claim: &SpecClaim, findings: &[Finding]) -> String {
let mut block = String::new();
block.push_str("\n\n<!-- bug-hunter-status -->\n");
block.push_str(&format!("**Bug Hunter Status:** {}\n", claim.status));
if !claim.implementations.is_empty() {
block.push_str("**Implementations:**\n");
for loc in &claim.implementations {
block.push_str(&format!("- `{}:{}` - {}\n", loc.file.display(), loc.line, loc.context));
}
}
if findings.is_empty() {
block.push_str("**Findings:** None ✓\n");
} else {
block.push_str(&format!("**Findings:** {} issue(s)\n", findings.len()));
for finding in findings.iter().take(5) {
block.push_str(&format!(
"- [{}]({}) - {}\n",
finding.id,
finding.location(),
finding.title
));
}
if findings.len() > 5 {
block.push_str(&format!("- ... and {} more\n", findings.len() - 5));
}
}
block.push_str("<!-- /bug-hunter-status -->\n");
block
}
fn remove_existing_status_blocks(content: &str) -> String {
let mut result = String::new();
let mut in_status_block = false;
for line in content.lines() {
if line.contains("<!-- bug-hunter-status -->") {
in_status_block = true;
continue;
}
if line.contains("<!-- /bug-hunter-status -->") {
in_status_block = false;
continue;
}
if !in_status_block {
result.push_str(line);
result.push('\n');
}
}
result
}
fn find_claim_end(content: &str, claim_id: &str) -> Option<usize> {
let mut offset = 0;
for line in content.lines() {
offset += line.len() + 1; if line.contains("###") && line.contains(claim_id) {
return Some(offset);
}
}
None
}
pub fn find_implementations(claim: &SpecClaim, project_path: &Path) -> Vec<CodeLocation> {
let mut locations = Vec::new();
let pattern = &claim.id;
if let Ok(entries) = glob::glob(&format!("{}/**/*.rs", project_path.display())) {
for entry in entries.flatten() {
if let Ok(content) = fs::read_to_string(&entry) {
for (line_num, line) in content.lines().enumerate() {
if line.contains(pattern) {
let context = line.trim().chars().take(60).collect::<String>();
locations.push(CodeLocation {
file: entry.clone(),
line: line_num + 1,
context,
});
}
}
}
}
}
locations
}
pub fn map_findings_to_claims(
claims: &[SpecClaim],
findings: &[Finding],
project_path: &Path,
) -> HashMap<String, Vec<Finding>> {
let mut mapping: HashMap<String, Vec<Finding>> = HashMap::new();
for claim in claims {
mapping.insert(claim.id.clone(), Vec::new());
}
for finding in findings {
for claim in claims {
let implementations = find_implementations(claim, project_path);
for impl_loc in &implementations {
if finding.file == impl_loc.file {
let distance = (finding.line as i64 - impl_loc.line as i64).unsigned_abs();
if distance < 50 {
mapping.entry(claim.id.clone()).or_default().push(finding.clone());
break;
}
}
}
}
}
mapping
}
#[cfg(test)]
#[path = "spec_tests.rs"]
mod tests;