Skip to main content

devscout_rs/
project.rs

1// A lightweight, hand-scanned model of the repo's `.csproj` projects: which
2// directory each project owns, which other projects it references, and
3// whether it is a test project. No MSBuild is invoked and no XML crate is
4// used -- `scan_csproj`/`scan_props` below hand-scan just enough of the file
5// to answer those three questions.
6//
7// ## Discovery
8//
9// `discover` walks each scope directory recursively (via
10// `walk::list_files_with_ext`, so it inherits that walk's `SKIP_DIRS` and
11// deterministic ordering) looking for `.csproj` and `.props` files, then
12// additionally checks each scope directory's ancestors -- one directory at a
13// time, NOT recursively -- up to and including `root`. That ancestor check
14// exists because a repo-root `Directory.Build.props` commonly sits above
15// every scope directory and would otherwise never be seen: the recursive
16// walk only covers subtrees rooted AT a scope directory. `.props` files are
17// filtered to the exact name `Directory.Build.props`; `.sln` files are never
18// looked for (no extension in the scan list) and so are always ignored.
19//
20// ## Units and directory ownership
21//
22// Every discovered `.csproj` becomes a `Unit` keyed by its repo-relative path
23// (`id`) and owning its containing directory (`dir`). `ProjectModel` maps a
24// directory to its unit only when that directory holds EXACTLY one `.csproj`
25// -- a directory holding two or more is deliberately left unmapped, so
26// `unit_of_file` walks past it to the nearest single-project ancestor
27// instead of guessing.
28//
29// ## Test detection
30//
31// `Unit.test` is decided by a fixed precedence, first source to answer wins:
32// 1. an explicit `<IsTestProject>` element in the csproj itself;
33// 2. an explicit `<IsTestProject>` in the nearest `Directory.Build.props`
34//    (searched from the csproj's own directory upward to `root`; a props
35//    file with no such element does not count as an answer and defers to the
36//    next source, but a further-out props file is never consulted once a
37//    nearer one has been found);
38// 3. a `Microsoft.NET.Test.Sdk` `PackageReference`;
39// 4. a project name ending in `.Tests` or `.Test`.
40//
41// ## Reference closure
42//
43// `Unit.refs` holds only DIRECT `ProjectReference` targets, normalized to
44// repo-relative ids (backslashes to forward slashes, `..`/`.` resolved
45// against the csproj's own directory). A reference that resolves above
46// `root` is kept as a literal string starting with `..` rather than clamped
47// -- clamping could accidentally alias it onto an unrelated real unit id, and
48// a leading `..` can never match one (ids are always repo-relative paths
49// with no `..` component), so it safely resolves to nothing everywhere a ref
50// id is looked up. A reference naming an id no `Unit` actually has is simply
51// ignored when the closure is built.
52//
53// `ProjectModel::from_units` computes each unit's full transitive closure by
54// BFS over `refs` once, up front, so `reachable` is an O(1) set lookup.
55
56use 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/// One `.csproj` project discovered under the repo.
66#[derive(Debug, Clone, PartialEq, Eq)]
67pub struct Unit {
68    /// Repo-relative path to the `.csproj` file itself (`/`-joined), e.g.
69    /// `"src/App/App.csproj"`. Doubles as this unit's identity: `refs`
70    /// entries and cross-unit lookups are matched against this string.
71    pub id: String,
72    /// The project name -- the csproj file name with its `.csproj`
73    /// extension stripped (e.g. `"App.Tests"` for `App.Tests.csproj`). Used
74    /// only for the name-suffix fallback in test detection; not otherwise
75    /// assumed to be unique.
76    pub name: String,
77    /// Repo-relative path to the directory containing the `.csproj` file
78    /// (`/`-joined, no trailing slash; `""` for a `.csproj` directly at
79    /// `root`).
80    pub dir: String,
81    /// The `id`s of this unit's DIRECT `ProjectReference` targets only (not
82    /// transitively closed -- see `ProjectModel::reachable` for that). An
83    /// entry that does not match any discovered unit's `id` (an out-of-root
84    /// target, or a project this scan never found) is kept verbatim; it
85    /// simply never resolves to anything.
86    pub refs: Vec<String>,
87    /// Whether this project is a test project, per the precedence documented
88    /// on the module.
89    pub test: bool,
90}
91
92/// The repo's discovered `.csproj` projects, their direct references, and
93/// the transitive closure over those references.
94#[derive(Debug, Clone)]
95pub struct ProjectModel {
96    /// All discovered units, sorted by `id`. A unit's position in this
97    /// vector is its index everywhere else in this type (`dir_to_unit`
98    /// values, `closure` entries, `reachable`'s arguments).
99    pub units: Vec<Unit>,
100    // Directory -> unit index, present only for a directory that owns
101    // EXACTLY one unit. A directory with zero units has no entry (nothing to
102    // map); a directory with two or more also has no entry, on purpose --
103    // see `unit_of_file`.
104    dir_to_unit: HashMap<String, usize>,
105    // closure[i] is the set of unit indices transitively reachable from unit
106    // i by following `refs`, NOT including i itself (even if a reference
107    // cycle loops back to it -- `reachable` handles the `from == to` case
108    // separately).
109    closure: Vec<HashSet<usize>>,
110}
111
112/// Discovers the repo's `.csproj` project model under `scope`.
113///
114/// `scope` holds directory names relative to `root`, `"."` meaning `root`
115/// itself -- same convention as `walk::list_source_files`. `Ok(None)` when
116/// zero `.csproj` files are found anywhere in scope or above it; discovery
117/// never fails just because nothing was there to discover.
118///
119/// See the module docs for the full discovery, ownership, test-detection and
120/// closure rules.
121pub 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    // Recursive walk of each scope directory.
126    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    // Each scope dir's ancestors up to root, checked non-recursively (one
139    // `read_dir` per ancestor, no descent into siblings).
140    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
163// The ancestor directories of `dir`, starting at its parent and continuing
164// up to and including `root`. Empty when `dir == root` (no ancestors left to
165// check within the repo) or when `dir` is not under `root` at all.
166fn 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
182// Non-recursive `.csproj`/`Directory.Build.props` scan of a single
183// directory's direct entries. A missing or unreadable directory (e.g. an
184// ancestor computed lexically from a scope element that does not exist on
185// disk) is silently skipped, matching `walk`'s "nonexistent scope element"
186// behavior rather than failing the whole discovery.
187fn 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
227// Reads and scans one discovered `.csproj` into its `Unit`. `props_by_dir`
228// is the full set of `Directory.Build.props` files discovery already found
229// (recursive-walk plus ancestor scan combined), keyed by their own
230// directory, so the nearest-ancestor search below never touches the disk
231// again.
232fn 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    // Precedence: explicit csproj value, then the nearest Directory.Build.props
257    // that has one, then the test-SDK package, then the name suffix.
258    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
274// Walks from `start_dir` (the unit's own directory) upward to and including
275// `root`, returning the `<IsTestProject>` value from the FIRST
276// `Directory.Build.props` found -- `None` if that nearest file has no such
277// element (a further-out props file is not consulted) or if none exists on
278// the path at all.
279fn 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    /// Builds a `ProjectModel` from an already-scanned unit list: sorts
298    /// `units` by `id`, maps each directory that owns exactly one unit to
299    /// it, and computes each unit's transitive reference closure by BFS.
300    /// This is the constructor `discover` itself uses once scanning is done,
301    /// and the one a persistence layer would use to rebuild a `ProjectModel`
302    /// from a stored unit list without re-scanning any files.
303    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    /// The unit owning `rel` (a repo-relative file path, `/`-joined): the
333    /// nearest ancestor directory of `rel` that owns EXACTLY one unit.
334    /// A directory owning zero units is walked past because nothing maps
335    /// it; a directory owning two or more is ALSO walked past, on purpose
336    /// (`ProjectModel::from_units` never maps an ambiguous directory) --
337    /// climbing to the next single-project ancestor instead of guessing
338    /// which of the colliding projects owns the file. `None` when no
339    /// ancestor, up to and including the repo root, owns exactly one unit.
340    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    /// Whether `to` is reachable from `from`: trivially true when they are
360    /// the same unit, otherwise true iff `to` is in `from`'s precomputed
361    /// transitive `ProjectReference` closure.
362    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
367// ---------------------------------------------------------------------------
368// Persistence bridge -- `graph.json`'s `units` array and the staleness
369// sidecar beside it. The on-disk row type (`graph::GraphUnit`) lives in
370// graph.rs with every other serde struct; the two conversions and the
371// comparison live here, next to the model they describe.
372// ---------------------------------------------------------------------------
373
374/// The model's units as graph.json persists them, in `ProjectModel` order
375/// (sorted by `id`). `dir` is dropped: it is always `id`'s parent directory
376/// and `units_from_graph` recomputes it.
377pub 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
390/// The inverse of `graph_units`: persisted rows back into `Unit`s, ready for
391/// `ProjectModel::from_units`.
392///
393/// `dir` is recomputed as `id`'s parent (`""` for
394/// a `.csproj` sitting directly at the repo root), which is exactly what
395/// `discover` derived it from in the first place.
396pub 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
411/// Whether `model` disagrees with the project-units sidecar last written for
412/// `root` -- `map`'s "did a `.csproj` change?" signal.
413///
414/// It has to exist because a `.csproj` is not a `SOURCE_EXT`: editing one
415/// moves no indexed file, so `graph::index_is_stale` stays false and the
416/// graph would keep a stale reference closure forever. The comparison is a
417/// raw BYTE compare against the sidecar rather than a parse-and-diff -- the
418/// sidecar holds exactly what `graph_units` serializes, so any difference at
419/// all (a new project, a dropped `ProjectReference`, a flipped `test`) shows
420/// up as different bytes, and an unreadable or truncated sidecar reads as
421/// differing, which costs one rebuild and is the safe direction.
422///
423/// A missing sidecar with a model is a difference (nothing was ever written,
424/// or the last run had no projects); a missing sidecar with NO model is not
425/// -- that is the steady state of every repo that has no `.csproj`, and it
426/// must never force a rebuild.
427pub 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        // A sidecar left over from when the repo still had projects: the
433        // model went away, so the graph's `units` must go away with it.
434        (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
442// BFS per unit over `refs` (resolved through `id_to_index`; an unresolved
443// ref id is simply skipped) to produce each unit's full transitive closure
444// once, up front.
445fn 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// ---------------------------------------------------------------------------
471// Hand-written XML scanning -- no XML crate. Just enough tag/attribute
472// recognition to answer `scan_csproj`/`scan_props`'s three questions; nothing
473// here builds a DOM or validates well-formedness.
474// ---------------------------------------------------------------------------
475
476// The facts `scan_csproj` extracts from a `.csproj` file's raw text.
477#[derive(Debug, Default, Clone, PartialEq, Eq)]
478struct CsprojFacts {
479    // Raw `ProjectReference` `Include` values, backslashes already turned to
480    // forward slashes, but NOT yet resolved against any directory (the caller
481    // does that with `resolve_ref`, since this function has no directory
482    // context).
483    project_refs: Vec<String>,
484    // Whether any `PackageReference Include="Microsoft.NET.Test.Sdk"` tag
485    // was seen.
486    has_test_sdk: bool,
487    // The parsed content of an `<IsTestProject>` element, if one was seen
488    // with a recognizable `true`/`false` value (case-insensitive).
489    is_test_project: Option<bool>,
490}
491
492// Scans `text` (a `.csproj` file's contents) for `ProjectReference`/
493// `PackageReference` `Include` attributes and an `<IsTestProject>` element.
494// XML comments (`<!-- ... -->`) are stripped first, so a fake reference
495// living inside a comment is never seen.
496fn 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
542// Scans a `Directory.Build.props` file's contents for an `<IsTestProject>`
543// element. Reuses `scan_csproj`'s scan (`Directory.Build.props` uses the same
544// MSBuild element syntax); the `ProjectReference`/`PackageReference` facts it
545// also computes are simply unused here.
546fn scan_props(text: &str) -> Option<bool> {
547    scan_csproj(text).is_test_project
548}
549
550// Resolves a raw (already backslash-normalized) `Include` path against
551// `csproj_dir` (the referencing csproj's own repo-relative directory,
552// `/`-joined, `""` for root) to a repo-relative id: `.` segments drop out,
553// `..` pops the last real segment. A `..` with nothing left to pop (the
554// reference climbs above `root`) is kept as a literal leading `..` segment
555// instead of being clamped -- see the module docs for why that is safe.
556fn 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
578// Strips `<!-- ... -->` regions from `text`. An unterminated comment (no
579// closing `-->`) drops everything from the `<!--` onward.
580fn 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
602// One `<...>` tag, already split into its name and raw attribute text.
603struct TagInfo<'a> {
604    name: &'a str,
605    is_closing: bool,
606    self_closing: bool,
607    // Everything after the name, unparsed -- `find_attr` reads out of this.
608    // Empty for a closing tag.
609    attrs: &'a str,
610}
611
612// Advances `*pos` past the next `<...>` tag in `text` found at or after
613// `*pos`, returning it along with the plain text that preceded it (the gap
614// between the previous tag's `>` and this tag's `<`) -- callers that need an
615// element's text content (`IsTestProject`) read it out of the NEXT call's
616// `gap`, since that is exactly the text between this tag and whatever tag
617// follows it. `None` once no further tag is found.
618//
619// The search for the tag's closing `>` tracks single/double-quoted spans so
620// a multi-line attribute value (or one that happens to be empty) does not
621// end the tag early; nothing here assumes a tag fits on one line.
622fn 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
688// Finds `name="value"` (or `name='value'`) inside a tag's raw attribute
689// text, requiring `name` to be a whole attribute name -- preceded by the
690// start of `attrs` or whitespace, and followed (after optional whitespace)
691// directly by `=`, not by more identifier characters. Values may span
692// multiple lines (the search does not treat `\n` specially).
693fn 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    // -- scan_csproj: ProjectReference / PackageReference -------------------
745
746    #[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    // -- resolve_ref ----------------------------------------------------------
822
823    #[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    // -- from_units: sorting, closure, reachability --------------------------
844
845    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        // A -> B -> C -> A: the walk must terminate, every unit must reach the
908        // other two, and a unit's own index is never listed in its closure.
909        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        // The only unit owns "src/A"; a file outside that whole directory
936        // chain (root itself owns no unit here) never resolves.
937        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    // -- discover: end to end over real temp directories ---------------------
942
943    #[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        // "src/shared" owns two csproj files, so it is never mapped; a file
1012        // under it (directly or nested) resolves to the nearest single-csproj
1013        // ancestor, "src" (owning Root.csproj).
1014        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        // Sibling of "src", one level below the ancestor "root" -- must NOT
1101        // be found: the ancestor check on "root" is non-recursive.
1102        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}