use std::collections::{HashMap, HashSet, VecDeque};
use std::fs;
use std::io;
use std::path::{Path, PathBuf};
use crate::graph;
use crate::repo;
use crate::walk;
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Unit {
pub id: String,
pub name: String,
pub dir: String,
pub refs: Vec<String>,
pub test: bool,
}
#[derive(Debug, Clone)]
pub struct ProjectModel {
pub units: Vec<Unit>,
dir_to_unit: HashMap<String, usize>,
closure: Vec<HashSet<usize>>,
}
pub fn discover(root: &Path, scope: &[String]) -> io::Result<Option<ProjectModel>> {
let mut csproj_files: HashSet<PathBuf> = HashSet::new();
let mut props_by_dir: HashMap<PathBuf, String> = HashMap::new();
let found = walk::list_files_with_ext(root, scope, &[".csproj", ".props"])?;
for rel in found {
let abs = root.join(&rel);
if rel.ends_with(".csproj") {
csproj_files.insert(abs);
} else if is_directory_build_props(&abs) {
if let (Some(dir), Some(text)) = (abs.parent(), read_lossy(&abs)) {
props_by_dir.insert(dir.to_path_buf(), text);
}
}
}
for d in scope {
let scope_abs = if d == "." {
root.to_path_buf()
} else {
root.join(d)
};
for ancestor in ancestors_up_to(root, &scope_abs) {
scan_ancestor_dir(&ancestor, &mut csproj_files, &mut props_by_dir);
}
}
if csproj_files.is_empty() {
return Ok(None);
}
let mut units = Vec::with_capacity(csproj_files.len());
for csproj_path in &csproj_files {
units.push(build_unit(root, csproj_path, &props_by_dir)?);
}
Ok(Some(ProjectModel::from_units(units)))
}
fn ancestors_up_to(root: &Path, dir: &Path) -> Vec<PathBuf> {
let mut out = Vec::new();
let mut cur = dir.to_path_buf();
while cur != root {
let Some(parent) = cur.parent() else {
break;
};
out.push(parent.to_path_buf());
if parent == root {
break;
}
cur = parent.to_path_buf();
}
out
}
fn scan_ancestor_dir(
dir: &Path,
csproj_files: &mut HashSet<PathBuf>,
props_by_dir: &mut HashMap<PathBuf, String>,
) {
let Ok(entries) = fs::read_dir(dir) else {
return;
};
for entry in entries.flatten() {
let Ok(file_type) = entry.file_type() else {
continue;
};
if !file_type.is_file() {
continue;
}
let path = entry.path();
if path
.file_name()
.and_then(|n| n.to_str())
.is_some_and(|n| n.ends_with(".csproj"))
{
csproj_files.insert(path);
} else if is_directory_build_props(&path) {
if let Some(text) = read_lossy(&path) {
props_by_dir.insert(dir.to_path_buf(), text);
}
}
}
}
fn is_directory_build_props(path: &Path) -> bool {
path.file_name().and_then(|n| n.to_str()) == Some("Directory.Build.props")
}
fn read_lossy(path: &Path) -> Option<String> {
fs::read(path)
.ok()
.map(|bytes| String::from_utf8_lossy(&bytes).into_owned())
}
fn build_unit(
root: &Path,
csproj_path: &Path,
props_by_dir: &HashMap<PathBuf, String>,
) -> io::Result<Unit> {
let id = repo::rel_path(root, csproj_path);
let dir_abs = csproj_path.parent().unwrap_or(root).to_path_buf();
let dir = repo::rel_path(root, &dir_abs);
let name = csproj_path
.file_stem()
.and_then(|s| s.to_str())
.unwrap_or("")
.to_string();
let bytes = fs::read(csproj_path)?;
let text = String::from_utf8_lossy(&bytes).into_owned();
let facts = scan_csproj(&text);
let refs = facts
.project_refs
.iter()
.map(|raw| resolve_ref(&dir, raw))
.collect();
let test = facts
.is_test_project
.or_else(|| nearest_props_is_test_project(root, &dir_abs, props_by_dir))
.unwrap_or_else(|| {
facts.has_test_sdk || name.ends_with(".Tests") || name.ends_with(".Test")
});
Ok(Unit {
id,
name,
dir,
refs,
test,
})
}
fn nearest_props_is_test_project(
root: &Path,
start_dir: &Path,
props_by_dir: &HashMap<PathBuf, String>,
) -> Option<bool> {
let mut cur = start_dir.to_path_buf();
loop {
if let Some(text) = props_by_dir.get(&cur) {
return scan_props(text);
}
if cur == root {
return None;
}
cur = cur.parent()?.to_path_buf();
}
}
impl ProjectModel {
pub fn from_units(mut units: Vec<Unit>) -> Self {
units.sort_by(|a, b| a.id.cmp(&b.id));
let id_to_index: HashMap<&str, usize> = units
.iter()
.enumerate()
.map(|(i, u)| (u.id.as_str(), i))
.collect();
let mut dir_counts: HashMap<&str, usize> = HashMap::new();
for u in &units {
*dir_counts.entry(u.dir.as_str()).or_insert(0) += 1;
}
let mut dir_to_unit = HashMap::new();
for (i, u) in units.iter().enumerate() {
if dir_counts.get(u.dir.as_str()) == Some(&1) {
dir_to_unit.insert(u.dir.clone(), i);
}
}
let closure = build_closure(&units, &id_to_index);
ProjectModel {
units,
dir_to_unit,
closure,
}
}
pub fn unit_of_file(&self, rel: &str) -> Option<usize> {
let mut dir = match rel.rfind('/') {
Some(i) => &rel[..i],
None => "",
};
loop {
if let Some(&idx) = self.dir_to_unit.get(dir) {
return Some(idx);
}
if dir.is_empty() {
return None;
}
dir = match dir.rfind('/') {
Some(i) => &dir[..i],
None => "",
};
}
}
pub fn reachable(&self, from: usize, to: usize) -> bool {
from == to || self.closure.get(from).is_some_and(|c| c.contains(&to))
}
}
pub fn graph_units(model: &ProjectModel) -> Vec<graph::GraphUnit> {
model
.units
.iter()
.map(|u| graph::GraphUnit {
id: u.id.clone(),
name: u.name.clone(),
refs: u.refs.clone(),
test: u.test,
})
.collect()
}
pub fn units_from_graph(rows: &[graph::GraphUnit]) -> Vec<Unit> {
rows.iter()
.map(|r| Unit {
id: r.id.clone(),
name: r.name.clone(),
dir: match r.id.rfind('/') {
Some(i) => r.id[..i].to_string(),
None => String::new(),
},
refs: r.refs.clone(),
test: r.test,
})
.collect()
}
pub fn sidecar_differs(root: &Path, model: Option<&ProjectModel>) -> bool {
let existing = fs::read(graph::project_units_path(root)).ok();
match (existing, model) {
(None, None) => false,
(None, Some(_)) => true,
(Some(_), None) => true,
(Some(bytes), Some(m)) => match serde_json::to_vec(&graph_units(m)) {
Ok(fresh) => bytes != fresh,
Err(_) => true,
},
}
}
fn build_closure(units: &[Unit], id_to_index: &HashMap<&str, usize>) -> Vec<HashSet<usize>> {
units
.iter()
.enumerate()
.map(|(i, _)| {
let mut seen = HashSet::new();
seen.insert(i);
let mut queue: VecDeque<usize> = VecDeque::new();
queue.push_back(i);
let mut result = HashSet::new();
while let Some(cur) = queue.pop_front() {
for r in &units[cur].refs {
if let Some(&idx) = id_to_index.get(r.as_str()) {
if seen.insert(idx) {
result.insert(idx);
queue.push_back(idx);
}
}
}
}
result
})
.collect()
}
#[derive(Debug, Default, Clone, PartialEq, Eq)]
struct CsprojFacts {
project_refs: Vec<String>,
has_test_sdk: bool,
is_test_project: Option<bool>,
}
fn scan_csproj(text: &str) -> CsprojFacts {
let clean = strip_xml_comments(text);
let mut facts = CsprojFacts::default();
let mut pos = 0usize;
let mut awaiting_close: Option<&str> = None;
while let Some((gap, tag)) = next_tag(&clean, &mut pos) {
if let Some(open_name) = awaiting_close {
if tag.is_closing && tag.name == open_name {
let value = gap.trim();
if value.eq_ignore_ascii_case("true") {
facts.is_test_project = Some(true);
} else if value.eq_ignore_ascii_case("false") {
facts.is_test_project = Some(false);
}
}
awaiting_close = None;
}
if tag.is_closing {
continue;
}
match tag.name {
"ProjectReference" => {
if let Some(include) = find_attr(tag.attrs, "Include") {
facts.project_refs.push(include.replace('\\', "/"));
}
}
"PackageReference" => {
if let Some(include) = find_attr(tag.attrs, "Include") {
if include == "Microsoft.NET.Test.Sdk" {
facts.has_test_sdk = true;
}
}
}
"IsTestProject" if !tag.self_closing => {
awaiting_close = Some("IsTestProject");
}
_ => {}
}
}
facts
}
fn scan_props(text: &str) -> Option<bool> {
scan_csproj(text).is_test_project
}
fn resolve_ref(csproj_dir: &str, raw: &str) -> String {
let mut stack: Vec<&str> = if csproj_dir.is_empty() {
Vec::new()
} else {
csproj_dir.split('/').collect()
};
for seg in raw.split('/') {
match seg {
"" | "." => {}
".." => {
if matches!(stack.last(), None | Some(&"..")) {
stack.push("..");
} else {
stack.pop();
}
}
other => stack.push(other),
}
}
stack.join("/")
}
fn strip_xml_comments(text: &str) -> String {
let mut out = String::with_capacity(text.len());
let mut rest = text;
loop {
match rest.find("<!--") {
Some(start) => {
out.push_str(&rest[..start]);
match rest[start..].find("-->") {
Some(end_rel) => {
rest = &rest[start + end_rel + 3..];
}
None => return out,
}
}
None => {
out.push_str(rest);
return out;
}
}
}
}
struct TagInfo<'a> {
name: &'a str,
is_closing: bool,
self_closing: bool,
attrs: &'a str,
}
fn next_tag<'a>(text: &'a str, pos: &mut usize) -> Option<(&'a str, TagInfo<'a>)> {
let search_from = *pos;
let lt = text[search_from..].find('<')? + search_from;
let gap = &text[search_from..lt];
let bytes = text.as_bytes();
let mut i = lt + 1;
let mut quote: Option<u8> = None;
let mut gt = None;
while i < bytes.len() {
let c = bytes[i];
match quote {
Some(q) => {
if c == q {
quote = None;
}
}
None => {
if c == b'"' || c == b'\'' {
quote = Some(c);
} else if c == b'>' {
gt = Some(i);
break;
}
}
}
i += 1;
}
let gt = gt?;
*pos = gt + 1;
let content = text[lt + 1..gt].trim();
if let Some(name) = content.strip_prefix('/') {
return Some((
gap,
TagInfo {
name: name.trim(),
is_closing: true,
self_closing: false,
attrs: "",
},
));
}
let self_closing = content.ends_with('/');
let body = if self_closing {
content[..content.len() - 1].trim_end()
} else {
content
};
let (name, attrs) = match body.find(|c: char| c.is_whitespace()) {
Some(i2) => (&body[..i2], body[i2..].trim()),
None => (body, ""),
};
Some((
gap,
TagInfo {
name,
is_closing: false,
self_closing,
attrs,
},
))
}
fn find_attr(attrs: &str, name: &str) -> Option<String> {
let mut i = 0usize;
while i < attrs.len() {
let found = attrs[i..].find(name)?;
let abs = i + found;
let boundary_before = abs == 0 || attrs.as_bytes()[abs - 1].is_ascii_whitespace();
if boundary_before {
let after = &attrs[abs + name.len()..];
let after_trimmed = after.trim_start();
if let Some(value_part) = after_trimmed.strip_prefix('=') {
let value_part = value_part.trim_start();
if let Some(quote) = value_part.chars().next() {
if quote == '"' || quote == '\'' {
if let Some(end) = value_part[quote.len_utf8()..].find(quote) {
return Some(
value_part[quote.len_utf8()..quote.len_utf8() + end].to_string(),
);
}
}
}
}
}
i = abs + name.len();
}
None
}
#[cfg(test)]
mod tests {
use super::*;
use std::sync::atomic::{AtomicU64, Ordering};
static COUNTER: AtomicU64 = AtomicU64::new(0);
fn scratch_dir(label: &str) -> PathBuf {
let n = COUNTER.fetch_add(1, Ordering::SeqCst);
let dir = std::env::temp_dir().join(format!(
"scout-project-rs-{label}-{}-{n}",
std::process::id()
));
fs::create_dir_all(&dir).expect("create scratch dir");
dir
}
fn write_file(path: &Path, contents: &str) {
if let Some(parent) = path.parent() {
fs::create_dir_all(parent).expect("create parent dirs");
}
fs::write(path, contents).expect("write file");
}
#[test]
fn scan_csproj_reads_include_from_a_self_closing_project_reference() {
let facts = scan_csproj(
r#"<Project><ItemGroup><ProjectReference Include="../B/B.csproj" /></ItemGroup></Project>"#,
);
assert_eq!(facts.project_refs, vec!["../B/B.csproj".to_string()]);
}
#[test]
fn scan_csproj_reads_include_from_an_open_close_project_reference() {
let facts = scan_csproj(
r#"<ItemGroup><ProjectReference Include="../B/B.csproj"><Private>false</Private></ProjectReference></ItemGroup>"#,
);
assert_eq!(facts.project_refs, vec!["../B/B.csproj".to_string()]);
}
#[test]
fn scan_csproj_reads_a_multi_line_attribute() {
let xml =
"<ItemGroup>\n <ProjectReference\n Include=\"../B/B.csproj\"\n />\n</ItemGroup>";
let facts = scan_csproj(xml);
assert_eq!(facts.project_refs, vec!["../B/B.csproj".to_string()]);
}
#[test]
fn scan_csproj_reads_single_quoted_attributes() {
let facts = scan_csproj(
r#"<ItemGroup><PackageReference Include='Microsoft.NET.Test.Sdk' Version='17.0.0' /></ItemGroup>"#,
);
assert!(facts.has_test_sdk);
}
#[test]
fn scan_csproj_skips_a_project_reference_inside_a_comment() {
let xml = r#"
<ItemGroup>
<!-- <ProjectReference Include="../Fake/Fake.csproj" /> -->
<ProjectReference Include="../Real/Real.csproj" />
</ItemGroup>
"#;
let facts = scan_csproj(xml);
assert_eq!(facts.project_refs, vec!["../Real/Real.csproj".to_string()]);
}
#[test]
fn scan_csproj_detects_the_test_sdk_package_reference() {
let facts = scan_csproj(
r#"<ItemGroup><PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.8.0" /></ItemGroup>"#,
);
assert!(facts.has_test_sdk);
}
#[test]
fn scan_csproj_reads_explicit_is_test_project_true() {
let facts =
scan_csproj("<PropertyGroup><IsTestProject>true</IsTestProject></PropertyGroup>");
assert_eq!(facts.is_test_project, Some(true));
}
#[test]
fn scan_csproj_reads_explicit_is_test_project_false() {
let facts =
scan_csproj("<PropertyGroup><IsTestProject>False</IsTestProject></PropertyGroup>");
assert_eq!(facts.is_test_project, Some(false));
}
#[test]
fn scan_props_reads_is_test_project() {
assert_eq!(
scan_props("<Project><PropertyGroup><IsTestProject>true</IsTestProject></PropertyGroup></Project>"),
Some(true)
);
assert_eq!(scan_props("<Project><PropertyGroup /></Project>"), None);
}
#[test]
fn resolve_ref_resolves_dotdot_and_backslashes_against_the_csproj_directory() {
let facts = scan_csproj(r#"<ProjectReference Include="..\..\Shared\Shared.csproj" />"#);
assert_eq!(
facts.project_refs,
vec!["../../Shared/Shared.csproj".to_string()]
);
assert_eq!(
resolve_ref("tests/T", "../../Shared/Shared.csproj"),
"Shared/Shared.csproj"
);
}
#[test]
fn resolve_ref_keeps_an_out_of_root_target_as_a_normalised_string_that_matches_nothing() {
let resolved = resolve_ref("src/A", "../../../Outside/Outside.csproj");
assert_eq!(resolved, "../Outside/Outside.csproj");
assert!(resolved.starts_with(".."));
}
fn unit(id: &str, dir: &str, refs: &[&str], test: bool) -> Unit {
Unit {
id: id.to_string(),
name: id.trim_end_matches(".csproj").to_string(),
dir: dir.to_string(),
refs: refs.iter().map(|r| r.to_string()).collect(),
test,
}
}
#[test]
fn from_units_sorts_by_id_and_reachable_is_reflexive_and_directional() {
let model = ProjectModel::from_units(vec![
unit("src/C/C.csproj", "src/C", &[], false),
unit("src/A/A.csproj", "src/A", &["src/B/B.csproj"], false),
unit("src/B/B.csproj", "src/B", &["src/C/C.csproj"], false),
]);
let ids: Vec<&str> = model.units.iter().map(|u| u.id.as_str()).collect();
assert_eq!(
ids,
vec!["src/A/A.csproj", "src/B/B.csproj", "src/C/C.csproj"]
);
let a = model
.units
.iter()
.position(|u| u.id == "src/A/A.csproj")
.unwrap();
let b = model
.units
.iter()
.position(|u| u.id == "src/B/B.csproj")
.unwrap();
let c = model
.units
.iter()
.position(|u| u.id == "src/C/C.csproj")
.unwrap();
assert!(model.reachable(a, a), "a unit reaches itself");
assert!(model.reachable(a, c), "A -> B -> C is transitive");
assert!(!model.reachable(b, a), "B does not reference A");
assert!(!model.reachable(c, b), "C references nothing");
assert!(!model.reachable(c, a), "C cannot reach A");
}
#[test]
fn from_units_ignores_an_unresolved_reference_in_the_closure() {
let model = ProjectModel::from_units(vec![unit(
"src/A/A.csproj",
"src/A",
&["src/Missing/Missing.csproj"],
false,
)]);
let a = 0;
assert!(model.closure[a].is_empty());
assert!(!model.reachable(a, 99));
}
#[test]
fn from_units_closes_a_reference_cycle_without_revisiting_a_unit() {
let model = ProjectModel::from_units(vec![
unit("src/A/A.csproj", "src/A", &["src/B/B.csproj"], false),
unit("src/B/B.csproj", "src/B", &["src/C/C.csproj"], false),
unit("src/C/C.csproj", "src/C", &["src/A/A.csproj"], false),
]);
for from in 0..3 {
assert_eq!(
model.closure[from].len(),
2,
"unit {from} reaches the other two"
);
assert!(!model.closure[from].contains(&from));
for to in 0..3 {
assert!(model.reachable(from, to), "{from} -> {to} inside the cycle");
}
}
}
#[test]
fn unit_of_file_climbs_to_a_root_level_unit_from_any_nested_file() {
let model = ProjectModel::from_units(vec![unit("A.csproj", "", &[], false)]);
assert_eq!(model.unit_of_file("Elsewhere/Deep/x.cs"), Some(0));
}
#[test]
fn unit_of_file_returns_none_when_no_ancestor_directory_has_exactly_one_unit() {
let model = ProjectModel::from_units(vec![unit("src/A/A.csproj", "src/A", &[], false)]);
assert_eq!(model.unit_of_file("other/y.cs"), None);
}
#[test]
fn discover_returns_none_when_no_csproj_is_under_scope_or_above_it() {
let root = scratch_dir("no-csproj");
write_file(&root.join("src/a.cs"), "class A {}\n");
let result = discover(&root, &["src".to_string()]).unwrap();
assert!(result.is_none());
fs::remove_dir_all(&root).ok();
}
#[test]
fn discover_builds_units_closure_and_test_flags_for_a_referenced_project_chain() {
let root = scratch_dir("chain");
write_file(
&root.join("src/A/A.csproj"),
r#"<Project><ItemGroup><ProjectReference Include="../B/B.csproj" /></ItemGroup></Project>"#,
);
write_file(
&root.join("src/B/B.csproj"),
r#"<Project><ItemGroup><ProjectReference Include="../C/C.csproj" /></ItemGroup></Project>"#,
);
write_file(&root.join("src/C/C.csproj"), "<Project></Project>");
write_file(
&root.join("tests/T/T.csproj"),
r#"<Project>
<ItemGroup>
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.8.0" />
<ProjectReference Include="../../src/A/A.csproj" />
</ItemGroup>
</Project>"#,
);
write_file(&root.join("src/A/Sub/x.cs"), "class X {}\n");
let model = discover(&root, &["src".to_string(), "tests".to_string()])
.unwrap()
.expect("csproj files were written under scope");
let idx = |id: &str| model.units.iter().position(|u| u.id == id).unwrap();
let a = idx("src/A/A.csproj");
let b = idx("src/B/B.csproj");
let c = idx("src/C/C.csproj");
let t = idx("tests/T/T.csproj");
assert!(model.reachable(a, c), "A -> B -> C must be transitive");
assert!(!model.reachable(b, a), "B does not reference A");
assert_eq!(model.unit_of_file("src/A/Sub/x.cs"), Some(a));
assert!(model.units[t].test, "T carries the test SDK package");
assert!(model.reachable(t, a), "T directly references A");
fs::remove_dir_all(&root).ok();
}
#[test]
fn discover_directory_with_two_csproj_files_is_skipped_when_resolving_a_file_beneath_it() {
let root = scratch_dir("two-csproj-dir");
write_file(&root.join("src/Root.csproj"), "<Project></Project>");
write_file(&root.join("src/shared/X.csproj"), "<Project></Project>");
write_file(&root.join("src/shared/Y.csproj"), "<Project></Project>");
write_file(&root.join("src/shared/deep/z.cs"), "class Z {}\n");
let model = discover(&root, &["src".to_string()]).unwrap().unwrap();
let root_unit = model
.units
.iter()
.position(|u| u.id == "src/Root.csproj")
.unwrap();
assert_eq!(model.unit_of_file("src/shared/deep/z.cs"), Some(root_unit));
assert_eq!(model.unit_of_file("src/shared/w.cs"), Some(root_unit));
fs::remove_dir_all(&root).ok();
}
#[test]
fn discover_props_precedence_beats_the_test_sdk_package() {
let root = scratch_dir("props-precedence");
write_file(
&root.join("src/App/App.csproj"),
r#"<Project><ItemGroup><PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.8.0" /></ItemGroup></Project>"#,
);
write_file(
&root.join("src/App/Directory.Build.props"),
"<Project><PropertyGroup><IsTestProject>false</IsTestProject></PropertyGroup></Project>",
);
let model = discover(&root, &["src".to_string()]).unwrap().unwrap();
let app = model
.units
.iter()
.position(|u| u.id == "src/App/App.csproj")
.unwrap();
assert!(
!model.units[app].test,
"an explicit Directory.Build.props IsTestProject must beat the test-SDK package"
);
fs::remove_dir_all(&root).ok();
}
#[test]
fn discover_name_suffix_fallback_when_nothing_else_says_test() {
let root = scratch_dir("name-suffix");
write_file(
&root.join("src/App.Tests/App.Tests.csproj"),
"<Project></Project>",
);
write_file(&root.join("src/App/App.csproj"), "<Project></Project>");
let model = discover(&root, &["src".to_string()]).unwrap().unwrap();
let tests_unit = model
.units
.iter()
.position(|u| u.id == "src/App.Tests/App.Tests.csproj")
.unwrap();
let app_unit = model
.units
.iter()
.position(|u| u.id == "src/App/App.csproj")
.unwrap();
assert!(model.units[tests_unit].test, "name ends with .Tests");
assert!(!model.units[app_unit].test);
fs::remove_dir_all(&root).ok();
}
#[test]
fn discover_finds_a_directory_build_props_above_the_scope_root_via_ancestor_scan() {
let root = scratch_dir("ancestor-props");
write_file(
&root.join("Directory.Build.props"),
"<Project><PropertyGroup><IsTestProject>true</IsTestProject></PropertyGroup></Project>",
);
write_file(&root.join("src/A/A.csproj"), "<Project></Project>");
let model = discover(&root, &["src".to_string()]).unwrap().unwrap();
let a = model
.units
.iter()
.position(|u| u.id == "src/A/A.csproj")
.unwrap();
assert!(
model.units[a].test,
"the root Directory.Build.props sits above the scope dir and must still be found"
);
fs::remove_dir_all(&root).ok();
}
#[test]
fn discover_ancestor_scan_does_not_recurse_into_sibling_directories() {
let root = scratch_dir("ancestor-no-recurse");
write_file(&root.join("src/A/A.csproj"), "<Project></Project>");
write_file(&root.join("other/Other.csproj"), "<Project></Project>");
let model = discover(&root, &["src".to_string()]).unwrap().unwrap();
assert_eq!(model.units.len(), 1);
assert_eq!(model.units[0].id, "src/A/A.csproj");
fs::remove_dir_all(&root).ok();
}
#[test]
fn discover_ignores_sln_files() {
let root = scratch_dir("sln-ignored");
write_file(
&root.join("Repo.sln"),
"Microsoft Visual Studio Solution File",
);
write_file(&root.join("src/A/A.csproj"), "<Project></Project>");
let model = discover(&root, &["src".to_string()]).unwrap().unwrap();
assert_eq!(model.units.len(), 1);
assert_eq!(model.units[0].id, "src/A/A.csproj");
fs::remove_dir_all(&root).ok();
}
}