Skip to main content

tldr_cli/commands/remaining/
types.rs

1//! Shared types for remaining commands
2//!
3//! This module defines all data types used across the remaining analysis
4//! commands. Types are designed for JSON serialization with serde.
5
6use std::collections::HashMap;
7
8use clap::ValueEnum;
9use serde::{Deserialize, Serialize};
10use serde_json::Value;
11
12// =============================================================================
13// Output Format
14// =============================================================================
15
16/// Output format for all commands.
17#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, ValueEnum, Serialize, Deserialize)]
18#[serde(rename_all = "lowercase")]
19pub enum OutputFormat {
20    /// JSON output (default)
21    #[default]
22    Json,
23    /// Human-readable text output
24    Text,
25    /// SARIF format (only for vuln command)
26    Sarif,
27}
28
29impl std::fmt::Display for OutputFormat {
30    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
31        match self {
32            Self::Json => write!(f, "json"),
33            Self::Text => write!(f, "text"),
34            Self::Sarif => write!(f, "sarif"),
35        }
36    }
37}
38
39// =============================================================================
40// Severity Level
41// =============================================================================
42
43/// Severity level for findings.
44#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, ValueEnum, Default)]
45#[serde(rename_all = "lowercase")]
46pub enum Severity {
47    Critical,
48    High,
49    #[default]
50    Medium,
51    Low,
52    Info,
53}
54
55impl Severity {
56    /// Returns ordering value (lower = more severe)
57    pub fn order(&self) -> u8 {
58        match self {
59            Self::Critical => 0,
60            Self::High => 1,
61            Self::Medium => 2,
62            Self::Low => 3,
63            Self::Info => 4,
64        }
65    }
66}
67
68impl std::fmt::Display for Severity {
69    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
70        match self {
71            Self::Critical => write!(f, "critical"),
72            Self::High => write!(f, "high"),
73            Self::Medium => write!(f, "medium"),
74            Self::Low => write!(f, "low"),
75            Self::Info => write!(f, "info"),
76        }
77    }
78}
79
80// =============================================================================
81// Location
82// =============================================================================
83
84/// A location in source code (shared across multiple commands).
85#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
86pub struct Location {
87    pub file: String,
88    pub line: u32,
89    #[serde(default)]
90    pub column: u32,
91    #[serde(skip_serializing_if = "Option::is_none")]
92    pub end_line: Option<u32>,
93    #[serde(skip_serializing_if = "Option::is_none")]
94    pub end_column: Option<u32>,
95}
96
97impl Location {
98    /// Create a new location
99    pub fn new(file: impl Into<String>, line: u32) -> Self {
100        Self {
101            file: file.into(),
102            line,
103            column: 0,
104            end_line: None,
105            end_column: None,
106        }
107    }
108
109    /// Create a location with column information
110    pub fn with_column(file: impl Into<String>, line: u32, column: u32) -> Self {
111        Self {
112            file: file.into(),
113            line,
114            column,
115            end_line: None,
116            end_column: None,
117        }
118    }
119}
120
121// =============================================================================
122// Todo Types
123// =============================================================================
124
125/// A single improvement item.
126#[derive(Debug, Clone, Serialize, Deserialize)]
127pub struct TodoItem {
128    /// Category of improvement (e.g., "dead_code", "complexity", "cohesion")
129    pub category: String,
130    /// Priority (lower = higher priority)
131    pub priority: u32,
132    /// Human-readable description
133    pub description: String,
134    /// File where the issue was found
135    #[serde(default)]
136    pub file: String,
137    /// Line number
138    #[serde(default)]
139    pub line: u32,
140    /// Severity level
141    #[serde(default)]
142    pub severity: String,
143    /// Score from sub-analysis (0.0-1.0 typically)
144    #[serde(default)]
145    pub score: f64,
146}
147
148impl TodoItem {
149    /// Create a new TodoItem
150    pub fn new(category: impl Into<String>, priority: u32, description: impl Into<String>) -> Self {
151        Self {
152            category: category.into(),
153            priority,
154            description: description.into(),
155            file: String::new(),
156            line: 0,
157            severity: String::new(),
158            score: 0.0,
159        }
160    }
161
162    /// Set file and line
163    pub fn with_location(mut self, file: impl Into<String>, line: u32) -> Self {
164        self.file = file.into();
165        self.line = line;
166        self
167    }
168
169    /// Set severity
170    pub fn with_severity(mut self, severity: impl Into<String>) -> Self {
171        self.severity = severity.into();
172        self
173    }
174
175    /// Set score
176    pub fn with_score(mut self, score: f64) -> Self {
177        self.score = score;
178        self
179    }
180}
181
182/// Summary of todo analysis.
183#[derive(Debug, Clone, Default, Serialize, Deserialize)]
184pub struct TodoSummary {
185    /// Number of dead code items found
186    pub dead_count: u32,
187    /// Number of similar code pairs found
188    pub similar_pairs: u32,
189    /// Number of classes with low cohesion
190    pub low_cohesion_count: u32,
191    /// Number of complexity hotspots
192    pub hotspot_count: u32,
193    /// Number of equivalence groups (redundant expressions)
194    pub equivalence_groups: u32,
195}
196
197/// Todo analysis report.
198#[derive(Debug, Clone, Serialize, Deserialize)]
199pub struct TodoReport {
200    /// Command identifier
201    pub wrapper: String,
202    /// Path that was analyzed
203    pub path: String,
204    /// Improvement items sorted by priority
205    pub items: Vec<TodoItem>,
206    /// Summary statistics
207    pub summary: TodoSummary,
208    /// Raw results from sub-analyses (only present when `--detail <name>`
209    /// is used; otherwise omitted from JSON output).
210    ///
211    /// WRAPPER-CROSS-CONSISTENCY-V1 (BUG-19): previously serialized as
212    /// `sub_results: {}` on every todo invocation. The empty `{}` was
213    /// misleading: todo does not populate sub_results unless `--detail`
214    /// is passed. Skipped when empty.
215    #[serde(default, skip_serializing_if = "HashMap::is_empty")]
216    pub sub_results: HashMap<String, Value>,
217    /// Total elapsed time in milliseconds
218    pub total_elapsed_ms: f64,
219}
220
221impl TodoReport {
222    /// Create a new TodoReport
223    pub fn new(path: impl Into<String>) -> Self {
224        Self {
225            wrapper: "todo".to_string(),
226            path: path.into(),
227            items: Vec::new(),
228            summary: TodoSummary::default(),
229            sub_results: HashMap::new(),
230            total_elapsed_ms: 0.0,
231        }
232    }
233}
234
235// =============================================================================
236// Secure Types
237// =============================================================================
238
239/// A security finding from the secure command.
240#[derive(Debug, Clone, Serialize, Deserialize)]
241pub struct SecureFinding {
242    /// Category of finding (e.g., "taint", "resource_leak", "bounds")
243    pub category: String,
244    /// Severity level
245    pub severity: String,
246    /// Human-readable description
247    pub description: String,
248    /// File where the issue was found
249    #[serde(default)]
250    pub file: String,
251    /// Line number
252    #[serde(default)]
253    pub line: u32,
254}
255
256impl SecureFinding {
257    /// Create a new SecureFinding
258    pub fn new(
259        category: impl Into<String>,
260        severity: impl Into<String>,
261        description: impl Into<String>,
262    ) -> Self {
263        Self {
264            category: category.into(),
265            severity: severity.into(),
266            description: description.into(),
267            file: String::new(),
268            line: 0,
269        }
270    }
271
272    /// Set file and line
273    pub fn with_location(mut self, file: impl Into<String>, line: u32) -> Self {
274        self.file = file.into();
275        self.line = line;
276        self
277    }
278}
279
280/// Security summary.
281///
282/// WRAPPER-CROSS-CONSISTENCY-V1: every `*_count` field is computed from the
283/// FINAL `SecureReport.findings` array via category group-by. The sum of
284/// all category counters in this struct (taint + leak + bounds + behavioral
285/// + unsafe_blocks + raw_pointer_ops + unwrap_calls + todo_markers +
286/// missing_contracts + mutable_params) MUST equal `findings.len()`.
287/// `taint_critical` is a sub-count of `taint_count` (severity refinement,
288/// not its own category) and is excluded from that invariant.
289#[derive(Debug, Clone, Default, Serialize, Deserialize)]
290pub struct SecureSummary {
291    /// Number of taint-related findings
292    pub taint_count: u32,
293    /// Number of critical taint findings
294    pub taint_critical: u32,
295    /// Number of resource leak findings
296    pub leak_count: u32,
297    /// Number of bounds/overflow warnings
298    pub bounds_warnings: u32,
299    /// Number of behavioral findings (e.g. bare `except:`)
300    ///
301    /// WRAPPER-CROSS-CONSISTENCY-V1 (BUG-15): previously the `behavioral`
302    /// category was emitted into `findings[]` but had no corresponding
303    /// summary counter, so `sum(*_count) != findings.len()`. Adding this
304    /// counter restores the invariant.
305    #[serde(default)]
306    pub behavioral_count: u32,
307    /// Number of missing contracts
308    pub missing_contracts: u32,
309    /// Number of mutable parameter issues
310    pub mutable_params: u32,
311    /// Number of Rust unsafe blocks
312    #[serde(default)]
313    pub unsafe_blocks: u32,
314    /// Number of Rust raw pointer operations
315    #[serde(default)]
316    pub raw_pointer_ops: u32,
317    /// Number of Rust unwrap calls
318    #[serde(default)]
319    pub unwrap_calls: u32,
320    /// Number of todo!/unimplemented! markers in non-test code
321    #[serde(default)]
322    pub todo_markers: u32,
323}
324
325/// Secure analysis report.
326#[derive(Debug, Clone, Serialize, Deserialize)]
327pub struct SecureReport {
328    /// Command identifier
329    pub wrapper: String,
330    /// Path that was analyzed.
331    ///
332    /// cross-command-consistency-v1 (BUG-14): renamed in JSON to `root`
333    /// so project-root field naming is identical across commands. The Rust
334    /// field is still `path` for backwards compatibility; JSON callers see
335    /// `root`. The `alias` keeps deserialisation of older bodies working.
336    #[serde(rename = "root", alias = "path")]
337    pub path: String,
338    /// Security findings sorted by severity
339    pub findings: Vec<SecureFinding>,
340    /// Summary statistics
341    pub summary: SecureSummary,
342    /// Raw results from sub-analyses (only present when `--detail <name>`
343    /// is used; otherwise omitted from JSON output).
344    ///
345    /// WRAPPER-CROSS-CONSISTENCY-V1 (BUG-19): previously serialized as
346    /// `sub_results: {}` on every secure invocation — a cargo-cult of
347    /// `verify`'s schema. The empty `{}` was misleading: secure does not
348    /// populate sub_results unless `--detail` is passed. Skipped when
349    /// empty so consumers don't conflate "no detail requested" with "no
350    /// sub-analyses ran".
351    #[serde(default, skip_serializing_if = "HashMap::is_empty")]
352    pub sub_results: HashMap<String, Value>,
353    /// Total elapsed time in milliseconds
354    pub total_elapsed_ms: f64,
355    /// Number of files skipped (e.g. non-UTF-8 input).
356    ///
357    /// SECURE-UTF8-TOLERANCE-V1: parser-test corpora (notably the Luau
358    /// `tests/conformance/literals.luau`, `pm.luau`, `sort.luau`) contain
359    /// raw non-UTF-8 bytes. Pre-fix, `tldr secure --lang luau` aborted
360    /// the entire scan on the first such file with `Error: stream did
361    /// not contain valid UTF-8`. Post-fix, those files are skipped and
362    /// surfaced via `files_skipped` + `warnings` while the rest of the
363    /// scan completes. Skipped from JSON when zero so existing consumers
364    /// don't observe a schema delta on UTF-8-clean inputs.
365    #[serde(default, skip_serializing_if = "is_zero_u32")]
366    pub files_skipped: u32,
367    /// Per-file warnings (e.g. non-UTF-8 skip messages with byte offsets).
368    ///
369    /// SECURE-UTF8-TOLERANCE-V1: companion to `files_skipped`. Each entry
370    /// pinpoints the file path and first invalid-UTF-8 byte offset so
371    /// callers can audit what was skipped. Skipped from JSON when empty.
372    #[serde(default, skip_serializing_if = "Vec::is_empty")]
373    pub warnings: Vec<String>,
374}
375
376/// Helper for `serde(skip_serializing_if)` on `u32` count fields that should
377/// be omitted from JSON when zero (preserves backward-compat schema).
378fn is_zero_u32(n: &u32) -> bool {
379    *n == 0
380}
381
382impl SecureReport {
383    /// Create a new SecureReport
384    pub fn new(path: impl Into<String>) -> Self {
385        Self {
386            wrapper: "secure".to_string(),
387            path: path.into(),
388            findings: Vec::new(),
389            summary: SecureSummary::default(),
390            sub_results: HashMap::new(),
391            total_elapsed_ms: 0.0,
392            files_skipped: 0,
393            warnings: Vec::new(),
394        }
395    }
396}
397
398// =============================================================================
399// Tests
400// =============================================================================
401
402#[cfg(test)]
403mod tests {
404    use super::*;
405
406    #[test]
407    fn test_output_format_serialization() {
408        let json = serde_json::to_string(&OutputFormat::Json).unwrap();
409        assert_eq!(json, r#""json""#);
410
411        let text = serde_json::to_string(&OutputFormat::Text).unwrap();
412        assert_eq!(text, r#""text""#);
413
414        let sarif = serde_json::to_string(&OutputFormat::Sarif).unwrap();
415        assert_eq!(sarif, r#""sarif""#);
416    }
417
418    #[test]
419    fn test_severity_ordering() {
420        assert!(Severity::Critical.order() < Severity::High.order());
421        assert!(Severity::High.order() < Severity::Medium.order());
422        assert!(Severity::Medium.order() < Severity::Low.order());
423        assert!(Severity::Low.order() < Severity::Info.order());
424    }
425
426    #[test]
427    fn test_location_serialization() {
428        let loc = Location::new("test.py", 42);
429        let json = serde_json::to_string(&loc).unwrap();
430        assert!(json.contains(r#""file":"test.py""#));
431        assert!(json.contains(r#""line":42"#));
432    }
433
434    #[test]
435    fn test_todo_report_serialization() {
436        let mut report = TodoReport::new("/path/to/file.py");
437        report
438            .items
439            .push(TodoItem::new("dead_code", 1, "Unused function"));
440        report.summary.dead_count = 1;
441
442        let json = serde_json::to_string(&report).unwrap();
443        assert!(json.contains(r#""wrapper":"todo""#));
444        assert!(json.contains(r#""dead_count":1"#));
445    }
446
447    #[test]
448    fn test_todo_item_builder() {
449        let item = TodoItem::new("complexity", 2, "High cyclomatic complexity")
450            .with_location("src/main.py", 100)
451            .with_severity("high")
452            .with_score(0.85);
453
454        assert_eq!(item.category, "complexity");
455        assert_eq!(item.file, "src/main.py");
456        assert_eq!(item.line, 100);
457        assert_eq!(item.severity, "high");
458        assert!((item.score - 0.85).abs() < 0.001);
459    }
460
461    #[test]
462    fn test_secure_report_serialization() {
463        let mut report = SecureReport::new("/path/to/file.py");
464        report
465            .findings
466            .push(SecureFinding::new("taint", "critical", "SQL injection"));
467        report.summary.taint_count = 1;
468        report.summary.taint_critical = 1;
469
470        let json = serde_json::to_string(&report).unwrap();
471        assert!(json.contains(r#""wrapper":"secure""#));
472        assert!(json.contains(r#""taint_count":1"#));
473        assert!(json.contains(r#""taint_critical":1"#));
474    }
475
476    #[test]
477    fn test_secure_finding_builder() {
478        let finding = SecureFinding::new("resource_leak", "high", "File not closed")
479            .with_location("src/db.py", 42);
480
481        assert_eq!(finding.category, "resource_leak");
482        assert_eq!(finding.severity, "high");
483        assert_eq!(finding.file, "src/db.py");
484        assert_eq!(finding.line, 42);
485    }
486}
487
488// =============================================================================
489// Explain Types
490// =============================================================================
491
492/// Parameter information.
493#[derive(Debug, Clone, Serialize, Deserialize)]
494pub struct ParamInfo {
495    /// Parameter name
496    pub name: String,
497    /// Type hint if present
498    #[serde(rename = "type", skip_serializing_if = "Option::is_none")]
499    pub type_hint: Option<String>,
500    /// Default value if present
501    #[serde(skip_serializing_if = "Option::is_none")]
502    pub default: Option<String>,
503}
504
505impl ParamInfo {
506    /// Create a new ParamInfo
507    pub fn new(name: impl Into<String>) -> Self {
508        Self {
509            name: name.into(),
510            type_hint: None,
511            default: None,
512        }
513    }
514
515    /// Set type hint
516    pub fn with_type(mut self, type_hint: impl Into<String>) -> Self {
517        self.type_hint = Some(type_hint.into());
518        self
519    }
520
521    /// Set default value
522    pub fn with_default(mut self, default: impl Into<String>) -> Self {
523        self.default = Some(default.into());
524        self
525    }
526}
527
528/// Function signature information.
529#[derive(Debug, Clone, Serialize, Deserialize)]
530pub struct SignatureInfo {
531    /// Parameters
532    pub params: Vec<ParamInfo>,
533    /// Return type if annotated
534    #[serde(skip_serializing_if = "Option::is_none")]
535    pub return_type: Option<String>,
536    /// Decorators
537    #[serde(default)]
538    pub decorators: Vec<String>,
539    /// Whether the function is async
540    #[serde(default)]
541    pub is_async: bool,
542    /// Docstring content
543    #[serde(skip_serializing_if = "Option::is_none")]
544    pub docstring: Option<String>,
545}
546
547impl SignatureInfo {
548    /// Create a new SignatureInfo
549    pub fn new() -> Self {
550        Self {
551            params: Vec::new(),
552            return_type: None,
553            decorators: Vec::new(),
554            is_async: false,
555            docstring: None,
556        }
557    }
558
559    /// Add a parameter
560    pub fn with_param(mut self, param: ParamInfo) -> Self {
561        self.params.push(param);
562        self
563    }
564
565    /// Set return type
566    pub fn with_return_type(mut self, return_type: impl Into<String>) -> Self {
567        self.return_type = Some(return_type.into());
568        self
569    }
570
571    /// Set docstring
572    pub fn with_docstring(mut self, docstring: impl Into<String>) -> Self {
573        self.docstring = Some(docstring.into());
574        self
575    }
576
577    /// Set async flag
578    pub fn set_async(mut self, is_async: bool) -> Self {
579        self.is_async = is_async;
580        self
581    }
582}
583
584impl Default for SignatureInfo {
585    fn default() -> Self {
586        Self::new()
587    }
588}
589
590/// Purity analysis result.
591#[derive(Debug, Clone, Serialize, Deserialize)]
592pub struct PurityInfo {
593    /// Classification: "pure", "impure", or "unknown"
594    pub classification: String,
595    /// List of detected effects (empty if pure)
596    #[serde(default)]
597    pub effects: Vec<String>,
598    /// Confidence: "high", "medium", or "low"
599    pub confidence: String,
600}
601
602impl PurityInfo {
603    /// Create a pure classification
604    pub fn pure() -> Self {
605        Self {
606            classification: "pure".to_string(),
607            effects: Vec::new(),
608            confidence: "high".to_string(),
609        }
610    }
611
612    /// Create an impure classification
613    pub fn impure(effects: Vec<String>) -> Self {
614        Self {
615            classification: "impure".to_string(),
616            effects,
617            confidence: "high".to_string(),
618        }
619    }
620
621    /// Create an unknown classification
622    pub fn unknown() -> Self {
623        Self {
624            classification: "unknown".to_string(),
625            effects: Vec::new(),
626            confidence: "low".to_string(),
627        }
628    }
629
630    /// Set confidence level
631    pub fn with_confidence(mut self, confidence: impl Into<String>) -> Self {
632        self.confidence = confidence.into();
633        self
634    }
635}
636
637impl Default for PurityInfo {
638    fn default() -> Self {
639        Self::unknown()
640    }
641}
642
643/// Complexity metrics.
644#[derive(Debug, Clone, Serialize, Deserialize)]
645pub struct ComplexityInfo {
646    /// Cyclomatic complexity
647    pub cyclomatic: u32,
648    /// Number of basic blocks
649    pub num_blocks: u32,
650    /// Number of edges in CFG
651    pub num_edges: u32,
652    /// Whether the function contains loops
653    pub has_loops: bool,
654}
655
656impl ComplexityInfo {
657    /// Create new complexity info
658    pub fn new(cyclomatic: u32, num_blocks: u32, num_edges: u32, has_loops: bool) -> Self {
659        Self {
660            cyclomatic,
661            num_blocks,
662            num_edges,
663            has_loops,
664        }
665    }
666}
667
668impl Default for ComplexityInfo {
669    fn default() -> Self {
670        Self {
671            cyclomatic: 1,
672            num_blocks: 1,
673            num_edges: 0,
674            has_loops: false,
675        }
676    }
677}
678
679/// Caller/callee information.
680#[derive(Debug, Clone, Serialize, Deserialize)]
681pub struct CallInfo {
682    /// Function name
683    pub name: String,
684    /// File path
685    pub file: String,
686    /// Line number
687    pub line: u32,
688}
689
690impl CallInfo {
691    /// Create new call info
692    pub fn new(name: impl Into<String>, file: impl Into<String>, line: u32) -> Self {
693        Self {
694            name: name.into(),
695            file: file.into(),
696            line,
697        }
698    }
699}
700
701/// Full explain report for a function.
702///
703/// schema-unification-v1 BUG-17: emits an additional `line` field
704/// (mapped from `line_start`) so consumers can use a unified `line`
705/// field name across `vuln`/`dead`/`extract`/`explain`/`health`.
706/// `line_start` and `line_end` remain for callers that need ranges.
707#[derive(Debug, Clone, Deserialize)]
708pub struct ExplainReport {
709    /// Function name.
710    ///
711    /// cross-command-consistency-v1 (BUG-14): emitted in JSON as
712    /// `function` so the function-name field is identical across commands
713    /// (`slice`, `dead-stores`, `resources`, `reaching-defs`, `taint`,
714    /// `explain`, ...). The custom `Serialize` impl below handles the
715    /// rename; deserialise still accepts both names via `alias`.
716    #[serde(alias = "function")]
717    pub function_name: String,
718    /// File path
719    pub file: String,
720    /// Start line of function
721    pub line_start: u32,
722    /// End line of function
723    pub line_end: u32,
724    /// Detected language
725    pub language: String,
726    /// Signature information
727    pub signature: SignatureInfo,
728    /// Purity analysis
729    pub purity: PurityInfo,
730    /// Complexity metrics (if available)
731    #[serde(skip_serializing_if = "Option::is_none")]
732    pub complexity: Option<ComplexityInfo>,
733    /// Functions that call this function
734    #[serde(default)]
735    pub callers: Vec<CallInfo>,
736    /// Functions called by this function
737    #[serde(default)]
738    pub callees: Vec<CallInfo>,
739}
740
741// schema-unification-v1 BUG-17: manual Serialize impl emits `line`
742// alongside the existing `line_start`/`line_end` pair.
743impl Serialize for ExplainReport {
744    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
745    where
746        S: serde::Serializer,
747    {
748        use serde::ser::SerializeStruct;
749        let mut count = 8; // function_name, file, line_start, line_end, line, language, signature, purity
750        if self.complexity.is_some() {
751            count += 1;
752        }
753        // callers/callees always emitted (matches original derive — `default`
754        // applies only to deserialize side).
755        count += 2;
756        let mut s = serializer.serialize_struct("ExplainReport", count)?;
757        // cross-command-consistency-v1 (BUG-14): emit canonical `function`
758        // (replaces the older `function_name`).
759        s.serialize_field("function", &self.function_name)?;
760        s.serialize_field("file", &self.file)?;
761        s.serialize_field("line_start", &self.line_start)?;
762        s.serialize_field("line_end", &self.line_end)?;
763        s.serialize_field("line", &self.line_start)?;
764        s.serialize_field("language", &self.language)?;
765        s.serialize_field("signature", &self.signature)?;
766        s.serialize_field("purity", &self.purity)?;
767        if let Some(c) = &self.complexity {
768            s.serialize_field("complexity", c)?;
769        }
770        s.serialize_field("callers", &self.callers)?;
771        s.serialize_field("callees", &self.callees)?;
772        s.end()
773    }
774}
775
776impl ExplainReport {
777    /// Create a new ExplainReport
778    pub fn new(
779        function_name: impl Into<String>,
780        file: impl Into<String>,
781        line_start: u32,
782        line_end: u32,
783        language: impl Into<String>,
784    ) -> Self {
785        Self {
786            function_name: function_name.into(),
787            file: file.into(),
788            line_start,
789            line_end,
790            language: language.into(),
791            signature: SignatureInfo::default(),
792            purity: PurityInfo::default(),
793            complexity: None,
794            callers: Vec::new(),
795            callees: Vec::new(),
796        }
797    }
798
799    /// Set signature
800    pub fn with_signature(mut self, signature: SignatureInfo) -> Self {
801        self.signature = signature;
802        self
803    }
804
805    /// Set purity
806    pub fn with_purity(mut self, purity: PurityInfo) -> Self {
807        self.purity = purity;
808        self
809    }
810
811    /// Set complexity
812    pub fn with_complexity(mut self, complexity: ComplexityInfo) -> Self {
813        self.complexity = Some(complexity);
814        self
815    }
816
817    /// Add a caller
818    pub fn add_caller(&mut self, caller: CallInfo) {
819        self.callers.push(caller);
820    }
821
822    /// Add a callee
823    pub fn add_callee(&mut self, callee: CallInfo) {
824        self.callees.push(callee);
825    }
826}
827
828// =============================================================================
829// Definition Types
830// =============================================================================
831
832/// Symbol kind.
833#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, ValueEnum, Default)]
834#[serde(rename_all = "snake_case")]
835pub enum SymbolKind {
836    Function,
837    Class,
838    Method,
839    Variable,
840    Parameter,
841    Constant,
842    Module,
843    Type,
844    Interface,
845    Property,
846    #[default]
847    Unknown,
848}
849
850/// Symbol information.
851#[derive(Debug, Clone, Serialize, Deserialize)]
852pub struct SymbolInfo {
853    /// Symbol name
854    pub name: String,
855    /// Symbol kind
856    pub kind: SymbolKind,
857    /// Location in source
858    #[serde(skip_serializing_if = "Option::is_none")]
859    pub location: Option<Location>,
860    /// Type annotation
861    #[serde(skip_serializing_if = "Option::is_none")]
862    pub type_annotation: Option<String>,
863    /// Docstring
864    #[serde(skip_serializing_if = "Option::is_none")]
865    pub docstring: Option<String>,
866    /// Whether this is a builtin
867    #[serde(default)]
868    pub is_builtin: bool,
869    /// Module path
870    #[serde(skip_serializing_if = "Option::is_none")]
871    pub module: Option<String>,
872}
873
874impl SymbolInfo {
875    /// Create a new SymbolInfo
876    pub fn new(name: impl Into<String>, kind: SymbolKind) -> Self {
877        Self {
878            name: name.into(),
879            kind,
880            location: None,
881            type_annotation: None,
882            docstring: None,
883            is_builtin: false,
884            module: None,
885        }
886    }
887}
888
889/// Definition lookup result.
890#[derive(Debug, Clone, Serialize, Deserialize)]
891pub struct DefinitionResult {
892    /// Symbol information
893    pub symbol: SymbolInfo,
894    /// Definition location
895    #[serde(skip_serializing_if = "Option::is_none")]
896    pub definition: Option<Location>,
897    /// Type definition location (for types)
898    #[serde(skip_serializing_if = "Option::is_none")]
899    pub type_definition: Option<Location>,
900}
901
902// =============================================================================
903// Diff Types
904// =============================================================================
905
906/// Type of AST change.
907#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
908#[serde(rename_all = "snake_case")]
909pub enum ChangeType {
910    Insert,
911    Delete,
912    Update,
913    Move,
914    Rename,
915    Extract,
916    Inline,
917    Format,
918}
919
920/// Diff granularity level.
921#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, ValueEnum, Serialize, Deserialize)]
922#[serde(rename_all = "snake_case")]
923pub enum DiffGranularity {
924    /// Token-level diff (L1)
925    Token,
926    /// Expression-level diff (L2)
927    Expression,
928    /// Statement-level diff (L3)
929    Statement,
930    /// Function-level diff (L4) - default
931    #[default]
932    Function,
933    /// Class-level diff (L5)
934    Class,
935    /// File-level diff (L6)
936    File,
937    /// Module-level diff (L7)
938    Module,
939    /// Architecture-level diff (L8)
940    Architecture,
941}
942
943/// Base class changes for class-level diff.
944#[derive(Debug, Clone, Serialize, Deserialize)]
945pub struct BaseChanges {
946    /// Base classes added
947    pub added: Vec<String>,
948    /// Base classes removed
949    pub removed: Vec<String>,
950}
951
952/// Kind of AST node.
953#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
954#[serde(rename_all = "snake_case")]
955pub enum NodeKind {
956    Function,
957    Class,
958    Method,
959    Field,
960    Statement,
961    Expression,
962    Block,
963}
964
965/// A single AST change.
966#[derive(Debug, Clone, Serialize, Deserialize)]
967pub struct ASTChange {
968    /// Type of change
969    pub change_type: ChangeType,
970    /// Kind of node changed
971    pub node_kind: NodeKind,
972    /// Name of the changed element
973    #[serde(skip_serializing_if = "Option::is_none")]
974    pub name: Option<String>,
975    /// Old location
976    #[serde(skip_serializing_if = "Option::is_none")]
977    pub old_location: Option<Location>,
978    /// New location
979    #[serde(skip_serializing_if = "Option::is_none")]
980    pub new_location: Option<Location>,
981    /// Old text (for updates)
982    #[serde(skip_serializing_if = "Option::is_none")]
983    pub old_text: Option<String>,
984    /// New text (for updates)
985    #[serde(skip_serializing_if = "Option::is_none")]
986    pub new_text: Option<String>,
987    /// Similarity score (for moves/renames)
988    #[serde(skip_serializing_if = "Option::is_none")]
989    pub similarity: Option<f64>,
990    /// Nested member changes (for class-level diff)
991    #[serde(skip_serializing_if = "Option::is_none")]
992    pub children: Option<Vec<ASTChange>>,
993    /// Base class changes (for class-level diff)
994    #[serde(skip_serializing_if = "Option::is_none")]
995    pub base_changes: Option<BaseChanges>,
996}
997
998/// Diff summary statistics.
999#[derive(Debug, Clone, Default, Serialize, Deserialize)]
1000pub struct DiffSummary {
1001    /// Total changes
1002    pub total_changes: u32,
1003    /// Semantic changes (excluding format)
1004    pub semantic_changes: u32,
1005    /// Number of inserts
1006    pub inserts: u32,
1007    /// Number of deletes
1008    pub deletes: u32,
1009    /// Number of updates
1010    pub updates: u32,
1011    /// Number of moves
1012    pub moves: u32,
1013    /// Number of renames
1014    pub renames: u32,
1015    /// Number of format-only changes
1016    pub formats: u32,
1017    /// Number of extracts
1018    pub extracts: u32,
1019}
1020
1021// =============================================================================
1022// L6: File-Level Types
1023// =============================================================================
1024
1025/// L6: File-level structural fingerprint change.
1026///
1027/// Represents a single file's structural change between two directory snapshots.
1028/// The fingerprint is a hash of sorted function/class signatures, so two files
1029/// with the same structure but different formatting will produce the same hash.
1030#[derive(Debug, Clone, Serialize, Deserialize)]
1031pub struct FileLevelChange {
1032    /// Relative path of the file within the compared directory
1033    pub relative_path: String,
1034    /// Type of change (Insert=added, Delete=removed, Update=modified)
1035    pub change_type: ChangeType,
1036    /// Structural fingerprint of the file in dir_a (None if added)
1037    #[serde(skip_serializing_if = "Option::is_none")]
1038    pub old_fingerprint: Option<u64>,
1039    /// Structural fingerprint of the file in dir_b (None if removed)
1040    #[serde(skip_serializing_if = "Option::is_none")]
1041    pub new_fingerprint: Option<u64>,
1042    /// Which signatures changed (only for Update)
1043    #[serde(skip_serializing_if = "Option::is_none")]
1044    pub signature_changes: Option<Vec<String>>,
1045}
1046
1047// =============================================================================
1048// L7: Module-Level Types
1049// =============================================================================
1050
1051/// L7: An import edge in the module dependency graph.
1052///
1053/// Represents a single `from X import Y` or `import X` statement,
1054/// capturing the source file, target module, and imported names.
1055#[derive(Debug, Clone, Serialize, Deserialize)]
1056pub struct ImportEdge {
1057    /// Source file that contains the import statement
1058    pub source_file: String,
1059    /// Target module being imported from
1060    pub target_module: String,
1061    /// Specific names imported (empty for `import X`)
1062    pub imported_names: Vec<String>,
1063}
1064
1065/// L7: Module-level change combining import graph and structural diffs.
1066#[derive(Debug, Clone, Serialize, Deserialize)]
1067pub struct ModuleLevelChange {
1068    /// Module path (relative file path)
1069    pub module_path: String,
1070    /// Type of change at the module level
1071    pub change_type: ChangeType,
1072    /// Import edges added in dir_b
1073    pub imports_added: Vec<ImportEdge>,
1074    /// Import edges removed from dir_a
1075    pub imports_removed: Vec<ImportEdge>,
1076    /// L6 file-level change data (if structure also changed)
1077    #[serde(skip_serializing_if = "Option::is_none")]
1078    pub file_change: Option<FileLevelChange>,
1079}
1080
1081/// L7: Summary of import graph differences between two directories.
1082#[derive(Debug, Clone, Serialize, Deserialize)]
1083pub struct ImportGraphSummary {
1084    /// Total import edges in dir_a
1085    pub total_edges_a: usize,
1086    /// Total import edges in dir_b
1087    pub total_edges_b: usize,
1088    /// Number of edges added (present in B but not A)
1089    pub edges_added: usize,
1090    /// Number of edges removed (present in A but not B)
1091    pub edges_removed: usize,
1092    /// Number of modules whose import set changed
1093    pub modules_with_import_changes: usize,
1094}
1095
1096// =============================================================================
1097// L8: Architecture-Level Types
1098// =============================================================================
1099
1100/// L8: Type of architectural change detected.
1101#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1102pub enum ArchChangeType {
1103    /// A directory migrated from one architectural layer to another
1104    LayerMigration,
1105    /// A new directory/layer was added
1106    Added,
1107    /// A directory/layer was removed
1108    Removed,
1109    /// The composition of a directory changed significantly
1110    CompositionChanged,
1111    /// A new dependency cycle was introduced
1112    CycleIntroduced,
1113    /// An existing dependency cycle was resolved
1114    CycleResolved,
1115}
1116
1117/// L8: Architecture-level change for a single directory.
1118#[derive(Debug, Clone, Serialize, Deserialize)]
1119pub struct ArchLevelChange {
1120    /// Directory path (relative)
1121    pub directory: String,
1122    /// Type of architectural change
1123    pub change_type: ArchChangeType,
1124    /// Previous layer classification (if applicable)
1125    #[serde(skip_serializing_if = "Option::is_none")]
1126    pub old_layer: Option<String>,
1127    /// New layer classification (if applicable)
1128    #[serde(skip_serializing_if = "Option::is_none")]
1129    pub new_layer: Option<String>,
1130    /// Functions that migrated between layers
1131    #[serde(default)]
1132    pub migrated_functions: Vec<String>,
1133}
1134
1135/// L8: Summary of architecture-level differences.
1136#[derive(Debug, Clone, Serialize, Deserialize)]
1137pub struct ArchDiffSummary {
1138    /// Number of directories that migrated between layers
1139    pub layer_migrations: usize,
1140    /// Number of new directories added
1141    pub directories_added: usize,
1142    /// Number of directories removed
1143    pub directories_removed: usize,
1144    /// Number of new dependency cycles introduced
1145    pub cycles_introduced: usize,
1146    /// Number of dependency cycles resolved
1147    pub cycles_resolved: usize,
1148    /// Overall stability score (1.0 = no changes, 0.0 = everything changed)
1149    pub stability_score: f64,
1150}
1151
1152// =============================================================================
1153// Diff Report
1154// =============================================================================
1155
1156/// Diff report.
1157#[derive(Debug, Clone, Serialize, Deserialize)]
1158pub struct DiffReport {
1159    /// First file
1160    pub file_a: String,
1161    /// Second file
1162    pub file_b: String,
1163    /// Whether files are identical
1164    pub identical: bool,
1165    /// List of changes
1166    pub changes: Vec<ASTChange>,
1167    /// Summary statistics
1168    #[serde(skip_serializing_if = "Option::is_none")]
1169    pub summary: Option<DiffSummary>,
1170    /// Granularity level of this diff
1171    #[serde(default)]
1172    pub granularity: DiffGranularity,
1173    /// L6: File-level structural changes (directory diff)
1174    #[serde(skip_serializing_if = "Option::is_none")]
1175    pub file_changes: Option<Vec<FileLevelChange>>,
1176    /// L7: Module-level changes with import graph diff
1177    #[serde(skip_serializing_if = "Option::is_none")]
1178    pub module_changes: Option<Vec<ModuleLevelChange>>,
1179    /// L7: Import graph summary
1180    #[serde(skip_serializing_if = "Option::is_none")]
1181    pub import_graph_summary: Option<ImportGraphSummary>,
1182    /// L8: Architecture-level changes
1183    #[serde(skip_serializing_if = "Option::is_none")]
1184    pub arch_changes: Option<Vec<ArchLevelChange>>,
1185    /// L8: Architecture diff summary
1186    #[serde(skip_serializing_if = "Option::is_none")]
1187    pub arch_summary: Option<ArchDiffSummary>,
1188}
1189
1190impl DiffReport {
1191    /// Create a new DiffReport
1192    pub fn new(file_a: impl Into<String>, file_b: impl Into<String>) -> Self {
1193        Self {
1194            file_a: file_a.into(),
1195            file_b: file_b.into(),
1196            identical: true,
1197            changes: Vec::new(),
1198            summary: Some(DiffSummary::default()),
1199            granularity: DiffGranularity::Function,
1200            file_changes: None,
1201            module_changes: None,
1202            import_graph_summary: None,
1203            arch_changes: None,
1204            arch_summary: None,
1205        }
1206    }
1207}
1208
1209// =============================================================================
1210// Diff Impact Types
1211// =============================================================================
1212
1213/// A function affected by changes.
1214#[derive(Debug, Clone, Serialize, Deserialize)]
1215pub struct ChangedFunction {
1216    /// Function name
1217    pub name: String,
1218    /// File path
1219    pub file: String,
1220    /// Line number
1221    pub line: u32,
1222    /// Functions that call this function
1223    #[serde(default)]
1224    pub callers: Vec<CallInfo>,
1225}
1226
1227/// Diff impact summary.
1228#[derive(Debug, Clone, Default, Serialize, Deserialize)]
1229pub struct DiffImpactSummary {
1230    /// Number of files changed
1231    pub files_changed: u32,
1232    /// Number of functions changed
1233    pub functions_changed: u32,
1234    /// Number of tests to run
1235    pub tests_to_run: u32,
1236}
1237
1238/// Diff impact report.
1239#[derive(Debug, Clone, Serialize, Deserialize)]
1240pub struct DiffImpactReport {
1241    /// Changed functions
1242    pub changed_functions: Vec<ChangedFunction>,
1243    /// Suggested tests to run
1244    pub suggested_tests: Vec<String>,
1245    /// Summary
1246    pub summary: DiffImpactSummary,
1247}
1248
1249impl DiffImpactReport {
1250    /// Create a new DiffImpactReport
1251    pub fn new() -> Self {
1252        Self {
1253            changed_functions: Vec::new(),
1254            suggested_tests: Vec::new(),
1255            summary: DiffImpactSummary::default(),
1256        }
1257    }
1258}
1259
1260impl Default for DiffImpactReport {
1261    fn default() -> Self {
1262        Self::new()
1263    }
1264}
1265
1266// =============================================================================
1267// API Check Types
1268// =============================================================================
1269
1270/// Categories of API misuse.
1271#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, ValueEnum)]
1272#[serde(rename_all = "snake_case")]
1273pub enum MisuseCategory {
1274    CallOrder,
1275    ErrorHandling,
1276    Parameters,
1277    Resources,
1278    Crypto,
1279    Concurrency,
1280    Security,
1281}
1282
1283/// Severity of API misuse.
1284#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, ValueEnum)]
1285#[serde(rename_all = "snake_case")]
1286pub enum MisuseSeverity {
1287    Info,
1288    Low,
1289    Medium,
1290    High,
1291}
1292
1293/// An API rule definition.
1294#[derive(Debug, Clone, Serialize, Deserialize)]
1295pub struct APIRule {
1296    /// Rule identifier
1297    pub id: String,
1298    /// Rule name
1299    pub name: String,
1300    /// Category
1301    pub category: MisuseCategory,
1302    /// Severity
1303    pub severity: MisuseSeverity,
1304    /// Description
1305    pub description: String,
1306    /// Example of correct usage
1307    pub correct_usage: String,
1308}
1309
1310/// A detected API misuse.
1311#[derive(Debug, Clone, Serialize, Deserialize)]
1312pub struct MisuseFinding {
1313    /// File path
1314    pub file: String,
1315    /// Line number
1316    pub line: u32,
1317    /// Column number
1318    pub column: u32,
1319    /// Rule that was violated
1320    pub rule: APIRule,
1321    /// The API call that violated the rule
1322    pub api_call: String,
1323    /// Human-readable message
1324    pub message: String,
1325    /// Suggested fix
1326    pub fix_suggestion: String,
1327    /// Code context
1328    #[serde(default)]
1329    pub code_context: String,
1330}
1331
1332/// API check summary.
1333#[derive(Debug, Clone, Default, Serialize, Deserialize)]
1334pub struct APICheckSummary {
1335    /// Total findings
1336    pub total_findings: u32,
1337    /// Findings by category
1338    #[serde(default)]
1339    pub by_category: HashMap<String, u32>,
1340    /// Findings by severity
1341    #[serde(default)]
1342    pub by_severity: HashMap<String, u32>,
1343    /// APIs that were checked
1344    #[serde(default)]
1345    pub apis_checked: Vec<String>,
1346    /// Number of files scanned
1347    pub files_scanned: u32,
1348}
1349
1350/// API check report.
1351#[derive(Debug, Clone, Deserialize)]
1352pub struct APICheckReport {
1353    /// Findings
1354    pub findings: Vec<MisuseFinding>,
1355    /// Summary
1356    pub summary: APICheckSummary,
1357    /// Number of rules applied
1358    pub rules_applied: u32,
1359}
1360
1361// residual-bugs-v1 (P15.AGG15-4): manual Serialize that mirrors
1362// `summary.total_findings` and `summary.files_scanned` to top-level
1363// keys `total_findings` and `files_scanned`. Audit P15 observed
1364// `tldr api-check … | jq '.total_findings'` returning `null` despite
1365// `.summary.total_findings` being correct, breaking the documented
1366// top-level-mirror pattern that peer commands honour.
1367impl Serialize for APICheckReport {
1368    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
1369    where
1370        S: serde::Serializer,
1371    {
1372        use serde::ser::SerializeStruct;
1373        let mut state = serializer.serialize_struct("APICheckReport", 5)?;
1374        state.serialize_field("findings", &self.findings)?;
1375        state.serialize_field("summary", &self.summary)?;
1376        state.serialize_field("rules_applied", &self.rules_applied)?;
1377        // Top-level mirrors (P15.AGG15-4).
1378        state.serialize_field("total_findings", &self.summary.total_findings)?;
1379        state.serialize_field("files_scanned", &self.summary.files_scanned)?;
1380        state.end()
1381    }
1382}
1383
1384impl APICheckReport {
1385    /// Create a new APICheckReport
1386    pub fn new() -> Self {
1387        Self {
1388            findings: Vec::new(),
1389            summary: APICheckSummary::default(),
1390            rules_applied: 0,
1391        }
1392    }
1393}
1394
1395impl Default for APICheckReport {
1396    fn default() -> Self {
1397        Self::new()
1398    }
1399}
1400
1401// =============================================================================
1402// Equivalence (GVN) Types
1403// =============================================================================
1404
1405/// Reference to an expression in source code.
1406#[derive(Debug, Clone, Serialize, Deserialize)]
1407pub struct ExpressionRef {
1408    /// Expression text
1409    pub text: String,
1410    /// Line number
1411    pub line: u32,
1412    /// Value number (GVN)
1413    pub value_number: u32,
1414}
1415
1416/// A group of expressions sharing the same value number.
1417#[derive(Debug, Clone, Serialize, Deserialize)]
1418pub struct GVNEquivalence {
1419    /// Value number for this group
1420    pub value_number: u32,
1421    /// Expressions in this equivalence class
1422    pub expressions: Vec<ExpressionRef>,
1423    /// Reason for equivalence
1424    #[serde(default)]
1425    pub reason: String,
1426}
1427
1428/// A redundant expression pair.
1429#[derive(Debug, Clone, Serialize, Deserialize)]
1430pub struct Redundancy {
1431    /// Original expression
1432    pub original: ExpressionRef,
1433    /// Redundant expression
1434    pub redundant: ExpressionRef,
1435    /// Reason why it's redundant
1436    #[serde(default)]
1437    pub reason: String,
1438}
1439
1440/// GVN summary statistics.
1441#[derive(Debug, Clone, Serialize, Deserialize)]
1442pub struct GVNSummary {
1443    /// Total expressions analyzed
1444    pub total_expressions: u32,
1445    /// Unique values (value numbers)
1446    pub unique_values: u32,
1447    /// Compression ratio (unique/total)
1448    pub compression_ratio: f64,
1449}
1450
1451impl Default for GVNSummary {
1452    fn default() -> Self {
1453        Self {
1454            total_expressions: 0,
1455            unique_values: 0,
1456            compression_ratio: 1.0,
1457        }
1458    }
1459}
1460
1461/// GVN report for a function.
1462#[derive(Debug, Clone, Serialize, Deserialize)]
1463pub struct GVNReport {
1464    /// Function name
1465    pub function: String,
1466    /// Equivalence classes
1467    #[serde(default)]
1468    pub equivalences: Vec<GVNEquivalence>,
1469    /// Redundant expressions
1470    #[serde(default)]
1471    pub redundancies: Vec<Redundancy>,
1472    /// Summary statistics
1473    pub summary: GVNSummary,
1474}
1475
1476impl GVNReport {
1477    /// Create a new GVNReport
1478    pub fn new(function: impl Into<String>) -> Self {
1479        Self {
1480            function: function.into(),
1481            equivalences: Vec::new(),
1482            redundancies: Vec::new(),
1483            summary: GVNSummary::default(),
1484        }
1485    }
1486}
1487
1488// =============================================================================
1489// Vuln Types
1490// =============================================================================
1491
1492/// Types of vulnerabilities detected.
1493///
1494/// `Ord`/`PartialOrd` are derived (analysis-precision-v1, BUG-10) so that
1495/// `VulnFinding` lists can be sorted by `(file, line, vuln_type)` ascending
1496/// — producing the same enumeration order across JSON, text, and SARIF
1497/// output formats. Variant declaration order defines the `Ord` ranking;
1498/// callers SHOULD treat the relative ordering as opaque (only stability
1499/// matters).
1500#[derive(
1501    Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize, ValueEnum,
1502)]
1503#[serde(rename_all = "snake_case")]
1504#[value(rename_all = "snake_case")]
1505pub enum VulnType {
1506    SqlInjection,
1507    Xss,
1508    CommandInjection,
1509    Ssrf,
1510    PathTraversal,
1511    Deserialization,
1512    UnsafeCode,
1513    MemorySafety,
1514    Panic,
1515    Xxe,
1516    OpenRedirect,
1517    LdapInjection,
1518    XpathInjection,
1519}
1520
1521impl VulnType {
1522    /// Get the CWE identifier for this vulnerability type.
1523    pub fn cwe_id(&self) -> &'static str {
1524        match self {
1525            Self::SqlInjection => "CWE-89",
1526            Self::Xss => "CWE-79",
1527            Self::CommandInjection => "CWE-78",
1528            Self::Ssrf => "CWE-918",
1529            Self::PathTraversal => "CWE-22",
1530            Self::Deserialization => "CWE-502",
1531            Self::UnsafeCode => "CWE-242",
1532            Self::MemorySafety => "CWE-119",
1533            Self::Panic => "CWE-703",
1534            Self::Xxe => "CWE-611",
1535            Self::OpenRedirect => "CWE-601",
1536            Self::LdapInjection => "CWE-90",
1537            Self::XpathInjection => "CWE-643",
1538        }
1539    }
1540
1541    /// Get the default severity for this vulnerability type.
1542    pub fn default_severity(&self) -> Severity {
1543        match self {
1544            Self::SqlInjection
1545            | Self::CommandInjection
1546            | Self::Deserialization
1547            | Self::MemorySafety => Severity::Critical,
1548            Self::Xxe
1549            | Self::Xss
1550            | Self::Ssrf
1551            | Self::PathTraversal
1552            | Self::LdapInjection
1553            | Self::XpathInjection
1554            | Self::UnsafeCode => Severity::High,
1555            Self::OpenRedirect | Self::Panic => Severity::Medium,
1556        }
1557    }
1558}
1559
1560/// A step in the taint flow.
1561#[derive(Debug, Clone, Serialize, Deserialize)]
1562pub struct TaintFlow {
1563    /// File path
1564    pub file: String,
1565    /// Line number
1566    pub line: u32,
1567    /// Column number
1568    pub column: u32,
1569    /// Code snippet
1570    pub code_snippet: String,
1571    /// Description of this step
1572    pub description: String,
1573}
1574
1575/// A vulnerability finding.
1576#[derive(Debug, Clone, Serialize, Deserialize)]
1577pub struct VulnFinding {
1578    /// Vulnerability type
1579    pub vuln_type: VulnType,
1580    /// Severity
1581    pub severity: Severity,
1582    /// CWE identifier
1583    pub cwe_id: String,
1584    /// Title
1585    pub title: String,
1586    /// Description
1587    pub description: String,
1588    /// File path
1589    pub file: String,
1590    /// Line number
1591    pub line: u32,
1592    /// Column number
1593    pub column: u32,
1594    /// Enclosing function name (None if the finding's line is at module
1595    /// scope and not inside any function/method body).
1596    ///
1597    /// schema-cleanup-v2 (P2.BUG-9): pre-fix, vuln findings carried no
1598    /// indication of which function contained the source line, blocking
1599    /// clean piping into `tldr taint <file> <function>` and `tldr slice
1600    /// <file> <function> <line>`. The enclosing function is resolved
1601    /// post-analysis via `extract_file` over `(file, line)` —
1602    /// reusing the same module-info AST extractor that `taint`/`slice`
1603    /// rely on, rather than rebuilding language-aware function-range
1604    /// scanning inside the vuln pipeline.
1605    #[serde(default, skip_serializing_if = "Option::is_none")]
1606    pub function: Option<String>,
1607    /// Taint flow from source to sink. When `direct_sink` is `true` this is
1608    /// a single-element vec describing the call site (the source-and-sink
1609    /// collapse to the same statement).
1610    pub taint_flow: Vec<TaintFlow>,
1611    /// Remediation advice
1612    pub remediation: String,
1613    /// Confidence score (0.0-1.0)
1614    pub confidence: f64,
1615    /// True when the source and sink resolve to the same statement (e.g.
1616    /// `let file = File::open(path)?` — `path` is tainted untrusted data
1617    /// and is consumed by the FileOpen sink in the same expression). The
1618    /// `taint_flow` is collapsed to a single entry to avoid emitting two
1619    /// identical source/sink records. Omitted from JSON when false.
1620    /// (M3 detection-accuracy-v1 BUG-17.)
1621    #[serde(default, skip_serializing_if = "is_false")]
1622    pub direct_sink: bool,
1623}
1624
1625/// Helper for `skip_serializing_if` on `direct_sink` — keeps the JSON shape
1626/// stable for non-degenerate findings (no spurious `direct_sink: false`).
1627fn is_false(b: &bool) -> bool {
1628    !*b
1629}
1630
1631/// Vulnerability summary statistics.
1632#[derive(Debug, Clone, Default, Serialize, Deserialize)]
1633pub struct VulnSummary {
1634    /// Total findings
1635    pub total_findings: u32,
1636    /// Findings by severity
1637    #[serde(default)]
1638    pub by_severity: HashMap<String, u32>,
1639    /// Findings by type
1640    #[serde(default)]
1641    pub by_type: HashMap<String, u32>,
1642    /// Files with vulnerabilities
1643    pub files_with_vulns: u32,
1644}
1645
1646/// Vulnerability report.
1647#[derive(Debug, Clone, Serialize, Deserialize)]
1648pub struct VulnReport {
1649    /// Findings
1650    pub findings: Vec<VulnFinding>,
1651    /// Summary (optional for incremental results)
1652    #[serde(skip_serializing_if = "Option::is_none")]
1653    pub summary: Option<VulnSummary>,
1654    /// Scan duration in milliseconds
1655    pub scan_duration_ms: u64,
1656    /// Number of files scanned
1657    pub files_scanned: u32,
1658    /// Number of files skipped (e.g. non-UTF-8 input).
1659    ///
1660    /// SECURE-UTF8-TOLERANCE-V1: parser-test corpora intentionally
1661    /// contain raw non-UTF-8 bytes. Pre-fix the vuln scanner silently
1662    /// dropped those files via an `if let Ok(..)` guard around
1663    /// `analyze_file`, so the user had no way to tell coverage was
1664    /// degraded. Post-fix the count is surfaced explicitly. Skipped from
1665    /// JSON when zero so UTF-8-clean inputs see no schema delta.
1666    #[serde(default, skip_serializing_if = "is_zero_u32")]
1667    pub files_skipped: u32,
1668    /// Per-file warnings (e.g. non-UTF-8 skip messages with byte offsets).
1669    ///
1670    /// SECURE-UTF8-TOLERANCE-V1: companion to `files_skipped`. Skipped
1671    /// from JSON when empty.
1672    #[serde(default, skip_serializing_if = "Vec::is_empty")]
1673    pub warnings: Vec<String>,
1674}
1675
1676impl VulnReport {
1677    /// Create a new VulnReport
1678    pub fn new() -> Self {
1679        Self {
1680            findings: Vec::new(),
1681            summary: None,
1682            scan_duration_ms: 0,
1683            files_scanned: 0,
1684            files_skipped: 0,
1685            warnings: Vec::new(),
1686        }
1687    }
1688}
1689
1690impl Default for VulnReport {
1691    fn default() -> Self {
1692        Self::new()
1693    }
1694}
1695
1696// =============================================================================
1697// Unit Tests for Types
1698// =============================================================================
1699
1700#[cfg(test)]
1701mod unit_types_tests {
1702    use super::*;
1703
1704    // =========================================================================
1705    // Output Format Tests
1706    // =========================================================================
1707
1708    #[test]
1709    fn test_output_format_serialization() {
1710        let json = serde_json::to_string(&OutputFormat::Json).unwrap();
1711        assert_eq!(json, r#""json""#);
1712
1713        let text = serde_json::to_string(&OutputFormat::Text).unwrap();
1714        assert_eq!(text, r#""text""#);
1715
1716        let sarif = serde_json::to_string(&OutputFormat::Sarif).unwrap();
1717        assert_eq!(sarif, r#""sarif""#);
1718    }
1719
1720    #[test]
1721    fn test_output_format_deserialization() {
1722        let json: OutputFormat = serde_json::from_str(r#""json""#).unwrap();
1723        assert_eq!(json, OutputFormat::Json);
1724
1725        let text: OutputFormat = serde_json::from_str(r#""text""#).unwrap();
1726        assert_eq!(text, OutputFormat::Text);
1727    }
1728
1729    // =========================================================================
1730    // Severity Tests
1731    // =========================================================================
1732
1733    #[test]
1734    fn test_severity_ordering() {
1735        assert!(Severity::Critical.order() < Severity::High.order());
1736        assert!(Severity::High.order() < Severity::Medium.order());
1737        assert!(Severity::Medium.order() < Severity::Low.order());
1738        assert!(Severity::Low.order() < Severity::Info.order());
1739    }
1740
1741    #[test]
1742    fn test_severity_serialization() {
1743        let critical = serde_json::to_string(&Severity::Critical).unwrap();
1744        assert_eq!(critical, r#""critical""#);
1745
1746        let info = serde_json::to_string(&Severity::Info).unwrap();
1747        assert_eq!(info, r#""info""#);
1748    }
1749
1750    // =========================================================================
1751    // Location Tests
1752    // =========================================================================
1753
1754    #[test]
1755    fn test_location_serialization() {
1756        let loc = Location::new("test.py", 42);
1757        let json = serde_json::to_string(&loc).unwrap();
1758        assert!(json.contains(r#""file":"test.py""#));
1759        assert!(json.contains(r#""line":42"#));
1760    }
1761
1762    #[test]
1763    fn test_location_with_column() {
1764        let loc = Location::with_column("test.py", 42, 10);
1765        assert_eq!(loc.column, 10);
1766    }
1767
1768    // =========================================================================
1769    // Todo Types Tests
1770    // =========================================================================
1771
1772    #[test]
1773    fn test_todo_report_serialization() {
1774        let mut report = TodoReport::new("/path/to/file.py");
1775        report
1776            .items
1777            .push(TodoItem::new("dead_code", 1, "Unused function"));
1778        report.summary.dead_count = 1;
1779
1780        let json = serde_json::to_string(&report).unwrap();
1781        assert!(json.contains(r#""wrapper":"todo""#));
1782        assert!(json.contains(r#""dead_count":1"#));
1783    }
1784
1785    #[test]
1786    fn test_todo_item_builder() {
1787        let item = TodoItem::new("complexity", 2, "High cyclomatic complexity")
1788            .with_location("src/main.py", 100)
1789            .with_severity("high")
1790            .with_score(0.85);
1791
1792        assert_eq!(item.category, "complexity");
1793        assert_eq!(item.file, "src/main.py");
1794        assert_eq!(item.line, 100);
1795        assert_eq!(item.severity, "high");
1796        assert!((item.score - 0.85).abs() < 0.001);
1797    }
1798
1799    // =========================================================================
1800    // Explain Types Tests
1801    // =========================================================================
1802
1803    #[test]
1804    fn test_explain_report_serialization() {
1805        let mut report = ExplainReport::new("calculate_total", "/path/file.py", 10, 20, "python");
1806        report.purity = PurityInfo::pure();
1807
1808        let json = serde_json::to_string(&report).unwrap();
1809        // cross-command-consistency-v1 (BUG-14): canonical key is `function`.
1810        assert!(json.contains(r#""function":"calculate_total""#));
1811        assert!(json.contains(r#""classification":"pure""#));
1812    }
1813
1814    #[test]
1815    fn test_signature_info_builder() {
1816        let sig = SignatureInfo::new()
1817            .with_param(ParamInfo::new("x").with_type("int"))
1818            .with_return_type("int")
1819            .with_docstring("Doubles the input");
1820
1821        assert_eq!(sig.params.len(), 1);
1822        assert_eq!(sig.params[0].name, "x");
1823        assert_eq!(sig.return_type.unwrap(), "int");
1824    }
1825
1826    // =========================================================================
1827    // Secure Types Tests
1828    // =========================================================================
1829
1830    #[test]
1831    fn test_secure_report_serialization() {
1832        let mut report = SecureReport::new("/path/to/file.py");
1833        report
1834            .findings
1835            .push(SecureFinding::new("taint", "critical", "SQL injection"));
1836
1837        let json = serde_json::to_string(&report).unwrap();
1838        assert!(json.contains(r#""wrapper":"secure""#));
1839    }
1840
1841    // =========================================================================
1842    // Definition Types Tests
1843    // =========================================================================
1844
1845    #[test]
1846    fn test_definition_result_serialization() {
1847        let result = DefinitionResult {
1848            symbol: SymbolInfo::new("my_func", SymbolKind::Function),
1849            definition: Some(Location::new("file.py", 10)),
1850            type_definition: None,
1851        };
1852
1853        let json = serde_json::to_string(&result).unwrap();
1854        assert!(json.contains(r#""name":"my_func""#));
1855        assert!(json.contains(r#""kind":"function""#));
1856    }
1857
1858    #[test]
1859    fn test_symbol_kind_serialization() {
1860        let kind = SymbolKind::Function;
1861        let json = serde_json::to_string(&kind).unwrap();
1862        assert_eq!(json, r#""function""#);
1863    }
1864
1865    // =========================================================================
1866    // Diff Types Tests
1867    // =========================================================================
1868
1869    #[test]
1870    fn test_diff_report_serialization() {
1871        let mut report = DiffReport::new("a.py", "b.py");
1872        report.identical = false;
1873        if let Some(ref mut summary) = report.summary {
1874            summary.inserts = 1;
1875        }
1876
1877        let json = serde_json::to_string(&report).unwrap();
1878        assert!(json.contains(r#""file_a":"a.py""#));
1879        assert!(json.contains(r#""identical":false"#));
1880    }
1881
1882    #[test]
1883    fn test_change_type_serialization() {
1884        let insert = serde_json::to_string(&ChangeType::Insert).unwrap();
1885        assert_eq!(insert, r#""insert""#);
1886
1887        let rename = serde_json::to_string(&ChangeType::Rename).unwrap();
1888        assert_eq!(rename, r#""rename""#);
1889    }
1890
1891    // =========================================================================
1892    // API Check Types Tests
1893    // =========================================================================
1894
1895    #[test]
1896    fn test_api_check_report_serialization() {
1897        let mut report = APICheckReport::new();
1898        report.rules_applied = 5;
1899        report.summary.total_findings = 2;
1900
1901        let json = serde_json::to_string(&report).unwrap();
1902        assert!(json.contains(r#""rules_applied":5"#));
1903        assert!(json.contains(r#""total_findings":2"#));
1904    }
1905
1906    // =========================================================================
1907    // GVN Types Tests
1908    // =========================================================================
1909
1910    #[test]
1911    fn test_gvn_report_serialization() {
1912        let mut report = GVNReport::new("test_func");
1913        report.summary.total_expressions = 10;
1914        report.summary.unique_values = 7;
1915        report.summary.compression_ratio = 0.7;
1916
1917        let json = serde_json::to_string(&report).unwrap();
1918        assert!(json.contains(r#""function":"test_func""#));
1919        assert!(json.contains(r#""compression_ratio":0.7"#));
1920    }
1921
1922    // =========================================================================
1923    // Vuln Types Tests
1924    // =========================================================================
1925
1926    #[test]
1927    fn test_vuln_report_serialization() {
1928        let mut report = VulnReport::new();
1929        report.files_scanned = 5;
1930        report.scan_duration_ms = 100;
1931
1932        let json = serde_json::to_string(&report).unwrap();
1933        assert!(json.contains(r#""files_scanned":5"#));
1934        assert!(json.contains(r#""scan_duration_ms":100"#));
1935    }
1936
1937    #[test]
1938    fn test_vuln_type_cwe_mapping() {
1939        assert_eq!(VulnType::SqlInjection.cwe_id(), "CWE-89");
1940        assert_eq!(VulnType::Xss.cwe_id(), "CWE-79");
1941        assert_eq!(VulnType::CommandInjection.cwe_id(), "CWE-78");
1942        assert_eq!(VulnType::Ssrf.cwe_id(), "CWE-918");
1943        assert_eq!(VulnType::PathTraversal.cwe_id(), "CWE-22");
1944        assert_eq!(VulnType::Deserialization.cwe_id(), "CWE-502");
1945        assert_eq!(VulnType::UnsafeCode.cwe_id(), "CWE-242");
1946        assert_eq!(VulnType::MemorySafety.cwe_id(), "CWE-119");
1947        assert_eq!(VulnType::Panic.cwe_id(), "CWE-703");
1948        assert_eq!(VulnType::Xxe.cwe_id(), "CWE-611");
1949        assert_eq!(VulnType::OpenRedirect.cwe_id(), "CWE-601");
1950        assert_eq!(VulnType::LdapInjection.cwe_id(), "CWE-90");
1951        assert_eq!(VulnType::XpathInjection.cwe_id(), "CWE-643");
1952    }
1953
1954    #[test]
1955    fn test_vuln_type_default_severity() {
1956        assert_eq!(
1957            VulnType::SqlInjection.default_severity(),
1958            Severity::Critical
1959        );
1960        assert_eq!(
1961            VulnType::CommandInjection.default_severity(),
1962            Severity::Critical
1963        );
1964        assert_eq!(
1965            VulnType::MemorySafety.default_severity(),
1966            Severity::Critical
1967        );
1968        assert_eq!(VulnType::Xss.default_severity(), Severity::High);
1969        assert_eq!(VulnType::UnsafeCode.default_severity(), Severity::High);
1970        assert_eq!(VulnType::OpenRedirect.default_severity(), Severity::Medium);
1971        assert_eq!(VulnType::Panic.default_severity(), Severity::Medium);
1972    }
1973
1974    #[test]
1975    fn test_vuln_type_serialization() {
1976        let sql_inj = serde_json::to_string(&VulnType::SqlInjection).unwrap();
1977        assert_eq!(sql_inj, r#""sql_injection""#);
1978
1979        let xss = serde_json::to_string(&VulnType::Xss).unwrap();
1980        assert_eq!(xss, r#""xss""#);
1981    }
1982}