1use std::collections::{HashMap, HashSet, VecDeque};
57use std::fs;
58use std::io;
59use std::path::{Path, PathBuf};
60
61use crate::graph;
62use crate::repo;
63use crate::walk;
64
65#[derive(Debug, Clone, PartialEq, Eq)]
67pub struct Unit {
68 pub id: String,
72 pub name: String,
77 pub dir: String,
81 pub refs: Vec<String>,
87 pub test: bool,
90}
91
92#[derive(Debug, Clone)]
95pub struct ProjectModel {
96 pub units: Vec<Unit>,
100 dir_to_unit: HashMap<String, usize>,
105 closure: Vec<HashSet<usize>>,
110}
111
112pub fn discover(root: &Path, scope: &[String]) -> io::Result<Option<ProjectModel>> {
122 let mut csproj_files: HashSet<PathBuf> = HashSet::new();
123 let mut props_by_dir: HashMap<PathBuf, String> = HashMap::new();
124
125 let found = walk::list_files_with_ext(root, scope, &[".csproj", ".props"])?;
127 for rel in found {
128 let abs = root.join(&rel);
129 if rel.ends_with(".csproj") {
130 csproj_files.insert(abs);
131 } else if is_directory_build_props(&abs) {
132 if let (Some(dir), Some(text)) = (abs.parent(), read_lossy(&abs)) {
133 props_by_dir.insert(dir.to_path_buf(), text);
134 }
135 }
136 }
137
138 for d in scope {
141 let scope_abs = if d == "." {
142 root.to_path_buf()
143 } else {
144 root.join(d)
145 };
146 for ancestor in ancestors_up_to(root, &scope_abs) {
147 scan_ancestor_dir(&ancestor, &mut csproj_files, &mut props_by_dir);
148 }
149 }
150
151 if csproj_files.is_empty() {
152 return Ok(None);
153 }
154
155 let mut units = Vec::with_capacity(csproj_files.len());
156 for csproj_path in &csproj_files {
157 units.push(build_unit(root, csproj_path, &props_by_dir)?);
158 }
159
160 Ok(Some(ProjectModel::from_units(units)))
161}
162
163fn ancestors_up_to(root: &Path, dir: &Path) -> Vec<PathBuf> {
167 let mut out = Vec::new();
168 let mut cur = dir.to_path_buf();
169 while cur != root {
170 let Some(parent) = cur.parent() else {
171 break;
172 };
173 out.push(parent.to_path_buf());
174 if parent == root {
175 break;
176 }
177 cur = parent.to_path_buf();
178 }
179 out
180}
181
182fn scan_ancestor_dir(
188 dir: &Path,
189 csproj_files: &mut HashSet<PathBuf>,
190 props_by_dir: &mut HashMap<PathBuf, String>,
191) {
192 let Ok(entries) = fs::read_dir(dir) else {
193 return;
194 };
195 for entry in entries.flatten() {
196 let Ok(file_type) = entry.file_type() else {
197 continue;
198 };
199 if !file_type.is_file() {
200 continue;
201 }
202 let path = entry.path();
203 if path
204 .file_name()
205 .and_then(|n| n.to_str())
206 .is_some_and(|n| n.ends_with(".csproj"))
207 {
208 csproj_files.insert(path);
209 } else if is_directory_build_props(&path) {
210 if let Some(text) = read_lossy(&path) {
211 props_by_dir.insert(dir.to_path_buf(), text);
212 }
213 }
214 }
215}
216
217fn is_directory_build_props(path: &Path) -> bool {
218 path.file_name().and_then(|n| n.to_str()) == Some("Directory.Build.props")
219}
220
221fn read_lossy(path: &Path) -> Option<String> {
222 fs::read(path)
223 .ok()
224 .map(|bytes| String::from_utf8_lossy(&bytes).into_owned())
225}
226
227fn build_unit(
233 root: &Path,
234 csproj_path: &Path,
235 props_by_dir: &HashMap<PathBuf, String>,
236) -> io::Result<Unit> {
237 let id = repo::rel_path(root, csproj_path);
238 let dir_abs = csproj_path.parent().unwrap_or(root).to_path_buf();
239 let dir = repo::rel_path(root, &dir_abs);
240 let name = csproj_path
241 .file_stem()
242 .and_then(|s| s.to_str())
243 .unwrap_or("")
244 .to_string();
245
246 let bytes = fs::read(csproj_path)?;
247 let text = String::from_utf8_lossy(&bytes).into_owned();
248 let facts = scan_csproj(&text);
249
250 let refs = facts
251 .project_refs
252 .iter()
253 .map(|raw| resolve_ref(&dir, raw))
254 .collect();
255
256 let test = facts
259 .is_test_project
260 .or_else(|| nearest_props_is_test_project(root, &dir_abs, props_by_dir))
261 .unwrap_or_else(|| {
262 facts.has_test_sdk || name.ends_with(".Tests") || name.ends_with(".Test")
263 });
264
265 Ok(Unit {
266 id,
267 name,
268 dir,
269 refs,
270 test,
271 })
272}
273
274fn nearest_props_is_test_project(
280 root: &Path,
281 start_dir: &Path,
282 props_by_dir: &HashMap<PathBuf, String>,
283) -> Option<bool> {
284 let mut cur = start_dir.to_path_buf();
285 loop {
286 if let Some(text) = props_by_dir.get(&cur) {
287 return scan_props(text);
288 }
289 if cur == root {
290 return None;
291 }
292 cur = cur.parent()?.to_path_buf();
293 }
294}
295
296impl ProjectModel {
297 pub fn from_units(mut units: Vec<Unit>) -> Self {
304 units.sort_by(|a, b| a.id.cmp(&b.id));
305
306 let id_to_index: HashMap<&str, usize> = units
307 .iter()
308 .enumerate()
309 .map(|(i, u)| (u.id.as_str(), i))
310 .collect();
311
312 let mut dir_counts: HashMap<&str, usize> = HashMap::new();
313 for u in &units {
314 *dir_counts.entry(u.dir.as_str()).or_insert(0) += 1;
315 }
316 let mut dir_to_unit = HashMap::new();
317 for (i, u) in units.iter().enumerate() {
318 if dir_counts.get(u.dir.as_str()) == Some(&1) {
319 dir_to_unit.insert(u.dir.clone(), i);
320 }
321 }
322
323 let closure = build_closure(&units, &id_to_index);
324
325 ProjectModel {
326 units,
327 dir_to_unit,
328 closure,
329 }
330 }
331
332 pub fn unit_of_file(&self, rel: &str) -> Option<usize> {
341 let mut dir = match rel.rfind('/') {
342 Some(i) => &rel[..i],
343 None => "",
344 };
345 loop {
346 if let Some(&idx) = self.dir_to_unit.get(dir) {
347 return Some(idx);
348 }
349 if dir.is_empty() {
350 return None;
351 }
352 dir = match dir.rfind('/') {
353 Some(i) => &dir[..i],
354 None => "",
355 };
356 }
357 }
358
359 pub fn reachable(&self, from: usize, to: usize) -> bool {
363 from == to || self.closure.get(from).is_some_and(|c| c.contains(&to))
364 }
365}
366
367pub fn graph_units(model: &ProjectModel) -> Vec<graph::GraphUnit> {
378 model
379 .units
380 .iter()
381 .map(|u| graph::GraphUnit {
382 id: u.id.clone(),
383 name: u.name.clone(),
384 refs: u.refs.clone(),
385 test: u.test,
386 })
387 .collect()
388}
389
390pub fn units_from_graph(rows: &[graph::GraphUnit]) -> Vec<Unit> {
397 rows.iter()
398 .map(|r| Unit {
399 id: r.id.clone(),
400 name: r.name.clone(),
401 dir: match r.id.rfind('/') {
402 Some(i) => r.id[..i].to_string(),
403 None => String::new(),
404 },
405 refs: r.refs.clone(),
406 test: r.test,
407 })
408 .collect()
409}
410
411pub fn sidecar_differs(root: &Path, model: Option<&ProjectModel>) -> bool {
428 let existing = fs::read(graph::project_units_path(root)).ok();
429 match (existing, model) {
430 (None, None) => false,
431 (None, Some(_)) => true,
432 (Some(_), None) => true,
435 (Some(bytes), Some(m)) => match serde_json::to_vec(&graph_units(m)) {
436 Ok(fresh) => bytes != fresh,
437 Err(_) => true,
438 },
439 }
440}
441
442fn build_closure(units: &[Unit], id_to_index: &HashMap<&str, usize>) -> Vec<HashSet<usize>> {
446 units
447 .iter()
448 .enumerate()
449 .map(|(i, _)| {
450 let mut seen = HashSet::new();
451 seen.insert(i);
452 let mut queue: VecDeque<usize> = VecDeque::new();
453 queue.push_back(i);
454 let mut result = HashSet::new();
455 while let Some(cur) = queue.pop_front() {
456 for r in &units[cur].refs {
457 if let Some(&idx) = id_to_index.get(r.as_str()) {
458 if seen.insert(idx) {
459 result.insert(idx);
460 queue.push_back(idx);
461 }
462 }
463 }
464 }
465 result
466 })
467 .collect()
468}
469
470#[derive(Debug, Default, Clone, PartialEq, Eq)]
478struct CsprojFacts {
479 project_refs: Vec<String>,
484 has_test_sdk: bool,
487 is_test_project: Option<bool>,
490}
491
492fn scan_csproj(text: &str) -> CsprojFacts {
497 let clean = strip_xml_comments(text);
498 let mut facts = CsprojFacts::default();
499 let mut pos = 0usize;
500 let mut awaiting_close: Option<&str> = None;
501
502 while let Some((gap, tag)) = next_tag(&clean, &mut pos) {
503 if let Some(open_name) = awaiting_close {
504 if tag.is_closing && tag.name == open_name {
505 let value = gap.trim();
506 if value.eq_ignore_ascii_case("true") {
507 facts.is_test_project = Some(true);
508 } else if value.eq_ignore_ascii_case("false") {
509 facts.is_test_project = Some(false);
510 }
511 }
512 awaiting_close = None;
513 }
514
515 if tag.is_closing {
516 continue;
517 }
518
519 match tag.name {
520 "ProjectReference" => {
521 if let Some(include) = find_attr(tag.attrs, "Include") {
522 facts.project_refs.push(include.replace('\\', "/"));
523 }
524 }
525 "PackageReference" => {
526 if let Some(include) = find_attr(tag.attrs, "Include") {
527 if include == "Microsoft.NET.Test.Sdk" {
528 facts.has_test_sdk = true;
529 }
530 }
531 }
532 "IsTestProject" if !tag.self_closing => {
533 awaiting_close = Some("IsTestProject");
534 }
535 _ => {}
536 }
537 }
538
539 facts
540}
541
542fn scan_props(text: &str) -> Option<bool> {
547 scan_csproj(text).is_test_project
548}
549
550fn resolve_ref(csproj_dir: &str, raw: &str) -> String {
557 let mut stack: Vec<&str> = if csproj_dir.is_empty() {
558 Vec::new()
559 } else {
560 csproj_dir.split('/').collect()
561 };
562 for seg in raw.split('/') {
563 match seg {
564 "" | "." => {}
565 ".." => {
566 if matches!(stack.last(), None | Some(&"..")) {
567 stack.push("..");
568 } else {
569 stack.pop();
570 }
571 }
572 other => stack.push(other),
573 }
574 }
575 stack.join("/")
576}
577
578fn strip_xml_comments(text: &str) -> String {
581 let mut out = String::with_capacity(text.len());
582 let mut rest = text;
583 loop {
584 match rest.find("<!--") {
585 Some(start) => {
586 out.push_str(&rest[..start]);
587 match rest[start..].find("-->") {
588 Some(end_rel) => {
589 rest = &rest[start + end_rel + 3..];
590 }
591 None => return out,
592 }
593 }
594 None => {
595 out.push_str(rest);
596 return out;
597 }
598 }
599 }
600}
601
602struct TagInfo<'a> {
604 name: &'a str,
605 is_closing: bool,
606 self_closing: bool,
607 attrs: &'a str,
610}
611
612fn next_tag<'a>(text: &'a str, pos: &mut usize) -> Option<(&'a str, TagInfo<'a>)> {
623 let search_from = *pos;
624 let lt = text[search_from..].find('<')? + search_from;
625 let gap = &text[search_from..lt];
626
627 let bytes = text.as_bytes();
628 let mut i = lt + 1;
629 let mut quote: Option<u8> = None;
630 let mut gt = None;
631 while i < bytes.len() {
632 let c = bytes[i];
633 match quote {
634 Some(q) => {
635 if c == q {
636 quote = None;
637 }
638 }
639 None => {
640 if c == b'"' || c == b'\'' {
641 quote = Some(c);
642 } else if c == b'>' {
643 gt = Some(i);
644 break;
645 }
646 }
647 }
648 i += 1;
649 }
650 let gt = gt?;
651 *pos = gt + 1;
652
653 let content = text[lt + 1..gt].trim();
654 if let Some(name) = content.strip_prefix('/') {
655 return Some((
656 gap,
657 TagInfo {
658 name: name.trim(),
659 is_closing: true,
660 self_closing: false,
661 attrs: "",
662 },
663 ));
664 }
665
666 let self_closing = content.ends_with('/');
667 let body = if self_closing {
668 content[..content.len() - 1].trim_end()
669 } else {
670 content
671 };
672 let (name, attrs) = match body.find(|c: char| c.is_whitespace()) {
673 Some(i2) => (&body[..i2], body[i2..].trim()),
674 None => (body, ""),
675 };
676
677 Some((
678 gap,
679 TagInfo {
680 name,
681 is_closing: false,
682 self_closing,
683 attrs,
684 },
685 ))
686}
687
688fn find_attr(attrs: &str, name: &str) -> Option<String> {
694 let mut i = 0usize;
695 while i < attrs.len() {
696 let found = attrs[i..].find(name)?;
697 let abs = i + found;
698 let boundary_before = abs == 0 || attrs.as_bytes()[abs - 1].is_ascii_whitespace();
699 if boundary_before {
700 let after = &attrs[abs + name.len()..];
701 let after_trimmed = after.trim_start();
702 if let Some(value_part) = after_trimmed.strip_prefix('=') {
703 let value_part = value_part.trim_start();
704 if let Some(quote) = value_part.chars().next() {
705 if quote == '"' || quote == '\'' {
706 if let Some(end) = value_part[quote.len_utf8()..].find(quote) {
707 return Some(
708 value_part[quote.len_utf8()..quote.len_utf8() + end].to_string(),
709 );
710 }
711 }
712 }
713 }
714 }
715 i = abs + name.len();
716 }
717 None
718}
719
720#[cfg(test)]
721mod tests {
722 use super::*;
723 use std::sync::atomic::{AtomicU64, Ordering};
724
725 static COUNTER: AtomicU64 = AtomicU64::new(0);
726
727 fn scratch_dir(label: &str) -> PathBuf {
728 let n = COUNTER.fetch_add(1, Ordering::SeqCst);
729 let dir = std::env::temp_dir().join(format!(
730 "scout-project-rs-{label}-{}-{n}",
731 std::process::id()
732 ));
733 fs::create_dir_all(&dir).expect("create scratch dir");
734 dir
735 }
736
737 fn write_file(path: &Path, contents: &str) {
738 if let Some(parent) = path.parent() {
739 fs::create_dir_all(parent).expect("create parent dirs");
740 }
741 fs::write(path, contents).expect("write file");
742 }
743
744 #[test]
747 fn scan_csproj_reads_include_from_a_self_closing_project_reference() {
748 let facts = scan_csproj(
749 r#"<Project><ItemGroup><ProjectReference Include="../B/B.csproj" /></ItemGroup></Project>"#,
750 );
751 assert_eq!(facts.project_refs, vec!["../B/B.csproj".to_string()]);
752 }
753
754 #[test]
755 fn scan_csproj_reads_include_from_an_open_close_project_reference() {
756 let facts = scan_csproj(
757 r#"<ItemGroup><ProjectReference Include="../B/B.csproj"><Private>false</Private></ProjectReference></ItemGroup>"#,
758 );
759 assert_eq!(facts.project_refs, vec!["../B/B.csproj".to_string()]);
760 }
761
762 #[test]
763 fn scan_csproj_reads_a_multi_line_attribute() {
764 let xml =
765 "<ItemGroup>\n <ProjectReference\n Include=\"../B/B.csproj\"\n />\n</ItemGroup>";
766 let facts = scan_csproj(xml);
767 assert_eq!(facts.project_refs, vec!["../B/B.csproj".to_string()]);
768 }
769
770 #[test]
771 fn scan_csproj_reads_single_quoted_attributes() {
772 let facts = scan_csproj(
773 r#"<ItemGroup><PackageReference Include='Microsoft.NET.Test.Sdk' Version='17.0.0' /></ItemGroup>"#,
774 );
775 assert!(facts.has_test_sdk);
776 }
777
778 #[test]
779 fn scan_csproj_skips_a_project_reference_inside_a_comment() {
780 let xml = r#"
781 <ItemGroup>
782 <!-- <ProjectReference Include="../Fake/Fake.csproj" /> -->
783 <ProjectReference Include="../Real/Real.csproj" />
784 </ItemGroup>
785 "#;
786 let facts = scan_csproj(xml);
787 assert_eq!(facts.project_refs, vec!["../Real/Real.csproj".to_string()]);
788 }
789
790 #[test]
791 fn scan_csproj_detects_the_test_sdk_package_reference() {
792 let facts = scan_csproj(
793 r#"<ItemGroup><PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.8.0" /></ItemGroup>"#,
794 );
795 assert!(facts.has_test_sdk);
796 }
797
798 #[test]
799 fn scan_csproj_reads_explicit_is_test_project_true() {
800 let facts =
801 scan_csproj("<PropertyGroup><IsTestProject>true</IsTestProject></PropertyGroup>");
802 assert_eq!(facts.is_test_project, Some(true));
803 }
804
805 #[test]
806 fn scan_csproj_reads_explicit_is_test_project_false() {
807 let facts =
808 scan_csproj("<PropertyGroup><IsTestProject>False</IsTestProject></PropertyGroup>");
809 assert_eq!(facts.is_test_project, Some(false));
810 }
811
812 #[test]
813 fn scan_props_reads_is_test_project() {
814 assert_eq!(
815 scan_props("<Project><PropertyGroup><IsTestProject>true</IsTestProject></PropertyGroup></Project>"),
816 Some(true)
817 );
818 assert_eq!(scan_props("<Project><PropertyGroup /></Project>"), None);
819 }
820
821 #[test]
824 fn resolve_ref_resolves_dotdot_and_backslashes_against_the_csproj_directory() {
825 let facts = scan_csproj(r#"<ProjectReference Include="..\..\Shared\Shared.csproj" />"#);
826 assert_eq!(
827 facts.project_refs,
828 vec!["../../Shared/Shared.csproj".to_string()]
829 );
830 assert_eq!(
831 resolve_ref("tests/T", "../../Shared/Shared.csproj"),
832 "Shared/Shared.csproj"
833 );
834 }
835
836 #[test]
837 fn resolve_ref_keeps_an_out_of_root_target_as_a_normalised_string_that_matches_nothing() {
838 let resolved = resolve_ref("src/A", "../../../Outside/Outside.csproj");
839 assert_eq!(resolved, "../Outside/Outside.csproj");
840 assert!(resolved.starts_with(".."));
841 }
842
843 fn unit(id: &str, dir: &str, refs: &[&str], test: bool) -> Unit {
846 Unit {
847 id: id.to_string(),
848 name: id.trim_end_matches(".csproj").to_string(),
849 dir: dir.to_string(),
850 refs: refs.iter().map(|r| r.to_string()).collect(),
851 test,
852 }
853 }
854
855 #[test]
856 fn from_units_sorts_by_id_and_reachable_is_reflexive_and_directional() {
857 let model = ProjectModel::from_units(vec![
858 unit("src/C/C.csproj", "src/C", &[], false),
859 unit("src/A/A.csproj", "src/A", &["src/B/B.csproj"], false),
860 unit("src/B/B.csproj", "src/B", &["src/C/C.csproj"], false),
861 ]);
862
863 let ids: Vec<&str> = model.units.iter().map(|u| u.id.as_str()).collect();
864 assert_eq!(
865 ids,
866 vec!["src/A/A.csproj", "src/B/B.csproj", "src/C/C.csproj"]
867 );
868
869 let a = model
870 .units
871 .iter()
872 .position(|u| u.id == "src/A/A.csproj")
873 .unwrap();
874 let b = model
875 .units
876 .iter()
877 .position(|u| u.id == "src/B/B.csproj")
878 .unwrap();
879 let c = model
880 .units
881 .iter()
882 .position(|u| u.id == "src/C/C.csproj")
883 .unwrap();
884
885 assert!(model.reachable(a, a), "a unit reaches itself");
886 assert!(model.reachable(a, c), "A -> B -> C is transitive");
887 assert!(!model.reachable(b, a), "B does not reference A");
888 assert!(!model.reachable(c, b), "C references nothing");
889 assert!(!model.reachable(c, a), "C cannot reach A");
890 }
891
892 #[test]
893 fn from_units_ignores_an_unresolved_reference_in_the_closure() {
894 let model = ProjectModel::from_units(vec![unit(
895 "src/A/A.csproj",
896 "src/A",
897 &["src/Missing/Missing.csproj"],
898 false,
899 )]);
900 let a = 0;
901 assert!(model.closure[a].is_empty());
902 assert!(!model.reachable(a, 99));
903 }
904
905 #[test]
906 fn from_units_closes_a_reference_cycle_without_revisiting_a_unit() {
907 let model = ProjectModel::from_units(vec![
910 unit("src/A/A.csproj", "src/A", &["src/B/B.csproj"], false),
911 unit("src/B/B.csproj", "src/B", &["src/C/C.csproj"], false),
912 unit("src/C/C.csproj", "src/C", &["src/A/A.csproj"], false),
913 ]);
914 for from in 0..3 {
915 assert_eq!(
916 model.closure[from].len(),
917 2,
918 "unit {from} reaches the other two"
919 );
920 assert!(!model.closure[from].contains(&from));
921 for to in 0..3 {
922 assert!(model.reachable(from, to), "{from} -> {to} inside the cycle");
923 }
924 }
925 }
926
927 #[test]
928 fn unit_of_file_climbs_to_a_root_level_unit_from_any_nested_file() {
929 let model = ProjectModel::from_units(vec![unit("A.csproj", "", &[], false)]);
930 assert_eq!(model.unit_of_file("Elsewhere/Deep/x.cs"), Some(0));
931 }
932
933 #[test]
934 fn unit_of_file_returns_none_when_no_ancestor_directory_has_exactly_one_unit() {
935 let model = ProjectModel::from_units(vec![unit("src/A/A.csproj", "src/A", &[], false)]);
938 assert_eq!(model.unit_of_file("other/y.cs"), None);
939 }
940
941 #[test]
944 fn discover_returns_none_when_no_csproj_is_under_scope_or_above_it() {
945 let root = scratch_dir("no-csproj");
946 write_file(&root.join("src/a.cs"), "class A {}\n");
947
948 let result = discover(&root, &["src".to_string()]).unwrap();
949 assert!(result.is_none());
950
951 fs::remove_dir_all(&root).ok();
952 }
953
954 #[test]
955 fn discover_builds_units_closure_and_test_flags_for_a_referenced_project_chain() {
956 let root = scratch_dir("chain");
957 write_file(
958 &root.join("src/A/A.csproj"),
959 r#"<Project><ItemGroup><ProjectReference Include="../B/B.csproj" /></ItemGroup></Project>"#,
960 );
961 write_file(
962 &root.join("src/B/B.csproj"),
963 r#"<Project><ItemGroup><ProjectReference Include="../C/C.csproj" /></ItemGroup></Project>"#,
964 );
965 write_file(&root.join("src/C/C.csproj"), "<Project></Project>");
966 write_file(
967 &root.join("tests/T/T.csproj"),
968 r#"<Project>
969 <ItemGroup>
970 <PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.8.0" />
971 <ProjectReference Include="../../src/A/A.csproj" />
972 </ItemGroup>
973 </Project>"#,
974 );
975 write_file(&root.join("src/A/Sub/x.cs"), "class X {}\n");
976
977 let model = discover(&root, &["src".to_string(), "tests".to_string()])
978 .unwrap()
979 .expect("csproj files were written under scope");
980
981 let idx = |id: &str| model.units.iter().position(|u| u.id == id).unwrap();
982 let a = idx("src/A/A.csproj");
983 let b = idx("src/B/B.csproj");
984 let c = idx("src/C/C.csproj");
985 let t = idx("tests/T/T.csproj");
986
987 assert!(model.reachable(a, c), "A -> B -> C must be transitive");
988 assert!(!model.reachable(b, a), "B does not reference A");
989 assert_eq!(model.unit_of_file("src/A/Sub/x.cs"), Some(a));
990 assert!(model.units[t].test, "T carries the test SDK package");
991 assert!(model.reachable(t, a), "T directly references A");
992
993 fs::remove_dir_all(&root).ok();
994 }
995
996 #[test]
997 fn discover_directory_with_two_csproj_files_is_skipped_when_resolving_a_file_beneath_it() {
998 let root = scratch_dir("two-csproj-dir");
999 write_file(&root.join("src/Root.csproj"), "<Project></Project>");
1000 write_file(&root.join("src/shared/X.csproj"), "<Project></Project>");
1001 write_file(&root.join("src/shared/Y.csproj"), "<Project></Project>");
1002 write_file(&root.join("src/shared/deep/z.cs"), "class Z {}\n");
1003
1004 let model = discover(&root, &["src".to_string()]).unwrap().unwrap();
1005
1006 let root_unit = model
1007 .units
1008 .iter()
1009 .position(|u| u.id == "src/Root.csproj")
1010 .unwrap();
1011 assert_eq!(model.unit_of_file("src/shared/deep/z.cs"), Some(root_unit));
1015 assert_eq!(model.unit_of_file("src/shared/w.cs"), Some(root_unit));
1016
1017 fs::remove_dir_all(&root).ok();
1018 }
1019
1020 #[test]
1021 fn discover_props_precedence_beats_the_test_sdk_package() {
1022 let root = scratch_dir("props-precedence");
1023 write_file(
1024 &root.join("src/App/App.csproj"),
1025 r#"<Project><ItemGroup><PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.8.0" /></ItemGroup></Project>"#,
1026 );
1027 write_file(
1028 &root.join("src/App/Directory.Build.props"),
1029 "<Project><PropertyGroup><IsTestProject>false</IsTestProject></PropertyGroup></Project>",
1030 );
1031
1032 let model = discover(&root, &["src".to_string()]).unwrap().unwrap();
1033 let app = model
1034 .units
1035 .iter()
1036 .position(|u| u.id == "src/App/App.csproj")
1037 .unwrap();
1038 assert!(
1039 !model.units[app].test,
1040 "an explicit Directory.Build.props IsTestProject must beat the test-SDK package"
1041 );
1042
1043 fs::remove_dir_all(&root).ok();
1044 }
1045
1046 #[test]
1047 fn discover_name_suffix_fallback_when_nothing_else_says_test() {
1048 let root = scratch_dir("name-suffix");
1049 write_file(
1050 &root.join("src/App.Tests/App.Tests.csproj"),
1051 "<Project></Project>",
1052 );
1053 write_file(&root.join("src/App/App.csproj"), "<Project></Project>");
1054
1055 let model = discover(&root, &["src".to_string()]).unwrap().unwrap();
1056 let tests_unit = model
1057 .units
1058 .iter()
1059 .position(|u| u.id == "src/App.Tests/App.Tests.csproj")
1060 .unwrap();
1061 let app_unit = model
1062 .units
1063 .iter()
1064 .position(|u| u.id == "src/App/App.csproj")
1065 .unwrap();
1066
1067 assert!(model.units[tests_unit].test, "name ends with .Tests");
1068 assert!(!model.units[app_unit].test);
1069
1070 fs::remove_dir_all(&root).ok();
1071 }
1072
1073 #[test]
1074 fn discover_finds_a_directory_build_props_above_the_scope_root_via_ancestor_scan() {
1075 let root = scratch_dir("ancestor-props");
1076 write_file(
1077 &root.join("Directory.Build.props"),
1078 "<Project><PropertyGroup><IsTestProject>true</IsTestProject></PropertyGroup></Project>",
1079 );
1080 write_file(&root.join("src/A/A.csproj"), "<Project></Project>");
1081
1082 let model = discover(&root, &["src".to_string()]).unwrap().unwrap();
1083 let a = model
1084 .units
1085 .iter()
1086 .position(|u| u.id == "src/A/A.csproj")
1087 .unwrap();
1088 assert!(
1089 model.units[a].test,
1090 "the root Directory.Build.props sits above the scope dir and must still be found"
1091 );
1092
1093 fs::remove_dir_all(&root).ok();
1094 }
1095
1096 #[test]
1097 fn discover_ancestor_scan_does_not_recurse_into_sibling_directories() {
1098 let root = scratch_dir("ancestor-no-recurse");
1099 write_file(&root.join("src/A/A.csproj"), "<Project></Project>");
1100 write_file(&root.join("other/Other.csproj"), "<Project></Project>");
1103
1104 let model = discover(&root, &["src".to_string()]).unwrap().unwrap();
1105 assert_eq!(model.units.len(), 1);
1106 assert_eq!(model.units[0].id, "src/A/A.csproj");
1107
1108 fs::remove_dir_all(&root).ok();
1109 }
1110
1111 #[test]
1112 fn discover_ignores_sln_files() {
1113 let root = scratch_dir("sln-ignored");
1114 write_file(
1115 &root.join("Repo.sln"),
1116 "Microsoft Visual Studio Solution File",
1117 );
1118 write_file(&root.join("src/A/A.csproj"), "<Project></Project>");
1119
1120 let model = discover(&root, &["src".to_string()]).unwrap().unwrap();
1121 assert_eq!(model.units.len(), 1);
1122 assert_eq!(model.units[0].id, "src/A/A.csproj");
1123
1124 fs::remove_dir_all(&root).ok();
1125 }
1126}