use std::fs;
use std::path::{Path, PathBuf};
use std::process::{Command, Output};
use std::sync::atomic::{AtomicU64, Ordering};
static NEXT: AtomicU64 = AtomicU64::new(0);
struct Fixture {
root: PathBuf,
registry: PathBuf,
}
impl Fixture {
fn new() -> Self {
let root = std::env::temp_dir().join(format!(
"devscout-qualified-external-{}-{}",
std::process::id(),
NEXT.fetch_add(1, Ordering::Relaxed)
));
fs::create_dir_all(&root).unwrap();
let source =
Path::new(env!("CARGO_MANIFEST_DIR")).join("fixtures/csharp-qualified-external");
for name in [
"ExchangeType.cs",
"JsonSerializer.cs",
"Member.cs",
"Configure.cs",
"Other.cs",
"Binding.cs",
"Adapter.cs",
"DomainConsumer.cs",
"LegitUser.cs",
] {
fs::copy(source.join(name), root.join(name)).unwrap();
}
let registry = root.join("registry.json");
let fixture = Self { root, registry };
fixture.ok(&["init", "--no-hooks"]);
fixture
}
fn run(&self, args: &[&str]) -> Output {
Command::new(env!("CARGO_BIN_EXE_devscout"))
.current_dir(&self.root)
.env("SCOUT_REGISTRY", &self.registry)
.args(args)
.output()
.unwrap()
}
fn ok(&self, args: &[&str]) -> String {
let output = self.run(args);
assert!(
output.status.success(),
"{args:?}: stdout={} stderr={}",
String::from_utf8_lossy(&output.stdout),
String::from_utf8_lossy(&output.stderr)
);
String::from_utf8(output.stdout).unwrap()
}
fn graph(&self) -> serde_json::Value {
let text = fs::read_to_string(self.root.join(".scout/graph/graph.json")).unwrap();
serde_json::from_str(&text).unwrap()
}
}
impl Drop for Fixture {
fn drop(&mut self) {
let _ = fs::remove_dir_all(&self.root);
}
}
fn edges_at<'a>(
graph: &'a serde_json::Value,
from_file: &str,
from_line: u64,
) -> Vec<&'a serde_json::Value> {
graph["edges"]
.as_array()
.unwrap()
.iter()
.filter(|e| e["from_file"] == from_file && e["from_line"].as_u64() == Some(from_line))
.collect()
}
fn edges_to<'a>(
graph: &'a serde_json::Value,
from_file: &str,
to: &str,
) -> Vec<&'a serde_json::Value> {
graph["edges"]
.as_array()
.unwrap()
.iter()
.filter(|e| e["from_file"] == from_file && e["to"] == to)
.collect()
}
#[test]
fn qualified_names_with_unmapped_qualifiers_do_not_fall_back_to_bare_resolution() {
let fixture = Fixture::new();
let graph = fixture.graph();
assert!(
edges_to(
&graph,
"Configure.cs",
"App.Transports.Fabric.ExchangeType.Fanout"
)
.is_empty(),
"a foreign qualifier must not fall back to a same-named local member: {graph:#}"
);
let own = edges_at(&graph, "Configure.cs", 8);
let own_precise: Vec<_> = own
.iter()
.filter(|e| {
e["to"] == "App.Transports.Fabric.ExchangeType.Topic" && e["heuristic"].is_null()
})
.collect();
assert_eq!(
own_precise.len(),
1,
"expected exactly one precise uses-member edge at line 8: {own:#?}"
);
assert_eq!(own_precise[0]["kind"], "uses-member");
let text_precise: Vec<_> = edges_at(&graph, "Configure.cs", 9)
.into_iter()
.filter(|e| e["to"] == "App.Infra.JsonSerializer" && e["heuristic"].is_null())
.collect();
assert!(
text_precise.is_empty(),
"a foreign qualifier must not resolve precisely to a same-named local type: {text_precise:#?}"
);
let text_heuristic: Vec<_> = edges_at(&graph, "Configure.cs", 9)
.into_iter()
.filter(|e| e["to"] == "App.Infra.JsonSerializer" && e["heuristic"] == true)
.collect();
assert_eq!(
text_heuristic.len(),
1,
"expected exactly one heuristic edge at line 9 (scored tier, untouched by the qualified-name rule): {graph:#}"
);
assert_eq!(text_heuristic[0]["tier"], "guess");
let local_precise: Vec<_> = edges_at(&graph, "Configure.cs", 10)
.into_iter()
.filter(|e| e["to"] == "App.Infra.JsonSerializer" && e["heuristic"].is_null())
.collect();
assert_eq!(
local_precise.len(),
1,
"expected exactly one precise uses-member edge at line 10: {graph:#}"
);
assert_eq!(local_precise[0]["kind"], "uses-member");
let member_precise: Vec<_> = edges_at(&graph, "Configure.cs", 11)
.into_iter()
.filter(|e| e["to"] == "App.Model.Member" && e["heuristic"].is_null())
.collect();
assert!(
member_precise.is_empty(),
"a receiver of unresolved external type must not resolve precisely to a same-named local type: {member_precise:#?}"
);
let member_heuristic: Vec<_> = edges_at(&graph, "Configure.cs", 11)
.into_iter()
.filter(|e| e["to"] == "App.Model.Member" && e["heuristic"] == true)
.collect();
assert_eq!(
member_heuristic.len(),
1,
"expected exactly one heuristic edge at line 11 (scored tier, untouched by the qualified-name rule): {graph:#}"
);
assert_eq!(member_heuristic[0]["tier"], "guess");
let other_member: Vec<_> = graph["edges"]
.as_array()
.unwrap()
.iter()
.filter(|e| {
e["from_file"] == "Other.cs"
&& e["kind"] == "uses-member"
&& e["to"] == "App.Transports.Fabric.ExchangeType.Fanout"
&& e["heuristic"].is_null()
})
.collect();
assert_eq!(
other_member.len(),
1,
"expected exactly one precise uses-member edge from Other.cs: {graph:#}"
);
let other_type: Vec<_> = graph["edges"]
.as_array()
.unwrap()
.iter()
.filter(|e| {
e["from_file"] == "Other.cs"
&& e["kind"] == "uses-type"
&& e["to"] == "App.Transports.Fabric.ExchangeType"
})
.collect();
assert_eq!(
other_type.len(),
1,
"expected a uses-type edge for the ExchangeType return type: {graph:#}"
);
assert_eq!(graph["stats"]["ambiguous_count"], 0);
}
#[test]
fn a_bare_qualifier_found_only_by_global_name_uniqueness_earns_no_precise_member_edge() {
let fixture = Fixture::new();
let graph = fixture.graph();
assert!(
edges_at(&graph, "Adapter.cs", 9).is_empty(),
"a global-uniqueness-only qualifier must not earn a member edge for an undeclared member: {graph:#}"
);
assert!(
edges_at(&graph, "Adapter.cs", 10).is_empty(),
"a global-uniqueness-only qualifier must not earn a member edge even for a member the enum declares: {graph:#}"
);
let guard: Vec<_> = edges_at(&graph, "DomainConsumer.cs", 7)
.into_iter()
.filter(|e| e["to"] == "App.Domain.Binding.Loose" && e["heuristic"].is_null())
.collect();
assert_eq!(
guard.len(),
1,
"same-namespace access to the enum must still resolve precisely: {graph:#}"
);
}
#[test]
fn an_enum_qualifier_earns_no_precise_edge_for_a_member_the_enum_does_not_declare() {
let fixture = Fixture::new();
let graph = fixture.graph();
assert!(
edges_at(&graph, "LegitUser.cs", 9).is_empty(),
"an enum member the enum does not declare must not fall back to an edge on the enum type: {graph:#}"
);
}
#[test]
fn refs_for_the_qualified_enum_member_excludes_the_foreign_qualifier_site() {
let fixture = Fixture::new();
let refs = fixture.ok(&["refs", "App.Transports.Fabric.ExchangeType.Fanout"]);
assert!(
!refs.contains("Configure.cs"),
"the RabbitMQ.Client-qualified reference must not show up as a ref: {refs}"
);
assert!(
refs.contains("Other.cs"),
"the using-qualified bare reference must still show up as a ref: {refs}"
);
}