use std::collections::HashMap;
use std::fmt;
use std::fs;
use std::io;
use std::marker::PhantomData;
use std::path::{Path, PathBuf};
use std::sync::atomic::{AtomicU64, Ordering};
use serde::de::{MapAccess, Visitor};
use serde::ser::SerializeMap;
use serde::{Deserialize, Deserializer, Serialize, Serializer};
use crate::extract;
use crate::repo::{git_common_dir, scout_dir};
fn graph_dir(root: &Path) -> PathBuf {
match git_common_dir(root) {
Some(common) => common.join("scout").join("graph"),
None => scout_dir(root).join("graph"),
}
}
pub fn graph_json_path(root: &Path) -> PathBuf {
graph_dir(root).join("graph.json")
}
fn fragments_cache_path(root: &Path) -> PathBuf {
graph_dir(root).join("fragments-v15.json")
}
fn fragments_index_path(root: &Path) -> PathBuf {
graph_dir(root).join("fragments-index-v15.json")
}
const SUPERSEDED_CACHE_FILES: &[&str] = &[
"fragments.json",
"fragments-index.json",
"fragments-v2.json",
"fragments-index-v2.json",
"fragments-v3.json",
"fragments-index-v3.json",
"fragments-v4.json",
"fragments-index-v4.json",
"fragments-v5.json",
"fragments-index-v5.json",
"fragments-v6.json",
"fragments-index-v6.json",
"fragments-v7.json",
"fragments-index-v7.json",
"fragments-v8.json",
"fragments-index-v8.json",
"fragments-v9.json",
"fragments-index-v9.json",
"fragments-v10.json",
"fragments-index-v10.json",
"fragments-v11.json",
"fragments-index-v11.json",
"fragments-v12.json",
"fragments-index-v12.json",
"fragments-v13.json",
"fragments-index-v13.json",
"fragments-v14.json",
"fragments-index-v14.json",
];
fn remove_superseded_caches(root: &Path) {
for name in SUPERSEDED_CACHE_FILES {
let _ = fs::remove_file(graph_dir(root).join(name));
}
}
static TMP_COUNTER: AtomicU64 = AtomicU64::new(0);
fn atomic_write_bytes(path: &Path, bytes: &[u8]) -> io::Result<()> {
let dir = path.parent().unwrap_or_else(|| Path::new("."));
fs::create_dir_all(dir)?;
let file_name = path
.file_name()
.and_then(|n| n.to_str())
.unwrap_or("artifact");
let counter = TMP_COUNTER.fetch_add(1, Ordering::Relaxed);
let tmp_path = dir.join(format!(".{file_name}.tmp.{}.{counter}", std::process::id()));
let write_result = fs::write(&tmp_path, bytes);
if write_result.is_err() {
let _ = fs::remove_file(&tmp_path);
write_result?;
}
fs::rename(&tmp_path, path)?;
Ok(())
}
fn atomic_write_json<T: Serialize>(path: &Path, value: &T) -> io::Result<()> {
let bytes = serde_json::to_vec(value).map_err(io::Error::other)?;
atomic_write_bytes(path, &bytes)
}
#[derive(Debug, Clone, PartialEq)]
pub struct OrderedMap<V> {
entries: Vec<(String, V)>,
index: HashMap<String, usize>,
}
impl<V> OrderedMap<V> {
pub fn new() -> Self {
Self {
entries: Vec::new(),
index: HashMap::new(),
}
}
pub fn insert(&mut self, key: String, value: V) {
match self.index.get(&key) {
Some(&i) => self.entries[i].1 = value,
None => {
self.index.insert(key.clone(), self.entries.len());
self.entries.push((key, value));
}
}
}
pub fn get(&self, key: &str) -> Option<&V> {
self.index.get(key).map(|&i| &self.entries[i].1)
}
pub fn len(&self) -> usize {
self.entries.len()
}
pub fn is_empty(&self) -> bool {
self.entries.is_empty()
}
pub fn iter(&self) -> impl Iterator<Item = (&String, &V)> {
self.entries.iter().map(|(k, v)| (k, v))
}
}
impl<V> Default for OrderedMap<V> {
fn default() -> Self {
Self::new()
}
}
impl<V: Serialize> Serialize for OrderedMap<V> {
fn serialize<S: Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
let mut map = serializer.serialize_map(Some(self.entries.len()))?;
for (k, v) in &self.entries {
map.serialize_entry(k, v)?;
}
map.end()
}
}
impl<'de, V: Deserialize<'de>> Deserialize<'de> for OrderedMap<V> {
fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
struct OrderedMapVisitor<V>(PhantomData<V>);
impl<'de, V: Deserialize<'de>> Visitor<'de> for OrderedMapVisitor<V> {
type Value = OrderedMap<V>;
fn expecting(&self, f: &mut fmt::Formatter) -> fmt::Result {
f.write_str("a JSON object")
}
fn visit_map<A: MapAccess<'de>>(self, mut map: A) -> Result<Self::Value, A::Error> {
let mut out = OrderedMap::new();
while let Some((k, v)) = map.next_entry::<String, V>()? {
out.insert(k, v);
}
Ok(out)
}
}
deserializer.deserialize_map(OrderedMapVisitor(PhantomData))
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct Percent1(pub i64);
impl Percent1 {
pub fn zero() -> Self {
Percent1(0)
}
pub fn from_ratio(numerator: usize, denominator: usize) -> Self {
if denominator == 0 {
return Percent1::zero();
}
let raw = (numerator as f64 / denominator as f64) * 1000.0;
Percent1(raw.round() as i64)
}
}
impl Serialize for Percent1 {
fn serialize<S: Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
if self.0 % 10 == 0 {
serializer.serialize_i64(self.0 / 10)
} else {
serializer.serialize_f64(self.0 as f64 / 10.0)
}
}
}
impl<'de> Deserialize<'de> for Percent1 {
fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
let v = f64::deserialize(deserializer)?;
Ok(Percent1((v * 10.0).round() as i64))
}
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct AlsoIn {
pub file: String,
pub line: usize,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct Def {
pub id: String,
pub name: String,
pub namespace: String,
pub kind: String,
pub file: String,
pub line: usize,
pub methods: Vec<String>,
#[serde(default, rename = "testMethods", skip_serializing_if = "Vec::is_empty")]
pub test_methods: Vec<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub also_in: Vec<AlsoIn>,
#[serde(default, rename = "endLine", skip_serializing_if = "is_zero")]
pub end_line: usize,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct Candidate {
pub id: String,
pub file: String,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(tag = "kind")]
pub enum Edge {
#[serde(rename = "inherits")]
Inherits {
from_file: String,
from_line: usize,
to: String,
to_file: String,
#[serde(default, skip_serializing_if = "is_false")]
heuristic: bool,
},
#[serde(rename = "uses-type")]
UsesType {
from_file: String,
from_line: usize,
to: String,
to_file: String,
#[serde(default, skip_serializing_if = "is_false")]
heuristic: bool,
},
#[serde(rename = "uses-member")]
UsesMember {
from_file: String,
from_line: usize,
to: String,
to_file: String,
#[serde(default, skip_serializing_if = "is_false")]
heuristic: bool,
},
#[serde(rename = "imports")]
Imports {
from_file: String,
from_line: usize,
target: String,
},
#[serde(rename = "ambiguous")]
Ambiguous {
origin: String,
from_file: String,
from_line: usize,
raw: String,
candidates: Vec<Candidate>,
candidate_count: usize,
},
#[serde(rename = "ctor-di")]
CtorDi {
from_file: String,
from_line: usize,
iface: String,
resolution: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
args: Option<Vec<String>>,
#[serde(default, skip_serializing_if = "Option::is_none")]
to: Option<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
candidates: Vec<Candidate>,
},
#[serde(rename = "import")]
Import {
from_file: String,
from_line: usize,
target: String,
to_file: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
via: Option<String>,
},
#[serde(rename = "call")]
Call {
from_file: String,
from_line: usize,
to: String,
to_file: String,
},
#[serde(rename = "jsx-use")]
JsxUse {
from_file: String,
from_line: usize,
to: String,
to_file: String,
},
#[serde(rename = "dispatch")]
Dispatch {
from_file: String,
from_line: usize,
to: String,
to_file: String,
},
}
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
pub struct EdgesByKind {
pub inherits: usize,
#[serde(rename = "uses-type")]
pub uses_type: usize,
pub imports: usize,
#[serde(rename = "uses-member")]
pub uses_member: usize,
#[serde(rename = "ctor-di")]
pub ctor_di: usize,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub import: Option<usize>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub call: Option<usize>,
#[serde(rename = "jsx-use", default, skip_serializing_if = "Option::is_none")]
pub jsx_use: Option<usize>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub dispatch: Option<usize>,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct Stats {
pub def_count: usize,
pub file_count: usize,
pub edges_by_kind: EdgesByKind,
pub ambiguous_count: usize,
pub ambiguous_pct: Percent1,
pub unresolved_external_count: usize,
#[serde(default)]
pub heuristic_edge_count: usize,
#[serde(default)]
pub test_def_count: usize,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub ts: Option<crate::tsgraph::TsStats>,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct GraphName {
pub name: String,
pub kind: String,
pub file: String,
pub line: usize,
#[serde(default, skip_serializing_if = "String::is_empty")]
pub owner: String,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct Graph {
pub schema_version: u32,
pub built_at_head: Option<String>,
pub defs: Vec<Def>,
pub edges: Vec<Edge>,
pub stats: Stats,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub names: Vec<GraphName>,
}
pub fn read_graph(root: &Path) -> Option<Graph> {
let text = fs::read_to_string(graph_json_path(root)).ok()?;
serde_json::from_str(&text).ok()
}
fn write_graph(root: &Path, graph: &Graph) -> io::Result<()> {
atomic_write_json(&graph_json_path(root), graph)
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct FragDef {
pub id: String,
pub name: String,
pub namespace: String,
pub kind: String,
pub line: usize,
pub methods: Vec<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub properties: Vec<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub fields: Vec<String>,
#[serde(
default,
rename = "methodReturns",
skip_serializing_if = "OrderedMap::is_empty"
)]
pub method_returns: OrderedMap<String>,
#[serde(
default,
rename = "extensionMethods",
skip_serializing_if = "Vec::is_empty"
)]
pub extension_methods: Vec<FragExtensionMethod>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub bases: Vec<String>,
#[serde(default, rename = "typeParams", skip_serializing_if = "Vec::is_empty")]
pub type_params: Vec<String>,
#[serde(
default,
rename = "baseGenericArgs",
skip_serializing_if = "OrderedMap::is_empty"
)]
pub base_generic_args: OrderedMap<Vec<String>>,
#[serde(default, rename = "testMethods", skip_serializing_if = "Vec::is_empty")]
pub test_methods: Vec<String>,
#[serde(
default,
rename = "propertyTypes",
skip_serializing_if = "OrderedMap::is_empty"
)]
pub property_types: OrderedMap<FragFact>,
#[serde(default, rename = "endLine", skip_serializing_if = "is_zero")]
pub end_line: usize,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct FragFact {
#[serde(rename = "type")]
pub type_name: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub args: Option<Vec<String>>,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct FragExtensionMethod {
pub name: String,
#[serde(rename = "thisType")]
pub this_type: String,
#[serde(rename = "arityMin")]
pub arity_min: usize,
#[serde(rename = "arityMax")]
pub arity_max: i64,
#[serde(default, rename = "thisArgs", skip_serializing_if = "Option::is_none")]
pub this_args: Option<Vec<String>>,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(untagged)]
pub enum FragUsing {
Alias {
alias: String,
target: String,
global: bool,
},
Plain {
text: String,
global: bool,
},
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct FragRef {
pub kind: String,
pub name: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub qualified: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub member: Option<String>,
pub line: usize,
pub namespace: Option<String>,
#[serde(
default,
rename = "typeArgCount",
skip_serializing_if = "Option::is_none"
)]
pub type_arg_count: Option<usize>,
#[serde(default, skip_serializing_if = "is_false")]
pub generic: bool,
#[serde(
default,
rename = "receiverType",
skip_serializing_if = "Option::is_none"
)]
pub receiver_type: Option<String>,
#[serde(default, rename = "argCount", skip_serializing_if = "Option::is_none")]
pub arg_count: Option<usize>,
#[serde(
default,
rename = "receiverArgs",
skip_serializing_if = "Option::is_none"
)]
pub receiver_args: Option<Vec<String>>,
#[serde(default, rename = "outerTypes", skip_serializing_if = "Vec::is_empty")]
pub outer_types: Vec<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub args: Option<Vec<String>>,
#[serde(
default,
rename = "receiverPropertyOwner",
skip_serializing_if = "Option::is_none"
)]
pub receiver_property_owner: Option<String>,
#[serde(
default,
rename = "receiverCallOwner",
skip_serializing_if = "Option::is_none"
)]
pub receiver_call_owner: Option<String>,
#[serde(
default,
rename = "receiverCallMember",
skip_serializing_if = "Option::is_none"
)]
pub receiver_call_member: Option<String>,
}
fn is_false(b: &bool) -> bool {
!*b
}
fn is_zero(n: &usize) -> bool {
*n == 0
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct FragName {
pub name: String,
pub kind: String,
pub line: usize,
#[serde(default, skip_serializing_if = "String::is_empty")]
pub owner: String,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct Fragment {
pub defs: Vec<FragDef>,
pub usings: Vec<FragUsing>,
pub refs: Vec<FragRef>,
#[serde(default)]
pub names: Vec<FragName>,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(untagged)]
pub enum AnyFragment {
Ts(extract::TsFragment),
Cs(Fragment),
}
impl From<Fragment> for AnyFragment {
fn from(f: Fragment) -> Self {
AnyFragment::Cs(f)
}
}
impl From<extract::TsFragment> for AnyFragment {
fn from(f: extract::TsFragment) -> Self {
AnyFragment::Ts(f)
}
}
pub fn fragment_from_extraction(e: &extract::Extraction) -> Fragment {
Fragment {
defs: e
.defs
.iter()
.map(|d| FragDef {
id: d.id.clone(),
name: d.name.clone(),
namespace: d.namespace.clone(),
kind: d.kind.clone(),
line: d.line,
methods: d.methods.clone(),
properties: d.properties.clone(),
fields: d.fields.clone(),
method_returns: {
let mut m = OrderedMap::new();
for (name, returns) in &d.method_returns {
m.insert(name.clone(), returns.clone());
}
m
},
extension_methods: d
.extension_methods
.iter()
.map(|e| FragExtensionMethod {
name: e.name.clone(),
this_type: e.this_type.clone(),
arity_min: e.arity_min,
arity_max: e.arity_max,
this_args: e.this_args.clone(),
})
.collect(),
bases: d.bases.clone(),
type_params: d.type_params.clone(),
base_generic_args: {
let mut m = OrderedMap::new();
for (name, args) in &d.base_generic_args {
m.insert(name.clone(), args.clone());
}
m
},
test_methods: d.test_methods.clone(),
property_types: {
let mut m = OrderedMap::new();
for (name, fact) in &d.property_types {
m.insert(
name.clone(),
FragFact {
type_name: fact.type_name.clone(),
args: fact.args.clone(),
},
);
}
m
},
end_line: d.end_line,
})
.collect(),
usings: e
.usings
.iter()
.map(|u| match u {
extract::UsingRecord::Alias {
alias,
target,
global,
} => FragUsing::Alias {
alias: alias.clone(),
target: target.clone(),
global: *global,
},
extract::UsingRecord::Plain { text, global } => FragUsing::Plain {
text: text.clone(),
global: *global,
},
})
.collect(),
refs: e
.refs
.iter()
.map(|r| FragRef {
kind: r.kind.clone(),
name: r.name.clone(),
qualified: r.qualified.clone(),
member: r.member.clone(),
line: r.line,
namespace: r.namespace.clone(),
type_arg_count: r.type_arg_count,
generic: r.generic,
receiver_type: r.receiver_type.clone(),
arg_count: r.arg_count,
receiver_args: r.receiver_args.clone(),
outer_types: r.outer_types.clone(),
args: r.args.clone(),
receiver_property_owner: r.receiver_property_owner.clone(),
receiver_call_owner: r.receiver_call_owner.clone(),
receiver_call_member: r.receiver_call_member.clone(),
})
.collect(),
names: e
.names
.iter()
.map(|n| FragName {
name: n.name.clone(),
kind: n.kind.clone(),
line: n.line,
owner: n.owner.clone(),
})
.collect(),
}
}
pub fn markup_fragment(root: &Path, rel: &str) -> Option<Fragment> {
let text = String::from_utf8_lossy(&fs::read(root.join(rel)).ok()?).into_owned();
let facts = crate::markup::markup_facts(rel, &text);
Some(Fragment {
defs: facts
.defs
.into_iter()
.map(|d| FragDef {
id: d.id,
name: d.name,
namespace: d.namespace,
kind: d.kind,
line: d.line,
methods: Vec::new(),
properties: Vec::new(),
fields: Vec::new(),
method_returns: OrderedMap::new(),
extension_methods: Vec::new(),
bases: Vec::new(),
type_params: Vec::new(),
base_generic_args: OrderedMap::new(),
test_methods: Vec::new(),
property_types: OrderedMap::new(),
end_line: d.line,
})
.collect(),
usings: Vec::new(),
refs: facts
.refs
.into_iter()
.map(|r| FragRef {
kind: r.kind,
name: r.name,
qualified: r.qualified,
member: r.member,
line: r.line,
namespace: Some(String::new()),
type_arg_count: Some(0),
generic: false,
receiver_type: None,
arg_count: None,
receiver_args: None,
outer_types: Vec::new(),
args: None,
receiver_property_owner: None,
receiver_call_owner: None,
receiver_call_member: None,
})
.collect(),
names: facts
.names
.into_iter()
.map(|n| FragName {
name: n.name,
kind: n.kind,
line: n.line,
owner: n.owner,
})
.collect(),
})
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct FragmentCacheEntry {
pub mtime: i64,
pub fragment: AnyFragment,
}
fn read_fragments_cache(root: &Path) -> OrderedMap<FragmentCacheEntry> {
match fs::read_to_string(fragments_cache_path(root)) {
Ok(text) => serde_json::from_str(&text).unwrap_or_default(),
Err(_) => OrderedMap::default(),
}
}
fn write_fragments_cache(root: &Path, cache: &OrderedMap<FragmentCacheEntry>) -> io::Result<()> {
atomic_write_json(&fragments_cache_path(root), cache)
}
pub fn read_fragments_index(root: &Path) -> OrderedMap<i64> {
match fs::read_to_string(fragments_index_path(root)) {
Ok(text) => serde_json::from_str(&text).unwrap_or_default(),
Err(_) => OrderedMap::default(),
}
}
fn write_fragments_index(root: &Path, cache: &OrderedMap<FragmentCacheEntry>) -> io::Result<()> {
let mut index = OrderedMap::new();
for (rel, entry) in cache.iter() {
index.insert(rel.clone(), entry.mtime);
}
atomic_write_json(&fragments_index_path(root), &index)
}
pub fn index_is_stale(index: &OrderedMap<i64>, graph_files: &[GraphFile]) -> bool {
graph_files
.iter()
.any(|f| index.get(&f.rel) != Some(&f.mtime))
|| index.len() != graph_files.len()
}
#[derive(Debug, Clone)]
pub struct GraphFile {
pub rel: String,
pub mtime: i64,
}
#[derive(Debug)]
pub enum RebuildOutcome {
NotRebuilt,
Rebuilt(Graph),
}
pub fn rebuild_graph(
root: &Path,
graph_files: &[GraphFile],
fresh_fragments: &HashMap<String, AnyFragment>,
changed: bool,
) -> io::Result<RebuildOutcome> {
if !changed && graph_json_path(root).exists() {
return Ok(RebuildOutcome::NotRebuilt);
}
let cache = read_fragments_cache(root);
let mut merged_cs: Vec<(String, Fragment)> = Vec::new();
let mut merged_ts: Vec<(String, extract::TsFragment)> = Vec::new();
let mut new_cache: OrderedMap<FragmentCacheEntry> = OrderedMap::new();
for f in graph_files {
let fragment = match cache.get(&f.rel) {
Some(entry) if entry.mtime == f.mtime => Some(entry.fragment.clone()),
_ => fresh_fragments.get(&f.rel).cloned(),
};
let Some(fragment) = fragment else { continue };
match &fragment {
AnyFragment::Cs(c) => merged_cs.push((f.rel.clone(), c.clone())),
AnyFragment::Ts(t) => merged_ts.push((f.rel.clone(), t.clone())),
}
new_cache.insert(
f.rel.clone(),
FragmentCacheEntry {
mtime: f.mtime,
fragment,
},
);
}
let graph = crate::resolve::resolve_graph_with_ts(root, &merged_cs, &merged_ts);
write_graph(root, &graph)?;
write_fragments_cache(root, &new_cache)?;
write_fragments_index(root, &new_cache)?;
remove_superseded_caches(root);
Ok(RebuildOutcome::Rebuilt(graph))
}
#[cfg(test)]
mod tests {
use super::*;
use std::time::{SystemTime, UNIX_EPOCH};
fn temp_dir(label: &str) -> PathBuf {
let nanos = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap()
.as_nanos();
let dir = std::env::temp_dir().join(format!("scout-graph-test-{label}-{nanos}"));
fs::create_dir_all(&dir).unwrap();
dir
}
#[test]
fn ordered_map_preserves_insertion_order_through_json_round_trip() {
let mut m: OrderedMap<i64> = OrderedMap::new();
m.insert("z.cs".to_string(), 3);
m.insert("a.cs".to_string(), 1);
m.insert("m.cs".to_string(), 2);
let json = serde_json::to_string(&m).unwrap();
assert_eq!(json, r#"{"z.cs":3,"a.cs":1,"m.cs":2}"#);
let reparsed: OrderedMap<i64> = serde_json::from_str(&json).unwrap();
let keys: Vec<&String> = reparsed.iter().map(|(k, _)| k).collect();
assert_eq!(keys, vec!["z.cs", "a.cs", "m.cs"]);
}
#[test]
fn ordered_map_insert_overwrite_keeps_original_position() {
let mut m: OrderedMap<i64> = OrderedMap::new();
m.insert("a".to_string(), 1);
m.insert("b".to_string(), 2);
m.insert("a".to_string(), 99);
let keys: Vec<&String> = m.iter().map(|(k, _)| k).collect();
assert_eq!(keys, vec!["a", "b"]);
assert_eq!(m.get("a"), Some(&99));
}
#[test]
fn percent1_whole_value_serializes_without_decimal_point() {
let p = Percent1::from_ratio(2, 10); assert_eq!(serde_json::to_string(&p).unwrap(), "20");
}
#[test]
fn percent1_fractional_value_serializes_with_one_decimal_digit() {
let p = Percent1::from_ratio(1, 3); assert_eq!(serde_json::to_string(&p).unwrap(), "33.3");
}
#[test]
fn percent1_zero_denominator_is_zero() {
assert_eq!(Percent1::from_ratio(0, 0), Percent1::zero());
assert_eq!(serde_json::to_string(&Percent1::zero()).unwrap(), "0");
}
fn frag_def(
methods: &[&str],
properties: &[&str],
fields: &[&str],
method_returns: &[(&str, &str)],
extension_methods: &[(&str, &str, usize, i64)],
) -> FragDef {
frag_def_with_bases(
methods,
properties,
fields,
method_returns,
extension_methods,
&[],
)
}
fn frag_def_with_bases(
methods: &[&str],
properties: &[&str],
fields: &[&str],
method_returns: &[(&str, &str)],
extension_methods: &[(&str, &str, usize, i64)],
bases: &[&str],
) -> FragDef {
let mut mr = OrderedMap::new();
for (k, v) in method_returns {
mr.insert((*k).to_string(), (*v).to_string());
}
FragDef {
id: "App.Facts.Widget".into(),
name: "Widget".into(),
namespace: "App.Facts".into(),
kind: "class".into(),
line: 3,
methods: methods.iter().map(|s| s.to_string()).collect(),
properties: properties.iter().map(|s| s.to_string()).collect(),
fields: fields.iter().map(|s| s.to_string()).collect(),
method_returns: mr,
extension_methods: extension_methods
.iter()
.map(|(n, t, lo, hi)| FragExtensionMethod {
name: (*n).to_string(),
this_type: (*t).to_string(),
arity_min: *lo,
arity_max: *hi,
this_args: None,
})
.collect(),
bases: bases.iter().map(|s| s.to_string()).collect(),
type_params: Vec::new(),
base_generic_args: OrderedMap::new(),
property_types: OrderedMap::new(),
test_methods: Vec::new(),
end_line: 0,
}
}
#[test]
fn frag_def_appends_properties_fields_and_method_returns_last_in_that_order() {
let json = serde_json::to_string(&frag_def(
&["GetAsync"],
&["Prefix"],
&["_log"],
&[("GetAsync", "Task")],
&[],
))
.unwrap();
assert_eq!(
json,
r#"{"id":"App.Facts.Widget","name":"Widget","namespace":"App.Facts","kind":"class","line":3,"methods":["GetAsync"],"properties":["Prefix"],"fields":["_log"],"methodReturns":{"GetAsync":"Task"}}"#
);
}
#[test]
fn frag_def_omits_all_three_new_keys_when_the_type_declares_none_of_them() {
let json = serde_json::to_string(&frag_def(&["Go"], &[], &[], &[], &[])).unwrap();
assert_eq!(
json,
r#"{"id":"App.Facts.Widget","name":"Widget","namespace":"App.Facts","kind":"class","line":3,"methods":["Go"]}"#,
"pre-stage-2 bytes preserved exactly"
);
}
#[test]
fn frag_def_appends_extension_methods_after_method_returns_with_the_arity_range_last() {
let json = serde_json::to_string(&frag_def(
&["Go"],
&["Prefix"],
&["_log"],
&[("Go", "Task")],
&[
("Render", "Widget", 0, 0),
("Render", "Widget", 2, 3),
("Trim", "string", 0, -1),
],
))
.unwrap();
assert_eq!(
json,
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}]}"#
);
}
#[test]
fn frag_def_this_args_lands_after_arity_max_and_bases_after_extension_methods() {
let mut d = frag_def_with_bases(
&["Go"],
&[],
&[],
&[],
&[("Each", "List", 0, 0)],
&["BaseWidget", "IWidget"],
);
d.extension_methods[0].this_args = Some(vec!["Widget".into()]);
let json = serde_json::to_string(&d).unwrap();
assert_eq!(
json,
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"]}"#
);
let reparsed: FragDef = serde_json::from_str(&json).unwrap();
assert_eq!(
reparsed.bases,
vec!["BaseWidget".to_string(), "IWidget".to_string()]
);
assert_eq!(
reparsed.extension_methods[0].this_args,
Some(vec!["Widget".to_string()])
);
}
#[test]
fn frag_def_omits_bases_when_the_type_lists_none() {
let json = serde_json::to_string(&frag_def(&["Go"], &[], &[], &[], &[])).unwrap();
assert!(
!json.contains("bases"),
"an empty base list must be omitted entirely: {json}"
);
let reparsed: FragDef = serde_json::from_str(&json).unwrap();
assert!(
reparsed.bases.is_empty(),
"an absent key reads back as \"lists none\""
);
}
#[test]
fn frag_def_appends_test_methods_last_after_bases() {
let mut d = frag_def_with_bases(&["Go"], &[], &[], &[], &[], &["BaseWidget"]);
d.test_methods = vec!["TotalsAnEmptyOrder".into(), "TotalsALineItem".into()];
let json = serde_json::to_string(&d).unwrap();
assert_eq!(
json,
r#"{"id":"App.Facts.Widget","name":"Widget","namespace":"App.Facts","kind":"class","line":3,"methods":["Go"],"bases":["BaseWidget"],"testMethods":["TotalsAnEmptyOrder","TotalsALineItem"]}"#
);
let reparsed: FragDef = serde_json::from_str(&json).unwrap();
assert_eq!(
reparsed.test_methods,
vec![
"TotalsAnEmptyOrder".to_string(),
"TotalsALineItem".to_string()
]
);
}
#[test]
fn frag_def_omits_test_methods_when_the_type_declares_no_tests() {
let json = serde_json::to_string(&frag_def(&["Go"], &[], &[], &[], &[])).unwrap();
assert!(
!json.contains("testMethods"),
"pre-stage bytes preserved exactly: {json}"
);
let reparsed: FragDef = serde_json::from_str(&json).unwrap();
assert!(
reparsed.test_methods.is_empty(),
"an absent key reads back as \"declares no tests\""
);
}
#[test]
fn frag_def_omits_extension_methods_when_the_type_declares_none() {
let json =
serde_json::to_string(&frag_def(&["Go"], &[], &[], &[("Go", "Task")], &[])).unwrap();
assert!(
!json.contains("extensionMethods"),
"pre-stage-3 bytes preserved exactly: {json}"
);
let reparsed: FragDef = serde_json::from_str(&json).unwrap();
assert!(
reparsed.extension_methods.is_empty(),
"an absent key reads back as \"declares none\""
);
}
#[test]
fn frag_def_method_returns_serializes_in_first_declaration_order_not_sorted() {
let json = serde_json::to_string(&frag_def(
&[],
&[],
&[],
&[("Zebra", "Z"), ("Alpha", "A")],
&[],
))
.unwrap();
assert!(
json.ends_with(r#""methodReturns":{"Zebra":"Z","Alpha":"A"}}"#),
"insertion order, not sorted: {json}"
);
let reparsed: FragDef = serde_json::from_str(&json).unwrap();
let keys: Vec<&String> = reparsed.method_returns.iter().map(|(k, _)| k).collect();
assert_eq!(
keys,
vec!["Zebra", "Alpha"],
"and the order survives a round trip"
);
}
#[test]
fn frag_def_appends_property_types_last_after_test_methods() {
let mut d =
frag_def_with_bases(&["Go"], &["Dial", "Slots"], &[], &[], &[], &["BaseWidget"]);
d.test_methods = vec!["TotalsALineItem".into()];
d.property_types.insert(
"Dial".into(),
FragFact {
type_name: "Gauge".into(),
args: None,
},
);
d.property_types.insert(
"Slots".into(),
FragFact {
type_name: "Toolbox".into(),
args: Some(vec!["Gadget".into()]),
},
);
let json = serde_json::to_string(&d).unwrap();
assert_eq!(
json,
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"]}}}"#
);
let reparsed: FragDef = serde_json::from_str(&json).unwrap();
let keys: Vec<&String> = reparsed.property_types.iter().map(|(k, _)| k).collect();
assert_eq!(
keys,
vec!["Dial", "Slots"],
"source order survives a round trip, like methodReturns"
);
}
#[test]
fn frag_def_omits_property_types_when_no_property_carries_a_fact() {
let json = serde_json::to_string(&frag_def(&["Go"], &["Label"], &[], &[], &[])).unwrap();
assert!(
!json.contains("propertyTypes"),
"pre-propertyTypes bytes preserved exactly: {json}"
);
let reparsed: FragDef = serde_json::from_str(&json).unwrap();
assert!(
reparsed.property_types.is_empty(),
"an absent key reads back as \"no property vouches for a type\""
);
}
fn frag_ref(generic: bool, receiver_type: Option<&str>, arg_count: Option<usize>) -> FragRef {
FragRef {
kind: "uses-member".into(),
name: "repo".into(),
qualified: None,
member: Some("Save".into()),
line: 7,
namespace: Some("App.Shape".into()),
type_arg_count: None,
generic,
receiver_type: receiver_type.map(String::from),
arg_count,
receiver_args: None,
outer_types: Vec::new(),
args: None,
receiver_property_owner: None,
receiver_call_owner: None,
receiver_call_member: None,
}
}
#[test]
fn frag_ref_receiver_args_is_appended_last_after_arg_count() {
let r = FragRef {
receiver_args: Some(vec!["FutureState".into(), "*".into()]),
..frag_ref(false, Some("Binder"), Some(1))
};
let json = serde_json::to_string(&r).unwrap();
assert_eq!(
json,
r#"{"kind":"uses-member","name":"repo","member":"Save","line":7,"namespace":"App.Shape","receiverType":"Binder","argCount":1,"receiverArgs":["FutureState","*"]}"#
);
let reparsed: FragRef = serde_json::from_str(&json).unwrap();
assert_eq!(
reparsed.receiver_args,
Some(vec!["FutureState".to_string(), "*".to_string()])
);
}
#[test]
fn frag_ref_receiver_type_then_arg_count_are_appended_last_after_generic() {
let json = serde_json::to_string(&frag_ref(true, Some("IRepo"), Some(2))).unwrap();
assert_eq!(
json,
r#"{"kind":"uses-member","name":"repo","member":"Save","line":7,"namespace":"App.Shape","generic":true,"receiverType":"IRepo","argCount":2}"#
);
}
#[test]
fn frag_ref_arg_count_zero_still_serializes_it() {
let json = serde_json::to_string(&frag_ref(false, Some("IRepo"), Some(0))).unwrap();
assert!(
json.ends_with(r#""receiverType":"IRepo","argCount":0}"#),
"argCount 0 must survive: {json}"
);
}
#[test]
fn frag_ref_without_a_fact_keeps_its_pre_stage_2_bytes() {
let json = serde_json::to_string(&frag_ref(false, None, None)).unwrap();
assert_eq!(
json,
r#"{"kind":"uses-member","name":"repo","member":"Save","line":7,"namespace":"App.Shape"}"#
);
let reparsed: FragRef = serde_json::from_str(&json).unwrap();
assert_eq!(
reparsed.receiver_type, None,
"an absent key reads back as no fact"
);
assert_eq!(
reparsed.arg_count, None,
"and an absent argCount reads back as \"not a call\""
);
assert_eq!(
reparsed.receiver_args, None,
"and an absent receiverArgs reads back as \"not generic\""
);
}
#[test]
fn def_without_also_in_omits_the_key() {
let d = Def {
id: "Ns.Type".into(),
name: "Type".into(),
namespace: "Ns".into(),
kind: "class".into(),
file: "Ns/Type.cs".into(),
line: 3,
methods: vec![],
test_methods: vec![],
also_in: vec![],
end_line: 0,
};
let json = serde_json::to_string(&d).unwrap();
assert!(
!json.contains("also_in"),
"empty also_in must be omitted: {json}"
);
}
#[test]
fn def_end_line_serializes_last_and_defaults_when_absent() {
let mut d = Def {
id: "Ns.Type".into(),
name: "Type".into(),
namespace: "Ns".into(),
kind: "class".into(),
file: "Ns/Type.cs".into(),
line: 3,
methods: vec![],
test_methods: vec![],
also_in: vec![],
end_line: 0,
};
assert!(!serde_json::to_string(&d).unwrap().contains("endLine"));
d.end_line = 9;
assert!(serde_json::to_string(&d)
.unwrap()
.ends_with(",\"endLine\":9}"));
}
#[test]
fn def_with_also_in_includes_it_after_methods() {
let d = Def {
id: "Ns.Type".into(),
name: "Type".into(),
namespace: "Ns".into(),
kind: "class".into(),
file: "Ns/Type.cs".into(),
line: 3,
methods: vec!["M".into()],
test_methods: vec![],
also_in: vec![AlsoIn {
file: "Ns/Type.Extra.cs".into(),
line: 5,
}],
end_line: 0,
};
let json = serde_json::to_string(&d).unwrap();
let methods_pos = json.find("\"methods\"").unwrap();
let also_in_pos = json.find("\"also_in\"").unwrap();
assert!(
methods_pos < also_in_pos,
"also_in must come after methods: {json}"
);
}
#[test]
fn def_row_places_test_methods_between_methods_and_also_in() {
let d = Def {
id: "Ns.TypeTests".into(),
name: "TypeTests".into(),
namespace: "Ns".into(),
kind: "class".into(),
file: "Ns/TypeTests.cs".into(),
line: 3,
methods: vec!["M".into()],
test_methods: vec!["Fact1".into()],
also_in: vec![AlsoIn {
file: "Ns/TypeTests.Extra.cs".into(),
line: 5,
}],
end_line: 0,
};
let json = serde_json::to_string(&d).unwrap();
assert_eq!(
json,
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}]}"#
);
}
#[test]
fn def_row_omits_test_methods_when_the_def_declares_no_tests() {
let d = Def {
id: "Ns.Type".into(),
name: "Type".into(),
namespace: "Ns".into(),
kind: "class".into(),
file: "Ns/Type.cs".into(),
line: 3,
methods: vec!["M".into()],
test_methods: vec![],
also_in: vec![],
end_line: 0,
};
let json = serde_json::to_string(&d).unwrap();
assert!(
!json.contains("testMethods"),
"empty testMethods must be omitted: {json}"
);
}
#[test]
fn stats_appends_test_def_count_last_and_always_writes_it() {
let stats = Stats {
def_count: 2,
file_count: 1,
edges_by_kind: EdgesByKind::default(),
ambiguous_count: 0,
ambiguous_pct: Percent1::zero(),
unresolved_external_count: 0,
heuristic_edge_count: 0,
test_def_count: 0,
ts: None,
};
let json = serde_json::to_string(&stats).unwrap();
assert!(
json.ends_with(r#""heuristic_edge_count":0,"test_def_count":0}"#),
"test_def_count is LAST: {json}"
);
}
#[test]
fn imports_edge_has_target_not_to() {
let e = Edge::Imports {
from_file: "F.cs".into(),
from_line: 1,
target: "System".into(),
};
let json = serde_json::to_string(&e).unwrap();
assert_eq!(
json,
r#"{"kind":"imports","from_file":"F.cs","from_line":1,"target":"System"}"#
);
}
#[test]
fn uses_type_edge_shape() {
let e = Edge::UsesType {
from_file: "F.cs".into(),
from_line: 1,
to: "Ns.T".into(),
to_file: "Ns/T.cs".into(),
heuristic: false,
};
let json = serde_json::to_string(&e).unwrap();
assert_eq!(
json,
r#"{"kind":"uses-type","from_file":"F.cs","from_line":1,"to":"Ns.T","to_file":"Ns/T.cs"}"#
);
}
#[test]
fn heuristic_flag_is_appended_last_and_omitted_when_false() {
let precise = Edge::UsesMember {
from_file: "F.cs".into(),
from_line: 1,
to: "Ns.T".into(),
to_file: "Ns/T.cs".into(),
heuristic: false,
};
assert_eq!(
serde_json::to_string(&precise).unwrap(),
r#"{"kind":"uses-member","from_file":"F.cs","from_line":1,"to":"Ns.T","to_file":"Ns/T.cs"}"#
);
let guess = Edge::UsesMember {
from_file: "F.cs".into(),
from_line: 1,
to: "Ns.T".into(),
to_file: "Ns/T.cs".into(),
heuristic: true,
};
assert_eq!(
serde_json::to_string(&guess).unwrap(),
r#"{"kind":"uses-member","from_file":"F.cs","from_line":1,"to":"Ns.T","to_file":"Ns/T.cs","heuristic":true}"#
);
assert_eq!(
serde_json::from_str::<Edge>(&serde_json::to_string(&guess).unwrap()).unwrap(),
guess
);
assert_eq!(
serde_json::from_str::<Edge>(&serde_json::to_string(&precise).unwrap()).unwrap(),
precise
);
}
#[test]
fn ambiguous_edge_shape() {
let e = Edge::Ambiguous {
origin: "uses-type".into(),
from_file: "F.cs".into(),
from_line: 4,
raw: "Money".into(),
candidates: vec![Candidate {
id: "A.Money".into(),
file: "A/Money.cs".into(),
}],
candidate_count: 2,
};
let json = serde_json::to_string(&e).unwrap();
assert_eq!(
json,
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}"#
);
}
#[test]
fn edges_by_kind_field_order_is_fixed_not_alphabetical() {
let s = EdgesByKind {
inherits: 1,
uses_type: 2,
imports: 3,
uses_member: 4,
ctor_di: 5,
..Default::default()
};
let json = serde_json::to_string(&s).unwrap();
assert_eq!(
json,
r#"{"inherits":1,"uses-type":2,"imports":3,"uses-member":4,"ctor-di":5}"#
);
}
#[test]
fn any_fragment_round_trips_both_shapes_and_never_reads_one_as_the_other() {
let cs = AnyFragment::Cs(Fragment {
defs: Vec::new(),
usings: Vec::new(),
refs: Vec::new(),
names: Vec::new(),
});
let cs_json = serde_json::to_string(&cs).unwrap();
assert_eq!(cs_json, r#"{"defs":[],"usings":[],"refs":[],"names":[]}"#);
assert!(matches!(
serde_json::from_str::<AnyFragment>(&cs_json).unwrap(),
AnyFragment::Cs(_)
));
let ts = AnyFragment::Ts(extract::TsFragment {
ts: 1,
defs: vec![extract::TsFragmentDef {
name: "x".into(),
kind: "const".into(),
line: 1,
end_line: 1,
}],
imports: Vec::new(),
reexports: Vec::new(),
refs: Vec::new(),
default: None,
});
let ts_json = serde_json::to_string(&ts).unwrap();
assert_eq!(
ts_json,
r#"{"ts":1,"defs":[{"name":"x","kind":"const","line":1,"endLine":1}],"imports":[],"reexports":[],"refs":[]}"#
);
assert!(matches!(
serde_json::from_str::<AnyFragment>(&ts_json).unwrap(),
AnyFragment::Ts(_)
));
assert_eq!(
serde_json::from_str::<AnyFragment>(&ts_json).unwrap(),
ts,
"and round-trips to the same value"
);
}
#[test]
fn edges_by_kind_appends_the_four_ts_counts_after_ctor_di_when_present() {
let s = EdgesByKind {
inherits: 1,
uses_type: 2,
imports: 3,
uses_member: 4,
ctor_di: 5,
import: Some(6),
call: Some(7),
jsx_use: Some(8),
dispatch: Some(9),
};
let json = serde_json::to_string(&s).unwrap();
assert_eq!(
json,
r#"{"inherits":1,"uses-type":2,"imports":3,"uses-member":4,"ctor-di":5,"import":6,"call":7,"jsx-use":8,"dispatch":9}"#
);
}
#[test]
fn atomic_write_leaves_target_intact_when_only_a_stray_tmp_exists() {
let dir = temp_dir("atomic-crash");
let target = dir.join("graph.json");
fs::write(&target, b"{\"old\":true}").unwrap();
fs::write(dir.join(".graph.json.tmp.99999.7"), b"GARBAGE-PARTIAL").unwrap();
assert_eq!(fs::read_to_string(&target).unwrap(), "{\"old\":true}");
}
#[test]
fn atomic_write_replaces_target_and_leaves_no_tmp_file_behind() {
let dir = temp_dir("atomic-clean");
let target = dir.join("graph.json");
fs::write(&target, b"{\"old\":true}").unwrap();
atomic_write_bytes(&target, b"{\"new\":true}").unwrap();
assert_eq!(fs::read_to_string(&target).unwrap(), "{\"new\":true}");
let leftover: Vec<_> = fs::read_dir(&dir)
.unwrap()
.filter_map(|e| e.ok())
.filter(|e| e.file_name().to_string_lossy().contains(".tmp."))
.collect();
assert!(
leftover.is_empty(),
"no tmp file should remain: {leftover:?}"
);
}
#[test]
fn atomic_write_creates_parent_directories() {
let dir = temp_dir("atomic-mkdir");
let target = dir.join("nested").join("deeper").join("graph.json");
atomic_write_bytes(&target, b"{}").unwrap();
assert_eq!(fs::read_to_string(&target).unwrap(), "{}");
}
#[test]
fn rebuild_graph_skips_when_unchanged_and_graph_already_exists() {
let dir = temp_dir("rebuild-unchanged");
fs::create_dir_all(graph_dir(&dir)).unwrap();
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();
let outcome = rebuild_graph(&dir, &[], &HashMap::new(), false).unwrap();
assert!(matches!(outcome, RebuildOutcome::NotRebuilt));
}
#[test]
fn rebuild_graph_reuses_a_cached_fragment_at_matching_mtime() {
let dir = temp_dir("rebuild-cache-hit");
let fragment = Fragment {
defs: vec![FragDef {
id: "A".into(),
name: "A".into(),
namespace: "".into(),
kind: "class".into(),
line: 1,
methods: vec![],
properties: vec![],
fields: vec![],
method_returns: OrderedMap::new(),
extension_methods: vec![],
bases: vec![],
type_params: vec![],
base_generic_args: OrderedMap::new(),
test_methods: vec![],
property_types: OrderedMap::new(),
end_line: 1,
}],
usings: vec![],
refs: vec![],
names: vec![],
};
let mut fresh = HashMap::new();
fresh.insert("A.cs".to_string(), AnyFragment::Cs(fragment.clone()));
let graph_files = vec![GraphFile {
rel: "A.cs".to_string(),
mtime: 111,
}];
let first = rebuild_graph(&dir, &graph_files, &fresh, true).unwrap();
assert!(matches!(first, RebuildOutcome::Rebuilt(_)));
let empty: HashMap<String, AnyFragment> = HashMap::new();
let second = rebuild_graph(&dir, &graph_files, &empty, true).unwrap();
match second {
RebuildOutcome::Rebuilt(g) => {
assert_eq!(g.defs.len(), 1, "cached fragment must still be used")
}
RebuildOutcome::NotRebuilt => panic!("changed=true must always rebuild"),
}
}
#[test]
fn rebuild_graph_writes_the_v13_caches_and_deletes_every_superseded_generation() {
let dir = temp_dir("rebuild-v13");
fs::create_dir_all(graph_dir(&dir)).unwrap();
assert_eq!(SUPERSEDED_CACHE_FILES.len(), 28, "v1..v14 pairs");
for stale in SUPERSEDED_CACHE_FILES {
fs::write(graph_dir(&dir).join(stale), b"{}").unwrap();
}
let fragment = Fragment {
defs: vec![FragDef {
id: "App.A".into(),
name: "A".into(),
namespace: "App".into(),
kind: "class".into(),
line: 1,
methods: vec![],
properties: vec![],
fields: vec![],
method_returns: OrderedMap::new(),
extension_methods: vec![],
bases: vec![],
type_params: vec![],
base_generic_args: OrderedMap::new(),
test_methods: vec![],
property_types: OrderedMap::new(),
end_line: 1,
}],
usings: vec![],
refs: vec![],
names: vec![],
};
let mut fresh = HashMap::new();
fresh.insert("src/A.cs".to_string(), AnyFragment::Cs(fragment));
let graph_files = vec![GraphFile {
rel: "src/A.cs".to_string(),
mtime: 222,
}];
rebuild_graph(&dir, &graph_files, &fresh, true).unwrap();
assert!(
graph_dir(&dir).join("fragments-v15.json").exists(),
"the v15 payload cache is what gets written"
);
assert!(
graph_dir(&dir).join("fragments-index-v15.json").exists(),
"and its mtime-only index alongside it"
);
for stale in SUPERSEDED_CACHE_FILES {
assert!(
!graph_dir(&dir).join(stale).exists(),
"{stale} must be deleted -- rename IS the invalidation"
);
}
}
#[test]
fn frag_ref_outer_types_is_appended_last_after_receiver_args() {
let r = FragRef {
receiver_args: Some(vec!["FutureState".into()]),
outer_types: vec!["Outer".into(), "Inner".into()],
..frag_ref(false, Some("Binder"), Some(1))
};
let json = serde_json::to_string(&r).unwrap();
assert_eq!(
json,
r#"{"kind":"uses-member","name":"repo","member":"Save","line":7,"namespace":"App.Shape","receiverType":"Binder","argCount":1,"receiverArgs":["FutureState"],"outerTypes":["Outer","Inner"]}"#
);
let reparsed: FragRef = serde_json::from_str(&json).unwrap();
assert_eq!(
reparsed.outer_types,
vec!["Outer".to_string(), "Inner".to_string()]
);
}
#[test]
fn frag_ref_property_owner_and_call_pair_are_appended_last_and_omitted_when_absent() {
let hop = FragRef {
qualified: Some("head.Dial".into()),
name: "Dial".into(),
receiver_property_owner: Some("Widget".into()),
..frag_ref(false, None, Some(0))
};
assert_eq!(
serde_json::to_string(&hop).unwrap(),
r#"{"kind":"uses-member","name":"Dial","qualified":"head.Dial","member":"Save","line":7,"namespace":"App.Shape","argCount":0,"receiverPropertyOwner":"Widget"}"#
);
let from_call = FragRef {
receiver_call_owner: Some("Factory".into()),
receiver_call_member: Some("Make".into()),
..frag_ref(false, None, Some(0))
};
assert_eq!(
serde_json::to_string(&from_call).unwrap(),
r#"{"kind":"uses-member","name":"repo","member":"Save","line":7,"namespace":"App.Shape","argCount":0,"receiverCallOwner":"Factory","receiverCallMember":"Make"}"#
);
let plain = serde_json::to_string(&frag_ref(false, Some("Binder"), Some(1))).unwrap();
assert!(
!plain.contains("receiverPropertyOwner") && !plain.contains("receiverCall"),
"{plain}"
);
let reparsed: FragRef = serde_json::from_str(&plain).unwrap();
assert_eq!(reparsed.receiver_property_owner, None);
assert_eq!(reparsed.receiver_call_owner, None);
assert_eq!(reparsed.receiver_call_member, None);
}
#[test]
fn frag_ref_an_empty_or_absent_outer_types_serializes_to_nothing_and_reads_back_empty() {
let json = serde_json::to_string(&frag_ref(false, None, None)).unwrap();
assert!(
!json.contains("outerTypes"),
"a namespace-level ref keeps its exact pre-v8 bytes: {json}"
);
let pre_v8: FragRef = serde_json::from_str(
r#"{"kind":"uses-type","name":"Widget","line":3,"namespace":"App.Core"}"#,
)
.unwrap();
assert!(pre_v8.outer_types.is_empty());
}
}