use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum Severity {
Critical,
Warning,
Info,
}
impl Severity {
pub fn priority(&self) -> u8 {
match self {
Self::Critical => 0,
Self::Warning => 1,
Self::Info => 2,
}
}
pub fn is_blocking(&self) -> bool {
matches!(self, Self::Critical)
}
}
impl std::fmt::Display for Severity {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::Critical => write!(f, "critical"),
Self::Warning => write!(f, "warning"),
Self::Info => write!(f, "info"),
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct RawFinding {
pub rule_id: String,
pub severity: Severity,
pub category: String,
pub file_path: String,
pub line: Option<usize>,
pub column: Option<usize>,
pub raw_match: String,
pub message: String,
}
impl RawFinding {
pub fn new(
rule_id: impl Into<String>,
severity: Severity,
category: impl Into<String>,
file_path: impl Into<String>,
message: impl Into<String>,
) -> Self {
Self {
rule_id: rule_id.into(),
severity,
category: category.into(),
file_path: file_path.into(),
line: None,
column: None,
raw_match: String::new(),
message: message.into(),
}
}
pub fn with_line(mut self, line: usize) -> Self {
self.line = Some(line);
self
}
pub fn with_column(mut self, column: usize) -> Self {
self.column = Some(column);
self
}
pub fn with_match(mut self, raw_match: impl Into<String>) -> Self {
self.raw_match = raw_match.into();
self
}
pub fn location(&self) -> String {
match (self.line, self.column) {
(Some(l), Some(c)) => format!("{}:{}:{}", self.file_path, l, c),
(Some(l), None) => format!("{}:{}", self.file_path, l),
_ => self.file_path.clone(),
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct EnrichedFinding {
pub rule_id: String,
pub severity: Severity,
pub category: String,
pub file_path: String,
pub line: Option<usize>,
pub issue: IssueDetails,
pub analysis: AnalysisContext,
pub fix: FixRecommendation,
pub references: Vec<DocReference>,
}
impl EnrichedFinding {
pub fn from_raw(raw: &RawFinding) -> Self {
Self {
rule_id: raw.rule_id.clone(),
severity: raw.severity,
category: raw.category.clone(),
file_path: raw.file_path.clone(),
line: raw.line,
issue: IssueDetails {
title: raw.message.clone(),
description: String::new(),
impact: String::new(),
},
analysis: AnalysisContext::default(),
fix: FixRecommendation::default(),
references: Vec::new(),
}
}
pub fn location(&self) -> String {
match self.line {
Some(l) => format!("{}:{}", self.file_path, l),
None => self.file_path.clone(),
}
}
pub fn is_blocking(&self) -> bool {
self.severity.is_blocking()
}
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct IssueDetails {
pub title: String,
pub description: String,
pub impact: String,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct AnalysisContext {
pub rag_sources: Vec<String>,
pub confidence: f32,
pub reasoning: String,
pub related_rules: Vec<String>,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct FixRecommendation {
pub action: FixAction,
pub target_file: String,
pub code_snippet: Option<String>,
pub steps: Vec<String>,
pub complexity: FixComplexity,
}
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum FixAction {
AddCode,
ModifyCode,
RemoveCode,
AddFile,
UpdateConfig,
#[default]
None,
}
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum FixComplexity {
Simple,
#[default]
Medium,
Complex,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct DocReference {
pub title: String,
pub url: String,
pub relevance: f32,
pub excerpt: Option<String>,
}
impl DocReference {
pub fn new(title: impl Into<String>, url: impl Into<String>) -> Self {
Self {
title: title.into(),
url: url.into(),
relevance: 1.0,
excerpt: None,
}
}
pub fn with_relevance(mut self, relevance: f32) -> Self {
self.relevance = relevance;
self
}
pub fn with_excerpt(mut self, excerpt: impl Into<String>) -> Self {
self.excerpt = Some(excerpt.into());
self
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ValidationResult {
pub id: String,
pub codebase_path: String,
pub stage1_duration_ms: u64,
pub stage2_duration_ms: u64,
pub findings: Vec<EnrichedFinding>,
pub summary: ValidationSummary,
}
impl ValidationResult {
pub fn new(codebase_path: impl Into<String>) -> Self {
Self {
id: uuid::Uuid::new_v4().to_string(),
codebase_path: codebase_path.into(),
stage1_duration_ms: 0,
stage2_duration_ms: 0,
findings: Vec::new(),
summary: ValidationSummary::default(),
}
}
pub fn calculate_summary(&mut self) {
let critical = self
.findings
.iter()
.filter(|f| f.severity == Severity::Critical)
.count();
let warnings = self
.findings
.iter()
.filter(|f| f.severity == Severity::Warning)
.count();
let info = self
.findings
.iter()
.filter(|f| f.severity == Severity::Info)
.count();
let status = if critical > 0 {
ValidationStatus::NotReady
} else if warnings > 0 {
ValidationStatus::NeedsReview
} else {
ValidationStatus::Ready
};
let score = (100i32 - (critical as i32 * 20) - (warnings as i32 * 5)).max(0) as u8;
self.summary = ValidationSummary {
status,
score,
critical_count: critical,
warning_count: warnings,
info_count: info,
next_steps: self.generate_next_steps(),
};
}
fn generate_next_steps(&self) -> Vec<String> {
self.findings
.iter()
.filter(|f| f.severity == Severity::Critical)
.take(5)
.map(|f| f.issue.title.clone())
.collect()
}
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct ValidationSummary {
pub status: ValidationStatus,
pub score: u8,
pub critical_count: usize,
pub warning_count: usize,
pub info_count: usize,
pub next_steps: Vec<String>,
}
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum ValidationStatus {
Ready,
NeedsReview,
#[default]
NotReady,
}
impl std::fmt::Display for ValidationStatus {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::Ready => write!(f, "ready"),
Self::NeedsReview => write!(f, "needs_review"),
Self::NotReady => write!(f, "not_ready"),
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct FileContext {
pub path: String,
pub content: String,
pub language: String,
pub line_count: usize,
}
impl FileContext {
pub fn new(path: impl Into<String>, content: impl Into<String>) -> Self {
let content = content.into();
let line_count = content.lines().count();
let path = path.into();
let language = Self::detect_language(&path);
Self {
path,
content,
language,
line_count,
}
}
fn detect_language(path: &str) -> String {
let ext = path.rsplit('.').next().unwrap_or("");
match ext {
"ts" | "tsx" => "typescript",
"js" | "jsx" => "javascript",
"rb" => "ruby",
"py" => "python",
"php" => "php",
"go" => "go",
"rs" => "rust",
"toml" => "toml",
"json" => "json",
"yaml" | "yml" => "yaml",
_ => "unknown",
}
.to_string()
}
pub fn snippet(&self, line: usize, context_lines: usize) -> String {
let lines: Vec<&str> = self.content.lines().collect();
let start = line.saturating_sub(context_lines + 1);
let end = (line + context_lines).min(lines.len());
lines[start..end].join("\n")
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_raw_finding_location() {
let finding = RawFinding::new(
"WH001",
Severity::Critical,
"webhooks",
"src/app.ts",
"Missing webhook",
)
.with_line(42)
.with_column(5);
assert_eq!(finding.location(), "src/app.ts:42:5");
}
#[test]
fn test_severity_priority() {
assert!(Severity::Critical.priority() < Severity::Warning.priority());
assert!(Severity::Warning.priority() < Severity::Info.priority());
}
#[test]
fn test_validation_result_summary() {
let mut result = ValidationResult::new("/app");
result
.findings
.push(EnrichedFinding::from_raw(&RawFinding::new(
"WH001",
Severity::Critical,
"webhooks",
"src/app.ts",
"Missing webhook",
)));
result
.findings
.push(EnrichedFinding::from_raw(&RawFinding::new(
"SEC001",
Severity::Warning,
"security",
"src/utils.ts",
"Eval usage",
)));
result.calculate_summary();
assert_eq!(result.summary.status, ValidationStatus::NotReady);
assert_eq!(result.summary.critical_count, 1);
assert_eq!(result.summary.warning_count, 1);
assert!(result.summary.score < 100);
}
#[test]
fn test_file_context_snippet() {
let content = "line1\nline2\nline3\nline4\nline5\nline6\nline7";
let ctx = FileContext::new("test.ts", content);
let snippet = ctx.snippet(4, 1);
assert!(snippet.contains("line3"));
assert!(snippet.contains("line4"));
assert!(snippet.contains("line5"));
}
}