Skip to main content

gatekpr_opencode/
models.rs

1//! Data models for OpenCode validation enrichment
2//!
3//! These models represent the two-stage validation flow:
4//! - Stage 1: Local pipeline produces RawFinding
5//! - Stage 2: OpenCode enriches to EnrichedFinding with RAG context
6
7use serde::{Deserialize, Serialize};
8
9// =============================================================================
10// SEVERITY
11// =============================================================================
12
13/// Severity level for findings
14#[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    /// Get priority for sorting (lower = higher priority)
24    pub fn priority(&self) -> u8 {
25        match self {
26            Self::Critical => 0,
27            Self::Warning => 1,
28            Self::Info => 2,
29        }
30    }
31
32    /// Check if this is a blocking severity
33    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// =============================================================================
49// RAW FINDING (Stage 1 - from Local Pipeline)
50// =============================================================================
51
52/// Raw finding from local pipeline (Stage 1)
53///
54/// This is produced by the pattern matching and rule engine.
55/// It contains the basic violation information without enrichment.
56#[derive(Debug, Clone, Serialize, Deserialize)]
57pub struct RawFinding {
58    /// Rule ID (e.g., "WH001", "SEC002")
59    pub rule_id: String,
60
61    /// Severity level
62    pub severity: Severity,
63
64    /// Category (e.g., "webhooks", "security", "billing")
65    pub category: String,
66
67    /// File path where the issue was found
68    pub file_path: String,
69
70    /// Line number (if available)
71    pub line: Option<usize>,
72
73    /// Column number (if available)
74    pub column: Option<usize>,
75
76    /// The matched pattern or code snippet
77    pub raw_match: String,
78
79    /// Brief message from the rule
80    pub message: String,
81}
82
83impl RawFinding {
84    /// Create a new raw finding
85    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    /// Set line number
105    pub fn with_line(mut self, line: usize) -> Self {
106        self.line = Some(line);
107        self
108    }
109
110    /// Set column number
111    pub fn with_column(mut self, column: usize) -> Self {
112        self.column = Some(column);
113        self
114    }
115
116    /// Set the raw match
117    pub fn with_match(mut self, raw_match: impl Into<String>) -> Self {
118        self.raw_match = raw_match.into();
119        self
120    }
121
122    /// Get location as string
123    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// =============================================================================
133// ENRICHED FINDING (Stage 2 - from OpenCode + RAG)
134// =============================================================================
135
136/// Enriched finding after OpenCode + RAG processing (Stage 2)
137///
138/// This contains the full analysis with explanations, fix recommendations,
139/// and documentation references from the RAG system.
140#[derive(Debug, Clone, Serialize, Deserialize)]
141pub struct EnrichedFinding {
142    // === From Stage 1 ===
143    /// Rule ID (e.g., "WH001", "SEC002")
144    pub rule_id: String,
145
146    /// Severity level
147    pub severity: Severity,
148
149    /// Category (e.g., "webhooks", "security", "billing")
150    pub category: String,
151
152    /// File path where the issue was found
153    pub file_path: String,
154
155    /// Line number (if available)
156    pub line: Option<usize>,
157
158    // === From Stage 2 (OpenCode + RAG) ===
159    /// Detailed issue information
160    pub issue: IssueDetails,
161
162    /// Analysis context from RAG
163    pub analysis: AnalysisContext,
164
165    /// Fix recommendation
166    pub fix: FixRecommendation,
167
168    /// Documentation references
169    pub references: Vec<DocReference>,
170}
171
172impl EnrichedFinding {
173    /// Create from a raw finding (without enrichment yet)
174    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    /// Get location as string
193    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    /// Check if this is a blocking issue
201    pub fn is_blocking(&self) -> bool {
202        self.severity.is_blocking()
203    }
204}
205
206/// Detailed issue information
207#[derive(Debug, Clone, Default, Serialize, Deserialize)]
208pub struct IssueDetails {
209    /// Short title for the issue
210    pub title: String,
211
212    /// Detailed description of what's wrong
213    pub description: String,
214
215    /// Impact if not fixed (e.g., "App will be rejected")
216    pub impact: String,
217}
218
219/// Analysis context from RAG
220#[derive(Debug, Clone, Default, Serialize, Deserialize)]
221pub struct AnalysisContext {
222    /// RAG sources that were consulted (collection:doc_id)
223    pub rag_sources: Vec<String>,
224
225    /// Confidence score (0.0 - 1.0)
226    pub confidence: f32,
227
228    /// Reasoning for the analysis
229    pub reasoning: String,
230
231    /// Related rules that may also apply
232    pub related_rules: Vec<String>,
233}
234
235/// Fix recommendation
236#[derive(Debug, Clone, Default, Serialize, Deserialize)]
237pub struct FixRecommendation {
238    /// Type of fix action required
239    pub action: FixAction,
240
241    /// Target file for the fix
242    pub target_file: String,
243
244    /// Code snippet to add/modify (if applicable)
245    pub code_snippet: Option<String>,
246
247    /// Step-by-step instructions
248    pub steps: Vec<String>,
249
250    /// Estimated complexity
251    pub complexity: FixComplexity,
252}
253
254/// Type of fix action
255#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
256#[serde(rename_all = "snake_case")]
257pub enum FixAction {
258    /// Add new code
259    AddCode,
260    /// Modify existing code
261    ModifyCode,
262    /// Remove code
263    RemoveCode,
264    /// Add a new file
265    AddFile,
266    /// Update configuration
267    UpdateConfig,
268    /// No action needed (informational)
269    #[default]
270    None,
271}
272
273/// Complexity of the fix
274#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
275#[serde(rename_all = "lowercase")]
276pub enum FixComplexity {
277    /// Simple fix (few lines)
278    Simple,
279    /// Medium complexity
280    #[default]
281    Medium,
282    /// Complex fix (architectural changes)
283    Complex,
284}
285
286/// Documentation reference
287#[derive(Debug, Clone, Serialize, Deserialize)]
288pub struct DocReference {
289    /// Document title
290    pub title: String,
291
292    /// URL to the documentation
293    pub url: String,
294
295    /// Relevance score (0.0 - 1.0)
296    pub relevance: f32,
297
298    /// Brief excerpt from the doc
299    pub excerpt: Option<String>,
300}
301
302impl DocReference {
303    /// Create a new documentation reference
304    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    /// Set relevance score
314    pub fn with_relevance(mut self, relevance: f32) -> Self {
315        self.relevance = relevance;
316        self
317    }
318
319    /// Set excerpt
320    pub fn with_excerpt(mut self, excerpt: impl Into<String>) -> Self {
321        self.excerpt = Some(excerpt.into());
322        self
323    }
324}
325
326// =============================================================================
327// VALIDATION RESULT
328// =============================================================================
329
330/// Complete validation result
331#[derive(Debug, Clone, Serialize, Deserialize)]
332pub struct ValidationResult {
333    /// Unique validation ID
334    pub id: String,
335
336    /// Path to the validated codebase
337    pub codebase_path: String,
338
339    /// Duration of Stage 1 (local pipeline) in milliseconds
340    pub stage1_duration_ms: u64,
341
342    /// Duration of Stage 2 (OpenCode enrichment) in milliseconds
343    pub stage2_duration_ms: u64,
344
345    /// All enriched findings
346    pub findings: Vec<EnrichedFinding>,
347
348    /// Summary of the validation
349    pub summary: ValidationSummary,
350}
351
352impl ValidationResult {
353    /// Create a new validation result
354    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    /// Calculate summary from findings
366    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        // Score: 100 - (critical * 20) - (warnings * 5)
392        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    /// Generate next steps from critical findings
405    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/// Validation summary
416#[derive(Debug, Clone, Default, Serialize, Deserialize)]
417pub struct ValidationSummary {
418    /// Overall status
419    pub status: ValidationStatus,
420
421    /// Score out of 100
422    pub score: u8,
423
424    /// Count of critical issues
425    pub critical_count: usize,
426
427    /// Count of warnings
428    pub warning_count: usize,
429
430    /// Count of info items
431    pub info_count: usize,
432
433    /// Next steps to fix blocking issues
434    pub next_steps: Vec<String>,
435}
436
437/// Validation status
438#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
439#[serde(rename_all = "snake_case")]
440pub enum ValidationStatus {
441    /// Ready for submission
442    Ready,
443    /// Needs review but may pass
444    NeedsReview,
445    /// Not ready, has blocking issues
446    #[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// =============================================================================
461// FILE CONTEXT
462// =============================================================================
463
464/// File context for enrichment
465#[derive(Debug, Clone, Serialize, Deserialize)]
466pub struct FileContext {
467    /// File path
468    pub path: String,
469
470    /// File content
471    pub content: String,
472
473    /// Detected language
474    pub language: String,
475
476    /// Line count
477    pub line_count: usize,
478}
479
480impl FileContext {
481    /// Create from file path and content
482    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    /// Detect language from file extension
497    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    /// Get a snippet around a line
516    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}