1use serde::{Deserialize, Deserializer, Serialize, Serializer};
2use std::borrow::Borrow;
3use std::collections::HashMap;
4use std::fmt;
5use std::path::PathBuf;
6
7#[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 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#[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#[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#[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#[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#[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#[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 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#[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#[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#[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#[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 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 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 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 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 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}