1use serde::{Deserialize, Serialize};
8
9#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
15#[serde(rename_all = "lowercase")]
16pub enum Severity {
17 Critical,
18 Warning,
19 Info,
20}
21
22impl Severity {
23 pub fn priority(&self) -> u8 {
25 match self {
26 Self::Critical => 0,
27 Self::Warning => 1,
28 Self::Info => 2,
29 }
30 }
31
32 pub fn is_blocking(&self) -> bool {
34 matches!(self, Self::Critical)
35 }
36}
37
38impl std::fmt::Display for Severity {
39 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
40 match self {
41 Self::Critical => write!(f, "critical"),
42 Self::Warning => write!(f, "warning"),
43 Self::Info => write!(f, "info"),
44 }
45 }
46}
47
48#[derive(Debug, Clone, Serialize, Deserialize)]
57pub struct RawFinding {
58 pub rule_id: String,
60
61 pub severity: Severity,
63
64 pub category: String,
66
67 pub file_path: String,
69
70 pub line: Option<usize>,
72
73 pub column: Option<usize>,
75
76 pub raw_match: String,
78
79 pub message: String,
81}
82
83impl RawFinding {
84 pub fn new(
86 rule_id: impl Into<String>,
87 severity: Severity,
88 category: impl Into<String>,
89 file_path: impl Into<String>,
90 message: impl Into<String>,
91 ) -> Self {
92 Self {
93 rule_id: rule_id.into(),
94 severity,
95 category: category.into(),
96 file_path: file_path.into(),
97 line: None,
98 column: None,
99 raw_match: String::new(),
100 message: message.into(),
101 }
102 }
103
104 pub fn with_line(mut self, line: usize) -> Self {
106 self.line = Some(line);
107 self
108 }
109
110 pub fn with_column(mut self, column: usize) -> Self {
112 self.column = Some(column);
113 self
114 }
115
116 pub fn with_match(mut self, raw_match: impl Into<String>) -> Self {
118 self.raw_match = raw_match.into();
119 self
120 }
121
122 pub fn location(&self) -> String {
124 match (self.line, self.column) {
125 (Some(l), Some(c)) => format!("{}:{}:{}", self.file_path, l, c),
126 (Some(l), None) => format!("{}:{}", self.file_path, l),
127 _ => self.file_path.clone(),
128 }
129 }
130}
131
132#[derive(Debug, Clone, Serialize, Deserialize)]
141pub struct EnrichedFinding {
142 pub rule_id: String,
145
146 pub severity: Severity,
148
149 pub category: String,
151
152 pub file_path: String,
154
155 pub line: Option<usize>,
157
158 pub issue: IssueDetails,
161
162 pub analysis: AnalysisContext,
164
165 pub fix: FixRecommendation,
167
168 pub references: Vec<DocReference>,
170}
171
172impl EnrichedFinding {
173 pub fn from_raw(raw: &RawFinding) -> Self {
175 Self {
176 rule_id: raw.rule_id.clone(),
177 severity: raw.severity,
178 category: raw.category.clone(),
179 file_path: raw.file_path.clone(),
180 line: raw.line,
181 issue: IssueDetails {
182 title: raw.message.clone(),
183 description: String::new(),
184 impact: String::new(),
185 },
186 analysis: AnalysisContext::default(),
187 fix: FixRecommendation::default(),
188 references: Vec::new(),
189 }
190 }
191
192 pub fn location(&self) -> String {
194 match self.line {
195 Some(l) => format!("{}:{}", self.file_path, l),
196 None => self.file_path.clone(),
197 }
198 }
199
200 pub fn is_blocking(&self) -> bool {
202 self.severity.is_blocking()
203 }
204}
205
206#[derive(Debug, Clone, Default, Serialize, Deserialize)]
208pub struct IssueDetails {
209 pub title: String,
211
212 pub description: String,
214
215 pub impact: String,
217}
218
219#[derive(Debug, Clone, Default, Serialize, Deserialize)]
221pub struct AnalysisContext {
222 pub rag_sources: Vec<String>,
224
225 pub confidence: f32,
227
228 pub reasoning: String,
230
231 pub related_rules: Vec<String>,
233}
234
235#[derive(Debug, Clone, Default, Serialize, Deserialize)]
237pub struct FixRecommendation {
238 pub action: FixAction,
240
241 pub target_file: String,
243
244 pub code_snippet: Option<String>,
246
247 pub steps: Vec<String>,
249
250 pub complexity: FixComplexity,
252}
253
254#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
256#[serde(rename_all = "snake_case")]
257pub enum FixAction {
258 AddCode,
260 ModifyCode,
262 RemoveCode,
264 AddFile,
266 UpdateConfig,
268 #[default]
270 None,
271}
272
273#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
275#[serde(rename_all = "lowercase")]
276pub enum FixComplexity {
277 Simple,
279 #[default]
281 Medium,
282 Complex,
284}
285
286#[derive(Debug, Clone, Serialize, Deserialize)]
288pub struct DocReference {
289 pub title: String,
291
292 pub url: String,
294
295 pub relevance: f32,
297
298 pub excerpt: Option<String>,
300}
301
302impl DocReference {
303 pub fn new(title: impl Into<String>, url: impl Into<String>) -> Self {
305 Self {
306 title: title.into(),
307 url: url.into(),
308 relevance: 1.0,
309 excerpt: None,
310 }
311 }
312
313 pub fn with_relevance(mut self, relevance: f32) -> Self {
315 self.relevance = relevance;
316 self
317 }
318
319 pub fn with_excerpt(mut self, excerpt: impl Into<String>) -> Self {
321 self.excerpt = Some(excerpt.into());
322 self
323 }
324}
325
326#[derive(Debug, Clone, Serialize, Deserialize)]
332pub struct ValidationResult {
333 pub id: String,
335
336 pub codebase_path: String,
338
339 pub stage1_duration_ms: u64,
341
342 pub stage2_duration_ms: u64,
344
345 pub findings: Vec<EnrichedFinding>,
347
348 pub summary: ValidationSummary,
350}
351
352impl ValidationResult {
353 pub fn new(codebase_path: impl Into<String>) -> Self {
355 Self {
356 id: uuid::Uuid::new_v4().to_string(),
357 codebase_path: codebase_path.into(),
358 stage1_duration_ms: 0,
359 stage2_duration_ms: 0,
360 findings: Vec::new(),
361 summary: ValidationSummary::default(),
362 }
363 }
364
365 pub fn calculate_summary(&mut self) {
367 let critical = self
368 .findings
369 .iter()
370 .filter(|f| f.severity == Severity::Critical)
371 .count();
372 let warnings = self
373 .findings
374 .iter()
375 .filter(|f| f.severity == Severity::Warning)
376 .count();
377 let info = self
378 .findings
379 .iter()
380 .filter(|f| f.severity == Severity::Info)
381 .count();
382
383 let status = if critical > 0 {
384 ValidationStatus::NotReady
385 } else if warnings > 0 {
386 ValidationStatus::NeedsReview
387 } else {
388 ValidationStatus::Ready
389 };
390
391 let score = (100i32 - (critical as i32 * 20) - (warnings as i32 * 5)).max(0) as u8;
393
394 self.summary = ValidationSummary {
395 status,
396 score,
397 critical_count: critical,
398 warning_count: warnings,
399 info_count: info,
400 next_steps: self.generate_next_steps(),
401 };
402 }
403
404 fn generate_next_steps(&self) -> Vec<String> {
406 self.findings
407 .iter()
408 .filter(|f| f.severity == Severity::Critical)
409 .take(5)
410 .map(|f| f.issue.title.clone())
411 .collect()
412 }
413}
414
415#[derive(Debug, Clone, Default, Serialize, Deserialize)]
417pub struct ValidationSummary {
418 pub status: ValidationStatus,
420
421 pub score: u8,
423
424 pub critical_count: usize,
426
427 pub warning_count: usize,
429
430 pub info_count: usize,
432
433 pub next_steps: Vec<String>,
435}
436
437#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
439#[serde(rename_all = "snake_case")]
440pub enum ValidationStatus {
441 Ready,
443 NeedsReview,
445 #[default]
447 NotReady,
448}
449
450impl std::fmt::Display for ValidationStatus {
451 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
452 match self {
453 Self::Ready => write!(f, "ready"),
454 Self::NeedsReview => write!(f, "needs_review"),
455 Self::NotReady => write!(f, "not_ready"),
456 }
457 }
458}
459
460#[derive(Debug, Clone, Serialize, Deserialize)]
466pub struct FileContext {
467 pub path: String,
469
470 pub content: String,
472
473 pub language: String,
475
476 pub line_count: usize,
478}
479
480impl FileContext {
481 pub fn new(path: impl Into<String>, content: impl Into<String>) -> Self {
483 let content = content.into();
484 let line_count = content.lines().count();
485 let path = path.into();
486 let language = Self::detect_language(&path);
487
488 Self {
489 path,
490 content,
491 language,
492 line_count,
493 }
494 }
495
496 fn detect_language(path: &str) -> String {
498 let ext = path.rsplit('.').next().unwrap_or("");
499 match ext {
500 "ts" | "tsx" => "typescript",
501 "js" | "jsx" => "javascript",
502 "rb" => "ruby",
503 "py" => "python",
504 "php" => "php",
505 "go" => "go",
506 "rs" => "rust",
507 "toml" => "toml",
508 "json" => "json",
509 "yaml" | "yml" => "yaml",
510 _ => "unknown",
511 }
512 .to_string()
513 }
514
515 pub fn snippet(&self, line: usize, context_lines: usize) -> String {
517 let lines: Vec<&str> = self.content.lines().collect();
518 let start = line.saturating_sub(context_lines + 1);
519 let end = (line + context_lines).min(lines.len());
520
521 lines[start..end].join("\n")
522 }
523}
524
525#[cfg(test)]
526mod tests {
527 use super::*;
528
529 #[test]
530 fn test_raw_finding_location() {
531 let finding = RawFinding::new(
532 "WH001",
533 Severity::Critical,
534 "webhooks",
535 "src/app.ts",
536 "Missing webhook",
537 )
538 .with_line(42)
539 .with_column(5);
540
541 assert_eq!(finding.location(), "src/app.ts:42:5");
542 }
543
544 #[test]
545 fn test_severity_priority() {
546 assert!(Severity::Critical.priority() < Severity::Warning.priority());
547 assert!(Severity::Warning.priority() < Severity::Info.priority());
548 }
549
550 #[test]
551 fn test_validation_result_summary() {
552 let mut result = ValidationResult::new("/app");
553 result
554 .findings
555 .push(EnrichedFinding::from_raw(&RawFinding::new(
556 "WH001",
557 Severity::Critical,
558 "webhooks",
559 "src/app.ts",
560 "Missing webhook",
561 )));
562 result
563 .findings
564 .push(EnrichedFinding::from_raw(&RawFinding::new(
565 "SEC001",
566 Severity::Warning,
567 "security",
568 "src/utils.ts",
569 "Eval usage",
570 )));
571
572 result.calculate_summary();
573
574 assert_eq!(result.summary.status, ValidationStatus::NotReady);
575 assert_eq!(result.summary.critical_count, 1);
576 assert_eq!(result.summary.warning_count, 1);
577 assert!(result.summary.score < 100);
578 }
579
580 #[test]
581 fn test_file_context_snippet() {
582 let content = "line1\nline2\nline3\nline4\nline5\nline6\nline7";
583 let ctx = FileContext::new("test.ts", content);
584
585 let snippet = ctx.snippet(4, 1);
586 assert!(snippet.contains("line3"));
587 assert!(snippet.contains("line4"));
588 assert!(snippet.contains("line5"));
589 }
590}