Skip to main content

the_code_graph_domain/
model.rs

1use serde::{Deserialize, Deserializer, Serialize, Serializer};
2use std::borrow::Borrow;
3use std::collections::HashMap;
4use std::fmt;
5use std::path::PathBuf;
6
7// ---------------------------------------------------------------------------
8// Enums
9// ---------------------------------------------------------------------------
10
11#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
12pub enum Language {
13    TypeScript,
14    JavaScript,
15    Rust,
16    Python,
17    Go,
18}
19
20impl Language {
21    /// Derive the language from a file path based on extension.
22    /// Falls back to `Rust` for unknown extensions.
23    pub fn from_path(path: &std::path::Path) -> Language {
24        match path.extension().and_then(|e| e.to_str()) {
25            Some("ts") | Some("tsx") => Language::TypeScript,
26            Some("js") | Some("jsx") | Some("mjs") | Some("cjs") => Language::JavaScript,
27            Some("rs") => Language::Rust,
28            Some("py") => Language::Python,
29            Some("go") => Language::Go,
30            _ => Language::Rust,
31        }
32    }
33}
34
35#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
36pub enum NodeKind {
37    File,
38    Symbol,
39    NonParsed,
40}
41
42#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
43pub enum SymbolKind {
44    Function,
45    Class,
46    Interface,
47    Struct,
48    Trait,
49    Enum,
50    TypeAlias,
51    Method,
52    Property,
53    Const,
54    Macro,
55    Variable,
56    Component,
57    Test,
58}
59
60#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
61pub enum NonParsedKind {
62    Doc,
63    Config,
64    CI,
65    Asset,
66    Other,
67}
68
69#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
70pub enum Visibility {
71    Public,
72    Private,
73    Crate,
74}
75
76/// Variant declaration order is load-bearing for PartialOrd/Ord.
77/// Structural < Low < Medium < High
78#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)]
79pub enum Confidence {
80    Structural,
81    Low,
82    Medium,
83    High,
84}
85
86#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
87pub enum EdgeKind {
88    Contains,
89    ChildOf,
90    Calls,
91    ImportsFrom,
92    Extends,
93    Implements,
94    TestedBy,
95    DependsOn,
96    BarrelReExportAll,
97    ConditionalImport,
98    SideEffectImport,
99    DotImport,
100    HasDecorator,
101    Embeds,
102    TypeReference,
103    ReExport,
104}
105
106impl EdgeKind {
107    pub fn confidence(&self) -> Confidence {
108        match self {
109            EdgeKind::Calls | EdgeKind::Extends | EdgeKind::Implements | EdgeKind::Embeds => {
110                Confidence::High
111            }
112            EdgeKind::ImportsFrom
113            | EdgeKind::BarrelReExportAll
114            | EdgeKind::ReExport
115            | EdgeKind::TypeReference
116            | EdgeKind::DotImport => Confidence::Medium,
117            EdgeKind::DependsOn | EdgeKind::ConditionalImport | EdgeKind::SideEffectImport => {
118                Confidence::Low
119            }
120            EdgeKind::Contains
121            | EdgeKind::ChildOf
122            | EdgeKind::HasDecorator
123            | EdgeKind::TestedBy => Confidence::Structural,
124        }
125    }
126}
127
128#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
129pub enum Direction {
130    Forward,
131    Backward,
132}
133
134// ---------------------------------------------------------------------------
135// Core structs
136// ---------------------------------------------------------------------------
137
138#[derive(Debug, Clone, Serialize, Deserialize)]
139pub struct Location {
140    pub file: PathBuf,
141    pub line_start: usize,
142    pub line_end: usize,
143    pub col_start: usize,
144    pub col_end: usize,
145}
146
147#[derive(Debug, Clone, Serialize, Deserialize)]
148pub struct FileNode {
149    pub path: PathBuf,
150    pub language: Language,
151    pub hash: String,
152}
153
154#[derive(Debug, Clone, Serialize, Deserialize)]
155pub struct SymbolNode {
156    pub name: String,
157    pub qualified_name: String,
158    pub kind: SymbolKind,
159    pub location: Location,
160    pub visibility: Visibility,
161    pub is_exported: bool,
162    pub is_async: bool,
163    pub is_test: bool,
164    pub decorators: Vec<String>,
165    pub signature: Option<String>,
166}
167
168#[derive(Debug, Clone, Serialize, Deserialize)]
169pub struct NonParsedNode {
170    pub path: PathBuf,
171    pub file_kind: NonParsedKind,
172    pub hash: String,
173}
174
175#[derive(Debug, Clone, Serialize, Deserialize)]
176pub enum Node {
177    File(FileNode),
178    Symbol(SymbolNode),
179    NonParsed(NonParsedNode),
180}
181
182impl Node {
183    pub fn id(&self) -> &str {
184        match self {
185            Node::File(f) => f.path.to_str().unwrap_or_default(),
186            Node::Symbol(s) => &s.qualified_name,
187            Node::NonParsed(n) => n.path.to_str().unwrap_or_default(),
188        }
189    }
190}
191
192#[derive(Debug, Clone, Serialize, Deserialize)]
193pub struct Edge {
194    pub kind: EdgeKind,
195    pub source: String,
196    pub target: String,
197    pub metadata: Option<String>,
198}
199
200// ---------------------------------------------------------------------------
201// Supporting types
202// ---------------------------------------------------------------------------
203
204#[derive(Debug, Clone, Serialize, Deserialize)]
205pub enum ImpactTarget {
206    File(PathBuf),
207    Symbol(String),
208}
209
210#[derive(Debug, Clone, Serialize, Deserialize)]
211pub struct TraversalResult {
212    pub node: String,
213    pub depth: usize,
214    pub path: Vec<String>,
215    pub edge_kind: EdgeKind,
216}
217
218#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
219#[serde(rename_all = "snake_case")]
220pub enum ScoreSource {
221    Hybrid,
222    Fts5,
223    Semantic,
224}
225
226#[derive(Debug, Clone, Serialize, Deserialize)]
227pub struct SearchResult {
228    pub qualified_name: String,
229    pub name: String,
230    pub kind: SymbolKind,
231    pub file_path: PathBuf,
232    pub score: f64,
233    #[serde(skip_serializing_if = "Option::is_none")]
234    pub score_source: Option<ScoreSource>,
235}
236
237#[derive(Debug, Clone)]
238pub struct EmbeddingEntry {
239    pub qualified_name: String,
240    pub vector: Vec<f32>,
241    pub text_hash: String,
242}
243
244#[derive(Debug, Clone)]
245pub struct EmbeddingConfig {
246    pub model: String,
247    pub batch_size: usize,
248}
249
250impl Default for EmbeddingConfig {
251    fn default() -> Self {
252        Self {
253            model: "all-MiniLM-L6-v2".into(),
254            batch_size: 64,
255        }
256    }
257}
258
259#[derive(Debug, Clone)]
260pub struct HybridSearchConfig {
261    pub rrf_k: usize,
262    pub kind_boost: bool,
263}
264
265impl Default for HybridSearchConfig {
266    fn default() -> Self {
267        Self {
268            rrf_k: 60,
269            kind_boost: true,
270        }
271    }
272}
273
274#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
275#[serde(rename_all = "snake_case")]
276pub enum SearchMode {
277    Hybrid,
278    FtsOnly,
279    SemanticOnly,
280}
281
282pub struct EmbedStats {
283    pub total_symbols: usize,
284    pub embedded: usize,
285    pub skipped: usize,
286    pub removed: usize,
287}
288
289#[derive(Debug, Clone, Serialize, Deserialize)]
290pub struct Reference {
291    pub symbol: String,
292    pub edge_kind: EdgeKind,
293    pub location: Option<Location>,
294}
295
296mod duration_millis {
297    use serde::{Deserialize, Deserializer, Serializer};
298    use std::time::Duration;
299
300    pub fn serialize<S: Serializer>(d: &Duration, s: S) -> Result<S::Ok, S::Error> {
301        s.serialize_u64(d.as_millis() as u64)
302    }
303
304    pub fn deserialize<'de, D: Deserializer<'de>>(d: D) -> Result<Duration, D::Error> {
305        let millis = u64::deserialize(d)?;
306        Ok(Duration::from_millis(millis))
307    }
308}
309
310#[derive(Debug, Clone, Serialize, Deserialize)]
311pub struct IndexStats {
312    pub files_indexed: usize,
313    pub symbols_extracted: usize,
314    pub edges_created: usize,
315    #[serde(with = "duration_millis")]
316    pub duration: std::time::Duration,
317}
318
319#[derive(Debug, Clone, Serialize, Deserialize)]
320pub struct GraphStats {
321    pub files: usize,
322    pub symbols: usize,
323    pub edges: usize,
324    #[serde(skip_serializing_if = "Option::is_none")]
325    pub entry_point_count: Option<usize>,
326    #[serde(skip_serializing_if = "Option::is_none")]
327    pub avg_criticality: Option<f64>,
328    #[serde(skip_serializing_if = "Option::is_none")]
329    pub clone_clusters: Option<usize>,
330    #[serde(skip_serializing_if = "Option::is_none")]
331    pub duplication_pct: Option<f64>,
332    #[serde(skip_serializing_if = "Option::is_none")]
333    pub most_duplicated: Option<String>,
334    #[serde(skip_serializing_if = "Option::is_none")]
335    pub avg_risk: Option<f64>,
336    #[serde(skip_serializing_if = "Option::is_none")]
337    pub p90_risk: Option<f64>,
338    #[serde(skip_serializing_if = "Option::is_none")]
339    pub community_count: Option<usize>,
340    #[serde(skip_serializing_if = "Option::is_none")]
341    pub modularity: Option<f64>,
342}
343
344#[derive(Debug, Clone, Serialize, Deserialize)]
345pub struct DiffHunk {
346    pub file: PathBuf,
347    pub old_start: usize,
348    pub old_count: usize,
349    pub new_start: usize,
350    pub new_count: usize,
351}
352
353#[derive(Debug, Clone, Serialize, Deserialize)]
354pub struct AffectedNode {
355    pub qualified_name: String,
356    pub depth: usize,
357    pub confidence: Confidence,
358    pub path: Vec<String>,
359}
360
361#[derive(Debug, Clone, Serialize, Deserialize)]
362pub struct ImpactReport {
363    pub targets: Vec<ImpactTarget>,
364    pub affected: Vec<AffectedNode>,
365    pub depth: usize,
366    pub min_confidence: Confidence,
367}
368
369#[derive(Debug, Clone, Serialize, Deserialize)]
370pub struct DiffImpactReport {
371    pub changed_symbols: Vec<SymbolNode>,
372    pub impact: ImpactReport,
373}
374
375// ---------------------------------------------------------------------------
376// Flow analysis types
377// ---------------------------------------------------------------------------
378
379#[derive(Debug, Clone, Serialize, Deserialize)]
380pub struct EntryPoint {
381    pub qualified_name: String,
382    pub kind: EntryPointKind,
383    pub confidence: f64,
384}
385
386#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
387pub enum EntryPointKind {
388    Main,
389    Test,
390    HttpHandler,
391    CliCommand,
392    PublicRoot,
393}
394
395#[derive(Debug, Clone, Serialize, Deserialize)]
396pub struct ExecutionFlow {
397    pub entry: String,
398    pub path: Vec<String>,
399    pub depth: usize,
400    pub truncated: bool,
401}
402
403#[derive(Debug, Clone, Serialize, Deserialize)]
404pub struct CriticalityScore {
405    pub qualified_name: String,
406    pub betweenness: f64,
407    pub flow_count: usize,
408    pub is_entry_point: bool,
409}
410
411#[derive(Debug, Clone, Serialize, Deserialize)]
412pub struct FlowAnalysis {
413    pub entry_points: Vec<EntryPoint>,
414    pub flows: Vec<ExecutionFlow>,
415    pub criticality: Vec<CriticalityScore>,
416    pub stats: FlowStats,
417}
418
419#[derive(Debug, Clone, Serialize, Deserialize)]
420pub struct FlowStats {
421    pub total_entry_points: usize,
422    pub total_flows: usize,
423    pub max_depth: usize,
424    pub avg_depth: f64,
425}
426
427#[derive(Debug, Clone)]
428pub struct FlowConfig {
429    pub max_depth: usize,
430    pub max_flows: usize,
431    pub visit_budget: usize,
432    pub max_public_roots: usize,
433    pub extra_entry_points: Vec<String>,
434    pub excluded_entry_points: Vec<String>,
435}
436
437impl Default for FlowConfig {
438    fn default() -> Self {
439        Self {
440            max_depth: 20,
441            max_flows: 1000,
442            visit_budget: 100_000,
443            max_public_roots: 50,
444            extra_entry_points: Vec::new(),
445            excluded_entry_points: Vec::new(),
446        }
447    }
448}
449
450// ---------------------------------------------------------------------------
451// Clone detection types
452// ---------------------------------------------------------------------------
453
454#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
455pub enum CloneType {
456    Type1,
457    Type2,
458    StructuralOnly,
459}
460
461#[derive(Debug, Clone)]
462pub struct StructuralFingerprint {
463    pub qualified_name: String,
464    pub symbol_kind: SymbolKind,
465    pub callee_count: usize,
466    pub caller_count: usize,
467    pub edge_kind_set: u32,
468    pub body_line_count: usize,
469    pub child_count: usize,
470    pub language: Language,
471    pub file: PathBuf,
472    pub line_start: usize,
473    pub line_end: usize,
474}
475
476#[derive(Debug, Clone, PartialEq, Eq, Hash)]
477pub struct BucketKey {
478    pub kind: SymbolKind,
479    pub callee_bin: u8,
480    pub caller_bin: u8,
481    pub line_bin: u8,
482    pub child_bin: u8,
483}
484
485#[derive(Debug, Clone, Serialize, Deserialize)]
486pub struct CloneMatch {
487    pub source: String,
488    pub target: String,
489    pub similarity: f64,
490    pub clone_type: CloneType,
491}
492
493#[derive(Debug, Clone, Serialize, Deserialize)]
494pub struct CloneCluster {
495    pub id: usize,
496    pub members: Vec<String>,
497    pub avg_similarity: f64,
498    pub clone_type: CloneType,
499    pub representative: String,
500    #[serde(skip_serializing_if = "Vec::is_empty")]
501    pub intra_matches: Vec<CloneMatch>,
502}
503
504#[derive(Debug, Clone, Serialize, Deserialize)]
505pub struct CloneAnalysis {
506    pub clusters: Vec<CloneCluster>,
507    pub total_symbols_analyzed: usize,
508    pub symbols_in_clones: usize,
509    pub duplication_pct: f64,
510    pub most_duplicated: Option<String>,
511}
512
513#[derive(Debug, Clone)]
514pub struct CloneConfig {
515    pub threshold: f64,
516    pub min_lines: usize,
517    pub max_candidates_per_bucket: usize,
518}
519
520impl Default for CloneConfig {
521    fn default() -> Self {
522        Self {
523            threshold: 0.7,
524            min_lines: 5,
525            max_candidates_per_bucket: 500,
526        }
527    }
528}
529
530// ---------------------------------------------------------------------------
531// Risk scoring types
532// ---------------------------------------------------------------------------
533
534#[derive(Debug, Clone)]
535pub struct RiskWeights {
536    pub criticality: f64,
537    pub coupling: f64,
538    pub test_gap: f64,
539    pub sensitivity: f64,
540}
541
542impl Default for RiskWeights {
543    fn default() -> Self {
544        Self {
545            criticality: 0.30,
546            coupling: 0.25,
547            test_gap: 0.25,
548            sensitivity: 0.20,
549        }
550    }
551}
552
553impl RiskWeights {
554    /// Normalize weights so they sum to 1.0, preserving relative proportions.
555    pub fn normalized(&self) -> Self {
556        let sum = self.criticality + self.coupling + self.test_gap + self.sensitivity;
557        if sum == 0.0 {
558            return Self::default();
559        }
560        Self {
561            criticality: self.criticality / sum,
562            coupling: self.coupling / sum,
563            test_gap: self.test_gap / sum,
564            sensitivity: self.sensitivity / sum,
565        }
566    }
567}
568
569#[derive(Debug, Clone)]
570pub struct RiskConfig {
571    pub weights: RiskWeights,
572    pub security_patterns: Vec<String>,
573    pub min_score: f64,
574}
575
576impl Default for RiskConfig {
577    fn default() -> Self {
578        Self {
579            weights: RiskWeights::default(),
580            security_patterns: vec![
581                "auth".into(),
582                "password".into(),
583                "secret".into(),
584                "token".into(),
585                "crypto".into(),
586                "credential".into(),
587                "sql".into(),
588                "exec".into(),
589                "eval".into(),
590                "unsafe".into(),
591            ],
592            min_score: 0.0,
593        }
594    }
595}
596
597#[derive(Debug, Clone, Serialize, Deserialize)]
598pub struct RiskFactors {
599    pub criticality: f64,
600    pub coupling: f64,
601    pub test_gap: f64,
602    pub sensitivity: f64,
603}
604
605#[derive(Debug, Clone, Serialize, Deserialize)]
606pub struct RiskScore {
607    pub qualified_name: String,
608    pub composite: f64,
609    pub factors: RiskFactors,
610}
611
612#[derive(Debug, Clone, Serialize, Deserialize)]
613pub struct FileRiskScore {
614    pub path: PathBuf,
615    pub composite: f64,
616    pub symbol_count: usize,
617    pub highest_symbol: String,
618}
619
620#[derive(Debug, Clone, Serialize, Deserialize)]
621pub struct RiskStats {
622    pub symbols_scored: usize,
623    pub files_scored: usize,
624    pub avg_risk: f64,
625    pub median_risk: f64,
626    pub p90_risk: f64,
627}
628
629#[derive(Debug, Clone, Serialize, Deserialize)]
630pub struct RiskAnalysis {
631    pub symbol_scores: Vec<RiskScore>,
632    pub file_scores: Vec<FileRiskScore>,
633    pub stats: RiskStats,
634}
635
636// ---------------------------------------------------------------------------
637// Community detection types
638// ---------------------------------------------------------------------------
639
640#[derive(Debug, Clone)]
641pub struct CommunityConfig {
642    pub resolution: f64,
643    pub min_community_size: usize,
644    pub seed: Option<u64>,
645}
646
647impl Default for CommunityConfig {
648    fn default() -> Self {
649        Self {
650            resolution: 1.0,
651            min_community_size: 2,
652            seed: None,
653        }
654    }
655}
656
657#[derive(Debug, Clone, Serialize, Deserialize)]
658pub struct Community {
659    pub id: usize,
660    pub name: String,
661    pub members: Vec<String>,
662    pub modularity_contribution: f64,
663    pub internal_edges: usize,
664    pub boundary_edges: usize,
665}
666
667#[derive(Debug, Clone, Serialize, Deserialize)]
668pub struct CommunityAnalysis {
669    pub communities: Vec<Community>,
670    pub modularity: f64,
671    pub stats: CommunityStats,
672}
673
674#[derive(Debug, Clone, Serialize, Deserialize)]
675pub struct CommunityStats {
676    pub count: usize,
677    pub avg_size: f64,
678    pub largest_size: usize,
679    pub isolated_nodes: usize,
680}
681
682// ---------------------------------------------------------------------------
683// Dead code detection types
684// ---------------------------------------------------------------------------
685
686#[derive(Debug, Clone)]
687pub struct DeadCodeConfig {
688    pub exclude_patterns: Vec<String>,
689    pub entry_point_patterns: Vec<String>,
690    pub include_tests: bool,
691    pub migration_patterns: Vec<String>,
692    pub kind_filter: Option<Vec<SymbolKind>>,
693}
694
695impl Default for DeadCodeConfig {
696    fn default() -> Self {
697        Self {
698            exclude_patterns: Vec::new(),
699            entry_point_patterns: Vec::new(),
700            include_tests: false,
701            migration_patterns: vec![
702                "**/migrations/**".into(),
703                "**/migrate/**".into(),
704                "**/alembic/**".into(),
705                "**/diesel/migrations/**".into(),
706            ],
707            kind_filter: None,
708        }
709    }
710}
711
712#[derive(Debug, Clone, Serialize, Deserialize)]
713pub struct DeadCodeAnalysis {
714    pub dead_symbols: Vec<DeadSymbol>,
715    pub summary: DeadCodeSummary,
716}
717
718#[derive(Debug, Clone, Serialize, Deserialize)]
719pub struct DeadSymbol {
720    pub qualified_name: String,
721    pub kind: SymbolKind,
722    pub file_path: String,
723    pub line: usize,
724    pub visibility: Visibility,
725}
726
727#[derive(Debug, Clone, Serialize, Deserialize)]
728pub struct DeadCodeSummary {
729    pub total_symbols: usize,
730    pub dead_count: usize,
731    pub dead_percentage: f64,
732    pub excluded_count: usize,
733    pub dead_by_kind: HashMap<SymbolKind, usize>,
734    pub dead_by_file: Vec<(String, usize)>,
735}
736
737#[derive(Debug, Clone, Serialize, Deserialize)]
738pub enum ExclusionReason {
739    EntryPoint,
740    Exported,
741    TestFunction,
742    MigrationFile,
743    UserPattern(String),
744}
745
746// ---------------------------------------------------------------------------
747// QualifiedName newtype
748// ---------------------------------------------------------------------------
749
750#[derive(Debug, Clone, PartialEq, Eq, Hash)]
751pub struct QualifiedName(String);
752
753impl QualifiedName {
754    pub fn parse(s: &str) -> crate::error::Result<Self> {
755        if s.is_empty() {
756            return Err(crate::error::CodeGraphError::Resolution(
757                "qualified name must not be empty".into(),
758            ));
759        }
760        let sep = "::";
761        let idx = s.find(sep).ok_or_else(|| {
762            crate::error::CodeGraphError::Resolution(format!(
763                "qualified name must contain '::' separator: {s}"
764            ))
765        })?;
766        let file = &s[..idx];
767        let symbol = &s[idx + sep.len()..];
768        if file.is_empty() {
769            return Err(crate::error::CodeGraphError::Resolution(
770                "file path part of qualified name must not be empty".into(),
771            ));
772        }
773        if symbol.is_empty() {
774            return Err(crate::error::CodeGraphError::Resolution(
775                "symbol path part of qualified name must not be empty".into(),
776            ));
777        }
778        Ok(QualifiedName(s.to_owned()))
779    }
780
781    pub fn file_path(&self) -> &str {
782        self.0.split("::").next().unwrap_or_default()
783    }
784
785    pub fn symbol_path(&self) -> &str {
786        self.0.split_once("::").map(|(_, s)| s).unwrap_or_default()
787    }
788
789    pub fn as_str(&self) -> &str {
790        &self.0
791    }
792}
793
794impl fmt::Display for QualifiedName {
795    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
796        f.write_str(&self.0)
797    }
798}
799
800impl Borrow<str> for QualifiedName {
801    fn borrow(&self) -> &str {
802        &self.0
803    }
804}
805
806impl AsRef<str> for QualifiedName {
807    fn as_ref(&self) -> &str {
808        &self.0
809    }
810}
811
812impl From<QualifiedName> for String {
813    fn from(qn: QualifiedName) -> String {
814        qn.0
815    }
816}
817
818impl Serialize for QualifiedName {
819    fn serialize<S: Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
820        serializer.serialize_str(&self.0)
821    }
822}
823
824impl<'de> Deserialize<'de> for QualifiedName {
825    fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
826        let s = String::deserialize(deserializer)?;
827        QualifiedName::parse(&s).map_err(serde::de::Error::custom)
828    }
829}
830
831// ---------------------------------------------------------------------------
832// Tests
833// ---------------------------------------------------------------------------
834
835#[cfg(test)]
836mod tests {
837    use super::*;
838
839    #[test]
840    fn confidence_ordering() {
841        assert!(Confidence::Structural < Confidence::Low);
842        assert!(Confidence::Low < Confidence::Medium);
843        assert!(Confidence::Medium < Confidence::High);
844    }
845
846    #[test]
847    fn all_16_edge_kinds_have_confidence() {
848        let edges = [
849            (EdgeKind::Calls, Confidence::High),
850            (EdgeKind::Extends, Confidence::High),
851            (EdgeKind::Implements, Confidence::High),
852            (EdgeKind::Embeds, Confidence::High),
853            (EdgeKind::ImportsFrom, Confidence::Medium),
854            (EdgeKind::BarrelReExportAll, Confidence::Medium),
855            (EdgeKind::ReExport, Confidence::Medium),
856            (EdgeKind::TypeReference, Confidence::Medium),
857            (EdgeKind::DotImport, Confidence::Medium),
858            (EdgeKind::DependsOn, Confidence::Low),
859            (EdgeKind::ConditionalImport, Confidence::Low),
860            (EdgeKind::SideEffectImport, Confidence::Low),
861            (EdgeKind::Contains, Confidence::Structural),
862            (EdgeKind::ChildOf, Confidence::Structural),
863            (EdgeKind::HasDecorator, Confidence::Structural),
864            (EdgeKind::TestedBy, Confidence::Structural),
865        ];
866        for (kind, expected) in &edges {
867            assert_eq!(
868                kind.confidence(),
869                *expected,
870                "wrong confidence for {kind:?}"
871            );
872        }
873        assert_eq!(edges.len(), 16, "expected 16 edge kinds");
874    }
875
876    #[test]
877    fn qualified_name_parse_valid() {
878        let qn = QualifiedName::parse("src/file.rs::MyStruct.method").unwrap();
879        assert_eq!(qn.file_path(), "src/file.rs");
880        assert_eq!(qn.symbol_path(), "MyStruct.method");
881        assert_eq!(qn.as_str(), "src/file.rs::MyStruct.method");
882    }
883
884    #[test]
885    fn qualified_name_rejects_empty() {
886        assert!(QualifiedName::parse("").is_err());
887    }
888
889    #[test]
890    fn qualified_name_rejects_missing_separator() {
891        assert!(QualifiedName::parse("no_separator").is_err());
892    }
893
894    #[test]
895    fn qualified_name_rejects_empty_file_path() {
896        assert!(QualifiedName::parse("::symbol").is_err());
897    }
898
899    #[test]
900    fn qualified_name_rejects_empty_symbol_path() {
901        assert!(QualifiedName::parse("file::").is_err());
902    }
903
904    #[test]
905    fn qualified_name_borrow_str_hashmap_lookup() {
906        use std::collections::HashMap;
907        let mut map: HashMap<QualifiedName, u32> = HashMap::new();
908        let qn = QualifiedName::parse("src/lib.rs::foo").unwrap();
909        map.insert(qn, 42);
910        // Exercises the Borrow<str> impl: look up by &str, not QualifiedName
911        assert_eq!(map.get("src/lib.rs::foo"), Some(&42));
912    }
913
914    #[test]
915    fn qualified_name_serde_roundtrip() {
916        let qn = QualifiedName::parse("src/lib.rs::Foo.bar").unwrap();
917        let json = serde_json::to_string(&qn).unwrap();
918        let qn2: QualifiedName = serde_json::from_str(&json).unwrap();
919        assert_eq!(qn, qn2);
920    }
921
922    #[test]
923    fn node_id_returns_correct_identifier() {
924        let file = Node::File(FileNode {
925            path: "src/main.rs".into(),
926            language: Language::Rust,
927            hash: "abc".into(),
928        });
929        assert_eq!(file.id(), "src/main.rs");
930
931        let sym = Node::Symbol(SymbolNode {
932            name: "foo".into(),
933            qualified_name: "src/lib.rs::foo".into(),
934            kind: SymbolKind::Function,
935            location: Location {
936                file: "src/lib.rs".into(),
937                line_start: 1,
938                line_end: 5,
939                col_start: 0,
940                col_end: 1,
941            },
942            visibility: Visibility::Public,
943            is_exported: true,
944            is_async: false,
945            is_test: false,
946            decorators: vec![],
947            signature: None,
948        });
949        assert_eq!(sym.id(), "src/lib.rs::foo");
950    }
951
952    #[test]
953    fn serde_roundtrip_all_supporting_types() {
954        macro_rules! assert_roundtrip {
955            ($val:expr, $ty:ty) => {{
956                let json = serde_json::to_string(&$val).unwrap();
957                let _: $ty = serde_json::from_str(&json).unwrap();
958            }};
959        }
960
961        // Enums
962        assert_roundtrip!(Language::Rust, Language);
963        assert_roundtrip!(NodeKind::Symbol, NodeKind);
964        assert_roundtrip!(SymbolKind::Function, SymbolKind);
965        assert_roundtrip!(NonParsedKind::Doc, NonParsedKind);
966        assert_roundtrip!(Visibility::Public, Visibility);
967        assert_roundtrip!(Confidence::High, Confidence);
968        assert_roundtrip!(EdgeKind::Calls, EdgeKind);
969        assert_roundtrip!(Direction::Forward, Direction);
970
971        // Core types
972        let loc = Location {
973            file: "f".into(),
974            line_start: 1,
975            line_end: 2,
976            col_start: 0,
977            col_end: 10,
978        };
979        assert_roundtrip!(loc, Location);
980
981        let file_node = FileNode {
982            path: "f".into(),
983            language: Language::Rust,
984            hash: "h".into(),
985        };
986        assert_roundtrip!(file_node.clone(), FileNode);
987        assert_roundtrip!(Node::File(file_node), Node);
988
989        let sym = SymbolNode {
990            name: "s".into(),
991            qualified_name: "f::s".into(),
992            kind: SymbolKind::Function,
993            location: Location {
994                file: "f".into(),
995                line_start: 1,
996                line_end: 2,
997                col_start: 0,
998                col_end: 0,
999            },
1000            visibility: Visibility::Public,
1001            is_exported: true,
1002            is_async: false,
1003            is_test: false,
1004            decorators: vec![],
1005            signature: None,
1006        };
1007        assert_roundtrip!(sym, SymbolNode);
1008
1009        let np = NonParsedNode {
1010            path: "r.md".into(),
1011            file_kind: NonParsedKind::Doc,
1012            hash: "h".into(),
1013        };
1014        assert_roundtrip!(np, NonParsedNode);
1015
1016        let edge = Edge {
1017            kind: EdgeKind::Calls,
1018            source: "a".into(),
1019            target: "b".into(),
1020            metadata: None,
1021        };
1022        assert_roundtrip!(edge, Edge);
1023
1024        // Supporting types
1025        assert_roundtrip!(ImpactTarget::File("f".into()), ImpactTarget);
1026        assert_roundtrip!(ImpactTarget::Symbol("s".into()), ImpactTarget);
1027        assert_roundtrip!(
1028            TraversalResult {
1029                node: "n".into(),
1030                depth: 1,
1031                path: vec![],
1032                edge_kind: EdgeKind::Calls
1033            },
1034            TraversalResult
1035        );
1036        assert_roundtrip!(
1037            SearchResult {
1038                qualified_name: "f::s".into(),
1039                name: "s".into(),
1040                kind: SymbolKind::Function,
1041                file_path: "f".into(),
1042                score: 1.0,
1043                score_source: None,
1044            },
1045            SearchResult
1046        );
1047        assert_roundtrip!(
1048            Reference {
1049                symbol: "s".into(),
1050                edge_kind: EdgeKind::Calls,
1051                location: None
1052            },
1053            Reference
1054        );
1055        assert_roundtrip!(
1056            IndexStats {
1057                files_indexed: 1,
1058                symbols_extracted: 2,
1059                edges_created: 3,
1060                duration: std::time::Duration::from_secs(1)
1061            },
1062            IndexStats
1063        );
1064        assert_roundtrip!(
1065            GraphStats {
1066                files: 1,
1067                symbols: 2,
1068                edges: 3,
1069                entry_point_count: None,
1070                avg_criticality: None,
1071                clone_clusters: None,
1072                duplication_pct: None,
1073                most_duplicated: None,
1074                avg_risk: None,
1075                p90_risk: None,
1076                community_count: None,
1077                modularity: None,
1078            },
1079            GraphStats
1080        );
1081        assert_roundtrip!(
1082            DiffHunk {
1083                file: "f".into(),
1084                old_start: 1,
1085                old_count: 2,
1086                new_start: 1,
1087                new_count: 3
1088            },
1089            DiffHunk
1090        );
1091        assert_roundtrip!(
1092            AffectedNode {
1093                qualified_name: "q".into(),
1094                depth: 1,
1095                confidence: Confidence::High,
1096                path: vec![]
1097            },
1098            AffectedNode
1099        );
1100        assert_roundtrip!(
1101            ImpactReport {
1102                targets: vec![],
1103                affected: vec![],
1104                depth: 3,
1105                min_confidence: Confidence::Structural
1106            },
1107            ImpactReport
1108        );
1109        assert_roundtrip!(
1110            DiffImpactReport {
1111                changed_symbols: vec![],
1112                impact: ImpactReport {
1113                    targets: vec![],
1114                    affected: vec![],
1115                    depth: 0,
1116                    min_confidence: Confidence::Structural
1117                }
1118            },
1119            DiffImpactReport
1120        );
1121    }
1122
1123    #[test]
1124    fn flow_types_serde_roundtrip() {
1125        let entry = EntryPoint {
1126            qualified_name: "src/main.rs::main".into(),
1127            kind: EntryPointKind::Main,
1128            confidence: 1.0,
1129        };
1130        let json = serde_json::to_string(&entry).unwrap();
1131        let _: EntryPoint = serde_json::from_str(&json).unwrap();
1132
1133        let flow = ExecutionFlow {
1134            entry: "src/main.rs::main".into(),
1135            path: vec!["src/main.rs::main".into(), "src/db.rs::connect".into()],
1136            depth: 2,
1137            truncated: false,
1138        };
1139        let json = serde_json::to_string(&flow).unwrap();
1140        let _: ExecutionFlow = serde_json::from_str(&json).unwrap();
1141
1142        let score = CriticalityScore {
1143            qualified_name: "src/db.rs::connect".into(),
1144            betweenness: 0.75,
1145            flow_count: 42,
1146            is_entry_point: false,
1147        };
1148        let json = serde_json::to_string(&score).unwrap();
1149        let _: CriticalityScore = serde_json::from_str(&json).unwrap();
1150
1151        let config = FlowConfig::default();
1152        assert_eq!(config.max_depth, 20);
1153        assert_eq!(config.max_flows, 1000);
1154        assert_eq!(config.visit_budget, 100_000);
1155        assert_eq!(config.max_public_roots, 50);
1156
1157        let analysis = FlowAnalysis {
1158            entry_points: vec![entry],
1159            flows: vec![flow],
1160            criticality: vec![score],
1161            stats: FlowStats {
1162                total_entry_points: 1,
1163                total_flows: 1,
1164                max_depth: 2,
1165                avg_depth: 2.0,
1166            },
1167        };
1168        let json = serde_json::to_string(&analysis).unwrap();
1169        let _: FlowAnalysis = serde_json::from_str(&json).unwrap();
1170    }
1171
1172    #[test]
1173    fn graph_stats_optional_fields_default_none() {
1174        let stats = GraphStats {
1175            files: 10,
1176            symbols: 50,
1177            edges: 100,
1178            entry_point_count: None,
1179            avg_criticality: None,
1180            clone_clusters: None,
1181            duplication_pct: None,
1182            most_duplicated: None,
1183            avg_risk: None,
1184            p90_risk: None,
1185            community_count: None,
1186            modularity: None,
1187        };
1188        let json = serde_json::to_string(&stats).unwrap();
1189        assert!(!json.contains("entry_point_count"));
1190        assert!(!json.contains("avg_criticality"));
1191    }
1192
1193    #[test]
1194    fn risk_weights_default_sum_to_one() {
1195        let w = RiskWeights::default();
1196        let sum = w.criticality + w.coupling + w.test_gap + w.sensitivity;
1197        assert!(
1198            (sum - 1.0).abs() < 1e-10,
1199            "default weights must sum to 1.0, got {sum}"
1200        );
1201    }
1202
1203    #[test]
1204    fn risk_score_serde_roundtrip() {
1205        let score = RiskScore {
1206            qualified_name: "src/auth.rs::verify_token".into(),
1207            composite: 0.87,
1208            factors: RiskFactors {
1209                criticality: 0.90,
1210                coupling: 0.80,
1211                test_gap: 0.85,
1212                sensitivity: 0.95,
1213            },
1214        };
1215        let json = serde_json::to_string(&score).unwrap();
1216        let decoded: RiskScore = serde_json::from_str(&json).unwrap();
1217        assert_eq!(decoded.qualified_name, score.qualified_name);
1218        assert!((decoded.composite - score.composite).abs() < 1e-10);
1219        assert!((decoded.factors.criticality - score.factors.criticality).abs() < 1e-10);
1220        assert!((decoded.factors.coupling - score.factors.coupling).abs() < 1e-10);
1221        assert!((decoded.factors.test_gap - score.factors.test_gap).abs() < 1e-10);
1222        assert!((decoded.factors.sensitivity - score.factors.sensitivity).abs() < 1e-10);
1223    }
1224
1225    #[test]
1226    fn risk_weights_normalized_preserves_proportions() {
1227        let w = RiskWeights {
1228            criticality: 3.0,
1229            coupling: 2.5,
1230            test_gap: 2.5,
1231            sensitivity: 2.0,
1232        };
1233        let n = w.normalized();
1234        let sum = n.criticality + n.coupling + n.test_gap + n.sensitivity;
1235        assert!(
1236            (sum - 1.0).abs() < 1e-10,
1237            "normalized weights must sum to 1.0, got {sum}"
1238        );
1239        // Proportions should match: original sums to 10.0
1240        assert!((n.criticality - 0.30).abs() < 1e-10);
1241        assert!((n.coupling - 0.25).abs() < 1e-10);
1242        assert!((n.test_gap - 0.25).abs() < 1e-10);
1243        assert!((n.sensitivity - 0.20).abs() < 1e-10);
1244    }
1245
1246    #[test]
1247    fn graph_stats_optional_fields_present() {
1248        let stats = GraphStats {
1249            files: 10,
1250            symbols: 50,
1251            edges: 100,
1252            entry_point_count: Some(5),
1253            avg_criticality: Some(0.034),
1254            clone_clusters: None,
1255            duplication_pct: None,
1256            most_duplicated: None,
1257            avg_risk: None,
1258            p90_risk: None,
1259            community_count: None,
1260            modularity: None,
1261        };
1262        let json = serde_json::to_string(&stats).unwrap();
1263        let parsed: serde_json::Value = serde_json::from_str(&json).unwrap();
1264        assert_eq!(parsed["entry_point_count"], 5);
1265    }
1266}