1use std::collections::HashMap;
56use std::fmt;
57use std::fs;
58use std::io;
59use std::marker::PhantomData;
60use std::path::{Path, PathBuf};
61use std::sync::atomic::{AtomicU64, Ordering};
62
63use serde::de::{MapAccess, Visitor};
64use serde::ser::SerializeMap;
65use serde::{Deserialize, Deserializer, Serialize, Serializer};
66
67use crate::extract;
68use crate::repo::{git_common_dir, scout_dir};
69
70fn graph_dir(root: &Path) -> PathBuf {
78 match git_common_dir(root) {
79 Some(common) => common.join("scout").join("graph"),
80 None => scout_dir(root).join("graph"),
81 }
82}
83
84pub fn graph_json_path(root: &Path) -> PathBuf {
86 graph_dir(root).join("graph.json")
87}
88
89fn fragments_cache_path(root: &Path) -> PathBuf {
106 graph_dir(root).join("fragments-v15.json")
107}
108
109fn fragments_index_path(root: &Path) -> PathBuf {
110 graph_dir(root).join("fragments-index-v15.json")
111}
112
113const SUPERSEDED_CACHE_FILES: &[&str] = &[
117 "fragments.json",
118 "fragments-index.json",
119 "fragments-v2.json",
120 "fragments-index-v2.json",
121 "fragments-v3.json",
122 "fragments-index-v3.json",
123 "fragments-v4.json",
124 "fragments-index-v4.json",
125 "fragments-v5.json",
126 "fragments-index-v5.json",
127 "fragments-v6.json",
128 "fragments-index-v6.json",
129 "fragments-v7.json",
130 "fragments-index-v7.json",
131 "fragments-v8.json",
132 "fragments-index-v8.json",
133 "fragments-v9.json",
134 "fragments-index-v9.json",
135 "fragments-v10.json",
136 "fragments-index-v10.json",
137 "fragments-v11.json",
138 "fragments-index-v11.json",
139 "fragments-v12.json",
140 "fragments-index-v12.json",
141 "fragments-v13.json",
142 "fragments-index-v13.json",
143 "fragments-v14.json",
144 "fragments-index-v14.json",
145];
146
147fn remove_superseded_caches(root: &Path) {
148 for name in SUPERSEDED_CACHE_FILES {
149 let _ = fs::remove_file(graph_dir(root).join(name));
151 }
152}
153
154static TMP_COUNTER: AtomicU64 = AtomicU64::new(0);
164
165fn atomic_write_bytes(path: &Path, bytes: &[u8]) -> io::Result<()> {
166 let dir = path.parent().unwrap_or_else(|| Path::new("."));
167 fs::create_dir_all(dir)?;
168 let file_name = path
169 .file_name()
170 .and_then(|n| n.to_str())
171 .unwrap_or("artifact");
172 let counter = TMP_COUNTER.fetch_add(1, Ordering::Relaxed);
173 let tmp_path = dir.join(format!(".{file_name}.tmp.{}.{counter}", std::process::id()));
174 let write_result = fs::write(&tmp_path, bytes);
175 if write_result.is_err() {
176 let _ = fs::remove_file(&tmp_path);
177 write_result?;
178 }
179 fs::rename(&tmp_path, path)?;
180 Ok(())
181}
182
183fn atomic_write_json<T: Serialize>(path: &Path, value: &T) -> io::Result<()> {
184 let bytes = serde_json::to_vec(value).map_err(io::Error::other)?;
185 atomic_write_bytes(path, &bytes)
186}
187
188#[derive(Debug, Clone, PartialEq)]
200pub struct OrderedMap<V> {
202 entries: Vec<(String, V)>,
203 index: HashMap<String, usize>,
204}
205
206impl<V> OrderedMap<V> {
207 pub fn new() -> Self {
209 Self {
210 entries: Vec::new(),
211 index: HashMap::new(),
212 }
213 }
214
215 pub fn insert(&mut self, key: String, value: V) {
219 match self.index.get(&key) {
220 Some(&i) => self.entries[i].1 = value,
221 None => {
222 self.index.insert(key.clone(), self.entries.len());
223 self.entries.push((key, value));
224 }
225 }
226 }
227
228 pub fn get(&self, key: &str) -> Option<&V> {
230 self.index.get(key).map(|&i| &self.entries[i].1)
231 }
232
233 pub fn len(&self) -> usize {
235 self.entries.len()
236 }
237
238 pub fn is_empty(&self) -> bool {
240 self.entries.is_empty()
241 }
242
243 pub fn iter(&self) -> impl Iterator<Item = (&String, &V)> {
245 self.entries.iter().map(|(k, v)| (k, v))
246 }
247}
248
249impl<V> Default for OrderedMap<V> {
250 fn default() -> Self {
251 Self::new()
252 }
253}
254
255impl<V: Serialize> Serialize for OrderedMap<V> {
256 fn serialize<S: Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
257 let mut map = serializer.serialize_map(Some(self.entries.len()))?;
258 for (k, v) in &self.entries {
259 map.serialize_entry(k, v)?;
260 }
261 map.end()
262 }
263}
264
265impl<'de, V: Deserialize<'de>> Deserialize<'de> for OrderedMap<V> {
266 fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
267 struct OrderedMapVisitor<V>(PhantomData<V>);
268
269 impl<'de, V: Deserialize<'de>> Visitor<'de> for OrderedMapVisitor<V> {
270 type Value = OrderedMap<V>;
271
272 fn expecting(&self, f: &mut fmt::Formatter) -> fmt::Result {
273 f.write_str("a JSON object")
274 }
275
276 fn visit_map<A: MapAccess<'de>>(self, mut map: A) -> Result<Self::Value, A::Error> {
277 let mut out = OrderedMap::new();
278 while let Some((k, v)) = map.next_entry::<String, V>()? {
279 out.insert(k, v);
280 }
281 Ok(out)
282 }
283 }
284
285 deserializer.deserialize_map(OrderedMapVisitor(PhantomData))
286 }
287}
288
289#[derive(Debug, Clone, Copy, PartialEq, Eq)]
296pub struct Percent1(pub i64);
298
299impl Percent1 {
300 pub fn zero() -> Self {
302 Percent1(0)
303 }
304
305 pub fn from_ratio(numerator: usize, denominator: usize) -> Self {
309 if denominator == 0 {
310 return Percent1::zero();
311 }
312 let raw = (numerator as f64 / denominator as f64) * 1000.0;
313 Percent1(raw.round() as i64)
314 }
315}
316
317impl Serialize for Percent1 {
318 fn serialize<S: Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
319 if self.0 % 10 == 0 {
320 serializer.serialize_i64(self.0 / 10)
321 } else {
322 serializer.serialize_f64(self.0 as f64 / 10.0)
323 }
324 }
325}
326
327impl<'de> Deserialize<'de> for Percent1 {
328 fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
329 let v = f64::deserialize(deserializer)?;
330 Ok(Percent1((v * 10.0).round() as i64))
331 }
332}
333
334#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
339pub struct AlsoIn {
341 pub file: String,
343 pub line: usize,
345}
346
347#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
348pub struct Def {
350 pub id: String,
352 pub name: String,
354 pub namespace: String,
356 pub kind: String,
358 pub file: String,
360 pub line: usize,
362 pub methods: Vec<String>,
364 #[serde(default, rename = "testMethods", skip_serializing_if = "Vec::is_empty")]
371 pub test_methods: Vec<String>,
372 #[serde(default, skip_serializing_if = "Vec::is_empty")]
373 pub also_in: Vec<AlsoIn>,
375 #[serde(default, rename = "endLine", skip_serializing_if = "is_zero")]
376 pub end_line: usize,
378}
379
380#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
381pub struct Candidate {
383 pub id: String,
385 pub file: String,
387}
388
389#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
399#[serde(tag = "kind")]
400pub enum Edge {
401 #[serde(rename = "inherits")]
402 Inherits {
404 from_file: String,
406 from_line: usize,
408 to: String,
410 to_file: String,
412 #[serde(default, skip_serializing_if = "is_false")]
413 heuristic: bool,
415 },
416 #[serde(rename = "uses-type")]
417 UsesType {
419 from_file: String,
421 from_line: usize,
423 to: String,
425 to_file: String,
427 #[serde(default, skip_serializing_if = "is_false")]
428 heuristic: bool,
430 },
431 #[serde(rename = "uses-member")]
432 UsesMember {
434 from_file: String,
436 from_line: usize,
438 to: String,
440 to_file: String,
442 #[serde(default, skip_serializing_if = "is_false")]
443 heuristic: bool,
445 },
446 #[serde(rename = "imports")]
447 Imports {
449 from_file: String,
451 from_line: usize,
453 target: String,
455 },
456 #[serde(rename = "ambiguous")]
457 Ambiguous {
459 origin: String,
461 from_file: String,
463 from_line: usize,
465 raw: String,
467 candidates: Vec<Candidate>,
469 candidate_count: usize,
471 },
472 #[serde(rename = "ctor-di")]
482 CtorDi {
483 from_file: String,
485 from_line: usize,
487 iface: String,
489 resolution: String,
491 #[serde(default, skip_serializing_if = "Option::is_none")]
492 args: Option<Vec<String>>,
494 #[serde(default, skip_serializing_if = "Option::is_none")]
495 to: Option<String>,
497 #[serde(default, skip_serializing_if = "Vec::is_empty")]
498 candidates: Vec<Candidate>,
500 },
501 #[serde(rename = "import")]
508 Import {
509 from_file: String,
511 from_line: usize,
513 target: String,
515 to_file: String,
517 #[serde(default, skip_serializing_if = "Option::is_none")]
518 via: Option<String>,
520 },
521 #[serde(rename = "call")]
524 Call {
526 from_file: String,
528 from_line: usize,
530 to: String,
532 to_file: String,
534 },
535 #[serde(rename = "jsx-use")]
537 JsxUse {
539 from_file: String,
541 from_line: usize,
543 to: String,
545 to_file: String,
547 },
548 #[serde(rename = "dispatch")]
551 Dispatch {
553 from_file: String,
555 from_line: usize,
557 to: String,
559 to_file: String,
561 },
562}
563
564#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
565pub struct EdgesByKind {
567 pub inherits: usize,
569 #[serde(rename = "uses-type")]
570 pub uses_type: usize,
572 pub imports: usize,
574 #[serde(rename = "uses-member")]
575 pub uses_member: usize,
577 #[serde(rename = "ctor-di")]
579 pub ctor_di: usize,
580 #[serde(default, skip_serializing_if = "Option::is_none")]
585 pub import: Option<usize>,
586 #[serde(default, skip_serializing_if = "Option::is_none")]
587 pub call: Option<usize>,
589 #[serde(rename = "jsx-use", default, skip_serializing_if = "Option::is_none")]
590 pub jsx_use: Option<usize>,
592 #[serde(default, skip_serializing_if = "Option::is_none")]
593 pub dispatch: Option<usize>,
595}
596
597#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
598pub struct Stats {
600 pub def_count: usize,
602 pub file_count: usize,
604 pub edges_by_kind: EdgesByKind,
606 pub ambiguous_count: usize,
608 pub ambiguous_pct: Percent1,
610 pub unresolved_external_count: usize,
612 #[serde(default)]
618 pub heuristic_edge_count: usize,
619 #[serde(default)]
623 pub test_def_count: usize,
624 #[serde(default, skip_serializing_if = "Option::is_none")]
628 pub ts: Option<crate::tsgraph::TsStats>,
629}
630
631#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
636pub struct GraphName {
637 pub name: String,
639 pub kind: String,
641 pub file: String,
643 pub line: usize,
645 #[serde(default, skip_serializing_if = "String::is_empty")]
646 pub owner: String,
648}
649
650#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
651pub struct Graph {
653 pub schema_version: u32,
655 pub built_at_head: Option<String>,
657 pub defs: Vec<Def>,
659 pub edges: Vec<Edge>,
661 pub stats: Stats,
663 #[serde(default, skip_serializing_if = "Vec::is_empty")]
667 pub names: Vec<GraphName>,
668}
669
670pub fn read_graph(root: &Path) -> Option<Graph> {
672 let text = fs::read_to_string(graph_json_path(root)).ok()?;
673 serde_json::from_str(&text).ok()
674}
675
676fn write_graph(root: &Path, graph: &Graph) -> io::Result<()> {
677 atomic_write_json(&graph_json_path(root), graph)
678}
679
680#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
693pub struct FragDef {
694 pub id: String,
696 pub name: String,
698 pub namespace: String,
700 pub kind: String,
702 pub line: usize,
704 pub methods: Vec<String>,
706 #[serde(default, skip_serializing_if = "Vec::is_empty")]
707 pub properties: Vec<String>,
709 #[serde(default, skip_serializing_if = "Vec::is_empty")]
710 pub fields: Vec<String>,
712 #[serde(
717 default,
718 rename = "methodReturns",
719 skip_serializing_if = "OrderedMap::is_empty"
720 )]
721 pub method_returns: OrderedMap<String>,
722 #[serde(
727 default,
728 rename = "extensionMethods",
729 skip_serializing_if = "Vec::is_empty"
730 )]
731 pub extension_methods: Vec<FragExtensionMethod>,
732 #[serde(default, skip_serializing_if = "Vec::is_empty")]
738 pub bases: Vec<String>,
739 #[serde(default, rename = "typeParams", skip_serializing_if = "Vec::is_empty")]
744 pub type_params: Vec<String>,
745 #[serde(
751 default,
752 rename = "baseGenericArgs",
753 skip_serializing_if = "OrderedMap::is_empty"
754 )]
755 pub base_generic_args: OrderedMap<Vec<String>>,
756 #[serde(default, rename = "testMethods", skip_serializing_if = "Vec::is_empty")]
761 pub test_methods: Vec<String>,
762 #[serde(
768 default,
769 rename = "propertyTypes",
770 skip_serializing_if = "OrderedMap::is_empty"
771 )]
772 pub property_types: OrderedMap<FragFact>,
773 #[serde(default, rename = "endLine", skip_serializing_if = "is_zero")]
774 pub end_line: usize,
776}
777
778#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
782pub struct FragFact {
783 #[serde(rename = "type")]
784 pub type_name: String,
786 #[serde(default, skip_serializing_if = "Option::is_none")]
787 pub args: Option<Vec<String>>,
789}
790
791#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
798pub struct FragExtensionMethod {
799 pub name: String,
801 #[serde(rename = "thisType")]
802 pub this_type: String,
804 #[serde(rename = "arityMin")]
805 pub arity_min: usize,
807 #[serde(rename = "arityMax")]
808 pub arity_max: i64,
810 #[serde(default, rename = "thisArgs", skip_serializing_if = "Option::is_none")]
813 pub this_args: Option<Vec<String>>,
814}
815
816#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
817#[serde(untagged)]
818pub enum FragUsing {
820 Alias {
822 alias: String,
824 target: String,
826 global: bool,
828 },
829 Plain {
831 text: String,
833 global: bool,
835 },
836}
837
838#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
839pub struct FragRef {
841 pub kind: String,
843 pub name: String,
845 #[serde(default, skip_serializing_if = "Option::is_none")]
846 pub qualified: Option<String>,
848 #[serde(default, skip_serializing_if = "Option::is_none")]
849 pub member: Option<String>,
851 pub line: usize,
853 pub namespace: Option<String>,
857 #[serde(
858 default,
859 rename = "typeArgCount",
860 skip_serializing_if = "Option::is_none"
861 )]
862 pub type_arg_count: Option<usize>,
864 #[serde(default, skip_serializing_if = "is_false")]
868 pub generic: bool,
869 #[serde(
874 default,
875 rename = "receiverType",
876 skip_serializing_if = "Option::is_none"
877 )]
878 pub receiver_type: Option<String>,
879 #[serde(default, rename = "argCount", skip_serializing_if = "Option::is_none")]
885 pub arg_count: Option<usize>,
886 #[serde(
891 default,
892 rename = "receiverArgs",
893 skip_serializing_if = "Option::is_none"
894 )]
895 pub receiver_args: Option<Vec<String>>,
896 #[serde(default, rename = "outerTypes", skip_serializing_if = "Vec::is_empty")]
902 pub outer_types: Vec<String>,
903 #[serde(default, skip_serializing_if = "Option::is_none")]
907 pub args: Option<Vec<String>>,
908 #[serde(
913 default,
914 rename = "receiverPropertyOwner",
915 skip_serializing_if = "Option::is_none"
916 )]
917 pub receiver_property_owner: Option<String>,
918 #[serde(
924 default,
925 rename = "receiverCallOwner",
926 skip_serializing_if = "Option::is_none"
927 )]
928 pub receiver_call_owner: Option<String>,
929 #[serde(
930 default,
931 rename = "receiverCallMember",
932 skip_serializing_if = "Option::is_none"
933 )]
934 pub receiver_call_member: Option<String>,
936}
937
938fn is_false(b: &bool) -> bool {
939 !*b
940}
941
942fn is_zero(n: &usize) -> bool {
943 *n == 0
944}
945
946#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
951pub struct FragName {
952 pub name: String,
954 pub kind: String,
956 pub line: usize,
958 #[serde(default, skip_serializing_if = "String::is_empty")]
959 pub owner: String,
961}
962
963#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
964pub struct Fragment {
966 pub defs: Vec<FragDef>,
968 pub usings: Vec<FragUsing>,
970 pub refs: Vec<FragRef>,
972 #[serde(default)]
976 pub names: Vec<FragName>,
977}
978
979#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
987#[serde(untagged)]
988pub enum AnyFragment {
989 Ts(extract::TsFragment),
991 Cs(Fragment),
993}
994
995impl From<Fragment> for AnyFragment {
996 fn from(f: Fragment) -> Self {
997 AnyFragment::Cs(f)
998 }
999}
1000
1001impl From<extract::TsFragment> for AnyFragment {
1002 fn from(f: extract::TsFragment) -> Self {
1003 AnyFragment::Ts(f)
1004 }
1005}
1006
1007pub fn fragment_from_extraction(e: &extract::Extraction) -> Fragment {
1011 Fragment {
1012 defs: e
1013 .defs
1014 .iter()
1015 .map(|d| FragDef {
1016 id: d.id.clone(),
1017 name: d.name.clone(),
1018 namespace: d.namespace.clone(),
1019 kind: d.kind.clone(),
1020 line: d.line,
1021 methods: d.methods.clone(),
1022 properties: d.properties.clone(),
1023 fields: d.fields.clone(),
1024 method_returns: {
1025 let mut m = OrderedMap::new();
1026 for (name, returns) in &d.method_returns {
1027 m.insert(name.clone(), returns.clone());
1028 }
1029 m
1030 },
1031 extension_methods: d
1032 .extension_methods
1033 .iter()
1034 .map(|e| FragExtensionMethod {
1035 name: e.name.clone(),
1036 this_type: e.this_type.clone(),
1037 arity_min: e.arity_min,
1038 arity_max: e.arity_max,
1039 this_args: e.this_args.clone(),
1040 })
1041 .collect(),
1042 bases: d.bases.clone(),
1043 type_params: d.type_params.clone(),
1044 base_generic_args: {
1045 let mut m = OrderedMap::new();
1046 for (name, args) in &d.base_generic_args {
1047 m.insert(name.clone(), args.clone());
1048 }
1049 m
1050 },
1051 test_methods: d.test_methods.clone(),
1052 property_types: {
1053 let mut m = OrderedMap::new();
1054 for (name, fact) in &d.property_types {
1055 m.insert(
1056 name.clone(),
1057 FragFact {
1058 type_name: fact.type_name.clone(),
1059 args: fact.args.clone(),
1060 },
1061 );
1062 }
1063 m
1064 },
1065 end_line: d.end_line,
1066 })
1067 .collect(),
1068 usings: e
1069 .usings
1070 .iter()
1071 .map(|u| match u {
1072 extract::UsingRecord::Alias {
1073 alias,
1074 target,
1075 global,
1076 } => FragUsing::Alias {
1077 alias: alias.clone(),
1078 target: target.clone(),
1079 global: *global,
1080 },
1081 extract::UsingRecord::Plain { text, global } => FragUsing::Plain {
1082 text: text.clone(),
1083 global: *global,
1084 },
1085 })
1086 .collect(),
1087 refs: e
1088 .refs
1089 .iter()
1090 .map(|r| FragRef {
1091 kind: r.kind.clone(),
1092 name: r.name.clone(),
1093 qualified: r.qualified.clone(),
1094 member: r.member.clone(),
1095 line: r.line,
1096 namespace: r.namespace.clone(),
1097 type_arg_count: r.type_arg_count,
1098 generic: r.generic,
1099 receiver_type: r.receiver_type.clone(),
1100 arg_count: r.arg_count,
1101 receiver_args: r.receiver_args.clone(),
1102 outer_types: r.outer_types.clone(),
1103 args: r.args.clone(),
1104 receiver_property_owner: r.receiver_property_owner.clone(),
1105 receiver_call_owner: r.receiver_call_owner.clone(),
1106 receiver_call_member: r.receiver_call_member.clone(),
1107 })
1108 .collect(),
1109 names: e
1110 .names
1111 .iter()
1112 .map(|n| FragName {
1113 name: n.name.clone(),
1114 kind: n.kind.clone(),
1115 line: n.line,
1116 owner: n.owner.clone(),
1117 })
1118 .collect(),
1119 }
1120}
1121
1122pub fn markup_fragment(root: &Path, rel: &str) -> Option<Fragment> {
1130 let text = String::from_utf8_lossy(&fs::read(root.join(rel)).ok()?).into_owned();
1133 let facts = crate::markup::markup_facts(rel, &text);
1134 Some(Fragment {
1135 defs: facts
1136 .defs
1137 .into_iter()
1138 .map(|d| FragDef {
1139 id: d.id,
1140 name: d.name,
1141 namespace: d.namespace,
1142 kind: d.kind,
1143 line: d.line,
1144 methods: Vec::new(),
1145 properties: Vec::new(),
1146 fields: Vec::new(),
1147 method_returns: OrderedMap::new(),
1148 extension_methods: Vec::new(),
1149 bases: Vec::new(),
1150 type_params: Vec::new(),
1151 base_generic_args: OrderedMap::new(),
1152 test_methods: Vec::new(),
1153 property_types: OrderedMap::new(),
1154 end_line: d.line,
1155 })
1156 .collect(),
1157 usings: Vec::new(),
1158 refs: facts
1159 .refs
1160 .into_iter()
1161 .map(|r| FragRef {
1162 kind: r.kind,
1163 name: r.name,
1164 qualified: r.qualified,
1165 member: r.member,
1166 line: r.line,
1167 namespace: Some(String::new()),
1171 type_arg_count: Some(0),
1172 generic: false,
1173 receiver_type: None,
1174 arg_count: None,
1175 receiver_args: None,
1176 outer_types: Vec::new(),
1177 args: None,
1178 receiver_property_owner: None,
1179 receiver_call_owner: None,
1180 receiver_call_member: None,
1181 })
1182 .collect(),
1183 names: facts
1184 .names
1185 .into_iter()
1186 .map(|n| FragName {
1187 name: n.name,
1188 kind: n.kind,
1189 line: n.line,
1190 owner: n.owner,
1191 })
1192 .collect(),
1193 })
1194}
1195
1196#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
1197pub struct FragmentCacheEntry {
1199 pub mtime: i64,
1201 pub fragment: AnyFragment,
1203}
1204
1205fn read_fragments_cache(root: &Path) -> OrderedMap<FragmentCacheEntry> {
1206 match fs::read_to_string(fragments_cache_path(root)) {
1207 Ok(text) => serde_json::from_str(&text).unwrap_or_default(),
1208 Err(_) => OrderedMap::default(),
1209 }
1210}
1211
1212fn write_fragments_cache(root: &Path, cache: &OrderedMap<FragmentCacheEntry>) -> io::Result<()> {
1213 atomic_write_json(&fragments_cache_path(root), cache)
1214}
1215
1216pub fn read_fragments_index(root: &Path) -> OrderedMap<i64> {
1219 match fs::read_to_string(fragments_index_path(root)) {
1220 Ok(text) => serde_json::from_str(&text).unwrap_or_default(),
1221 Err(_) => OrderedMap::default(),
1222 }
1223}
1224
1225fn write_fragments_index(root: &Path, cache: &OrderedMap<FragmentCacheEntry>) -> io::Result<()> {
1226 let mut index = OrderedMap::new();
1227 for (rel, entry) in cache.iter() {
1228 index.insert(rel.clone(), entry.mtime);
1229 }
1230 atomic_write_json(&fragments_index_path(root), &index)
1231}
1232
1233pub fn index_is_stale(index: &OrderedMap<i64>, graph_files: &[GraphFile]) -> bool {
1236 graph_files
1237 .iter()
1238 .any(|f| index.get(&f.rel) != Some(&f.mtime))
1239 || index.len() != graph_files.len()
1240}
1241
1242#[derive(Debug, Clone)]
1250pub struct GraphFile {
1251 pub rel: String,
1253 pub mtime: i64,
1255}
1256
1257#[derive(Debug)]
1258pub enum RebuildOutcome {
1260 NotRebuilt,
1262 Rebuilt(Graph),
1264}
1265
1266pub fn rebuild_graph(
1272 root: &Path,
1273 graph_files: &[GraphFile],
1274 fresh_fragments: &HashMap<String, AnyFragment>,
1275 changed: bool,
1276) -> io::Result<RebuildOutcome> {
1277 if !changed && graph_json_path(root).exists() {
1278 return Ok(RebuildOutcome::NotRebuilt);
1279 }
1280
1281 let cache = read_fragments_cache(root);
1282 let mut merged_cs: Vec<(String, Fragment)> = Vec::new();
1288 let mut merged_ts: Vec<(String, extract::TsFragment)> = Vec::new();
1289 let mut new_cache: OrderedMap<FragmentCacheEntry> = OrderedMap::new();
1290 for f in graph_files {
1291 let fragment = match cache.get(&f.rel) {
1292 Some(entry) if entry.mtime == f.mtime => Some(entry.fragment.clone()),
1293 _ => fresh_fragments.get(&f.rel).cloned(),
1294 };
1295 let Some(fragment) = fragment else { continue };
1296 match &fragment {
1297 AnyFragment::Cs(c) => merged_cs.push((f.rel.clone(), c.clone())),
1298 AnyFragment::Ts(t) => merged_ts.push((f.rel.clone(), t.clone())),
1299 }
1300 new_cache.insert(
1301 f.rel.clone(),
1302 FragmentCacheEntry {
1303 mtime: f.mtime,
1304 fragment,
1305 },
1306 );
1307 }
1308
1309 let graph = crate::resolve::resolve_graph_with_ts(root, &merged_cs, &merged_ts);
1310 write_graph(root, &graph)?;
1311 write_fragments_cache(root, &new_cache)?;
1312 write_fragments_index(root, &new_cache)?;
1313 remove_superseded_caches(root);
1314 Ok(RebuildOutcome::Rebuilt(graph))
1315}
1316
1317#[cfg(test)]
1318mod tests {
1319 use super::*;
1320 use std::time::{SystemTime, UNIX_EPOCH};
1321
1322 fn temp_dir(label: &str) -> PathBuf {
1323 let nanos = SystemTime::now()
1324 .duration_since(UNIX_EPOCH)
1325 .unwrap()
1326 .as_nanos();
1327 let dir = std::env::temp_dir().join(format!("scout-graph-test-{label}-{nanos}"));
1328 fs::create_dir_all(&dir).unwrap();
1329 dir
1330 }
1331
1332 #[test]
1335 fn ordered_map_preserves_insertion_order_through_json_round_trip() {
1336 let mut m: OrderedMap<i64> = OrderedMap::new();
1337 m.insert("z.cs".to_string(), 3);
1338 m.insert("a.cs".to_string(), 1);
1339 m.insert("m.cs".to_string(), 2);
1340 let json = serde_json::to_string(&m).unwrap();
1341 assert_eq!(json, r#"{"z.cs":3,"a.cs":1,"m.cs":2}"#);
1342
1343 let reparsed: OrderedMap<i64> = serde_json::from_str(&json).unwrap();
1344 let keys: Vec<&String> = reparsed.iter().map(|(k, _)| k).collect();
1345 assert_eq!(keys, vec!["z.cs", "a.cs", "m.cs"]);
1346 }
1347
1348 #[test]
1349 fn ordered_map_insert_overwrite_keeps_original_position() {
1350 let mut m: OrderedMap<i64> = OrderedMap::new();
1351 m.insert("a".to_string(), 1);
1352 m.insert("b".to_string(), 2);
1353 m.insert("a".to_string(), 99);
1354 let keys: Vec<&String> = m.iter().map(|(k, _)| k).collect();
1355 assert_eq!(keys, vec!["a", "b"]);
1356 assert_eq!(m.get("a"), Some(&99));
1357 }
1358
1359 #[test]
1362 fn percent1_whole_value_serializes_without_decimal_point() {
1363 let p = Percent1::from_ratio(2, 10); assert_eq!(serde_json::to_string(&p).unwrap(), "20");
1365 }
1366
1367 #[test]
1368 fn percent1_fractional_value_serializes_with_one_decimal_digit() {
1369 let p = Percent1::from_ratio(1, 3); assert_eq!(serde_json::to_string(&p).unwrap(), "33.3");
1371 }
1372
1373 #[test]
1374 fn percent1_zero_denominator_is_zero() {
1375 assert_eq!(Percent1::from_ratio(0, 0), Percent1::zero());
1376 assert_eq!(serde_json::to_string(&Percent1::zero()).unwrap(), "0");
1377 }
1378
1379 fn frag_def(
1382 methods: &[&str],
1383 properties: &[&str],
1384 fields: &[&str],
1385 method_returns: &[(&str, &str)],
1386 extension_methods: &[(&str, &str, usize, i64)],
1387 ) -> FragDef {
1388 frag_def_with_bases(
1389 methods,
1390 properties,
1391 fields,
1392 method_returns,
1393 extension_methods,
1394 &[],
1395 )
1396 }
1397
1398 fn frag_def_with_bases(
1399 methods: &[&str],
1400 properties: &[&str],
1401 fields: &[&str],
1402 method_returns: &[(&str, &str)],
1403 extension_methods: &[(&str, &str, usize, i64)],
1404 bases: &[&str],
1405 ) -> FragDef {
1406 let mut mr = OrderedMap::new();
1407 for (k, v) in method_returns {
1408 mr.insert((*k).to_string(), (*v).to_string());
1409 }
1410 FragDef {
1411 id: "App.Facts.Widget".into(),
1412 name: "Widget".into(),
1413 namespace: "App.Facts".into(),
1414 kind: "class".into(),
1415 line: 3,
1416 methods: methods.iter().map(|s| s.to_string()).collect(),
1417 properties: properties.iter().map(|s| s.to_string()).collect(),
1418 fields: fields.iter().map(|s| s.to_string()).collect(),
1419 method_returns: mr,
1420 extension_methods: extension_methods
1421 .iter()
1422 .map(|(n, t, lo, hi)| FragExtensionMethod {
1423 name: (*n).to_string(),
1424 this_type: (*t).to_string(),
1425 arity_min: *lo,
1426 arity_max: *hi,
1427 this_args: None,
1428 })
1429 .collect(),
1430 bases: bases.iter().map(|s| s.to_string()).collect(),
1431 type_params: Vec::new(),
1432 base_generic_args: OrderedMap::new(),
1433 property_types: OrderedMap::new(),
1434 test_methods: Vec::new(),
1435 end_line: 0,
1436 }
1437 }
1438
1439 #[test]
1440 fn frag_def_appends_properties_fields_and_method_returns_last_in_that_order() {
1441 let json = serde_json::to_string(&frag_def(
1442 &["GetAsync"],
1443 &["Prefix"],
1444 &["_log"],
1445 &[("GetAsync", "Task")],
1446 &[],
1447 ))
1448 .unwrap();
1449 assert_eq!(
1450 json,
1451 r#"{"id":"App.Facts.Widget","name":"Widget","namespace":"App.Facts","kind":"class","line":3,"methods":["GetAsync"],"properties":["Prefix"],"fields":["_log"],"methodReturns":{"GetAsync":"Task"}}"#
1452 );
1453 }
1454
1455 #[test]
1456 fn frag_def_omits_all_three_new_keys_when_the_type_declares_none_of_them() {
1457 let json = serde_json::to_string(&frag_def(&["Go"], &[], &[], &[], &[])).unwrap();
1458 assert_eq!(
1459 json,
1460 r#"{"id":"App.Facts.Widget","name":"Widget","namespace":"App.Facts","kind":"class","line":3,"methods":["Go"]}"#,
1461 "pre-stage-2 bytes preserved exactly"
1462 );
1463 }
1464
1465 #[test]
1470 fn frag_def_appends_extension_methods_after_method_returns_with_the_arity_range_last() {
1471 let json = serde_json::to_string(&frag_def(
1472 &["Go"],
1473 &["Prefix"],
1474 &["_log"],
1475 &[("Go", "Task")],
1476 &[
1477 ("Render", "Widget", 0, 0),
1478 ("Render", "Widget", 2, 3),
1479 ("Trim", "string", 0, -1),
1480 ],
1481 ))
1482 .unwrap();
1483 assert_eq!(
1484 json,
1485 r#"{"id":"App.Facts.Widget","name":"Widget","namespace":"App.Facts","kind":"class","line":3,"methods":["Go"],"properties":["Prefix"],"fields":["_log"],"methodReturns":{"Go":"Task"},"extensionMethods":[{"name":"Render","thisType":"Widget","arityMin":0,"arityMax":0},{"name":"Render","thisType":"Widget","arityMin":2,"arityMax":3},{"name":"Trim","thisType":"string","arityMin":0,"arityMax":-1}]}"#
1486 );
1487 }
1488
1489 #[test]
1490 fn frag_def_this_args_lands_after_arity_max_and_bases_after_extension_methods() {
1491 let mut d = frag_def_with_bases(
1492 &["Go"],
1493 &[],
1494 &[],
1495 &[],
1496 &[("Each", "List", 0, 0)],
1497 &["BaseWidget", "IWidget"],
1498 );
1499 d.extension_methods[0].this_args = Some(vec!["Widget".into()]);
1500 let json = serde_json::to_string(&d).unwrap();
1501 assert_eq!(
1502 json,
1503 r#"{"id":"App.Facts.Widget","name":"Widget","namespace":"App.Facts","kind":"class","line":3,"methods":["Go"],"extensionMethods":[{"name":"Each","thisType":"List","arityMin":0,"arityMax":0,"thisArgs":["Widget"]}],"bases":["BaseWidget","IWidget"]}"#
1504 );
1505 let reparsed: FragDef = serde_json::from_str(&json).unwrap();
1506 assert_eq!(
1507 reparsed.bases,
1508 vec!["BaseWidget".to_string(), "IWidget".to_string()]
1509 );
1510 assert_eq!(
1511 reparsed.extension_methods[0].this_args,
1512 Some(vec!["Widget".to_string()])
1513 );
1514 }
1515
1516 #[test]
1517 fn frag_def_omits_bases_when_the_type_lists_none() {
1518 let json = serde_json::to_string(&frag_def(&["Go"], &[], &[], &[], &[])).unwrap();
1519 assert!(
1520 !json.contains("bases"),
1521 "an empty base list must be omitted entirely: {json}"
1522 );
1523 let reparsed: FragDef = serde_json::from_str(&json).unwrap();
1524 assert!(
1525 reparsed.bases.is_empty(),
1526 "an absent key reads back as \"lists none\""
1527 );
1528 }
1529
1530 #[test]
1534 fn frag_def_appends_test_methods_last_after_bases() {
1535 let mut d = frag_def_with_bases(&["Go"], &[], &[], &[], &[], &["BaseWidget"]);
1536 d.test_methods = vec!["TotalsAnEmptyOrder".into(), "TotalsALineItem".into()];
1537 let json = serde_json::to_string(&d).unwrap();
1538 assert_eq!(
1539 json,
1540 r#"{"id":"App.Facts.Widget","name":"Widget","namespace":"App.Facts","kind":"class","line":3,"methods":["Go"],"bases":["BaseWidget"],"testMethods":["TotalsAnEmptyOrder","TotalsALineItem"]}"#
1541 );
1542 let reparsed: FragDef = serde_json::from_str(&json).unwrap();
1543 assert_eq!(
1544 reparsed.test_methods,
1545 vec![
1546 "TotalsAnEmptyOrder".to_string(),
1547 "TotalsALineItem".to_string()
1548 ]
1549 );
1550 }
1551
1552 #[test]
1553 fn frag_def_omits_test_methods_when_the_type_declares_no_tests() {
1554 let json = serde_json::to_string(&frag_def(&["Go"], &[], &[], &[], &[])).unwrap();
1555 assert!(
1556 !json.contains("testMethods"),
1557 "pre-stage bytes preserved exactly: {json}"
1558 );
1559 let reparsed: FragDef = serde_json::from_str(&json).unwrap();
1560 assert!(
1561 reparsed.test_methods.is_empty(),
1562 "an absent key reads back as \"declares no tests\""
1563 );
1564 }
1565
1566 #[test]
1567 fn frag_def_omits_extension_methods_when_the_type_declares_none() {
1568 let json =
1569 serde_json::to_string(&frag_def(&["Go"], &[], &[], &[("Go", "Task")], &[])).unwrap();
1570 assert!(
1571 !json.contains("extensionMethods"),
1572 "pre-stage-3 bytes preserved exactly: {json}"
1573 );
1574 let reparsed: FragDef = serde_json::from_str(&json).unwrap();
1575 assert!(
1576 reparsed.extension_methods.is_empty(),
1577 "an absent key reads back as \"declares none\""
1578 );
1579 }
1580
1581 #[test]
1582 fn frag_def_method_returns_serializes_in_first_declaration_order_not_sorted() {
1583 let json = serde_json::to_string(&frag_def(
1586 &[],
1587 &[],
1588 &[],
1589 &[("Zebra", "Z"), ("Alpha", "A")],
1590 &[],
1591 ))
1592 .unwrap();
1593 assert!(
1594 json.ends_with(r#""methodReturns":{"Zebra":"Z","Alpha":"A"}}"#),
1595 "insertion order, not sorted: {json}"
1596 );
1597 let reparsed: FragDef = serde_json::from_str(&json).unwrap();
1598 let keys: Vec<&String> = reparsed.method_returns.iter().map(|(k, _)| k).collect();
1599 assert_eq!(
1600 keys,
1601 vec!["Zebra", "Alpha"],
1602 "and the order survives a round trip"
1603 );
1604 }
1605
1606 #[test]
1610 fn frag_def_appends_property_types_last_after_test_methods() {
1611 let mut d =
1612 frag_def_with_bases(&["Go"], &["Dial", "Slots"], &[], &[], &[], &["BaseWidget"]);
1613 d.test_methods = vec!["TotalsALineItem".into()];
1614 d.property_types.insert(
1615 "Dial".into(),
1616 FragFact {
1617 type_name: "Gauge".into(),
1618 args: None,
1619 },
1620 );
1621 d.property_types.insert(
1622 "Slots".into(),
1623 FragFact {
1624 type_name: "Toolbox".into(),
1625 args: Some(vec!["Gadget".into()]),
1626 },
1627 );
1628 let json = serde_json::to_string(&d).unwrap();
1629 assert_eq!(
1630 json,
1631 r#"{"id":"App.Facts.Widget","name":"Widget","namespace":"App.Facts","kind":"class","line":3,"methods":["Go"],"properties":["Dial","Slots"],"bases":["BaseWidget"],"testMethods":["TotalsALineItem"],"propertyTypes":{"Dial":{"type":"Gauge"},"Slots":{"type":"Toolbox","args":["Gadget"]}}}"#
1632 );
1633 let reparsed: FragDef = serde_json::from_str(&json).unwrap();
1634 let keys: Vec<&String> = reparsed.property_types.iter().map(|(k, _)| k).collect();
1635 assert_eq!(
1636 keys,
1637 vec!["Dial", "Slots"],
1638 "source order survives a round trip, like methodReturns"
1639 );
1640 }
1641
1642 #[test]
1643 fn frag_def_omits_property_types_when_no_property_carries_a_fact() {
1644 let json = serde_json::to_string(&frag_def(&["Go"], &["Label"], &[], &[], &[])).unwrap();
1645 assert!(
1646 !json.contains("propertyTypes"),
1647 "pre-propertyTypes bytes preserved exactly: {json}"
1648 );
1649 let reparsed: FragDef = serde_json::from_str(&json).unwrap();
1650 assert!(
1651 reparsed.property_types.is_empty(),
1652 "an absent key reads back as \"no property vouches for a type\""
1653 );
1654 }
1655
1656 fn frag_ref(generic: bool, receiver_type: Option<&str>, arg_count: Option<usize>) -> FragRef {
1660 FragRef {
1661 kind: "uses-member".into(),
1662 name: "repo".into(),
1663 qualified: None,
1664 member: Some("Save".into()),
1665 line: 7,
1666 namespace: Some("App.Shape".into()),
1667 type_arg_count: None,
1668 generic,
1669 receiver_type: receiver_type.map(String::from),
1670 arg_count,
1671 receiver_args: None,
1672 outer_types: Vec::new(),
1673 args: None,
1674 receiver_property_owner: None,
1675 receiver_call_owner: None,
1676 receiver_call_member: None,
1677 }
1678 }
1679
1680 #[test]
1681 fn frag_ref_receiver_args_is_appended_last_after_arg_count() {
1682 let r = FragRef {
1683 receiver_args: Some(vec!["FutureState".into(), "*".into()]),
1684 ..frag_ref(false, Some("Binder"), Some(1))
1685 };
1686 let json = serde_json::to_string(&r).unwrap();
1687 assert_eq!(
1688 json,
1689 r#"{"kind":"uses-member","name":"repo","member":"Save","line":7,"namespace":"App.Shape","receiverType":"Binder","argCount":1,"receiverArgs":["FutureState","*"]}"#
1690 );
1691 let reparsed: FragRef = serde_json::from_str(&json).unwrap();
1692 assert_eq!(
1693 reparsed.receiver_args,
1694 Some(vec!["FutureState".to_string(), "*".to_string()])
1695 );
1696 }
1697
1698 #[test]
1699 fn frag_ref_receiver_type_then_arg_count_are_appended_last_after_generic() {
1700 let json = serde_json::to_string(&frag_ref(true, Some("IRepo"), Some(2))).unwrap();
1701 assert_eq!(
1702 json,
1703 r#"{"kind":"uses-member","name":"repo","member":"Save","line":7,"namespace":"App.Shape","generic":true,"receiverType":"IRepo","argCount":2}"#
1704 );
1705 }
1706
1707 #[test]
1708 fn frag_ref_arg_count_zero_still_serializes_it() {
1709 let json = serde_json::to_string(&frag_ref(false, Some("IRepo"), Some(0))).unwrap();
1713 assert!(
1714 json.ends_with(r#""receiverType":"IRepo","argCount":0}"#),
1715 "argCount 0 must survive: {json}"
1716 );
1717 }
1718
1719 #[test]
1720 fn frag_ref_without_a_fact_keeps_its_pre_stage_2_bytes() {
1721 let json = serde_json::to_string(&frag_ref(false, None, None)).unwrap();
1722 assert_eq!(
1723 json,
1724 r#"{"kind":"uses-member","name":"repo","member":"Save","line":7,"namespace":"App.Shape"}"#
1725 );
1726 let reparsed: FragRef = serde_json::from_str(&json).unwrap();
1727 assert_eq!(
1728 reparsed.receiver_type, None,
1729 "an absent key reads back as no fact"
1730 );
1731 assert_eq!(
1732 reparsed.arg_count, None,
1733 "and an absent argCount reads back as \"not a call\""
1734 );
1735 assert_eq!(
1736 reparsed.receiver_args, None,
1737 "and an absent receiverArgs reads back as \"not generic\""
1738 );
1739 }
1740
1741 #[test]
1744 fn def_without_also_in_omits_the_key() {
1745 let d = Def {
1746 id: "Ns.Type".into(),
1747 name: "Type".into(),
1748 namespace: "Ns".into(),
1749 kind: "class".into(),
1750 file: "Ns/Type.cs".into(),
1751 line: 3,
1752 methods: vec![],
1753 test_methods: vec![],
1754 also_in: vec![],
1755 end_line: 0,
1756 };
1757 let json = serde_json::to_string(&d).unwrap();
1758 assert!(
1759 !json.contains("also_in"),
1760 "empty also_in must be omitted: {json}"
1761 );
1762 }
1763
1764 #[test]
1765 fn def_end_line_serializes_last_and_defaults_when_absent() {
1766 let mut d = Def {
1767 id: "Ns.Type".into(),
1768 name: "Type".into(),
1769 namespace: "Ns".into(),
1770 kind: "class".into(),
1771 file: "Ns/Type.cs".into(),
1772 line: 3,
1773 methods: vec![],
1774 test_methods: vec![],
1775 also_in: vec![],
1776 end_line: 0,
1777 };
1778 assert!(!serde_json::to_string(&d).unwrap().contains("endLine"));
1779 d.end_line = 9;
1780 assert!(serde_json::to_string(&d)
1781 .unwrap()
1782 .ends_with(",\"endLine\":9}"));
1783 }
1784
1785 #[test]
1786 fn def_with_also_in_includes_it_after_methods() {
1787 let d = Def {
1788 id: "Ns.Type".into(),
1789 name: "Type".into(),
1790 namespace: "Ns".into(),
1791 kind: "class".into(),
1792 file: "Ns/Type.cs".into(),
1793 line: 3,
1794 methods: vec!["M".into()],
1795 test_methods: vec![],
1796 also_in: vec![AlsoIn {
1797 file: "Ns/Type.Extra.cs".into(),
1798 line: 5,
1799 }],
1800 end_line: 0,
1801 };
1802 let json = serde_json::to_string(&d).unwrap();
1803 let methods_pos = json.find("\"methods\"").unwrap();
1804 let also_in_pos = json.find("\"also_in\"").unwrap();
1805 assert!(
1806 methods_pos < also_in_pos,
1807 "also_in must come after methods: {json}"
1808 );
1809 }
1810
1811 #[test]
1815 fn def_row_places_test_methods_between_methods_and_also_in() {
1816 let d = Def {
1817 id: "Ns.TypeTests".into(),
1818 name: "TypeTests".into(),
1819 namespace: "Ns".into(),
1820 kind: "class".into(),
1821 file: "Ns/TypeTests.cs".into(),
1822 line: 3,
1823 methods: vec!["M".into()],
1824 test_methods: vec!["Fact1".into()],
1825 also_in: vec![AlsoIn {
1826 file: "Ns/TypeTests.Extra.cs".into(),
1827 line: 5,
1828 }],
1829 end_line: 0,
1830 };
1831 let json = serde_json::to_string(&d).unwrap();
1832 assert_eq!(
1833 json,
1834 r#"{"id":"Ns.TypeTests","name":"TypeTests","namespace":"Ns","kind":"class","file":"Ns/TypeTests.cs","line":3,"methods":["M"],"testMethods":["Fact1"],"also_in":[{"file":"Ns/TypeTests.Extra.cs","line":5}]}"#
1835 );
1836 }
1837
1838 #[test]
1839 fn def_row_omits_test_methods_when_the_def_declares_no_tests() {
1840 let d = Def {
1841 id: "Ns.Type".into(),
1842 name: "Type".into(),
1843 namespace: "Ns".into(),
1844 kind: "class".into(),
1845 file: "Ns/Type.cs".into(),
1846 line: 3,
1847 methods: vec!["M".into()],
1848 test_methods: vec![],
1849 also_in: vec![],
1850 end_line: 0,
1851 };
1852 let json = serde_json::to_string(&d).unwrap();
1853 assert!(
1854 !json.contains("testMethods"),
1855 "empty testMethods must be omitted: {json}"
1856 );
1857 }
1858
1859 #[test]
1862 fn stats_appends_test_def_count_last_and_always_writes_it() {
1863 let stats = Stats {
1864 def_count: 2,
1865 file_count: 1,
1866 edges_by_kind: EdgesByKind::default(),
1867 ambiguous_count: 0,
1868 ambiguous_pct: Percent1::zero(),
1869 unresolved_external_count: 0,
1870 heuristic_edge_count: 0,
1871 test_def_count: 0,
1872 ts: None,
1873 };
1874 let json = serde_json::to_string(&stats).unwrap();
1875 assert!(
1876 json.ends_with(r#""heuristic_edge_count":0,"test_def_count":0}"#),
1877 "test_def_count is LAST: {json}"
1878 );
1879 }
1880
1881 #[test]
1884 fn imports_edge_has_target_not_to() {
1885 let e = Edge::Imports {
1886 from_file: "F.cs".into(),
1887 from_line: 1,
1888 target: "System".into(),
1889 };
1890 let json = serde_json::to_string(&e).unwrap();
1891 assert_eq!(
1892 json,
1893 r#"{"kind":"imports","from_file":"F.cs","from_line":1,"target":"System"}"#
1894 );
1895 }
1896
1897 #[test]
1898 fn uses_type_edge_shape() {
1899 let e = Edge::UsesType {
1900 from_file: "F.cs".into(),
1901 from_line: 1,
1902 to: "Ns.T".into(),
1903 to_file: "Ns/T.cs".into(),
1904 heuristic: false,
1905 };
1906 let json = serde_json::to_string(&e).unwrap();
1907 assert_eq!(
1908 json,
1909 r#"{"kind":"uses-type","from_file":"F.cs","from_line":1,"to":"Ns.T","to_file":"Ns/T.cs"}"#
1910 );
1911 }
1912
1913 #[test]
1917 fn heuristic_flag_is_appended_last_and_omitted_when_false() {
1918 let precise = Edge::UsesMember {
1919 from_file: "F.cs".into(),
1920 from_line: 1,
1921 to: "Ns.T".into(),
1922 to_file: "Ns/T.cs".into(),
1923 heuristic: false,
1924 };
1925 assert_eq!(
1926 serde_json::to_string(&precise).unwrap(),
1927 r#"{"kind":"uses-member","from_file":"F.cs","from_line":1,"to":"Ns.T","to_file":"Ns/T.cs"}"#
1928 );
1929 let guess = Edge::UsesMember {
1930 from_file: "F.cs".into(),
1931 from_line: 1,
1932 to: "Ns.T".into(),
1933 to_file: "Ns/T.cs".into(),
1934 heuristic: true,
1935 };
1936 assert_eq!(
1937 serde_json::to_string(&guess).unwrap(),
1938 r#"{"kind":"uses-member","from_file":"F.cs","from_line":1,"to":"Ns.T","to_file":"Ns/T.cs","heuristic":true}"#
1939 );
1940 assert_eq!(
1943 serde_json::from_str::<Edge>(&serde_json::to_string(&guess).unwrap()).unwrap(),
1944 guess
1945 );
1946 assert_eq!(
1947 serde_json::from_str::<Edge>(&serde_json::to_string(&precise).unwrap()).unwrap(),
1948 precise
1949 );
1950 }
1951
1952 #[test]
1953 fn ambiguous_edge_shape() {
1954 let e = Edge::Ambiguous {
1955 origin: "uses-type".into(),
1956 from_file: "F.cs".into(),
1957 from_line: 4,
1958 raw: "Money".into(),
1959 candidates: vec![Candidate {
1960 id: "A.Money".into(),
1961 file: "A/Money.cs".into(),
1962 }],
1963 candidate_count: 2,
1964 };
1965 let json = serde_json::to_string(&e).unwrap();
1966 assert_eq!(
1967 json,
1968 r#"{"kind":"ambiguous","origin":"uses-type","from_file":"F.cs","from_line":4,"raw":"Money","candidates":[{"id":"A.Money","file":"A/Money.cs"}],"candidate_count":2}"#
1969 );
1970 }
1971
1972 #[test]
1975 fn edges_by_kind_field_order_is_fixed_not_alphabetical() {
1976 let s = EdgesByKind {
1977 inherits: 1,
1978 uses_type: 2,
1979 imports: 3,
1980 uses_member: 4,
1981 ctor_di: 5,
1982 ..Default::default()
1983 };
1984 let json = serde_json::to_string(&s).unwrap();
1985 assert_eq!(
1986 json,
1987 r#"{"inherits":1,"uses-type":2,"imports":3,"uses-member":4,"ctor-di":5}"#
1988 );
1989 }
1990
1991 #[test]
1998 fn any_fragment_round_trips_both_shapes_and_never_reads_one_as_the_other() {
1999 let cs = AnyFragment::Cs(Fragment {
2000 defs: Vec::new(),
2001 usings: Vec::new(),
2002 refs: Vec::new(),
2003 names: Vec::new(),
2004 });
2005 let cs_json = serde_json::to_string(&cs).unwrap();
2006 assert_eq!(cs_json, r#"{"defs":[],"usings":[],"refs":[],"names":[]}"#);
2007 assert!(matches!(
2008 serde_json::from_str::<AnyFragment>(&cs_json).unwrap(),
2009 AnyFragment::Cs(_)
2010 ));
2011
2012 let ts = AnyFragment::Ts(extract::TsFragment {
2013 ts: 1,
2014 defs: vec![extract::TsFragmentDef {
2015 name: "x".into(),
2016 kind: "const".into(),
2017 line: 1,
2018 end_line: 1,
2019 }],
2020 imports: Vec::new(),
2021 reexports: Vec::new(),
2022 refs: Vec::new(),
2023 default: None,
2024 });
2025 let ts_json = serde_json::to_string(&ts).unwrap();
2026 assert_eq!(
2027 ts_json,
2028 r#"{"ts":1,"defs":[{"name":"x","kind":"const","line":1,"endLine":1}],"imports":[],"reexports":[],"refs":[]}"#
2029 );
2030 assert!(matches!(
2031 serde_json::from_str::<AnyFragment>(&ts_json).unwrap(),
2032 AnyFragment::Ts(_)
2033 ));
2034 assert_eq!(
2035 serde_json::from_str::<AnyFragment>(&ts_json).unwrap(),
2036 ts,
2037 "and round-trips to the same value"
2038 );
2039 }
2040
2041 #[test]
2045 fn edges_by_kind_appends_the_four_ts_counts_after_ctor_di_when_present() {
2046 let s = EdgesByKind {
2047 inherits: 1,
2048 uses_type: 2,
2049 imports: 3,
2050 uses_member: 4,
2051 ctor_di: 5,
2052 import: Some(6),
2053 call: Some(7),
2054 jsx_use: Some(8),
2055 dispatch: Some(9),
2056 };
2057 let json = serde_json::to_string(&s).unwrap();
2058 assert_eq!(
2059 json,
2060 r#"{"inherits":1,"uses-type":2,"imports":3,"uses-member":4,"ctor-di":5,"import":6,"call":7,"jsx-use":8,"dispatch":9}"#
2061 );
2062 }
2063
2064 #[test]
2067 fn atomic_write_leaves_target_intact_when_only_a_stray_tmp_exists() {
2068 let dir = temp_dir("atomic-crash");
2069 let target = dir.join("graph.json");
2070 fs::write(&target, b"{\"old\":true}").unwrap();
2071 fs::write(dir.join(".graph.json.tmp.99999.7"), b"GARBAGE-PARTIAL").unwrap();
2075 assert_eq!(fs::read_to_string(&target).unwrap(), "{\"old\":true}");
2076 }
2077
2078 #[test]
2079 fn atomic_write_replaces_target_and_leaves_no_tmp_file_behind() {
2080 let dir = temp_dir("atomic-clean");
2081 let target = dir.join("graph.json");
2082 fs::write(&target, b"{\"old\":true}").unwrap();
2083 atomic_write_bytes(&target, b"{\"new\":true}").unwrap();
2084 assert_eq!(fs::read_to_string(&target).unwrap(), "{\"new\":true}");
2085 let leftover: Vec<_> = fs::read_dir(&dir)
2086 .unwrap()
2087 .filter_map(|e| e.ok())
2088 .filter(|e| e.file_name().to_string_lossy().contains(".tmp."))
2089 .collect();
2090 assert!(
2091 leftover.is_empty(),
2092 "no tmp file should remain: {leftover:?}"
2093 );
2094 }
2095
2096 #[test]
2097 fn atomic_write_creates_parent_directories() {
2098 let dir = temp_dir("atomic-mkdir");
2099 let target = dir.join("nested").join("deeper").join("graph.json");
2100 atomic_write_bytes(&target, b"{}").unwrap();
2101 assert_eq!(fs::read_to_string(&target).unwrap(), "{}");
2102 }
2103
2104 #[test]
2107 fn rebuild_graph_skips_when_unchanged_and_graph_already_exists() {
2108 let dir = temp_dir("rebuild-unchanged");
2109 fs::create_dir_all(graph_dir(&dir)).unwrap();
2110 fs::write(graph_json_path(&dir), b"{\"schema_version\":1,\"built_at_head\":null,\"defs\":[],\"edges\":[],\"stats\":{\"def_count\":0,\"file_count\":0,\"edges_by_kind\":{\"inherits\":0,\"uses-type\":0,\"imports\":0,\"uses-member\":0},\"ambiguous_count\":0,\"ambiguous_pct\":0,\"unresolved_external_count\":0}}").unwrap();
2111 let outcome = rebuild_graph(&dir, &[], &HashMap::new(), false).unwrap();
2112 assert!(matches!(outcome, RebuildOutcome::NotRebuilt));
2113 }
2114
2115 #[test]
2116 fn rebuild_graph_reuses_a_cached_fragment_at_matching_mtime() {
2117 let dir = temp_dir("rebuild-cache-hit");
2118 let fragment = Fragment {
2119 defs: vec![FragDef {
2120 id: "A".into(),
2121 name: "A".into(),
2122 namespace: "".into(),
2123 kind: "class".into(),
2124 line: 1,
2125 methods: vec![],
2126 properties: vec![],
2127 fields: vec![],
2128 method_returns: OrderedMap::new(),
2129 extension_methods: vec![],
2130 bases: vec![],
2131 type_params: vec![],
2132 base_generic_args: OrderedMap::new(),
2133 test_methods: vec![],
2134 property_types: OrderedMap::new(),
2135 end_line: 1,
2136 }],
2137 usings: vec![],
2138 refs: vec![],
2139 names: vec![],
2140 };
2141 let mut fresh = HashMap::new();
2143 fresh.insert("A.cs".to_string(), AnyFragment::Cs(fragment.clone()));
2144 let graph_files = vec![GraphFile {
2145 rel: "A.cs".to_string(),
2146 mtime: 111,
2147 }];
2148 let first = rebuild_graph(&dir, &graph_files, &fresh, true).unwrap();
2149 assert!(matches!(first, RebuildOutcome::Rebuilt(_)));
2150
2151 let empty: HashMap<String, AnyFragment> = HashMap::new();
2154 let second = rebuild_graph(&dir, &graph_files, &empty, true).unwrap();
2155 match second {
2156 RebuildOutcome::Rebuilt(g) => {
2157 assert_eq!(g.defs.len(), 1, "cached fragment must still be used")
2158 }
2159 RebuildOutcome::NotRebuilt => panic!("changed=true must always rebuild"),
2160 }
2161 }
2162
2163 #[test]
2166 fn rebuild_graph_writes_the_v13_caches_and_deletes_every_superseded_generation() {
2167 let dir = temp_dir("rebuild-v13");
2168 fs::create_dir_all(graph_dir(&dir)).unwrap();
2169 assert_eq!(SUPERSEDED_CACHE_FILES.len(), 28, "v1..v14 pairs");
2188 for stale in SUPERSEDED_CACHE_FILES {
2189 fs::write(graph_dir(&dir).join(stale), b"{}").unwrap();
2190 }
2191
2192 let fragment = Fragment {
2193 defs: vec![FragDef {
2194 id: "App.A".into(),
2195 name: "A".into(),
2196 namespace: "App".into(),
2197 kind: "class".into(),
2198 line: 1,
2199 methods: vec![],
2200 properties: vec![],
2201 fields: vec![],
2202 method_returns: OrderedMap::new(),
2203 extension_methods: vec![],
2204 bases: vec![],
2205 type_params: vec![],
2206 base_generic_args: OrderedMap::new(),
2207 test_methods: vec![],
2208 property_types: OrderedMap::new(),
2209 end_line: 1,
2210 }],
2211 usings: vec![],
2212 refs: vec![],
2213 names: vec![],
2214 };
2215 let mut fresh = HashMap::new();
2216 fresh.insert("src/A.cs".to_string(), AnyFragment::Cs(fragment));
2217 let graph_files = vec![GraphFile {
2218 rel: "src/A.cs".to_string(),
2219 mtime: 222,
2220 }];
2221 rebuild_graph(&dir, &graph_files, &fresh, true).unwrap();
2222
2223 assert!(
2224 graph_dir(&dir).join("fragments-v15.json").exists(),
2225 "the v15 payload cache is what gets written"
2226 );
2227 assert!(
2228 graph_dir(&dir).join("fragments-index-v15.json").exists(),
2229 "and its mtime-only index alongside it"
2230 );
2231 for stale in SUPERSEDED_CACHE_FILES {
2232 assert!(
2233 !graph_dir(&dir).join(stale).exists(),
2234 "{stale} must be deleted -- rename IS the invalidation"
2235 );
2236 }
2237 }
2238
2239 #[test]
2242 fn frag_ref_outer_types_is_appended_last_after_receiver_args() {
2243 let r = FragRef {
2244 receiver_args: Some(vec!["FutureState".into()]),
2245 outer_types: vec!["Outer".into(), "Inner".into()],
2246 ..frag_ref(false, Some("Binder"), Some(1))
2247 };
2248 let json = serde_json::to_string(&r).unwrap();
2249 assert_eq!(
2250 json,
2251 r#"{"kind":"uses-member","name":"repo","member":"Save","line":7,"namespace":"App.Shape","receiverType":"Binder","argCount":1,"receiverArgs":["FutureState"],"outerTypes":["Outer","Inner"]}"#
2252 );
2253 let reparsed: FragRef = serde_json::from_str(&json).unwrap();
2254 assert_eq!(
2255 reparsed.outer_types,
2256 vec!["Outer".to_string(), "Inner".to_string()]
2257 );
2258 }
2259
2260 #[test]
2263 fn frag_ref_property_owner_and_call_pair_are_appended_last_and_omitted_when_absent() {
2264 let hop = FragRef {
2265 qualified: Some("head.Dial".into()),
2266 name: "Dial".into(),
2267 receiver_property_owner: Some("Widget".into()),
2268 ..frag_ref(false, None, Some(0))
2269 };
2270 assert_eq!(
2271 serde_json::to_string(&hop).unwrap(),
2272 r#"{"kind":"uses-member","name":"Dial","qualified":"head.Dial","member":"Save","line":7,"namespace":"App.Shape","argCount":0,"receiverPropertyOwner":"Widget"}"#
2273 );
2274
2275 let from_call = FragRef {
2276 receiver_call_owner: Some("Factory".into()),
2277 receiver_call_member: Some("Make".into()),
2278 ..frag_ref(false, None, Some(0))
2279 };
2280 assert_eq!(
2281 serde_json::to_string(&from_call).unwrap(),
2282 r#"{"kind":"uses-member","name":"repo","member":"Save","line":7,"namespace":"App.Shape","argCount":0,"receiverCallOwner":"Factory","receiverCallMember":"Make"}"#
2283 );
2284
2285 let plain = serde_json::to_string(&frag_ref(false, Some("Binder"), Some(1))).unwrap();
2289 assert!(
2290 !plain.contains("receiverPropertyOwner") && !plain.contains("receiverCall"),
2291 "{plain}"
2292 );
2293 let reparsed: FragRef = serde_json::from_str(&plain).unwrap();
2294 assert_eq!(reparsed.receiver_property_owner, None);
2295 assert_eq!(reparsed.receiver_call_owner, None);
2296 assert_eq!(reparsed.receiver_call_member, None);
2297 }
2298
2299 #[test]
2300 fn frag_ref_an_empty_or_absent_outer_types_serializes_to_nothing_and_reads_back_empty() {
2301 let json = serde_json::to_string(&frag_ref(false, None, None)).unwrap();
2302 assert!(
2303 !json.contains("outerTypes"),
2304 "a namespace-level ref keeps its exact pre-v8 bytes: {json}"
2305 );
2306 let pre_v8: FragRef = serde_json::from_str(
2309 r#"{"kind":"uses-type","name":"Widget","line":3,"namespace":"App.Core"}"#,
2310 )
2311 .unwrap();
2312 assert!(pre_v8.outer_types.is_empty());
2313 }
2314}