1use std::collections::HashMap;
79use std::fmt;
80use std::fs;
81use std::io;
82use std::io::Read;
83use std::marker::PhantomData;
84use std::path::{Path, PathBuf};
85use std::sync::atomic::{AtomicU64, Ordering};
86
87use serde::de::{MapAccess, Visitor};
88use serde::ser::SerializeMap;
89use serde::{Deserialize, Deserializer, Serialize, Serializer};
90
91use crate::extract;
92use crate::repo::{git_common_dir, scout_dir};
93
94fn graph_dir(root: &Path) -> PathBuf {
102 match git_common_dir(root) {
103 Some(common) => common.join("scout").join("graph"),
104 None => scout_dir(root).join("graph"),
105 }
106}
107
108pub fn graph_json_path(root: &Path) -> PathBuf {
110 graph_dir(root).join("graph.json")
111}
112
113pub fn project_units_path(root: &Path) -> PathBuf {
124 graph_dir(root).join("project-units.json")
125}
126
127fn fragments_cache_path(root: &Path) -> PathBuf {
156 graph_dir(root).join("fragments-v18.json")
157}
158
159fn fragments_index_path(root: &Path) -> PathBuf {
160 graph_dir(root).join("fragments-index-v18.json")
161}
162
163const SUPERSEDED_CACHE_FILES: &[&str] = &[
167 "fragments.json",
168 "fragments-index.json",
169 "fragments-v2.json",
170 "fragments-index-v2.json",
171 "fragments-v3.json",
172 "fragments-index-v3.json",
173 "fragments-v4.json",
174 "fragments-index-v4.json",
175 "fragments-v5.json",
176 "fragments-index-v5.json",
177 "fragments-v6.json",
178 "fragments-index-v6.json",
179 "fragments-v7.json",
180 "fragments-index-v7.json",
181 "fragments-v8.json",
182 "fragments-index-v8.json",
183 "fragments-v9.json",
184 "fragments-index-v9.json",
185 "fragments-v10.json",
186 "fragments-index-v10.json",
187 "fragments-v11.json",
188 "fragments-index-v11.json",
189 "fragments-v12.json",
190 "fragments-index-v12.json",
191 "fragments-v13.json",
192 "fragments-index-v13.json",
193 "fragments-v14.json",
194 "fragments-index-v14.json",
195 "fragments-v15.json",
196 "fragments-index-v15.json",
197 "fragments-v16.json",
198 "fragments-index-v16.json",
199 "fragments-v17.json",
200 "fragments-index-v17.json",
201];
202
203fn remove_superseded_caches(root: &Path) {
204 for name in SUPERSEDED_CACHE_FILES {
205 let _ = fs::remove_file(graph_dir(root).join(name));
207 }
208}
209
210static TMP_COUNTER: AtomicU64 = AtomicU64::new(0);
220
221fn atomic_write_bytes(path: &Path, bytes: &[u8]) -> io::Result<()> {
222 let dir = path.parent().unwrap_or_else(|| Path::new("."));
223 fs::create_dir_all(dir)?;
224 let file_name = path
225 .file_name()
226 .and_then(|n| n.to_str())
227 .unwrap_or("artifact");
228 let counter = TMP_COUNTER.fetch_add(1, Ordering::Relaxed);
229 let tmp_path = dir.join(format!(".{file_name}.tmp.{}.{counter}", std::process::id()));
230 let write_result = fs::write(&tmp_path, bytes);
231 if write_result.is_err() {
232 let _ = fs::remove_file(&tmp_path);
233 write_result?;
234 }
235 fs::rename(&tmp_path, path)?;
236 Ok(())
237}
238
239fn atomic_write_json<T: Serialize>(path: &Path, value: &T) -> io::Result<()> {
240 let bytes = serde_json::to_vec(value).map_err(io::Error::other)?;
241 atomic_write_bytes(path, &bytes)
242}
243
244#[derive(Debug, Clone, PartialEq)]
256pub struct OrderedMap<V> {
258 entries: Vec<(String, V)>,
259 index: HashMap<String, usize>,
260}
261
262impl<V> OrderedMap<V> {
263 pub fn new() -> Self {
265 Self {
266 entries: Vec::new(),
267 index: HashMap::new(),
268 }
269 }
270
271 pub fn insert(&mut self, key: String, value: V) {
275 match self.index.get(&key) {
276 Some(&i) => self.entries[i].1 = value,
277 None => {
278 self.index.insert(key.clone(), self.entries.len());
279 self.entries.push((key, value));
280 }
281 }
282 }
283
284 pub fn get(&self, key: &str) -> Option<&V> {
286 self.index.get(key).map(|&i| &self.entries[i].1)
287 }
288
289 pub fn len(&self) -> usize {
291 self.entries.len()
292 }
293
294 pub fn is_empty(&self) -> bool {
296 self.entries.is_empty()
297 }
298
299 pub fn iter(&self) -> impl Iterator<Item = (&String, &V)> {
301 self.entries.iter().map(|(k, v)| (k, v))
302 }
303}
304
305impl<V> Default for OrderedMap<V> {
306 fn default() -> Self {
307 Self::new()
308 }
309}
310
311impl<V: Serialize> Serialize for OrderedMap<V> {
312 fn serialize<S: Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
313 let mut map = serializer.serialize_map(Some(self.entries.len()))?;
314 for (k, v) in &self.entries {
315 map.serialize_entry(k, v)?;
316 }
317 map.end()
318 }
319}
320
321impl<'de, V: Deserialize<'de>> Deserialize<'de> for OrderedMap<V> {
322 fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
323 struct OrderedMapVisitor<V>(PhantomData<V>);
324
325 impl<'de, V: Deserialize<'de>> Visitor<'de> for OrderedMapVisitor<V> {
326 type Value = OrderedMap<V>;
327
328 fn expecting(&self, f: &mut fmt::Formatter) -> fmt::Result {
329 f.write_str("a JSON object")
330 }
331
332 fn visit_map<A: MapAccess<'de>>(self, mut map: A) -> Result<Self::Value, A::Error> {
333 let mut out = OrderedMap::new();
334 while let Some((k, v)) = map.next_entry::<String, V>()? {
335 out.insert(k, v);
336 }
337 Ok(out)
338 }
339 }
340
341 deserializer.deserialize_map(OrderedMapVisitor(PhantomData))
342 }
343}
344
345#[derive(Debug, Clone, Copy, PartialEq, Eq)]
352pub struct Percent1(pub i64);
354
355impl Percent1 {
356 pub fn zero() -> Self {
358 Percent1(0)
359 }
360
361 pub fn from_ratio(numerator: usize, denominator: usize) -> Self {
365 if denominator == 0 {
366 return Percent1::zero();
367 }
368 let raw = (numerator as f64 / denominator as f64) * 1000.0;
369 Percent1(raw.round() as i64)
370 }
371}
372
373impl Serialize for Percent1 {
374 fn serialize<S: Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
375 if self.0 % 10 == 0 {
376 serializer.serialize_i64(self.0 / 10)
377 } else {
378 serializer.serialize_f64(self.0 as f64 / 10.0)
379 }
380 }
381}
382
383impl<'de> Deserialize<'de> for Percent1 {
384 fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
385 let v = f64::deserialize(deserializer)?;
386 Ok(Percent1((v * 10.0).round() as i64))
387 }
388}
389
390#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
395pub struct AlsoIn {
397 pub file: String,
399 pub line: usize,
401}
402
403#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
404pub struct Def {
406 pub id: String,
408 pub name: String,
410 pub namespace: String,
412 pub kind: String,
414 pub file: String,
416 pub line: usize,
418 pub methods: Vec<String>,
420 #[serde(default, rename = "testMethods", skip_serializing_if = "Vec::is_empty")]
427 pub test_methods: Vec<String>,
428 #[serde(default, skip_serializing_if = "Vec::is_empty")]
429 pub also_in: Vec<AlsoIn>,
431 #[serde(default, rename = "endLine", skip_serializing_if = "is_zero")]
432 pub end_line: usize,
434}
435
436#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
437pub struct Candidate {
439 pub id: String,
441 pub file: String,
443}
444
445#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
453#[serde(rename_all = "lowercase")]
454pub enum HeuristicTier {
455 Ext,
457 Guess,
459}
460
461#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
484#[serde(tag = "kind")]
485pub enum Edge {
486 #[serde(rename = "inherits")]
487 Inherits {
489 from_file: String,
491 from_line: usize,
493 to: String,
495 to_file: String,
497 #[serde(default, skip_serializing_if = "is_false")]
498 heuristic: bool,
500 },
501 #[serde(rename = "uses-type")]
502 UsesType {
504 from_file: String,
506 from_line: usize,
508 to: String,
510 to_file: String,
512 #[serde(default, skip_serializing_if = "is_false")]
513 heuristic: bool,
515 },
516 #[serde(rename = "uses-member")]
517 UsesMember {
519 from_file: String,
521 from_line: usize,
523 to: String,
525 to_file: String,
527 #[serde(default, skip_serializing_if = "is_false")]
528 heuristic: bool,
530 #[serde(default, skip_serializing_if = "Option::is_none")]
531 tier: Option<HeuristicTier>,
535 #[serde(default, skip_serializing_if = "Option::is_none")]
536 member: Option<String>,
540 },
541 #[serde(rename = "imports")]
542 Imports {
544 from_file: String,
546 from_line: usize,
548 target: String,
550 },
551 #[serde(rename = "ambiguous")]
552 Ambiguous {
554 origin: String,
556 from_file: String,
558 from_line: usize,
560 raw: String,
562 candidates: Vec<Candidate>,
564 candidate_count: usize,
566 },
567 #[serde(rename = "ctor-di")]
577 CtorDi {
578 from_file: String,
580 from_line: usize,
582 iface: String,
584 resolution: String,
586 #[serde(default, skip_serializing_if = "Option::is_none")]
587 args: Option<Vec<String>>,
589 #[serde(default, skip_serializing_if = "Option::is_none")]
590 to: Option<String>,
592 #[serde(default, skip_serializing_if = "Vec::is_empty")]
593 candidates: Vec<Candidate>,
595 },
596 #[serde(rename = "import")]
603 Import {
604 from_file: String,
606 from_line: usize,
608 target: String,
610 to_file: String,
612 #[serde(default, skip_serializing_if = "Option::is_none")]
613 via: Option<String>,
615 },
616 #[serde(rename = "call")]
619 Call {
621 from_file: String,
623 from_line: usize,
625 to: String,
627 to_file: String,
629 },
630 #[serde(rename = "jsx-use")]
632 JsxUse {
634 from_file: String,
636 from_line: usize,
638 to: String,
640 to_file: String,
642 },
643 #[serde(rename = "dispatch")]
646 Dispatch {
648 from_file: String,
650 from_line: usize,
652 to: String,
654 to_file: String,
656 },
657}
658
659impl Edge {
660 pub fn tier(&self) -> Option<HeuristicTier> {
664 match self {
665 Edge::UsesMember { tier, .. } => *tier,
666 _ => None,
667 }
668 }
669
670 pub fn is_heuristic(&self) -> bool {
673 match self {
674 Edge::Inherits { heuristic, .. }
675 | Edge::UsesType { heuristic, .. }
676 | Edge::UsesMember { heuristic, .. } => *heuristic,
677 _ => false,
678 }
679 }
680
681 pub fn uses_member(
686 from_file: String,
687 from_line: usize,
688 to: String,
689 to_file: String,
690 member: Option<String>,
691 tier: Option<HeuristicTier>,
692 ) -> Edge {
693 Edge::UsesMember {
694 from_file,
695 from_line,
696 to,
697 to_file,
698 heuristic: tier.is_some(),
699 tier,
700 member,
701 }
702 }
703}
704
705#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
706pub struct EdgesByKind {
708 pub inherits: usize,
710 #[serde(rename = "uses-type")]
711 pub uses_type: usize,
713 pub imports: usize,
715 #[serde(rename = "uses-member")]
716 pub uses_member: usize,
718 #[serde(rename = "ctor-di")]
720 pub ctor_di: usize,
721 #[serde(default, skip_serializing_if = "Option::is_none")]
726 pub import: Option<usize>,
727 #[serde(default, skip_serializing_if = "Option::is_none")]
728 pub call: Option<usize>,
730 #[serde(rename = "jsx-use", default, skip_serializing_if = "Option::is_none")]
731 pub jsx_use: Option<usize>,
733 #[serde(default, skip_serializing_if = "Option::is_none")]
734 pub dispatch: Option<usize>,
736}
737
738#[derive(Debug, Clone, Copy, Default, PartialEq, Serialize, Deserialize)]
745pub struct HeuristicByTier {
746 pub ext: usize,
748 pub guess: usize,
750}
751
752#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
753pub struct Stats {
755 pub def_count: usize,
757 pub file_count: usize,
759 pub edges_by_kind: EdgesByKind,
761 pub ambiguous_count: usize,
763 pub ambiguous_pct: Percent1,
765 pub unresolved_external_count: usize,
767 #[serde(default)]
773 pub heuristic_edge_count: usize,
774 #[serde(default)]
778 pub test_def_count: usize,
779 #[serde(default, skip_serializing_if = "Option::is_none")]
786 pub ts: Option<crate::tsgraph::TsStats>,
787 #[serde(default)]
795 pub heuristic_by_tier: HeuristicByTier,
796}
797
798#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
803pub struct GraphName {
804 pub name: String,
806 pub kind: String,
808 pub file: String,
810 pub line: usize,
812 #[serde(default, skip_serializing_if = "String::is_empty")]
813 pub owner: String,
815}
816
817#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
829pub struct GraphUnit {
830 pub id: String,
833 pub name: String,
835 #[serde(default, skip_serializing_if = "Vec::is_empty")]
838 pub refs: Vec<String>,
839 #[serde(default, skip_serializing_if = "is_not_test")]
841 pub test: bool,
842}
843
844fn is_not_test(b: &bool) -> bool {
845 !*b
846}
847
848pub const GRAPH_SCHEMA_VERSION: u32 = 2;
856
857#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
858pub struct Graph {
860 pub schema_version: u32,
862 pub built_at_head: Option<String>,
864 pub defs: Vec<Def>,
866 pub edges: Vec<Edge>,
868 pub stats: Stats,
870 #[serde(default, skip_serializing_if = "Vec::is_empty")]
874 pub names: Vec<GraphName>,
875 #[serde(default, skip_serializing_if = "Vec::is_empty")]
880 pub units: Vec<GraphUnit>,
881}
882
883pub fn read_graph(root: &Path) -> Option<Graph> {
885 let text = fs::read_to_string(graph_json_path(root)).ok()?;
886 serde_json::from_str(&text).ok()
887}
888
889fn write_graph(root: &Path, graph: &Graph) -> io::Result<()> {
890 atomic_write_json(&graph_json_path(root), graph)
891}
892
893#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
906pub struct FragDef {
907 pub id: String,
909 pub name: String,
911 pub namespace: String,
913 pub kind: String,
915 pub line: usize,
917 pub methods: Vec<String>,
919 #[serde(default, skip_serializing_if = "Vec::is_empty")]
920 pub properties: Vec<String>,
922 #[serde(default, skip_serializing_if = "Vec::is_empty")]
923 pub fields: Vec<String>,
925 #[serde(
930 default,
931 rename = "methodReturns",
932 skip_serializing_if = "OrderedMap::is_empty"
933 )]
934 pub method_returns: OrderedMap<String>,
935 #[serde(
940 default,
941 rename = "extensionMethods",
942 skip_serializing_if = "Vec::is_empty"
943 )]
944 pub extension_methods: Vec<FragExtensionMethod>,
945 #[serde(default, skip_serializing_if = "Vec::is_empty")]
951 pub bases: Vec<String>,
952 #[serde(default, rename = "typeParams", skip_serializing_if = "Vec::is_empty")]
957 pub type_params: Vec<String>,
958 #[serde(
964 default,
965 rename = "baseGenericArgs",
966 skip_serializing_if = "OrderedMap::is_empty"
967 )]
968 pub base_generic_args: OrderedMap<Vec<String>>,
969 #[serde(default, rename = "testMethods", skip_serializing_if = "Vec::is_empty")]
974 pub test_methods: Vec<String>,
975 #[serde(
981 default,
982 rename = "propertyTypes",
983 skip_serializing_if = "OrderedMap::is_empty"
984 )]
985 pub property_types: OrderedMap<FragFact>,
986 #[serde(
995 default,
996 rename = "fieldTypes",
997 skip_serializing_if = "OrderedMap::is_empty"
998 )]
999 pub field_types: OrderedMap<FragFact>,
1000 #[serde(
1013 default,
1014 rename = "methodReturnArgs",
1015 skip_serializing_if = "OrderedMap::is_empty"
1016 )]
1017 pub method_return_args: OrderedMap<Vec<String>>,
1018 #[serde(
1031 default,
1032 rename = "nonPublicMethods",
1033 skip_serializing_if = "Vec::is_empty"
1034 )]
1035 pub non_public_methods: Vec<String>,
1036 #[serde(
1048 default,
1049 rename = "methodArities",
1050 skip_serializing_if = "OrderedMap::is_empty"
1051 )]
1052 pub method_arities: OrderedMap<Vec<(usize, i64)>>,
1053 #[serde(
1067 default,
1068 rename = "methodParams",
1069 skip_serializing_if = "OrderedMap::is_empty"
1070 )]
1071 pub method_params: OrderedMap<Vec<Vec<String>>>,
1072 #[serde(default, rename = "endLine", skip_serializing_if = "is_zero")]
1073 pub end_line: usize,
1075}
1076
1077#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
1081pub struct FragFact {
1082 #[serde(rename = "type")]
1083 pub type_name: String,
1085 #[serde(default, skip_serializing_if = "Option::is_none")]
1086 pub args: Option<Vec<String>>,
1088}
1089
1090#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
1097pub struct FragExtensionMethod {
1098 pub name: String,
1100 #[serde(rename = "thisType")]
1101 pub this_type: String,
1103 #[serde(rename = "arityMin")]
1104 pub arity_min: usize,
1106 #[serde(rename = "arityMax")]
1107 pub arity_max: i64,
1109 #[serde(default, rename = "thisArgs", skip_serializing_if = "Option::is_none")]
1112 pub this_args: Option<Vec<String>>,
1113}
1114
1115#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
1121#[serde(rename_all = "camelCase")]
1122pub struct FragLambdaSlot {
1123 pub owner: String,
1125 pub member: String,
1127 pub arg_count: usize,
1129 pub arg_index: usize,
1131 pub arity: usize,
1133 pub index: usize,
1135}
1136
1137#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
1138#[serde(untagged)]
1139pub enum FragUsing {
1141 Alias {
1143 alias: String,
1145 target: String,
1147 global: bool,
1149 },
1150 Plain {
1152 text: String,
1154 global: bool,
1156 },
1157}
1158
1159#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
1160pub struct FragRef {
1162 pub kind: String,
1164 pub name: String,
1166 #[serde(default, skip_serializing_if = "Option::is_none")]
1167 pub qualified: Option<String>,
1169 #[serde(default, skip_serializing_if = "Option::is_none")]
1170 pub member: Option<String>,
1172 pub line: usize,
1174 pub namespace: Option<String>,
1178 #[serde(
1179 default,
1180 rename = "typeArgCount",
1181 skip_serializing_if = "Option::is_none"
1182 )]
1183 pub type_arg_count: Option<usize>,
1185 #[serde(default, skip_serializing_if = "is_false")]
1189 pub generic: bool,
1190 #[serde(
1195 default,
1196 rename = "receiverType",
1197 skip_serializing_if = "Option::is_none"
1198 )]
1199 pub receiver_type: Option<String>,
1200 #[serde(default, rename = "argCount", skip_serializing_if = "Option::is_none")]
1206 pub arg_count: Option<usize>,
1207 #[serde(
1212 default,
1213 rename = "receiverArgs",
1214 skip_serializing_if = "Option::is_none"
1215 )]
1216 pub receiver_args: Option<Vec<String>>,
1217 #[serde(default, rename = "outerTypes", skip_serializing_if = "Vec::is_empty")]
1223 pub outer_types: Vec<String>,
1224 #[serde(default, skip_serializing_if = "Option::is_none")]
1228 pub args: Option<Vec<String>>,
1229 #[serde(
1234 default,
1235 rename = "receiverPropertyOwner",
1236 skip_serializing_if = "Option::is_none"
1237 )]
1238 pub receiver_property_owner: Option<String>,
1239 #[serde(
1245 default,
1246 rename = "receiverCallOwner",
1247 skip_serializing_if = "Option::is_none"
1248 )]
1249 pub receiver_call_owner: Option<String>,
1250 #[serde(
1251 default,
1252 rename = "receiverCallMember",
1253 skip_serializing_if = "Option::is_none"
1254 )]
1255 pub receiver_call_member: Option<String>,
1257 #[serde(default, rename = "receiverBase", skip_serializing_if = "is_false")]
1264 pub receiver_base: bool,
1265 #[serde(default, rename = "receiverAwaited", skip_serializing_if = "is_false")]
1274 pub receiver_awaited: bool,
1275 #[serde(default, rename = "receiverLocal", skip_serializing_if = "is_false")]
1291 pub receiver_local: bool,
1292 #[serde(
1301 default,
1302 rename = "receiverLambda",
1303 skip_serializing_if = "Option::is_none"
1304 )]
1305 pub receiver_lambda: Option<FragLambdaSlot>,
1306}
1307
1308fn is_false(b: &bool) -> bool {
1309 !*b
1310}
1311
1312fn is_zero(n: &usize) -> bool {
1313 *n == 0
1314}
1315
1316#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
1321pub struct FragName {
1322 pub name: String,
1324 pub kind: String,
1326 pub line: usize,
1328 #[serde(default, skip_serializing_if = "String::is_empty")]
1329 pub owner: String,
1331}
1332
1333#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
1334pub struct Fragment {
1336 pub defs: Vec<FragDef>,
1338 pub usings: Vec<FragUsing>,
1340 pub refs: Vec<FragRef>,
1342 #[serde(default)]
1346 pub names: Vec<FragName>,
1347}
1348
1349#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
1357#[serde(untagged)]
1358pub enum AnyFragment {
1359 Ts(extract::TsFragment),
1361 Cs(Fragment),
1363}
1364
1365impl From<Fragment> for AnyFragment {
1366 fn from(f: Fragment) -> Self {
1367 AnyFragment::Cs(f)
1368 }
1369}
1370
1371impl From<extract::TsFragment> for AnyFragment {
1372 fn from(f: extract::TsFragment) -> Self {
1373 AnyFragment::Ts(f)
1374 }
1375}
1376
1377#[allow(
1381 clippy::too_many_lines,
1382 reason = "one ordered field-by-field translation from Extraction into Fragment; splitting it would separate fields that must stay mapped consistently"
1383)]
1384pub fn fragment_from_extraction(e: &extract::Extraction) -> Fragment {
1385 Fragment {
1386 defs: e
1387 .defs
1388 .iter()
1389 .map(|d| FragDef {
1390 id: d.id.clone(),
1391 name: d.name.clone(),
1392 namespace: d.namespace.clone(),
1393 kind: d.kind.clone(),
1394 line: d.line,
1395 methods: d.methods.clone(),
1396 properties: d.properties.clone(),
1397 fields: d.fields.clone(),
1398 method_returns: {
1399 let mut m = OrderedMap::new();
1400 for (name, returns) in &d.method_returns {
1401 m.insert(name.clone(), returns.clone());
1402 }
1403 m
1404 },
1405 extension_methods: d
1406 .extension_methods
1407 .iter()
1408 .map(|e| FragExtensionMethod {
1409 name: e.name.clone(),
1410 this_type: e.this_type.clone(),
1411 arity_min: e.arity_min,
1412 arity_max: e.arity_max,
1413 this_args: e.this_args.clone(),
1414 })
1415 .collect(),
1416 bases: d.bases.clone(),
1417 type_params: d.type_params.clone(),
1418 base_generic_args: {
1419 let mut m = OrderedMap::new();
1420 for (name, args) in &d.base_generic_args {
1421 m.insert(name.clone(), args.clone());
1422 }
1423 m
1424 },
1425 test_methods: d.test_methods.clone(),
1426 property_types: {
1427 let mut m = OrderedMap::new();
1428 for (name, fact) in &d.property_types {
1429 m.insert(
1430 name.clone(),
1431 FragFact {
1432 type_name: fact.type_name.clone(),
1433 args: fact.args.clone(),
1434 },
1435 );
1436 }
1437 m
1438 },
1439 field_types: {
1440 let mut m = OrderedMap::new();
1441 for (name, fact) in &d.field_types {
1442 m.insert(
1443 name.clone(),
1444 FragFact {
1445 type_name: fact.type_name.clone(),
1446 args: fact.args.clone(),
1447 },
1448 );
1449 }
1450 m
1451 },
1452 method_return_args: {
1453 let mut m = OrderedMap::new();
1454 for (name, args) in &d.method_return_args {
1455 m.insert(name.clone(), args.clone());
1456 }
1457 m
1458 },
1459 non_public_methods: d.non_public_methods.clone(),
1460 method_arities: {
1461 let mut m = OrderedMap::new();
1462 for (name, ranges) in &d.method_arities {
1463 m.insert(name.clone(), ranges.clone());
1464 }
1465 m
1466 },
1467 method_params: {
1468 let mut m = OrderedMap::new();
1469 for (name, overloads) in &d.method_params {
1470 m.insert(name.clone(), overloads.clone());
1471 }
1472 m
1473 },
1474 end_line: d.end_line,
1475 })
1476 .collect(),
1477 usings: e
1478 .usings
1479 .iter()
1480 .map(|u| match u {
1481 extract::UsingRecord::Alias {
1482 alias,
1483 target,
1484 global,
1485 } => FragUsing::Alias {
1486 alias: alias.clone(),
1487 target: target.clone(),
1488 global: *global,
1489 },
1490 extract::UsingRecord::Plain { text, global } => FragUsing::Plain {
1491 text: text.clone(),
1492 global: *global,
1493 },
1494 })
1495 .collect(),
1496 refs: e
1497 .refs
1498 .iter()
1499 .map(|r| FragRef {
1500 kind: r.kind.clone(),
1501 name: r.name.clone(),
1502 qualified: r.qualified.clone(),
1503 member: r.member.clone(),
1504 line: r.line,
1505 namespace: r.namespace.clone(),
1506 type_arg_count: r.type_arg_count,
1507 generic: r.generic,
1508 receiver_type: r.receiver_type.clone(),
1509 arg_count: r.arg_count,
1510 receiver_args: r.receiver_args.clone(),
1511 outer_types: r.outer_types.clone(),
1512 args: r.args.clone(),
1513 receiver_property_owner: r.receiver_property_owner.clone(),
1514 receiver_call_owner: r.receiver_call_owner.clone(),
1515 receiver_call_member: r.receiver_call_member.clone(),
1516 receiver_base: r.receiver_base,
1517 receiver_awaited: r.receiver_awaited,
1518 receiver_local: r.receiver_local,
1519 receiver_lambda: r.receiver_lambda.as_ref().map(|s| FragLambdaSlot {
1520 owner: s.owner.clone(),
1521 member: s.member.clone(),
1522 arg_count: s.arg_count,
1523 arg_index: s.arg_index,
1524 arity: s.arity,
1525 index: s.index,
1526 }),
1527 })
1528 .collect(),
1529 names: e
1530 .names
1531 .iter()
1532 .map(|n| FragName {
1533 name: n.name.clone(),
1534 kind: n.kind.clone(),
1535 line: n.line,
1536 owner: n.owner.clone(),
1537 })
1538 .collect(),
1539 }
1540}
1541
1542pub fn markup_fragment(root: &Path, rel: &str) -> Option<Fragment> {
1550 let text = String::from_utf8_lossy(&fs::read(root.join(rel)).ok()?).into_owned();
1553 let facts = crate::markup::markup_facts(rel, &text);
1554 Some(Fragment {
1555 defs: facts
1556 .defs
1557 .into_iter()
1558 .map(|d| FragDef {
1559 id: d.id,
1560 name: d.name,
1561 namespace: d.namespace,
1562 kind: d.kind,
1563 line: d.line,
1564 methods: Vec::new(),
1565 properties: Vec::new(),
1566 fields: Vec::new(),
1567 method_returns: OrderedMap::new(),
1568 extension_methods: Vec::new(),
1569 bases: Vec::new(),
1570 type_params: Vec::new(),
1571 base_generic_args: OrderedMap::new(),
1572 test_methods: Vec::new(),
1573 property_types: OrderedMap::new(),
1574 field_types: OrderedMap::new(),
1575 method_return_args: OrderedMap::new(),
1576 non_public_methods: Vec::new(),
1577 method_arities: OrderedMap::new(),
1578 method_params: OrderedMap::new(),
1579 end_line: d.line,
1580 })
1581 .collect(),
1582 usings: Vec::new(),
1583 refs: facts
1584 .refs
1585 .into_iter()
1586 .map(|r| FragRef {
1587 kind: r.kind,
1588 name: r.name,
1589 qualified: r.qualified,
1590 member: r.member,
1591 line: r.line,
1592 namespace: Some(String::new()),
1596 type_arg_count: Some(0),
1597 generic: false,
1598 receiver_type: None,
1599 arg_count: None,
1600 receiver_args: None,
1601 outer_types: Vec::new(),
1602 args: None,
1603 receiver_property_owner: None,
1604 receiver_call_owner: None,
1605 receiver_call_member: None,
1606 receiver_base: false,
1607 receiver_awaited: false,
1608 receiver_local: false,
1609 receiver_lambda: None,
1610 })
1611 .collect(),
1612 names: facts
1613 .names
1614 .into_iter()
1615 .map(|n| FragName {
1616 name: n.name,
1617 kind: n.kind,
1618 line: n.line,
1619 owner: n.owner,
1620 })
1621 .collect(),
1622 })
1623}
1624
1625#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
1626pub struct FragmentCacheEntry {
1628 pub mtime: i64,
1630 pub fragment: AnyFragment,
1632}
1633
1634fn read_fragments_cache(root: &Path) -> OrderedMap<FragmentCacheEntry> {
1635 match fs::read_to_string(fragments_cache_path(root)) {
1636 Ok(text) => serde_json::from_str(&text).unwrap_or_default(),
1637 Err(_) => OrderedMap::default(),
1638 }
1639}
1640
1641fn write_fragments_cache(root: &Path, cache: &OrderedMap<FragmentCacheEntry>) -> io::Result<()> {
1642 atomic_write_json(&fragments_cache_path(root), cache)
1643}
1644
1645pub fn read_fragments_index(root: &Path) -> OrderedMap<i64> {
1648 match fs::read_to_string(fragments_index_path(root)) {
1649 Ok(text) => serde_json::from_str(&text).unwrap_or_default(),
1650 Err(_) => OrderedMap::default(),
1651 }
1652}
1653
1654fn write_fragments_index(root: &Path, cache: &OrderedMap<FragmentCacheEntry>) -> io::Result<()> {
1655 let mut index = OrderedMap::new();
1656 for (rel, entry) in cache.iter() {
1657 index.insert(rel.clone(), entry.mtime);
1658 }
1659 atomic_write_json(&fragments_index_path(root), &index)
1660}
1661
1662pub fn index_is_stale(index: &OrderedMap<i64>, graph_files: &[GraphFile]) -> bool {
1665 graph_files
1666 .iter()
1667 .any(|f| index.get(&f.rel) != Some(&f.mtime))
1668 || index.len() != graph_files.len()
1669}
1670
1671#[derive(Debug, Clone)]
1679pub struct GraphFile {
1680 pub rel: String,
1682 pub mtime: i64,
1684}
1685
1686#[derive(Debug)]
1687pub enum RebuildOutcome {
1689 NotRebuilt,
1691 Rebuilt(Graph),
1693}
1694
1695fn graph_schema_is_current(root: &Path) -> bool {
1703 let Ok(mut file) = fs::File::open(graph_json_path(root)) else {
1704 return false;
1705 };
1706 let mut head = [0u8; 64];
1707 let mut filled = 0;
1708 while filled < head.len() {
1709 match file.read(&mut head[filled..]) {
1710 Ok(0) => break,
1711 Ok(n) => filled += n,
1712 Err(_) => return false,
1713 }
1714 }
1715 head[..filled].starts_with(format!("{{\"schema_version\":{GRAPH_SCHEMA_VERSION},").as_bytes())
1716}
1717
1718fn write_project_units(
1723 root: &Path,
1724 model: Option<&crate::project::ProjectModel>,
1725) -> io::Result<()> {
1726 match model {
1727 Some(m) => atomic_write_json(&project_units_path(root), &crate::project::graph_units(m)),
1728 None => {
1729 let path = project_units_path(root);
1730 match fs::remove_file(&path) {
1731 Ok(()) => Ok(()),
1732 Err(e) if e.kind() == io::ErrorKind::NotFound => Ok(()),
1733 Err(e) => Err(e),
1734 }
1735 }
1736 }
1737}
1738
1739pub fn rebuild_graph(
1750 root: &Path,
1751 graph_files: &[GraphFile],
1752 fresh_fragments: &HashMap<String, AnyFragment>,
1753 changed: bool,
1754 model: Option<&crate::project::ProjectModel>,
1755) -> io::Result<RebuildOutcome> {
1756 if !changed && graph_json_path(root).exists() && graph_schema_is_current(root) {
1757 return Ok(RebuildOutcome::NotRebuilt);
1758 }
1759
1760 let cache = read_fragments_cache(root);
1761 let mut merged_cs: Vec<(String, Fragment)> = Vec::new();
1767 let mut merged_ts: Vec<(String, extract::TsFragment)> = Vec::new();
1768 let mut new_cache: OrderedMap<FragmentCacheEntry> = OrderedMap::new();
1769 for f in graph_files {
1770 let fragment = match cache.get(&f.rel) {
1771 Some(entry) if entry.mtime == f.mtime => Some(entry.fragment.clone()),
1772 _ => fresh_fragments.get(&f.rel).cloned(),
1773 };
1774 let Some(fragment) = fragment else { continue };
1775 match &fragment {
1776 AnyFragment::Cs(c) => merged_cs.push((f.rel.clone(), c.clone())),
1777 AnyFragment::Ts(t) => merged_ts.push((f.rel.clone(), t.clone())),
1778 }
1779 new_cache.insert(
1780 f.rel.clone(),
1781 FragmentCacheEntry {
1782 mtime: f.mtime,
1783 fragment,
1784 },
1785 );
1786 }
1787
1788 let graph = crate::resolve::resolve_graph_with_model(root, &merged_cs, &merged_ts, model);
1789 write_graph(root, &graph)?;
1790 write_project_units(root, model)?;
1791 write_fragments_cache(root, &new_cache)?;
1792 write_fragments_index(root, &new_cache)?;
1793 remove_superseded_caches(root);
1794 Ok(RebuildOutcome::Rebuilt(graph))
1795}
1796
1797#[cfg(test)]
1798mod tests {
1799 use super::*;
1800 use std::time::{SystemTime, UNIX_EPOCH};
1801
1802 fn temp_dir(label: &str) -> PathBuf {
1803 let nanos = SystemTime::now()
1804 .duration_since(UNIX_EPOCH)
1805 .unwrap()
1806 .as_nanos();
1807 let dir = std::env::temp_dir().join(format!("scout-graph-test-{label}-{nanos}"));
1808 fs::create_dir_all(&dir).unwrap();
1809 dir
1810 }
1811
1812 #[test]
1815 fn ordered_map_preserves_insertion_order_through_json_round_trip() {
1816 let mut m: OrderedMap<i64> = OrderedMap::new();
1817 m.insert("z.cs".to_string(), 3);
1818 m.insert("a.cs".to_string(), 1);
1819 m.insert("m.cs".to_string(), 2);
1820 let json = serde_json::to_string(&m).unwrap();
1821 assert_eq!(json, r#"{"z.cs":3,"a.cs":1,"m.cs":2}"#);
1822
1823 let reparsed: OrderedMap<i64> = serde_json::from_str(&json).unwrap();
1824 let keys: Vec<&String> = reparsed.iter().map(|(k, _)| k).collect();
1825 assert_eq!(keys, vec!["z.cs", "a.cs", "m.cs"]);
1826 }
1827
1828 #[test]
1829 fn ordered_map_insert_overwrite_keeps_original_position() {
1830 let mut m: OrderedMap<i64> = OrderedMap::new();
1831 m.insert("a".to_string(), 1);
1832 m.insert("b".to_string(), 2);
1833 m.insert("a".to_string(), 99);
1834 let keys: Vec<&String> = m.iter().map(|(k, _)| k).collect();
1835 assert_eq!(keys, vec!["a", "b"]);
1836 assert_eq!(m.get("a"), Some(&99));
1837 }
1838
1839 #[test]
1842 fn percent1_whole_value_serializes_without_decimal_point() {
1843 let p = Percent1::from_ratio(2, 10); assert_eq!(serde_json::to_string(&p).unwrap(), "20");
1845 }
1846
1847 #[test]
1848 fn percent1_fractional_value_serializes_with_one_decimal_digit() {
1849 let p = Percent1::from_ratio(1, 3); assert_eq!(serde_json::to_string(&p).unwrap(), "33.3");
1851 }
1852
1853 #[test]
1854 fn percent1_zero_denominator_is_zero() {
1855 assert_eq!(Percent1::from_ratio(0, 0), Percent1::zero());
1856 assert_eq!(serde_json::to_string(&Percent1::zero()).unwrap(), "0");
1857 }
1858
1859 fn frag_def(
1862 methods: &[&str],
1863 properties: &[&str],
1864 fields: &[&str],
1865 method_returns: &[(&str, &str)],
1866 extension_methods: &[(&str, &str, usize, i64)],
1867 ) -> FragDef {
1868 frag_def_with_bases(
1869 methods,
1870 properties,
1871 fields,
1872 method_returns,
1873 extension_methods,
1874 &[],
1875 )
1876 }
1877
1878 fn frag_def_with_bases(
1879 methods: &[&str],
1880 properties: &[&str],
1881 fields: &[&str],
1882 method_returns: &[(&str, &str)],
1883 extension_methods: &[(&str, &str, usize, i64)],
1884 bases: &[&str],
1885 ) -> FragDef {
1886 let mut mr = OrderedMap::new();
1887 for (k, v) in method_returns {
1888 mr.insert((*k).to_string(), (*v).to_string());
1889 }
1890 FragDef {
1891 id: "App.Facts.Widget".into(),
1892 name: "Widget".into(),
1893 namespace: "App.Facts".into(),
1894 kind: "class".into(),
1895 line: 3,
1896 methods: methods.iter().map(|s| s.to_string()).collect(),
1897 properties: properties.iter().map(|s| s.to_string()).collect(),
1898 fields: fields.iter().map(|s| s.to_string()).collect(),
1899 method_returns: mr,
1900 extension_methods: extension_methods
1901 .iter()
1902 .map(|(n, t, lo, hi)| FragExtensionMethod {
1903 name: (*n).to_string(),
1904 this_type: (*t).to_string(),
1905 arity_min: *lo,
1906 arity_max: *hi,
1907 this_args: None,
1908 })
1909 .collect(),
1910 bases: bases.iter().map(|s| s.to_string()).collect(),
1911 type_params: Vec::new(),
1912 base_generic_args: OrderedMap::new(),
1913 property_types: OrderedMap::new(),
1914 field_types: OrderedMap::new(),
1915 method_return_args: OrderedMap::new(),
1916 non_public_methods: Vec::new(),
1917 method_arities: OrderedMap::new(),
1918 method_params: OrderedMap::new(),
1919 test_methods: Vec::new(),
1920 end_line: 0,
1921 }
1922 }
1923
1924 #[test]
1925 fn frag_def_appends_properties_fields_and_method_returns_last_in_that_order() {
1926 let json = serde_json::to_string(&frag_def(
1927 &["GetAsync"],
1928 &["Prefix"],
1929 &["_log"],
1930 &[("GetAsync", "Task")],
1931 &[],
1932 ))
1933 .unwrap();
1934 assert_eq!(
1935 json,
1936 r#"{"id":"App.Facts.Widget","name":"Widget","namespace":"App.Facts","kind":"class","line":3,"methods":["GetAsync"],"properties":["Prefix"],"fields":["_log"],"methodReturns":{"GetAsync":"Task"}}"#
1937 );
1938 }
1939
1940 #[test]
1941 fn frag_def_omits_all_three_new_keys_when_the_type_declares_none_of_them() {
1942 let json = serde_json::to_string(&frag_def(&["Go"], &[], &[], &[], &[])).unwrap();
1943 assert_eq!(
1944 json,
1945 r#"{"id":"App.Facts.Widget","name":"Widget","namespace":"App.Facts","kind":"class","line":3,"methods":["Go"]}"#,
1946 "pre-stage-2 bytes preserved exactly"
1947 );
1948 }
1949
1950 #[test]
1955 fn frag_def_appends_extension_methods_after_method_returns_with_the_arity_range_last() {
1956 let json = serde_json::to_string(&frag_def(
1957 &["Go"],
1958 &["Prefix"],
1959 &["_log"],
1960 &[("Go", "Task")],
1961 &[
1962 ("Render", "Widget", 0, 0),
1963 ("Render", "Widget", 2, 3),
1964 ("Trim", "string", 0, -1),
1965 ],
1966 ))
1967 .unwrap();
1968 assert_eq!(
1969 json,
1970 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}]}"#
1971 );
1972 }
1973
1974 #[test]
1975 fn frag_def_this_args_lands_after_arity_max_and_bases_after_extension_methods() {
1976 let mut d = frag_def_with_bases(
1977 &["Go"],
1978 &[],
1979 &[],
1980 &[],
1981 &[("Each", "List", 0, 0)],
1982 &["BaseWidget", "IWidget"],
1983 );
1984 d.extension_methods[0].this_args = Some(vec!["Widget".into()]);
1985 let json = serde_json::to_string(&d).unwrap();
1986 assert_eq!(
1987 json,
1988 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"]}"#
1989 );
1990 let reparsed: FragDef = serde_json::from_str(&json).unwrap();
1991 assert_eq!(
1992 reparsed.bases,
1993 vec!["BaseWidget".to_string(), "IWidget".to_string()]
1994 );
1995 assert_eq!(
1996 reparsed.extension_methods[0].this_args,
1997 Some(vec!["Widget".to_string()])
1998 );
1999 }
2000
2001 #[test]
2002 fn frag_def_omits_bases_when_the_type_lists_none() {
2003 let json = serde_json::to_string(&frag_def(&["Go"], &[], &[], &[], &[])).unwrap();
2004 assert!(
2005 !json.contains("bases"),
2006 "an empty base list must be omitted entirely: {json}"
2007 );
2008 let reparsed: FragDef = serde_json::from_str(&json).unwrap();
2009 assert!(
2010 reparsed.bases.is_empty(),
2011 "an absent key reads back as \"lists none\""
2012 );
2013 }
2014
2015 #[test]
2019 fn frag_def_appends_test_methods_last_after_bases() {
2020 let mut d = frag_def_with_bases(&["Go"], &[], &[], &[], &[], &["BaseWidget"]);
2021 d.test_methods = vec!["TotalsAnEmptyOrder".into(), "TotalsALineItem".into()];
2022 let json = serde_json::to_string(&d).unwrap();
2023 assert_eq!(
2024 json,
2025 r#"{"id":"App.Facts.Widget","name":"Widget","namespace":"App.Facts","kind":"class","line":3,"methods":["Go"],"bases":["BaseWidget"],"testMethods":["TotalsAnEmptyOrder","TotalsALineItem"]}"#
2026 );
2027 let reparsed: FragDef = serde_json::from_str(&json).unwrap();
2028 assert_eq!(
2029 reparsed.test_methods,
2030 vec![
2031 "TotalsAnEmptyOrder".to_string(),
2032 "TotalsALineItem".to_string()
2033 ]
2034 );
2035 }
2036
2037 #[test]
2038 fn frag_def_omits_test_methods_when_the_type_declares_no_tests() {
2039 let json = serde_json::to_string(&frag_def(&["Go"], &[], &[], &[], &[])).unwrap();
2040 assert!(
2041 !json.contains("testMethods"),
2042 "pre-stage bytes preserved exactly: {json}"
2043 );
2044 let reparsed: FragDef = serde_json::from_str(&json).unwrap();
2045 assert!(
2046 reparsed.test_methods.is_empty(),
2047 "an absent key reads back as \"declares no tests\""
2048 );
2049 }
2050
2051 #[test]
2052 fn frag_def_omits_extension_methods_when_the_type_declares_none() {
2053 let json =
2054 serde_json::to_string(&frag_def(&["Go"], &[], &[], &[("Go", "Task")], &[])).unwrap();
2055 assert!(
2056 !json.contains("extensionMethods"),
2057 "pre-stage-3 bytes preserved exactly: {json}"
2058 );
2059 let reparsed: FragDef = serde_json::from_str(&json).unwrap();
2060 assert!(
2061 reparsed.extension_methods.is_empty(),
2062 "an absent key reads back as \"declares none\""
2063 );
2064 }
2065
2066 #[test]
2067 fn frag_def_method_returns_serializes_in_first_declaration_order_not_sorted() {
2068 let json = serde_json::to_string(&frag_def(
2071 &[],
2072 &[],
2073 &[],
2074 &[("Zebra", "Z"), ("Alpha", "A")],
2075 &[],
2076 ))
2077 .unwrap();
2078 assert!(
2079 json.ends_with(r#""methodReturns":{"Zebra":"Z","Alpha":"A"}}"#),
2080 "insertion order, not sorted: {json}"
2081 );
2082 let reparsed: FragDef = serde_json::from_str(&json).unwrap();
2083 let keys: Vec<&String> = reparsed.method_returns.iter().map(|(k, _)| k).collect();
2084 assert_eq!(
2085 keys,
2086 vec!["Zebra", "Alpha"],
2087 "and the order survives a round trip"
2088 );
2089 }
2090
2091 #[test]
2095 fn frag_def_appends_property_types_last_after_test_methods() {
2096 let mut d =
2097 frag_def_with_bases(&["Go"], &["Dial", "Slots"], &[], &[], &[], &["BaseWidget"]);
2098 d.test_methods = vec!["TotalsALineItem".into()];
2099 d.property_types.insert(
2100 "Dial".into(),
2101 FragFact {
2102 type_name: "Gauge".into(),
2103 args: None,
2104 },
2105 );
2106 d.property_types.insert(
2107 "Slots".into(),
2108 FragFact {
2109 type_name: "Toolbox".into(),
2110 args: Some(vec!["Gadget".into()]),
2111 },
2112 );
2113 let json = serde_json::to_string(&d).unwrap();
2114 assert_eq!(
2115 json,
2116 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"]}}}"#
2117 );
2118 let reparsed: FragDef = serde_json::from_str(&json).unwrap();
2119 let keys: Vec<&String> = reparsed.property_types.iter().map(|(k, _)| k).collect();
2120 assert_eq!(
2121 keys,
2122 vec!["Dial", "Slots"],
2123 "source order survives a round trip, like methodReturns"
2124 );
2125 }
2126
2127 #[test]
2128 fn frag_def_omits_property_types_when_no_property_carries_a_fact() {
2129 let json = serde_json::to_string(&frag_def(&["Go"], &["Label"], &[], &[], &[])).unwrap();
2130 assert!(
2131 !json.contains("propertyTypes"),
2132 "pre-propertyTypes bytes preserved exactly: {json}"
2133 );
2134 let reparsed: FragDef = serde_json::from_str(&json).unwrap();
2135 assert!(
2136 reparsed.property_types.is_empty(),
2137 "an absent key reads back as \"no property vouches for a type\""
2138 );
2139 }
2140
2141 fn frag_ref(generic: bool, receiver_type: Option<&str>, arg_count: Option<usize>) -> FragRef {
2145 FragRef {
2146 kind: "uses-member".into(),
2147 name: "repo".into(),
2148 qualified: None,
2149 member: Some("Save".into()),
2150 line: 7,
2151 namespace: Some("App.Shape".into()),
2152 type_arg_count: None,
2153 generic,
2154 receiver_type: receiver_type.map(String::from),
2155 arg_count,
2156 receiver_args: None,
2157 outer_types: Vec::new(),
2158 args: None,
2159 receiver_property_owner: None,
2160 receiver_call_owner: None,
2161 receiver_call_member: None,
2162 receiver_base: false,
2163 receiver_awaited: false,
2164 receiver_local: false,
2165 receiver_lambda: None,
2166 }
2167 }
2168
2169 #[test]
2170 fn frag_ref_receiver_args_is_appended_last_after_arg_count() {
2171 let r = FragRef {
2172 receiver_args: Some(vec!["FutureState".into(), "*".into()]),
2173 ..frag_ref(false, Some("Binder"), Some(1))
2174 };
2175 let json = serde_json::to_string(&r).unwrap();
2176 assert_eq!(
2177 json,
2178 r#"{"kind":"uses-member","name":"repo","member":"Save","line":7,"namespace":"App.Shape","receiverType":"Binder","argCount":1,"receiverArgs":["FutureState","*"]}"#
2179 );
2180 let reparsed: FragRef = serde_json::from_str(&json).unwrap();
2181 assert_eq!(
2182 reparsed.receiver_args,
2183 Some(vec!["FutureState".to_string(), "*".to_string()])
2184 );
2185 }
2186
2187 #[test]
2188 fn frag_ref_receiver_type_then_arg_count_are_appended_last_after_generic() {
2189 let json = serde_json::to_string(&frag_ref(true, Some("IRepo"), Some(2))).unwrap();
2190 assert_eq!(
2191 json,
2192 r#"{"kind":"uses-member","name":"repo","member":"Save","line":7,"namespace":"App.Shape","generic":true,"receiverType":"IRepo","argCount":2}"#
2193 );
2194 }
2195
2196 #[test]
2197 fn frag_ref_arg_count_zero_still_serializes_it() {
2198 let json = serde_json::to_string(&frag_ref(false, Some("IRepo"), Some(0))).unwrap();
2202 assert!(
2203 json.ends_with(r#""receiverType":"IRepo","argCount":0}"#),
2204 "argCount 0 must survive: {json}"
2205 );
2206 }
2207
2208 #[test]
2209 fn frag_ref_without_a_fact_keeps_its_pre_stage_2_bytes() {
2210 let json = serde_json::to_string(&frag_ref(false, None, None)).unwrap();
2211 assert_eq!(
2212 json,
2213 r#"{"kind":"uses-member","name":"repo","member":"Save","line":7,"namespace":"App.Shape"}"#
2214 );
2215 let reparsed: FragRef = serde_json::from_str(&json).unwrap();
2216 assert_eq!(
2217 reparsed.receiver_type, None,
2218 "an absent key reads back as no fact"
2219 );
2220 assert_eq!(
2221 reparsed.arg_count, None,
2222 "and an absent argCount reads back as \"not a call\""
2223 );
2224 assert_eq!(
2225 reparsed.receiver_args, None,
2226 "and an absent receiverArgs reads back as \"not generic\""
2227 );
2228 }
2229
2230 #[test]
2233 fn def_without_also_in_omits_the_key() {
2234 let d = Def {
2235 id: "Ns.Type".into(),
2236 name: "Type".into(),
2237 namespace: "Ns".into(),
2238 kind: "class".into(),
2239 file: "Ns/Type.cs".into(),
2240 line: 3,
2241 methods: vec![],
2242 test_methods: vec![],
2243 also_in: vec![],
2244 end_line: 0,
2245 };
2246 let json = serde_json::to_string(&d).unwrap();
2247 assert!(
2248 !json.contains("also_in"),
2249 "empty also_in must be omitted: {json}"
2250 );
2251 }
2252
2253 #[test]
2254 fn def_end_line_serializes_last_and_defaults_when_absent() {
2255 let mut d = Def {
2256 id: "Ns.Type".into(),
2257 name: "Type".into(),
2258 namespace: "Ns".into(),
2259 kind: "class".into(),
2260 file: "Ns/Type.cs".into(),
2261 line: 3,
2262 methods: vec![],
2263 test_methods: vec![],
2264 also_in: vec![],
2265 end_line: 0,
2266 };
2267 assert!(!serde_json::to_string(&d).unwrap().contains("endLine"));
2268 d.end_line = 9;
2269 assert!(serde_json::to_string(&d)
2270 .unwrap()
2271 .ends_with(",\"endLine\":9}"));
2272 }
2273
2274 #[test]
2275 fn def_with_also_in_includes_it_after_methods() {
2276 let d = Def {
2277 id: "Ns.Type".into(),
2278 name: "Type".into(),
2279 namespace: "Ns".into(),
2280 kind: "class".into(),
2281 file: "Ns/Type.cs".into(),
2282 line: 3,
2283 methods: vec!["M".into()],
2284 test_methods: vec![],
2285 also_in: vec![AlsoIn {
2286 file: "Ns/Type.Extra.cs".into(),
2287 line: 5,
2288 }],
2289 end_line: 0,
2290 };
2291 let json = serde_json::to_string(&d).unwrap();
2292 let methods_pos = json.find("\"methods\"").unwrap();
2293 let also_in_pos = json.find("\"also_in\"").unwrap();
2294 assert!(
2295 methods_pos < also_in_pos,
2296 "also_in must come after methods: {json}"
2297 );
2298 }
2299
2300 #[test]
2304 fn def_row_places_test_methods_between_methods_and_also_in() {
2305 let d = Def {
2306 id: "Ns.TypeTests".into(),
2307 name: "TypeTests".into(),
2308 namespace: "Ns".into(),
2309 kind: "class".into(),
2310 file: "Ns/TypeTests.cs".into(),
2311 line: 3,
2312 methods: vec!["M".into()],
2313 test_methods: vec!["Fact1".into()],
2314 also_in: vec![AlsoIn {
2315 file: "Ns/TypeTests.Extra.cs".into(),
2316 line: 5,
2317 }],
2318 end_line: 0,
2319 };
2320 let json = serde_json::to_string(&d).unwrap();
2321 assert_eq!(
2322 json,
2323 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}]}"#
2324 );
2325 }
2326
2327 #[test]
2328 fn def_row_omits_test_methods_when_the_def_declares_no_tests() {
2329 let d = Def {
2330 id: "Ns.Type".into(),
2331 name: "Type".into(),
2332 namespace: "Ns".into(),
2333 kind: "class".into(),
2334 file: "Ns/Type.cs".into(),
2335 line: 3,
2336 methods: vec!["M".into()],
2337 test_methods: vec![],
2338 also_in: vec![],
2339 end_line: 0,
2340 };
2341 let json = serde_json::to_string(&d).unwrap();
2342 assert!(
2343 !json.contains("testMethods"),
2344 "empty testMethods must be omitted: {json}"
2345 );
2346 }
2347
2348 #[test]
2351 fn stats_appends_test_def_count_last_and_always_writes_it() {
2352 let stats = Stats {
2353 def_count: 2,
2354 file_count: 1,
2355 edges_by_kind: EdgesByKind::default(),
2356 ambiguous_count: 0,
2357 ambiguous_pct: Percent1::zero(),
2358 unresolved_external_count: 0,
2359 heuristic_edge_count: 0,
2360 test_def_count: 0,
2361 heuristic_by_tier: HeuristicByTier::default(),
2362 ts: None,
2363 };
2364 let json = serde_json::to_string(&stats).unwrap();
2365 assert!(
2366 json.ends_with(
2367 r#""heuristic_edge_count":0,"test_def_count":0,"heuristic_by_tier":{"ext":0,"guess":0}}"#
2368 ),
2369 "heuristic_by_tier is LAST, after test_def_count, and both its keys are always written: {json}"
2370 );
2371 }
2372
2373 #[test]
2380 fn stats_keeps_heuristic_by_tier_last_even_when_a_ts_block_is_present() {
2381 let stats = Stats {
2382 def_count: 0,
2383 file_count: 0,
2384 edges_by_kind: EdgesByKind::default(),
2385 ambiguous_count: 0,
2386 ambiguous_pct: Percent1::zero(),
2387 unresolved_external_count: 0,
2388 heuristic_edge_count: 0,
2389 test_def_count: 0,
2390 heuristic_by_tier: HeuristicByTier::default(),
2391 ts: Some(crate::tsgraph::TsStats {
2392 ts_file_count: 1,
2393 ts_def_count: 2,
2394 external_import_count: 3,
2395 unresolved_ref_count: 4,
2396 }),
2397 };
2398 let json = serde_json::to_string(&stats).unwrap();
2399 assert!(
2400 json.ends_with(concat!(
2401 r#""test_def_count":0,"#,
2402 r#""ts":{"ts_file_count":1,"ts_def_count":2,"external_import_count":3,"unresolved_ref_count":4},"#,
2403 r#""heuristic_by_tier":{"ext":0,"guess":0}}"#
2404 )),
2405 "key order must be test_def_count, ts, heuristic_by_tier: {json}"
2406 );
2407 }
2408
2409 #[test]
2412 fn imports_edge_has_target_not_to() {
2413 let e = Edge::Imports {
2414 from_file: "F.cs".into(),
2415 from_line: 1,
2416 target: "System".into(),
2417 };
2418 let json = serde_json::to_string(&e).unwrap();
2419 assert_eq!(
2420 json,
2421 r#"{"kind":"imports","from_file":"F.cs","from_line":1,"target":"System"}"#
2422 );
2423 }
2424
2425 #[test]
2426 fn uses_type_edge_shape() {
2427 let e = Edge::UsesType {
2428 from_file: "F.cs".into(),
2429 from_line: 1,
2430 to: "Ns.T".into(),
2431 to_file: "Ns/T.cs".into(),
2432 heuristic: false,
2433 };
2434 let json = serde_json::to_string(&e).unwrap();
2435 assert_eq!(
2436 json,
2437 r#"{"kind":"uses-type","from_file":"F.cs","from_line":1,"to":"Ns.T","to_file":"Ns/T.cs"}"#
2438 );
2439 }
2440
2441 #[test]
2445 fn heuristic_flag_is_appended_last_and_omitted_when_false() {
2446 let precise = Edge::UsesMember {
2447 from_file: "F.cs".into(),
2448 from_line: 1,
2449 to: "Ns.T".into(),
2450 to_file: "Ns/T.cs".into(),
2451 heuristic: false,
2452 tier: None,
2453 member: None,
2454 };
2455 assert_eq!(
2456 serde_json::to_string(&precise).unwrap(),
2457 r#"{"kind":"uses-member","from_file":"F.cs","from_line":1,"to":"Ns.T","to_file":"Ns/T.cs"}"#
2458 );
2459 let guess = Edge::UsesMember {
2460 from_file: "F.cs".into(),
2461 from_line: 1,
2462 to: "Ns.T".into(),
2463 to_file: "Ns/T.cs".into(),
2464 heuristic: true,
2465 tier: Some(HeuristicTier::Guess),
2466 member: Some("M".into()),
2467 };
2468 assert_eq!(
2469 serde_json::to_string(&guess).unwrap(),
2470 r#"{"kind":"uses-member","from_file":"F.cs","from_line":1,"to":"Ns.T","to_file":"Ns/T.cs","heuristic":true,"tier":"guess","member":"M"}"#
2471 );
2472 let ext = Edge::UsesMember {
2473 from_file: "F.cs".into(),
2474 from_line: 1,
2475 to: "Ns.T".into(),
2476 to_file: "Ns/T.cs".into(),
2477 heuristic: true,
2478 tier: Some(HeuristicTier::Ext),
2479 member: Some("M".into()),
2480 };
2481 assert_eq!(
2482 serde_json::to_string(&ext).unwrap(),
2483 r#"{"kind":"uses-member","from_file":"F.cs","from_line":1,"to":"Ns.T","to_file":"Ns/T.cs","heuristic":true,"tier":"ext","member":"M"}"#
2484 );
2485 assert_eq!(
2490 serde_json::from_str::<Edge>(&serde_json::to_string(&guess).unwrap()).unwrap(),
2491 guess
2492 );
2493 assert_eq!(
2494 serde_json::from_str::<Edge>(&serde_json::to_string(&ext).unwrap()).unwrap(),
2495 ext
2496 );
2497 assert_eq!(
2498 serde_json::from_str::<Edge>(&serde_json::to_string(&precise).unwrap()).unwrap(),
2499 precise
2500 );
2501 }
2502
2503 #[test]
2509 fn uses_member_edge_appends_tier_then_member_after_heuristic_and_omits_both_when_precise() {
2510 let precise = Edge::uses_member(
2511 "F.cs".into(),
2512 1,
2513 "Ns.T".into(),
2514 "Ns/T.cs".into(),
2515 Some("M".into()),
2516 None,
2517 );
2518 assert_eq!(
2519 serde_json::to_string(&precise).unwrap(),
2520 r#"{"kind":"uses-member","from_file":"F.cs","from_line":1,"to":"Ns.T","to_file":"Ns/T.cs","member":"M"}"#
2521 );
2522 assert!(!precise.is_heuristic(), "no tier means no guess");
2523 assert_eq!(precise.tier(), None);
2524
2525 for (tier, word) in [(HeuristicTier::Ext, "ext"), (HeuristicTier::Guess, "guess")] {
2526 let e = Edge::uses_member(
2527 "F.cs".into(),
2528 1,
2529 "Ns.T".into(),
2530 "Ns/T.cs".into(),
2531 Some("M".into()),
2532 Some(tier),
2533 );
2534 assert_eq!(
2535 serde_json::to_string(&e).unwrap(),
2536 format!(
2537 r#"{{"kind":"uses-member","from_file":"F.cs","from_line":1,"to":"Ns.T","to_file":"Ns/T.cs","heuristic":true,"tier":"{word}","member":"M"}}"#
2538 ),
2539 "heuristic, then tier, then member"
2540 );
2541 assert!(e.is_heuristic());
2544 assert_eq!(e.tier(), Some(tier));
2545 }
2546
2547 assert_eq!(
2550 Edge::UsesType {
2551 from_file: "F.cs".into(),
2552 from_line: 1,
2553 to: "Ns.T".into(),
2554 to_file: "Ns/T.cs".into(),
2555 heuristic: true,
2556 }
2557 .tier(),
2558 None
2559 );
2560 }
2561
2562 #[test]
2563 fn ambiguous_edge_shape() {
2564 let e = Edge::Ambiguous {
2565 origin: "uses-type".into(),
2566 from_file: "F.cs".into(),
2567 from_line: 4,
2568 raw: "Money".into(),
2569 candidates: vec![Candidate {
2570 id: "A.Money".into(),
2571 file: "A/Money.cs".into(),
2572 }],
2573 candidate_count: 2,
2574 };
2575 let json = serde_json::to_string(&e).unwrap();
2576 assert_eq!(
2577 json,
2578 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}"#
2579 );
2580 }
2581
2582 fn empty_stats() -> Stats {
2585 Stats {
2586 def_count: 0,
2587 file_count: 0,
2588 edges_by_kind: EdgesByKind::default(),
2589 ambiguous_count: 0,
2590 ambiguous_pct: Percent1::zero(),
2591 unresolved_external_count: 0,
2592 heuristic_edge_count: 0,
2593 test_def_count: 0,
2594 heuristic_by_tier: HeuristicByTier::default(),
2595 ts: None,
2596 }
2597 }
2598
2599 #[test]
2607 fn graph_omits_units_when_empty_and_appends_them_after_names_otherwise() {
2608 let mut g = Graph {
2609 schema_version: GRAPH_SCHEMA_VERSION,
2610 built_at_head: None,
2611 defs: Vec::new(),
2612 edges: Vec::new(),
2613 stats: empty_stats(),
2614 names: vec![GraphName {
2615 name: "A".to_string(),
2616 kind: "class".to_string(),
2617 file: "A/A.cs".to_string(),
2618 line: 1,
2619 owner: String::new(),
2620 }],
2621 units: Vec::new(),
2622 };
2623
2624 const WITHOUT_UNITS: &str = concat!(
2625 r#"{"schema_version":2,"built_at_head":null,"defs":[],"edges":[],"stats":{"def_count":0,"#,
2626 r#""file_count":0,"edges_by_kind":{"inherits":0,"uses-type":0,"imports":0,"uses-member":0,"#,
2627 r#""ctor-di":0},"ambiguous_count":0,"ambiguous_pct":0,"unresolved_external_count":0,"#,
2628 r#""heuristic_edge_count":0,"test_def_count":0,"heuristic_by_tier":{"ext":0,"guess":0}},"#,
2629 r#""names":[{"name":"A","kind":"class","file":"A/A.cs","line":1}]}"#,
2630 );
2631 assert_eq!(
2632 serde_json::to_string(&g).unwrap(),
2633 WITHOUT_UNITS,
2634 "an empty unit list must not emit a `units` key at all"
2635 );
2636
2637 g.units = vec![
2638 GraphUnit {
2639 id: "A/A.csproj".to_string(),
2640 name: "A".to_string(),
2641 refs: vec!["B/B.csproj".to_string()],
2642 test: false,
2643 },
2644 GraphUnit {
2645 id: "B/B.csproj".to_string(),
2646 name: "B".to_string(),
2647 refs: Vec::new(),
2648 test: false,
2649 },
2650 GraphUnit {
2651 id: "T/T.Tests.csproj".to_string(),
2652 name: "T.Tests".to_string(),
2653 refs: vec!["A/A.csproj".to_string()],
2654 test: true,
2655 },
2656 ];
2657
2658 const WITH_UNITS: &str = concat!(
2659 r#""names":[{"name":"A","kind":"class","file":"A/A.cs","line":1}],"#,
2660 r#""units":[{"id":"A/A.csproj","name":"A","refs":["B/B.csproj"]},"#,
2661 r#"{"id":"B/B.csproj","name":"B"},"#,
2662 r#"{"id":"T/T.Tests.csproj","name":"T.Tests","refs":["A/A.csproj"],"test":true}]}"#,
2663 );
2664 let json = serde_json::to_string(&g).unwrap();
2665 assert_eq!(
2666 json,
2667 format!(
2668 "{}{}",
2669 WITHOUT_UNITS
2670 .strip_suffix(
2671 r#""names":[{"name":"A","kind":"class","file":"A/A.cs","line":1}]}"#
2672 )
2673 .unwrap(),
2674 WITH_UNITS
2675 ),
2676 "`units` is appended after `names` and changes nothing before it"
2677 );
2678
2679 let reparsed: Graph = serde_json::from_str(WITHOUT_UNITS).unwrap();
2682 assert!(reparsed.units.is_empty());
2683 let round_tripped: Graph = serde_json::from_str(&json).unwrap();
2684 assert_eq!(round_tripped.units, g.units);
2685 }
2686
2687 #[test]
2690 fn edges_by_kind_field_order_is_fixed_not_alphabetical() {
2691 let s = EdgesByKind {
2692 inherits: 1,
2693 uses_type: 2,
2694 imports: 3,
2695 uses_member: 4,
2696 ctor_di: 5,
2697 ..Default::default()
2698 };
2699 let json = serde_json::to_string(&s).unwrap();
2700 assert_eq!(
2701 json,
2702 r#"{"inherits":1,"uses-type":2,"imports":3,"uses-member":4,"ctor-di":5}"#
2703 );
2704 }
2705
2706 #[test]
2713 fn any_fragment_round_trips_both_shapes_and_never_reads_one_as_the_other() {
2714 let cs = AnyFragment::Cs(Fragment {
2715 defs: Vec::new(),
2716 usings: Vec::new(),
2717 refs: Vec::new(),
2718 names: Vec::new(),
2719 });
2720 let cs_json = serde_json::to_string(&cs).unwrap();
2721 assert_eq!(cs_json, r#"{"defs":[],"usings":[],"refs":[],"names":[]}"#);
2722 assert!(matches!(
2723 serde_json::from_str::<AnyFragment>(&cs_json).unwrap(),
2724 AnyFragment::Cs(_)
2725 ));
2726
2727 let ts = AnyFragment::Ts(extract::TsFragment {
2728 ts: 1,
2729 defs: vec![extract::TsFragmentDef {
2730 name: "x".into(),
2731 kind: "const".into(),
2732 line: 1,
2733 end_line: 1,
2734 }],
2735 imports: Vec::new(),
2736 reexports: Vec::new(),
2737 refs: Vec::new(),
2738 default: None,
2739 });
2740 let ts_json = serde_json::to_string(&ts).unwrap();
2741 assert_eq!(
2742 ts_json,
2743 r#"{"ts":1,"defs":[{"name":"x","kind":"const","line":1,"endLine":1}],"imports":[],"reexports":[],"refs":[]}"#
2744 );
2745 assert!(matches!(
2746 serde_json::from_str::<AnyFragment>(&ts_json).unwrap(),
2747 AnyFragment::Ts(_)
2748 ));
2749 assert_eq!(
2750 serde_json::from_str::<AnyFragment>(&ts_json).unwrap(),
2751 ts,
2752 "and round-trips to the same value"
2753 );
2754 }
2755
2756 #[test]
2760 fn edges_by_kind_appends_the_four_ts_counts_after_ctor_di_when_present() {
2761 let s = EdgesByKind {
2762 inherits: 1,
2763 uses_type: 2,
2764 imports: 3,
2765 uses_member: 4,
2766 ctor_di: 5,
2767 import: Some(6),
2768 call: Some(7),
2769 jsx_use: Some(8),
2770 dispatch: Some(9),
2771 };
2772 let json = serde_json::to_string(&s).unwrap();
2773 assert_eq!(
2774 json,
2775 r#"{"inherits":1,"uses-type":2,"imports":3,"uses-member":4,"ctor-di":5,"import":6,"call":7,"jsx-use":8,"dispatch":9}"#
2776 );
2777 }
2778
2779 #[test]
2782 fn atomic_write_leaves_target_intact_when_only_a_stray_tmp_exists() {
2783 let dir = temp_dir("atomic-crash");
2784 let target = dir.join("graph.json");
2785 fs::write(&target, b"{\"old\":true}").unwrap();
2786 fs::write(dir.join(".graph.json.tmp.99999.7"), b"GARBAGE-PARTIAL").unwrap();
2790 assert_eq!(fs::read_to_string(&target).unwrap(), "{\"old\":true}");
2791 }
2792
2793 #[test]
2794 fn atomic_write_replaces_target_and_leaves_no_tmp_file_behind() {
2795 let dir = temp_dir("atomic-clean");
2796 let target = dir.join("graph.json");
2797 fs::write(&target, b"{\"old\":true}").unwrap();
2798 atomic_write_bytes(&target, b"{\"new\":true}").unwrap();
2799 assert_eq!(fs::read_to_string(&target).unwrap(), "{\"new\":true}");
2800 let leftover: Vec<_> = fs::read_dir(&dir)
2801 .unwrap()
2802 .filter_map(|e| e.ok())
2803 .filter(|e| e.file_name().to_string_lossy().contains(".tmp."))
2804 .collect();
2805 assert!(
2806 leftover.is_empty(),
2807 "no tmp file should remain: {leftover:?}"
2808 );
2809 }
2810
2811 #[test]
2812 fn atomic_write_creates_parent_directories() {
2813 let dir = temp_dir("atomic-mkdir");
2814 let target = dir.join("nested").join("deeper").join("graph.json");
2815 atomic_write_bytes(&target, b"{}").unwrap();
2816 assert_eq!(fs::read_to_string(&target).unwrap(), "{}");
2817 }
2818
2819 #[test]
2822 fn rebuild_graph_skips_when_unchanged_and_graph_already_exists() {
2823 let dir = temp_dir("rebuild-unchanged");
2824 fs::create_dir_all(graph_dir(&dir)).unwrap();
2825 fs::write(graph_json_path(&dir), b"{\"schema_version\":2,\"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();
2826 let outcome = rebuild_graph(&dir, &[], &HashMap::new(), false, None).unwrap();
2827 assert!(matches!(outcome, RebuildOutcome::NotRebuilt));
2828 }
2829
2830 #[test]
2836 fn rebuild_graph_rebuilds_when_the_existing_graph_carries_an_older_schema_version() {
2837 let dir = temp_dir("rebuild-old-schema");
2838 fs::create_dir_all(graph_dir(&dir)).unwrap();
2839 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();
2840 let outcome = rebuild_graph(&dir, &[], &HashMap::new(), false, None).unwrap();
2841 let RebuildOutcome::Rebuilt(graph) = outcome else {
2842 panic!("an older-schema graph must be rebuilt on the unchanged path");
2843 };
2844 assert_eq!(graph.schema_version, GRAPH_SCHEMA_VERSION);
2845 assert!(
2846 fs::read_to_string(graph_json_path(&dir))
2847 .unwrap()
2848 .starts_with(r#"{"schema_version":2,"#),
2849 "and the rebuilt artifact carries the current version on disk"
2850 );
2851 }
2852
2853 #[test]
2856 fn rebuild_graph_rebuilds_when_the_existing_graph_is_too_short_to_carry_a_version() {
2857 let dir = temp_dir("rebuild-truncated");
2858 fs::create_dir_all(graph_dir(&dir)).unwrap();
2859 fs::write(graph_json_path(&dir), b"{").unwrap();
2860 assert!(!graph_schema_is_current(&dir));
2861 assert!(matches!(
2862 rebuild_graph(&dir, &[], &HashMap::new(), false, None).unwrap(),
2863 RebuildOutcome::Rebuilt(_)
2864 ));
2865 }
2866
2867 #[test]
2868 fn rebuild_graph_reuses_a_cached_fragment_at_matching_mtime() {
2869 let dir = temp_dir("rebuild-cache-hit");
2870 let fragment = Fragment {
2871 defs: vec![FragDef {
2872 id: "A".into(),
2873 name: "A".into(),
2874 namespace: "".into(),
2875 kind: "class".into(),
2876 line: 1,
2877 methods: vec![],
2878 properties: vec![],
2879 fields: vec![],
2880 method_returns: OrderedMap::new(),
2881 extension_methods: vec![],
2882 bases: vec![],
2883 type_params: vec![],
2884 base_generic_args: OrderedMap::new(),
2885 test_methods: vec![],
2886 property_types: OrderedMap::new(),
2887 field_types: OrderedMap::new(),
2888 method_return_args: OrderedMap::new(),
2889 non_public_methods: vec![],
2890 method_arities: OrderedMap::new(),
2891 method_params: OrderedMap::new(),
2892 end_line: 1,
2893 }],
2894 usings: vec![],
2895 refs: vec![],
2896 names: vec![],
2897 };
2898 let mut fresh = HashMap::new();
2900 fresh.insert("A.cs".to_string(), AnyFragment::Cs(fragment.clone()));
2901 let graph_files = vec![GraphFile {
2902 rel: "A.cs".to_string(),
2903 mtime: 111,
2904 }];
2905 let first = rebuild_graph(&dir, &graph_files, &fresh, true, None).unwrap();
2906 assert!(matches!(first, RebuildOutcome::Rebuilt(_)));
2907
2908 let empty: HashMap<String, AnyFragment> = HashMap::new();
2911 let second = rebuild_graph(&dir, &graph_files, &empty, true, None).unwrap();
2912 match second {
2913 RebuildOutcome::Rebuilt(g) => {
2914 assert_eq!(g.defs.len(), 1, "cached fragment must still be used")
2915 }
2916 RebuildOutcome::NotRebuilt => panic!("changed=true must always rebuild"),
2917 }
2918 }
2919
2920 #[test]
2923 fn rebuild_graph_writes_the_v13_caches_and_deletes_every_superseded_generation() {
2924 let dir = temp_dir("rebuild-v13");
2925 fs::create_dir_all(graph_dir(&dir)).unwrap();
2926 assert_eq!(SUPERSEDED_CACHE_FILES.len(), 34, "v1..v17 pairs");
2945 for stale in SUPERSEDED_CACHE_FILES {
2946 fs::write(graph_dir(&dir).join(stale), b"{}").unwrap();
2947 }
2948
2949 let fragment = Fragment {
2950 defs: vec![FragDef {
2951 id: "App.A".into(),
2952 name: "A".into(),
2953 namespace: "App".into(),
2954 kind: "class".into(),
2955 line: 1,
2956 methods: vec![],
2957 properties: vec![],
2958 fields: vec![],
2959 method_returns: OrderedMap::new(),
2960 extension_methods: vec![],
2961 bases: vec![],
2962 type_params: vec![],
2963 base_generic_args: OrderedMap::new(),
2964 test_methods: vec![],
2965 property_types: OrderedMap::new(),
2966 field_types: OrderedMap::new(),
2967 method_return_args: OrderedMap::new(),
2968 non_public_methods: vec![],
2969 method_arities: OrderedMap::new(),
2970 method_params: OrderedMap::new(),
2971 end_line: 1,
2972 }],
2973 usings: vec![],
2974 refs: vec![],
2975 names: vec![],
2976 };
2977 let mut fresh = HashMap::new();
2978 fresh.insert("src/A.cs".to_string(), AnyFragment::Cs(fragment));
2979 let graph_files = vec![GraphFile {
2980 rel: "src/A.cs".to_string(),
2981 mtime: 222,
2982 }];
2983 rebuild_graph(&dir, &graph_files, &fresh, true, None).unwrap();
2984
2985 assert!(
2986 graph_dir(&dir).join("fragments-v18.json").exists(),
2987 "the v18 payload cache is what gets written"
2988 );
2989 assert!(
2990 graph_dir(&dir).join("fragments-index-v18.json").exists(),
2991 "and its mtime-only index alongside it"
2992 );
2993 for stale in SUPERSEDED_CACHE_FILES {
2994 assert!(
2995 !graph_dir(&dir).join(stale).exists(),
2996 "{stale} must be deleted -- rename IS the invalidation"
2997 );
2998 }
2999 }
3000
3001 #[test]
3004 fn fragments_cache_v18_supersedes_v17() {
3005 let dir = temp_dir("fragments-cache-v18-paths");
3006 assert_eq!(
3007 fragments_cache_path(&dir),
3008 graph_dir(&dir).join("fragments-v18.json")
3009 );
3010 assert_eq!(
3011 fragments_index_path(&dir),
3012 graph_dir(&dir).join("fragments-index-v18.json")
3013 );
3014 assert!(
3015 SUPERSEDED_CACHE_FILES.contains(&"fragments-v17.json"),
3016 "v17 joined the superseded list when the v18 bump landed"
3017 );
3018 assert!(
3019 SUPERSEDED_CACHE_FILES.contains(&"fragments-index-v17.json"),
3020 "its index pairs with it, same as every other generation"
3021 );
3022
3023 let dir = temp_dir("rebuild-v18");
3024 fs::create_dir_all(graph_dir(&dir)).unwrap();
3025 fs::write(graph_dir(&dir).join("fragments-v17.json"), b"{}").unwrap();
3026 fs::write(graph_dir(&dir).join("fragments-index-v17.json"), b"{}").unwrap();
3027
3028 let fragment = Fragment {
3029 defs: vec![],
3030 usings: vec![],
3031 refs: vec![],
3032 names: vec![],
3033 };
3034 let mut fresh = HashMap::new();
3035 fresh.insert("src/A.cs".to_string(), AnyFragment::Cs(fragment));
3036 let graph_files = vec![GraphFile {
3037 rel: "src/A.cs".to_string(),
3038 mtime: 1,
3039 }];
3040 rebuild_graph(&dir, &graph_files, &fresh, true, None).unwrap();
3041
3042 assert!(graph_dir(&dir).join("fragments-v18.json").exists());
3043 assert!(graph_dir(&dir).join("fragments-index-v18.json").exists());
3044 assert!(
3045 !graph_dir(&dir).join("fragments-v17.json").exists(),
3046 "the v17 pair is deleted -- rename IS the invalidation"
3047 );
3048 assert!(!graph_dir(&dir).join("fragments-index-v16.json").exists());
3049 }
3050
3051 #[test]
3054 fn frag_ref_outer_types_is_appended_last_after_receiver_args() {
3055 let r = FragRef {
3056 receiver_args: Some(vec!["FutureState".into()]),
3057 outer_types: vec!["Outer".into(), "Inner".into()],
3058 ..frag_ref(false, Some("Binder"), Some(1))
3059 };
3060 let json = serde_json::to_string(&r).unwrap();
3061 assert_eq!(
3062 json,
3063 r#"{"kind":"uses-member","name":"repo","member":"Save","line":7,"namespace":"App.Shape","receiverType":"Binder","argCount":1,"receiverArgs":["FutureState"],"outerTypes":["Outer","Inner"]}"#
3064 );
3065 let reparsed: FragRef = serde_json::from_str(&json).unwrap();
3066 assert_eq!(
3067 reparsed.outer_types,
3068 vec!["Outer".to_string(), "Inner".to_string()]
3069 );
3070 }
3071
3072 #[test]
3075 fn frag_ref_property_owner_and_call_pair_are_appended_last_and_omitted_when_absent() {
3076 let hop = FragRef {
3077 qualified: Some("head.Dial".into()),
3078 name: "Dial".into(),
3079 receiver_property_owner: Some("Widget".into()),
3080 ..frag_ref(false, None, Some(0))
3081 };
3082 assert_eq!(
3083 serde_json::to_string(&hop).unwrap(),
3084 r#"{"kind":"uses-member","name":"Dial","qualified":"head.Dial","member":"Save","line":7,"namespace":"App.Shape","argCount":0,"receiverPropertyOwner":"Widget"}"#
3085 );
3086
3087 let from_call = FragRef {
3088 receiver_call_owner: Some("Factory".into()),
3089 receiver_call_member: Some("Make".into()),
3090 ..frag_ref(false, None, Some(0))
3091 };
3092 assert_eq!(
3093 serde_json::to_string(&from_call).unwrap(),
3094 r#"{"kind":"uses-member","name":"repo","member":"Save","line":7,"namespace":"App.Shape","argCount":0,"receiverCallOwner":"Factory","receiverCallMember":"Make"}"#
3095 );
3096
3097 let plain = serde_json::to_string(&frag_ref(false, Some("Binder"), Some(1))).unwrap();
3101 assert!(
3102 !plain.contains("receiverPropertyOwner") && !plain.contains("receiverCall"),
3103 "{plain}"
3104 );
3105 let reparsed: FragRef = serde_json::from_str(&plain).unwrap();
3106 assert_eq!(reparsed.receiver_property_owner, None);
3107 assert_eq!(reparsed.receiver_call_owner, None);
3108 assert_eq!(reparsed.receiver_call_member, None);
3109 }
3110
3111 #[test]
3112 fn frag_ref_an_empty_or_absent_outer_types_serializes_to_nothing_and_reads_back_empty() {
3113 let json = serde_json::to_string(&frag_ref(false, None, None)).unwrap();
3114 assert!(
3115 !json.contains("outerTypes"),
3116 "a namespace-level ref keeps its exact pre-v8 bytes: {json}"
3117 );
3118 let pre_v8: FragRef = serde_json::from_str(
3121 r#"{"kind":"uses-type","name":"Widget","line":3,"namespace":"App.Core"}"#,
3122 )
3123 .unwrap();
3124 assert!(pre_v8.outer_types.is_empty());
3125 }
3126
3127 #[test]
3130 fn fragment_round_trip_keeps_method_params_and_receiver_lambda() {
3131 let mut method_params = OrderedMap::new();
3132 method_params.insert(
3133 "Register".to_string(),
3134 vec![
3135 vec!["Action<Options>".to_string()],
3136 vec!["string".to_string(), "Func<Options,bool>".to_string()],
3137 ],
3138 );
3139 let d = FragDef {
3140 method_params: method_params.clone(),
3141 ..frag_def(&[], &[], &[], &[], &[])
3142 };
3143 let d_json = serde_json::to_string(&d).unwrap();
3144 assert!(
3145 d_json.ends_with(
3146 r#""methodParams":{"Register":[["Action<Options>"],["string","Func<Options,bool>"]]}}"#
3147 ),
3148 "methodParams is appended last, after methodArities: {d_json}"
3149 );
3150 let d_reparsed: FragDef = serde_json::from_str(&d_json).unwrap();
3151 assert_eq!(d_reparsed.method_params, method_params);
3152
3153 let d_plain_json = serde_json::to_string(&frag_def(&[], &[], &[], &[], &[])).unwrap();
3157 assert!(!d_plain_json.contains("methodParams"), "{d_plain_json}");
3158 let d_plain_reparsed: FragDef = serde_json::from_str(&d_plain_json).unwrap();
3159 assert!(d_plain_reparsed.method_params.is_empty());
3160
3161 let slot = FragLambdaSlot {
3162 owner: "Registrar".to_string(),
3163 member: "Register".to_string(),
3164 arg_count: 1,
3165 arg_index: 0,
3166 arity: 1,
3167 index: 0,
3168 };
3169 let r = FragRef {
3170 receiver_lambda: Some(slot.clone()),
3171 ..frag_ref(false, None, None)
3172 };
3173 let r_json = serde_json::to_string(&r).unwrap();
3174 assert!(
3175 r_json.ends_with(
3176 r#""receiverLambda":{"owner":"Registrar","member":"Register","argCount":1,"argIndex":0,"arity":1,"index":0}}"#
3177 ),
3178 "receiverLambda is appended last, after receiverLocal: {r_json}"
3179 );
3180 let r_reparsed: FragRef = serde_json::from_str(&r_json).unwrap();
3181 assert_eq!(r_reparsed.receiver_lambda, Some(slot));
3182
3183 let r_plain_json = serde_json::to_string(&frag_ref(false, None, None)).unwrap();
3185 assert!(!r_plain_json.contains("receiverLambda"), "{r_plain_json}");
3186 let r_plain_reparsed: FragRef = serde_json::from_str(&r_plain_json).unwrap();
3187 assert_eq!(r_plain_reparsed.receiver_lambda, None);
3188 }
3189}