Skip to main content

changepacks_csharp/
finder.rs

1use anyhow::{Context, Result};
2use async_trait::async_trait;
3use changepacks_core::{Project, ProjectFinder, has_extension_ignore_ascii_case, is_regular_file};
4use quick_xml::Reader;
5use quick_xml::XmlVersion;
6use quick_xml::escape::resolve_predefined_entity;
7use quick_xml::events::{BytesEnd, BytesRef, BytesStart, Event};
8use std::{
9    collections::HashMap,
10    path::{Path, PathBuf},
11};
12
13use crate::{package::CSharpPackage, xml_utils::is_unconditional_project_property_group};
14
15/// Manifest filenames this finder recognizes. Static because the list is
16/// compile-time constant — no per-instance heap `Vec` is needed and the
17/// `ProjectFinder::project_files` return type (`&[&str]`) already accepts
18/// a `&'static [&'static str]`.
19const PROJECT_FILES: &[&str] = &[".csproj"];
20
21#[derive(Debug, Default)]
22pub struct CSharpProjectFinder {
23    projects: HashMap<PathBuf, Project>,
24}
25
26impl CSharpProjectFinder {
27    #[must_use]
28    pub fn new() -> Self {
29        Self::default()
30    }
31
32    /// Extract the project name from the .csproj file path (filename without extension)
33    fn extract_name_from_path(path: &Path) -> Option<String> {
34        path.file_stem()
35            .and_then(|s| s.to_str())
36            .map(std::string::ToString::to_string)
37    }
38
39    /// Walk the .csproj XML ONCE and extract the project version, its
40    /// `ProjectReference` dependency names, and default publishability in a
41    /// single pass. The
42    /// previous shape (`extract_version` + `extract_project_references`)
43    /// ran two independent `quick_xml::Reader` passes over the identical
44    /// bytes; merging them halves the parse cost on repos with many
45    /// `.csproj` files (Unity / dotnet monorepos) while preserving existing
46    /// version and project-reference behavior.
47    fn parse_csproj_metadata(content: &str) -> Result<(Option<String>, Vec<String>, bool)> {
48        let mut reader = Reader::from_str(content);
49        // Preallocate the XML event buffer to skip the first few
50        // geometric-doubling reallocations. Mirrors the
51        // `Vec::with_capacity(paths.len())` preallocation policy already
52        // applied across `sort_by_dep.rs`, `gen_update_map.rs`, and
53        // `find_project_dirs.rs`. `read_event_into` calls `buf.clear()`
54        // between events so the capacity persists; 256 bytes comfortably
55        // covers the largest single event (attribute-laden `<Project Sdk=
56        // "Microsoft.NET.Sdk"...>`, ~1-2 dozen bytes for the common
57        // `<Version>` and `<ProjectReference>` shapes) without over-
58        // reserving on tiny `.csproj` files.
59        let mut buf = Vec::with_capacity(256);
60        // Every piece of mutable scan state lives in `CsprojScan`, so this
61        // function is left with only the reader plumbing and the event
62        // dispatch. See `CsprojScan` for the per-field rationale.
63        let mut scan = CsprojScan::new();
64
65        loop {
66            match reader.read_event_into(&mut buf) {
67                Ok(Event::Start(e)) => scan.on_start(&e)?,
68                Ok(Event::Empty(e)) if e.local_name().as_ref() == b"ProjectReference" => {
69                    collect_project_reference(&e, &mut scan.projects)?;
70                }
71                Ok(Event::End(e)) => scan.on_end(&e)?,
72                Ok(Event::Text(e)) => record_decoded_csproj_text(
73                    e.decode(),
74                    scan.in_version,
75                    scan.in_is_packable,
76                    &mut scan.version_text,
77                    &mut scan.publishable_by_default,
78                )?,
79                Ok(Event::CData(e)) => record_decoded_csproj_text(
80                    e.decode(),
81                    scan.in_version,
82                    scan.in_is_packable,
83                    &mut scan.version_text,
84                    &mut scan.publishable_by_default,
85                )?,
86                // A character or general entity reference inside the eligible
87                // `<Version>` is its own event, never part of the surrounding
88                // `Event::Text`. Without this arm it fell through the wildcard
89                // below and the version silently lost everything from the
90                // reference onwards. The C# *writer* already treats this event
91                // class explicitly (`xml_utils::update_version_in_xml`), so
92                // reader and writer now agree about the same document.
93                // References outside `<Version>` keep passing through untouched.
94                Ok(Event::GeneralRef(e)) if scan.in_version => {
95                    append_resolved_reference(&e, &mut scan.version_text)?;
96                }
97                Ok(Event::Eof) => {
98                    anyhow::ensure!(scan.element_depth == 0, "unexpected end of XML document");
99                    break;
100                }
101                Err(error) => return Err(error.into()),
102                _ => {}
103            }
104            buf.clear();
105        }
106        Ok(scan.finish())
107    }
108}
109
110/// Mutable state carried across the single `.csproj` event walk driven by
111/// [`CSharpProjectFinder::parse_csproj_metadata`].
112///
113/// The nine fields used to be nine `let mut` bindings threaded through one
114/// 137-line function, which made the element-open and element-close rules hard
115/// to read in isolation. Grouping them here lets `on_start` / `on_end` own the
116/// two non-trivial transition rules while `parse_csproj_metadata` keeps only
117/// the reader setup and the event dispatch. Behaviour, error messages, and the
118/// preallocation policy are unchanged.
119struct CsprojScan {
120    /// Depth of the currently open unconditional `<PropertyGroup>`, if any.
121    eligible_property_group_depth: Option<usize>,
122    in_version: bool,
123    in_is_packable: bool,
124    element_depth: usize,
125    /// Depth of the outermost `<Project>` element, set on first sight.
126    project_depth: Option<usize>,
127    version: Option<String>,
128    /// Fragment accumulator for the `<Version>` element currently being
129    /// read. quick-xml splits element content at every `&...;` reference:
130    /// `<Version>1.2&#46;3</Version>` arrives as `Text("1.2")`,
131    /// `GeneralRef("#46")`, `Text("3")`. Recording only the first fragment
132    /// therefore truncated such a version to `1.2`. Fragments are appended
133    /// here while `in_version` holds and folded into `version` on the
134    /// matching `</Version>`, so the "first non-empty `<Version>` wins"
135    /// rule is unchanged for every manifest without a reference.
136    version_text: String,
137    publishable_by_default: bool,
138    projects: Vec<String>,
139}
140
141impl CsprojScan {
142    fn new() -> Self {
143        Self {
144            eligible_property_group_depth: None,
145            in_version: false,
146            in_is_packable: false,
147            element_depth: 0,
148            project_depth: None,
149            version: None,
150            version_text: String::new(),
151            publishable_by_default: true,
152            // Preallocate against the typical `<ProjectReference>` fan-out
153            // observed in test fixtures (2 refs in
154            // `test_visit_package_with_project_references`,
155            // `test_extract_project_references`, and
156            // `test_parse_csproj_metadata_returns_version_and_refs_in_one_pass`).
157            // 4 comfortably covers the common 1-4 range without over-reserving
158            // on `.csproj` files with zero project references. Matches the
159            // `Vec::with_capacity(256)` policy applied to the event buffer in
160            // `parse_csproj_metadata` — the sibling preallocation policy shared
161            // with `sort_by_dep.rs`, `gen_update_map.rs`, and
162            // `find_project_dirs.rs`.
163            projects: Vec::with_capacity(4),
164        }
165    }
166
167    /// Apply one `Event::Start`: descend a level, then update whichever piece
168    /// of state this element name governs.
169    fn on_start(&mut self, e: &BytesStart<'_>) -> Result<()> {
170        self.element_depth += 1;
171        let name = e.local_name();
172        if name.as_ref() == b"Project" && self.project_depth.is_none() {
173            self.project_depth = Some(self.element_depth);
174        } else if name.as_ref() == b"PropertyGroup"
175            && is_unconditional_project_property_group(e, self.element_depth, self.project_depth)?
176        {
177            self.eligible_property_group_depth = Some(self.element_depth);
178        } else if name.as_ref() == b"Version" {
179            self.in_version =
180                is_eligible_property_child(self.eligible_property_group_depth, self.element_depth);
181        } else if name.as_ref() == b"IsPackable" {
182            self.in_is_packable =
183                is_eligible_property_child(self.eligible_property_group_depth, self.element_depth);
184        } else if name.as_ref() == b"ProjectReference" {
185            collect_project_reference(e, &mut self.projects)?;
186        }
187        Ok(())
188    }
189
190    /// Apply one `Event::End`: close whichever accumulator this element name
191    /// governs, then ascend a level.
192    fn on_end(&mut self, e: &BytesEnd<'_>) -> Result<()> {
193        let name = e.local_name();
194        if name.as_ref() == b"PropertyGroup"
195            && self.eligible_property_group_depth == Some(self.element_depth)
196        {
197            self.eligible_property_group_depth = None;
198        } else if name.as_ref() == b"Version" {
199            self.in_version = false;
200            // Fold the accumulated fragments in. `version.is_none()`
201            // keeps the original first-non-empty-wins rule: a
202            // whitespace-only `<Version>` still leaves `None` (so a
203            // later element may still win) and a second populated
204            // `<Version>` is still ignored.
205            if self.version.is_none() {
206                let candidate = self.version_text.trim();
207                if !candidate.is_empty() {
208                    self.version = Some(candidate.to_string());
209                }
210            }
211            self.version_text.clear();
212        } else if name.as_ref() == b"IsPackable" {
213            self.in_is_packable = false;
214        }
215        self.element_depth = self
216            .element_depth
217            .checked_sub(1)
218            .context("unexpected XML end tag")?;
219        Ok(())
220    }
221
222    /// Consume the finished scan into the tuple `parse_csproj_metadata`
223    /// returns: `(version, project_references, publishable_by_default)`.
224    fn finish(self) -> (Option<String>, Vec<String>, bool) {
225        (self.version, self.projects, self.publishable_by_default)
226    }
227}
228
229/// Whether the element currently being opened is a DIRECT child of the
230/// currently open unconditional `<PropertyGroup>`.
231///
232/// `<Version>` and `<IsPackable>` are only honoured one level below an
233/// eligible `<PropertyGroup>`; a deeper nesting (or no open eligible group at
234/// all) must not switch the accumulators on. Both element arms of
235/// `parse_csproj_metadata` evaluated this identical predicate inline, so it
236/// lives here once. It stays *inside* those arms rather than being hoisted
237/// above the else-if chain, keeping it evaluated only for those two element
238/// names and the per-event parse cost unchanged.
239const fn is_eligible_property_child(
240    eligible_property_group_depth: Option<usize>,
241    element_depth: usize,
242) -> bool {
243    matches!(eligible_property_group_depth, Some(depth) if element_depth == depth + 1)
244}
245
246/// Fold one decoded `<Version>` / `<IsPackable>` text or CDATA node into the
247/// accumulating metadata.
248///
249/// The `decoded` argument is exactly what `BytesText::decode` and
250/// `BytesCData::decode` return in the pinned quick-xml version, so a decode
251/// failure is surfaced to the caller instead of being discarded. The previous
252/// shape swallowed the `EncodingError`, which silently produced
253/// `version = None` and `publishable_by_default = true` for a `.csproj` whose
254/// text node could not be decoded — changepacks then treated a versioned
255/// project as unversioned and bumped it from `0.0.0`.
256fn record_decoded_csproj_text(
257    decoded: std::result::Result<std::borrow::Cow<'_, str>, quick_xml::encoding::EncodingError>,
258    in_version: bool,
259    in_is_packable: bool,
260    version_text: &mut String,
261    publishable_by_default: &mut bool,
262) -> Result<()> {
263    let text = decoded.context("Failed to decode .csproj text node")?;
264    let text = text.as_ref();
265    // Append rather than commit: `</Version>` decides which accumulated value
266    // wins, so a value split across fragments by an entity reference survives
267    // intact while single-fragment values behave exactly as before.
268    if in_version {
269        version_text.push_str(text);
270    }
271    if in_is_packable && text.trim().eq_ignore_ascii_case("false") {
272        *publishable_by_default = false;
273    }
274    Ok(())
275}
276
277/// Append the resolved text of one `&...;` reference found inside an eligible
278/// `<Version>` element to the in-progress version value.
279///
280/// Numeric character references (`&#46;`, `&#x2E;`) resolve through quick-xml's
281/// own `resolve_char_ref`; the five predefined XML entities resolve through
282/// `resolve_predefined_entity`. Anything else is an entity this parser cannot
283/// expand — a DTD-declared one — and producing a silently wrong version number
284/// from it is worse than failing, so it surfaces as a contextual error naming
285/// the entity instead.
286fn append_resolved_reference(reference: &BytesRef<'_>, version_text: &mut String) -> Result<()> {
287    if let Some(character) = reference
288        .resolve_char_ref()
289        .context("Failed to resolve .csproj character reference")?
290    {
291        version_text.push(character);
292        return Ok(());
293    }
294
295    let name = reference
296        .decode()
297        .context("Failed to decode .csproj entity reference")?;
298    let resolved = resolve_predefined_entity(&name).with_context(|| {
299        format!("Unresolvable entity reference `&{name};` in .csproj <Version>")
300    })?;
301    version_text.push_str(resolved);
302    Ok(())
303}
304
305/// Walk a `<ProjectReference Include="...">` / `Update="..."` element's attributes and
306/// push its extracted project name into `projects`. Shared by both the
307/// `Event::Start` and `Event::Empty` arms of `parse_csproj_metadata` so
308/// the attribute-parsing lives in exactly one place.
309fn collect_project_reference(
310    e: &quick_xml::events::BytesStart<'_>,
311    projects: &mut Vec<String>,
312) -> Result<()> {
313    let mut include_name = None;
314    let mut update_name = None;
315
316    for attr in e.attributes() {
317        let attr = attr.context("Failed to parse ProjectReference attribute")?;
318        let attr_name = attr.key.as_ref();
319        if !matches!(attr_name, b"Include" | b"Update") {
320            continue;
321        }
322        let value = attr
323            .normalized_value(XmlVersion::Implicit1_0)
324            .context("Failed to normalize ProjectReference attribute value")?;
325        let Some(name) = extract_project_name_from_path(&value) else {
326            continue;
327        };
328        if attr_name == b"Include" {
329            include_name = Some(name);
330        } else {
331            update_name = Some(name);
332        }
333    }
334
335    if let Some(name) = include_name.or(update_name) {
336        projects.push(name);
337    }
338    Ok(())
339}
340
341/// Extract project name from a path string, handling both Windows and Unix separators
342/// Input: `"..\CoreLib\CoreLib.csproj"` or `"../CoreLib/CoreLib.csproj"`
343/// Output: `"CoreLib"`
344///
345/// Case-insensitive `.csproj` match so `Include=".\Foo\Foo.CSPROJ"` (mixed-
346/// case, common in older solutions and hand-written `.csproj` files) resolves
347/// the same as the canonical `Foo.csproj`. The previous `strip_suffix(".csproj")`
348/// was case-sensitive and silently dropped uppercase / mixed-case references,
349/// which fed `sort_by_dependencies` a missing edge and skipped the reverse-dep
350/// propagation in `apply_reverse_dependencies` on Windows-native repos.
351/// Mirrors the case-insensitive extension gate now applied in `visit`.
352fn extract_project_name_from_path(path_str: &str) -> Option<String> {
353    // Split by both Windows (\) and Unix (/) separators; if there is no
354    // separator, the whole `path_str` IS the filename. `rsplit_once` returns
355    // `Some((prefix, tail))` when a separator is found and `None` otherwise,
356    // so `map_or` falls back to `path_str` intact — self-documenting, no
357    // unreachable panic surface. The extension gate below is the sole
358    // actual `None` source for this function.
359    let filename = path_str
360        .rsplit_once(['\\', '/'])
361        .map_or(path_str, |(_, tail)| tail);
362
363    // Split filename on the LAST `.` so `Foo.csproj` → (`Foo`, `csproj`)
364    // and `Foo.tests.csproj` → (`Foo.tests`, `csproj`). Then gate on the
365    // extension using `eq_ignore_ascii_case` so mixed-case suffixes
366    // (`.CSPROJ`, `.CsProj`) match the same as lowercase. Preserves the
367    // previous `Option<String>` return and the "invalid extension → None"
368    // contract byte-for-byte on the canonical `.csproj` case.
369    let (stem, ext) = filename.rsplit_once('.')?;
370    ext.eq_ignore_ascii_case("csproj").then(|| stem.to_string())
371}
372
373#[async_trait]
374impl ProjectFinder for CSharpProjectFinder {
375    changepacks_core::impl_projects_hashmap_accessors!();
376
377    fn project_files(&self) -> &[&str] {
378        PROJECT_FILES
379    }
380
381    async fn visit(&mut self, path: &Path, relative_path: &Path) -> Result<()> {
382        // Cheap-checks-first ordering (mirrors the `matches_project_file`
383        // reorder in `changepacks-core`): reject on the file-extension
384        // gate BEFORE hitting `tokio::fs::metadata`, so every non-
385        // `.csproj` file in a `find_project_dirs` walk skips the async
386        // stat entirely. On a 10 000-file monorepo with zero `.csproj`
387        // entries this saves 10 000 stats per `visit` sweep.
388        //
389        // Extension match is case-insensitive so `.CSPROJ` /
390        // `.CsProj` (mixed-case, common in Windows tooling and hand-
391        // written project files) resolves the same as the canonical
392        // lowercase form. Matches the case-insensitive suffix decoder
393        // used by `extract_project_name_from_path`.
394        if !has_extension_ignore_ascii_case(path, "csproj") {
395            return Ok(());
396        }
397
398        // Already-discovered probe, shared with every other finder via
399        // `ProjectFinder::contains_project`. It stays a separate statement
400        // here (rather than folding into `should_visit_manifest`) because
401        // this finder claims an EXTENSION entry, which the name-based
402        // `matches_project_file` gate can never match — see its docs.
403        // Extension-first ordering is therefore preserved exactly.
404        if self.contains_project(path) {
405            return Ok(());
406        }
407
408        // Only after the cheap gates pass do we pay for a stat. Delegates
409        // to the shared `is_regular_file` helper in `changepacks_core`
410        // so missing paths and directories are skipped while other metadata
411        // errors are propagated to the discovery caller.
412        if !is_regular_file(path).await? {
413            return Ok(());
414        }
415
416        // Read .csproj content
417        let csproj_content = crate::read_csproj(path).await?;
418
419        let name = Self::extract_name_from_path(path);
420        // Single-pass metadata extraction — replaces the previous
421        // `extract_version(...)` + `extract_project_references(...)`
422        // pair that each constructed its own `quick_xml::Reader` and
423        // walked the identical XML bytes. Halves parse work per
424        // `.csproj` (meaningful on Unity/dotnet monorepos).
425        let (version, project_refs, publishable_by_default) =
426            Self::parse_csproj_metadata(&csproj_content)
427                .with_context(|| format!("Failed to parse C# project XML: {}", path.display()))?;
428        let path_key = path.to_path_buf();
429        let relative_path_key = relative_path.to_path_buf();
430        let mut project = Project::Package(Box::new(CSharpPackage::new_discovered(
431            name,
432            version,
433            path_key.clone(),
434            relative_path_key,
435            publishable_by_default,
436        )));
437
438        // Add ProjectReference dependencies (local project references)
439        // — `project_refs` came from the single-pass
440        // `parse_csproj_metadata` call above, so no second walk of the
441        // XML is needed here.
442        for dep in project_refs {
443            project.add_dependency(&dep);
444        }
445
446        self.projects.insert(path_key, project);
447        Ok(())
448    }
449}
450
451#[cfg(test)]
452mod tests {
453    use super::*;
454    use changepacks_core::UpdateType;
455    use changepacks_utils::sort_by_dependencies;
456    use rstest::rstest;
457    use std::fs;
458    use tempfile::TempDir;
459    use tokio::fs as async_fs;
460
461    struct VersionPolicyCase {
462        name: &'static str,
463        input: &'static str,
464        discovered: Option<&'static str>,
465        expected: &'static str,
466    }
467
468    #[tokio::test]
469    async fn test_new() {
470        let finder = CSharpProjectFinder::new();
471        assert_eq!(finder.project_files(), &[".csproj"]);
472        assert_eq!(finder.projects().len(), 0);
473    }
474
475    #[tokio::test]
476    async fn test_default() {
477        let finder = CSharpProjectFinder::default();
478        assert_eq!(finder.project_files(), &[".csproj"]);
479        assert_eq!(finder.projects().len(), 0);
480    }
481
482    #[tokio::test]
483    async fn test_visit_package() {
484        let temp_dir = TempDir::new().unwrap();
485        let csproj_path = temp_dir.path().join("TestProject.csproj");
486        fs::write(
487            &csproj_path,
488            r#"<Project Sdk="Microsoft.NET.Sdk">
489  <PropertyGroup>
490    <Version>1.0.0</Version>
491  </PropertyGroup>
492</Project>
493"#,
494        )
495        .unwrap();
496
497        let mut finder = CSharpProjectFinder::new();
498        finder
499            .visit(&csproj_path, &PathBuf::from("TestProject.csproj"))
500            .await
501            .unwrap();
502
503        assert_eq!(finder.projects().len(), 1);
504        let pkg = finder.projects()[0].expect_package();
505        assert_eq!(pkg.name(), Some("TestProject"));
506        assert_eq!(pkg.version(), Some("1.0.0"));
507
508        temp_dir.close().unwrap();
509    }
510
511    // `visit` gates on `has_extension_ignore_ascii_case(path, "csproj")`, so a
512    // mixed-case `.CSPROJ` manifest MUST be discovered exactly like the
513    // canonical lowercase form. Every other `visit` test in this module uses a
514    // lowercase fixture, leaving the case-insensitive branch — the one that
515    // actually fires on Windows-native and hand-written solutions — unexercised.
516    // This is the discovery-side counterpart to the
517    // `extract_project_name_from_path` `.CSPROJ` cases below.
518    #[tokio::test]
519    async fn test_visit_mixed_case_csproj_extension() {
520        let temp_dir = TempDir::new().unwrap();
521        let csproj_path = temp_dir.path().join("App.CSPROJ");
522        fs::write(
523            &csproj_path,
524            r#"<Project Sdk="Microsoft.NET.Sdk">
525  <PropertyGroup>
526    <Version>1.0.0</Version>
527  </PropertyGroup>
528</Project>
529"#,
530        )
531        .unwrap();
532
533        let mut finder = CSharpProjectFinder::new();
534        finder
535            .visit(&csproj_path, &PathBuf::from("App.CSPROJ"))
536            .await
537            .unwrap();
538
539        assert_eq!(finder.project_count(), 1);
540        assert_eq!(finder.projects().len(), 1);
541        let pkg = finder.projects()[0].expect_package();
542        assert_eq!(pkg.name(), Some("App"));
543        assert_eq!(pkg.version(), Some("1.0.0"));
544
545        temp_dir.close().unwrap();
546    }
547
548    #[tokio::test]
549    async fn test_root_solution_csproj_manifests_are_packages() {
550        let temp_dir = TempDir::new().unwrap();
551        let library_path = temp_dir.path().join("Library.csproj");
552        let app_path = temp_dir.path().join("App.csproj");
553        fs::write(
554            temp_dir.path().join("Product.sln"),
555            "Microsoft Visual Studio Solution File",
556        )
557        .unwrap();
558        fs::write(
559            &library_path,
560            r#"<Project Sdk="Microsoft.NET.Sdk">
561  <PropertyGroup>
562    <Version>1.2.3</Version>
563  </PropertyGroup>
564</Project>
565"#,
566        )
567        .unwrap();
568        fs::write(
569            &app_path,
570            r#"<Project Sdk="Microsoft.NET.Sdk">
571  <PropertyGroup>
572    <Version>4.5.6</Version>
573  </PropertyGroup>
574  <ItemGroup>
575    <ProjectReference Include="Library.csproj" />
576  </ItemGroup>
577</Project>
578"#,
579        )
580        .unwrap();
581
582        let mut finder = CSharpProjectFinder::new();
583        finder
584            .visit(&app_path, Path::new("App.csproj"))
585            .await
586            .unwrap();
587        finder
588            .visit(&library_path, Path::new("Library.csproj"))
589            .await
590            .unwrap();
591
592        let projects = sort_by_dependencies(finder.projects()).unwrap();
593        assert_eq!(projects.len(), 2);
594        assert!(
595            projects
596                .iter()
597                .all(|project| matches!(project, Project::Package(_))),
598            "solution-contained manifests must remain packages: {projects:?}"
599        );
600        assert_eq!(
601            projects
602                .iter()
603                .map(|project| (project.name(), project.version(), project.relative_path()))
604                .collect::<Vec<_>>(),
605            vec![
606                (Some("Library"), Some("1.2.3"), Path::new("Library.csproj"),),
607                (Some("App"), Some("4.5.6"), Path::new("App.csproj")),
608            ]
609        );
610        assert!(projects[1].dependencies().contains("Library"));
611
612        temp_dir.close().unwrap();
613    }
614
615    #[tokio::test]
616    async fn test_nested_solution_csproj_manifests_are_packages() {
617        let temp_dir = TempDir::new().unwrap();
618        let solution_dir = temp_dir.path().join("solutions").join("Product");
619        let library_path = solution_dir
620            .join("src")
621            .join("Library")
622            .join("Library.csproj");
623        let app_path = solution_dir.join("src").join("App").join("App.csproj");
624        fs::create_dir_all(library_path.parent().unwrap()).unwrap();
625        fs::create_dir_all(app_path.parent().unwrap()).unwrap();
626        fs::write(
627            solution_dir.join("Product.sln"),
628            "Microsoft Visual Studio Solution File",
629        )
630        .unwrap();
631        fs::write(
632            &library_path,
633            r#"<Project Sdk="Microsoft.NET.Sdk">
634  <PropertyGroup>
635    <Version>2.0.0</Version>
636  </PropertyGroup>
637</Project>
638"#,
639        )
640        .unwrap();
641        fs::write(
642            &app_path,
643            r#"<Project Sdk="Microsoft.NET.Sdk">
644  <PropertyGroup>
645    <Version>3.1.4</Version>
646  </PropertyGroup>
647  <ItemGroup>
648    <ProjectReference Include="..\Library\Library.csproj" />
649  </ItemGroup>
650</Project>
651"#,
652        )
653        .unwrap();
654
655        let mut finder = CSharpProjectFinder::new();
656        finder
657            .visit(&app_path, Path::new("solutions/Product/src/App/App.csproj"))
658            .await
659            .unwrap();
660        finder
661            .visit(
662                &library_path,
663                Path::new("solutions/Product/src/Library/Library.csproj"),
664            )
665            .await
666            .unwrap();
667
668        let projects = sort_by_dependencies(finder.projects()).unwrap();
669        assert_eq!(projects.len(), 2);
670        assert!(
671            projects
672                .iter()
673                .all(|project| matches!(project, Project::Package(_))),
674            "nested solution manifests must remain packages: {projects:?}"
675        );
676        assert_eq!(
677            projects
678                .iter()
679                .map(|project| (project.name(), project.version(), project.relative_path()))
680                .collect::<Vec<_>>(),
681            vec![
682                (
683                    Some("Library"),
684                    Some("2.0.0"),
685                    Path::new("solutions/Product/src/Library/Library.csproj"),
686                ),
687                (
688                    Some("App"),
689                    Some("3.1.4"),
690                    Path::new("solutions/Product/src/App/App.csproj"),
691                ),
692            ]
693        );
694        assert!(projects[1].dependencies().contains("Library"));
695
696        temp_dir.close().unwrap();
697    }
698
699    #[tokio::test]
700    async fn test_visit_package_reads_version_from_cdata() {
701        let temp_dir = TempDir::new().unwrap();
702        let csproj_path = temp_dir.path().join("TestProject.csproj");
703        fs::write(
704            &csproj_path,
705            r#"<Project Sdk="Microsoft.NET.Sdk">
706  <PropertyGroup>
707    <Version><![CDATA[1.2.3]]></Version>
708  </PropertyGroup>
709</Project>
710"#,
711        )
712        .unwrap();
713
714        let mut finder = CSharpProjectFinder::new();
715        finder
716            .visit(&csproj_path, &PathBuf::from("TestProject.csproj"))
717            .await
718            .unwrap();
719
720        assert_eq!(
721            finder.projects()[0].expect_package().version(),
722            Some("1.2.3")
723        );
724
725        temp_dir.close().unwrap();
726    }
727
728    #[tokio::test]
729    async fn test_visit_package_ignores_sln_directory() {
730        let temp_dir = TempDir::new().unwrap();
731        let csproj_path = temp_dir.path().join("TestProject.csproj");
732        fs::create_dir(temp_dir.path().join("Fake.sln")).unwrap();
733        fs::write(
734            &csproj_path,
735            r#"<Project Sdk="Microsoft.NET.Sdk">
736  <PropertyGroup>
737    <Version>1.0.0</Version>
738  </PropertyGroup>
739</Project>
740"#,
741        )
742        .unwrap();
743
744        let mut finder = CSharpProjectFinder::new();
745        finder
746            .visit(&csproj_path, &PathBuf::from("TestProject.csproj"))
747            .await
748            .unwrap();
749
750        let projects = finder.projects();
751        assert_eq!(projects.len(), 1);
752        assert!(
753            matches!(projects[0], Project::Package(_)),
754            "expected Package when only a .sln directory exists, got {:?}",
755            projects[0]
756        );
757
758        temp_dir.close().unwrap();
759    }
760
761    #[tokio::test]
762    async fn test_visit_package_without_version() {
763        let temp_dir = TempDir::new().unwrap();
764        let csproj_path = temp_dir.path().join("TestProject.csproj");
765        fs::write(
766            &csproj_path,
767            r#"<Project Sdk="Microsoft.NET.Sdk">
768  <PropertyGroup>
769    <OutputType>Exe</OutputType>
770  </PropertyGroup>
771</Project>
772"#,
773        )
774        .unwrap();
775
776        let mut finder = CSharpProjectFinder::new();
777        finder
778            .visit(&csproj_path, &PathBuf::from("TestProject.csproj"))
779            .await
780            .unwrap();
781
782        assert_eq!(finder.projects().len(), 1);
783        let pkg = finder.projects()[0].expect_package();
784        assert_eq!(pkg.name(), Some("TestProject"));
785        assert_eq!(pkg.version(), None);
786
787        temp_dir.close().unwrap();
788    }
789
790    #[tokio::test]
791    async fn test_visit_non_csproj_file() {
792        let temp_dir = TempDir::new().unwrap();
793        let other_file = temp_dir.path().join("other.xml");
794        fs::write(&other_file, r"<root>content</root>").unwrap();
795
796        let mut finder = CSharpProjectFinder::new();
797        finder
798            .visit(&other_file, &PathBuf::from("other.xml"))
799            .await
800            .unwrap();
801
802        assert_eq!(finder.projects().len(), 0);
803
804        temp_dir.close().unwrap();
805    }
806
807    #[tokio::test]
808    async fn test_visit_directory() {
809        let temp_dir = TempDir::new().unwrap();
810        let dir_path = temp_dir.path().join("some_dir");
811        fs::create_dir_all(&dir_path).unwrap();
812
813        let mut finder = CSharpProjectFinder::new();
814        finder
815            .visit(&dir_path, &PathBuf::from("some_dir"))
816            .await
817            .unwrap();
818
819        assert_eq!(finder.projects().len(), 0);
820
821        temp_dir.close().unwrap();
822    }
823
824    #[tokio::test]
825    async fn test_visit_duplicate() {
826        let temp_dir = TempDir::new().unwrap();
827        let csproj_path = temp_dir.path().join("TestProject.csproj");
828        fs::write(
829            &csproj_path,
830            r#"<Project Sdk="Microsoft.NET.Sdk">
831  <PropertyGroup>
832    <Version>1.0.0</Version>
833  </PropertyGroup>
834</Project>
835"#,
836        )
837        .unwrap();
838
839        let mut finder = CSharpProjectFinder::new();
840        finder
841            .visit(&csproj_path, &PathBuf::from("TestProject.csproj"))
842            .await
843            .unwrap();
844        finder
845            .visit(&csproj_path, &PathBuf::from("TestProject.csproj"))
846            .await
847            .unwrap();
848
849        assert_eq!(finder.projects().len(), 1);
850
851        temp_dir.close().unwrap();
852    }
853
854    #[tokio::test]
855    async fn test_visit_multiple_packages() {
856        let temp_dir = TempDir::new().unwrap();
857        let csproj1 = temp_dir.path().join("Project1").join("Project1.csproj");
858        let csproj2 = temp_dir.path().join("Project2").join("Project2.csproj");
859        fs::create_dir_all(csproj1.parent().unwrap()).unwrap();
860        fs::create_dir_all(csproj2.parent().unwrap()).unwrap();
861        fs::write(
862            &csproj1,
863            r#"<Project Sdk="Microsoft.NET.Sdk">
864  <PropertyGroup>
865    <Version>1.0.0</Version>
866  </PropertyGroup>
867</Project>
868"#,
869        )
870        .unwrap();
871        fs::write(
872            &csproj2,
873            r#"<Project Sdk="Microsoft.NET.Sdk">
874  <PropertyGroup>
875    <Version>2.0.0</Version>
876  </PropertyGroup>
877</Project>
878"#,
879        )
880        .unwrap();
881
882        let mut finder = CSharpProjectFinder::new();
883        finder
884            .visit(&csproj1, &PathBuf::from("Project1/Project1.csproj"))
885            .await
886            .unwrap();
887        finder
888            .visit(&csproj2, &PathBuf::from("Project2/Project2.csproj"))
889            .await
890            .unwrap();
891
892        assert_eq!(finder.projects().len(), 2);
893
894        temp_dir.close().unwrap();
895    }
896
897    #[tokio::test]
898    async fn test_projects_mut() {
899        let temp_dir = TempDir::new().unwrap();
900        let csproj_path = temp_dir.path().join("TestProject.csproj");
901        fs::write(
902            &csproj_path,
903            r#"<Project Sdk="Microsoft.NET.Sdk">
904  <PropertyGroup>
905    <Version>1.0.0</Version>
906  </PropertyGroup>
907</Project>
908"#,
909        )
910        .unwrap();
911
912        let mut finder = CSharpProjectFinder::new();
913        finder
914            .visit(&csproj_path, &PathBuf::from("TestProject.csproj"))
915            .await
916            .unwrap();
917
918        let mut projects = finder.projects_mut();
919        assert_eq!(projects.len(), 1);
920        let pkg = projects[0].expect_package_mut();
921        assert!(!pkg.is_changed());
922        pkg.set_changed(true);
923        assert!(pkg.is_changed());
924
925        temp_dir.close().unwrap();
926    }
927
928    #[tokio::test]
929    async fn test_visit_package_with_project_references() {
930        let temp_dir = TempDir::new().unwrap();
931        let csproj_path = temp_dir.path().join("TestProject.csproj");
932        fs::write(
933            &csproj_path,
934            r#"<Project Sdk="Microsoft.NET.Sdk">
935  <PropertyGroup>
936    <Version>1.0.0</Version>
937  </PropertyGroup>
938  <ItemGroup>
939    <PackageReference Include="Newtonsoft.Json" Version="13.0.1" />
940  </ItemGroup>
941  <ItemGroup>
942    <ProjectReference Include="..\CoreLib\CoreLib.csproj" />
943    <ProjectReference Include="..\Utils\Utils.csproj" />
944  </ItemGroup>
945</Project>
946"#,
947        )
948        .unwrap();
949
950        let mut finder = CSharpProjectFinder::new();
951        finder
952            .visit(&csproj_path, &PathBuf::from("TestProject.csproj"))
953            .await
954            .unwrap();
955
956        let projects = finder.projects();
957        assert_eq!(projects.len(), 1);
958        let pkg = projects[0].expect_package();
959        assert_eq!(pkg.name(), Some("TestProject"));
960        let deps = pkg.dependencies();
961        // Only ProjectReferences are tracked (not PackageReferences)
962        assert_eq!(deps.len(), 2);
963        assert!(deps.contains("CoreLib"));
964        assert!(deps.contains("Utils"));
965
966        temp_dir.close().unwrap();
967    }
968
969    // Fixtures for `test_extract_version` — one per XML shape the finder
970    // must handle. Named consts keep each rstest `#[case]` line short and
971    // self-describing.
972
973    const XML_STANDARD_VERSION: &str = r#"<Project Sdk="Microsoft.NET.Sdk">
974  <PropertyGroup>
975    <Version>1.2.3</Version>
976  </PropertyGroup>
977</Project>"#;
978
979    const XML_NO_VERSION_ELEMENT: &str = r#"<Project Sdk="Microsoft.NET.Sdk">
980  <PropertyGroup>
981    <OutputType>Exe</OutputType>
982  </PropertyGroup>
983</Project>"#;
984
985    const XML_VERSION_WITH_END_TAG_WHITESPACE: &str = r"<Project><PropertyGroup><Version>
986   1.2.3
987   </Version></PropertyGroup></Project>";
988
989    const XML_EMPTY_VERSION: &str =
990        r"<Project><PropertyGroup><Version>  </Version></PropertyGroup></Project>";
991
992    // Self-closing tags like <IsPackable /> generate Event::Empty, which
993    // exercises the wildcard `_ => {}` arm in extract_version.
994    const XML_VERSION_AFTER_EMPTY_ELEMENT: &str = r#"<Project Sdk="Microsoft.NET.Sdk">
995  <PropertyGroup>
996    <IsPackable />
997    <Version>3.2.1</Version>
998  </PropertyGroup>
999</Project>"#;
1000
1001    // XML comments generate Event::Comment, exercising the wildcard arm.
1002    const XML_VERSION_AFTER_COMMENT: &str = r"<Project>
1003  <PropertyGroup>
1004    <!-- version follows -->
1005    <Version>4.0.0</Version>
1006  </PropertyGroup>
1007</Project>";
1008
1009    // A decimal character reference splits the value into
1010    // Text("1.2") / GeneralRef("#46") / Text("3"). Before the GeneralRef arm
1011    // existed, only the first fragment was kept and the version read as `1.2`.
1012    const XML_VERSION_WITH_DECIMAL_CHAR_REF: &str =
1013        r"<Project><PropertyGroup><Version>1.2&#46;3</Version></PropertyGroup></Project>";
1014
1015    // Same split, hexadecimal spelling of the same code point.
1016    const XML_VERSION_WITH_HEX_CHAR_REF: &str =
1017        r"<Project><PropertyGroup><Version>1.2&#x2E;3</Version></PropertyGroup></Project>";
1018
1019    // A predefined XML entity is a GeneralRef too, and resolves to `&`.
1020    const XML_VERSION_WITH_PREDEFINED_ENTITY: &str =
1021        r"<Project><PropertyGroup><Version>1.0.0-a&amp;b</Version></PropertyGroup></Project>";
1022
1023    // The reference is the WHOLE value, so the pre-fix parser saw no leading
1024    // text fragment at all and returned None.
1025    const XML_VERSION_IS_ONLY_A_CHAR_REF: &str =
1026        r"<Project><PropertyGroup><Version>&#49;&#46;&#48;</Version></PropertyGroup></Project>";
1027
1028    // Two `<Version>` elements in the same eligible group: the first still wins.
1029    const XML_DUPLICATE_VERSION: &str = r"<Project><PropertyGroup><Version>1.0.0</Version><Version>2.0.0</Version></PropertyGroup></Project>";
1030
1031    // Whitespace-only first `<Version>` must still leave the value unset, so a
1032    // later populated element wins — the accumulator must not "claim" it.
1033    const XML_EMPTY_THEN_POPULATED_VERSION: &str = r"<Project><PropertyGroup><Version>  </Version><Version>2.0.0</Version></PropertyGroup></Project>";
1034
1035    // CDATA content is a separate event class and must accumulate the same way.
1036    const XML_CDATA_VERSION: &str =
1037        r"<Project><PropertyGroup><Version><![CDATA[1.2.3]]></Version></PropertyGroup></Project>";
1038
1039    // A reference inside a conditional (non-eligible) group is not part of any
1040    // candidate version and must be ignored, not accumulated.
1041    const XML_CONDITIONAL_VERSION_WITH_CHAR_REF: &str = r#"<Project><PropertyGroup Condition="'$(Configuration)' == 'Release'"><Version>9.9&#46;9</Version></PropertyGroup><PropertyGroup><Version>1.2.3</Version></PropertyGroup></Project>"#;
1042
1043    // A `<PropertyGroup>` nested inside a `<Target>` sits one level too deep to
1044    // be an unconditional project property group, so its `<Version>` is a
1045    // build-time local and must never become the package version — the
1046    // top-level group's value wins. The WRITE path already pins this shape
1047    // (the `target-local` fixture in `test_update_version_in_xml_cases`), but
1048    // no read-path case placed a `<Version>` outside an eligible group's direct
1049    // children, so nothing failed if the eligibility guard on `in_version` were
1050    // dropped.
1051    const XML_TARGET_LOCAL_VERSION: &str = r#"<Project>
1052  <Target Name="Build">
1053    <PropertyGroup>
1054      <Version>7.0.0</Version>
1055    </PropertyGroup>
1056  </Target>
1057  <PropertyGroup>
1058    <Version>1.2.3</Version>
1059  </PropertyGroup>
1060</Project>"#;
1061
1062    // The other half of the same rule, this time with an eligible group OPEN:
1063    // MSBuild property values may themselves contain XML, so `<Version>` two
1064    // levels below the group is part of the `<PackageMetadata>` property's
1065    // literal value, not a property of its own. Only the DIRECT child counts.
1066    // This is the case that pins the depth comparison in
1067    // `is_eligible_property_child` rather than just its `None` arm.
1068    const XML_VERSION_NESTED_BELOW_PROPERTY: &str = r"<Project>
1069  <PropertyGroup>
1070    <PackageMetadata><Version>9.9.9</Version></PackageMetadata>
1071    <Version>1.2.3</Version>
1072  </PropertyGroup>
1073</Project>";
1074
1075    #[rstest]
1076    // Standard `<Version>` inside `<PropertyGroup>`.
1077    #[case(XML_STANDARD_VERSION, Some("1.2.3"))]
1078    // No `<Version>` element at all → None.
1079    #[case(XML_NO_VERSION_ELEMENT, None)]
1080    // Whitespace/newlines around the version value are trimmed.
1081    #[case(XML_VERSION_WITH_END_TAG_WHITESPACE, Some("1.2.3"))]
1082    // Whitespace-only value returns None (empty after trim).
1083    #[case(XML_EMPTY_VERSION, None)]
1084    // Version element after a self-closing sibling (Event::Empty path).
1085    #[case(XML_VERSION_AFTER_EMPTY_ELEMENT, Some("3.2.1"))]
1086    // Version element after an XML comment (Event::Comment path).
1087    #[case(XML_VERSION_AFTER_COMMENT, Some("4.0.0"))]
1088    // Regression: a decimal character reference must not truncate the version.
1089    #[case(XML_VERSION_WITH_DECIMAL_CHAR_REF, Some("1.2.3"))]
1090    // Same for the hexadecimal spelling.
1091    #[case(XML_VERSION_WITH_HEX_CHAR_REF, Some("1.2.3"))]
1092    // Predefined entities resolve to their replacement text.
1093    #[case(XML_VERSION_WITH_PREDEFINED_ENTITY, Some("1.0.0-a&b"))]
1094    // A value made up entirely of references used to parse as None.
1095    #[case(XML_VERSION_IS_ONLY_A_CHAR_REF, Some("1.0"))]
1096    // Unchanged: the first populated `<Version>` wins.
1097    #[case(XML_DUPLICATE_VERSION, Some("1.0.0"))]
1098    // Unchanged: a whitespace-only element does not claim the value.
1099    #[case(XML_EMPTY_THEN_POPULATED_VERSION, Some("2.0.0"))]
1100    // Unchanged: CDATA content still yields the version.
1101    #[case(XML_CDATA_VERSION, Some("1.2.3"))]
1102    // Unchanged: a conditional group's `<Version>` is ignored, references included.
1103    #[case(XML_CONDITIONAL_VERSION_WITH_CHAR_REF, Some("1.2.3"))]
1104    // A `<Target>`-local `<Version>` is a build-time property, not the package
1105    // version: the top-level group still wins.
1106    #[case(XML_TARGET_LOCAL_VERSION, Some("1.2.3"))]
1107    // A `<Version>` nested inside another property's value is not a direct
1108    // child of the eligible group, so it loses to the real one.
1109    #[case(XML_VERSION_NESTED_BELOW_PROPERTY, Some("1.2.3"))]
1110    fn test_extract_version(#[case] content: &str, #[case] expected: Option<&str>) {
1111        assert_eq!(
1112            CSharpProjectFinder::parse_csproj_metadata(content)
1113                .unwrap()
1114                .0,
1115            expected.map(std::string::ToString::to_string)
1116        );
1117    }
1118
1119    // A DTD-declared entity cannot be expanded by this parser. Silently
1120    // dropping it would emit a wrong version number (and then a wrong bump),
1121    // so it must fail loudly and name the offending entity.
1122    #[test]
1123    fn test_unresolvable_version_entity_returns_contextual_error() {
1124        let content =
1125            r"<Project><PropertyGroup><Version>1.0&mystery;0</Version></PropertyGroup></Project>";
1126
1127        let error = CSharpProjectFinder::parse_csproj_metadata(content).unwrap_err();
1128
1129        assert!(
1130            format!("{error:#}").contains("Unresolvable entity reference `&mystery;`"),
1131            "unexpected error: {error:#}"
1132        );
1133    }
1134
1135    // The other failing branch of `append_resolved_reference`: the reference IS
1136    // numeric, so `resolve_char_ref` owns it, but `D800` is a UTF-16 surrogate
1137    // and therefore not a Unicode scalar value, so quick-xml cannot turn it
1138    // into a `char`. Without the `.context(...)` on `resolve_char_ref` the
1139    // failure would surface as quick-xml's bare escape error with no hint that
1140    // a `.csproj` `<Version>` produced it, so the literal context string is the
1141    // assertion.
1142    #[test]
1143    fn test_out_of_range_version_char_ref_returns_contextual_error() {
1144        let content =
1145            r"<Project><PropertyGroup><Version>1.0&#xD800;0</Version></PropertyGroup></Project>";
1146
1147        let error = CSharpProjectFinder::parse_csproj_metadata(content).unwrap_err();
1148
1149        assert!(
1150            format!("{error:#}").contains("Failed to resolve .csproj character reference"),
1151            "unexpected error: {error:#}"
1152        );
1153    }
1154
1155    // The GeneralRef arm is scoped to `<Version>`: an unresolvable entity
1156    // anywhere else in the manifest stays a pass-through, exactly as before,
1157    // and must neither fail the parse nor leak into the version.
1158    #[test]
1159    fn test_entity_reference_outside_version_is_ignored() {
1160        let content = r"<Project><PropertyGroup><Description>Hello &custom; World</Description><Version>1.2.3</Version><IsPackable>fa&#108;se</IsPackable></PropertyGroup></Project>";
1161
1162        let (version, refs, publishable_by_default) =
1163            CSharpProjectFinder::parse_csproj_metadata(content).unwrap();
1164
1165        assert_eq!(version.as_deref(), Some("1.2.3"));
1166        assert!(refs.is_empty());
1167        // `<IsPackable>` keeps its unchanged per-fragment semantics: no
1168        // fragment equals "false" on its own, so the default stands.
1169        assert!(publishable_by_default);
1170    }
1171
1172    #[test]
1173    fn test_parse_csproj_metadata_is_packable_publishability() {
1174        let cases = [
1175            (
1176                "false",
1177                "<Project><PropertyGroup><IsPackable>false</IsPackable></PropertyGroup></Project>",
1178                false,
1179            ),
1180            (
1181                "trimmed mixed case false",
1182                "<Project><PropertyGroup><IsPackable>\n False\t </IsPackable></PropertyGroup></Project>",
1183                false,
1184            ),
1185            (
1186                "true",
1187                "<Project><PropertyGroup><IsPackable>true</IsPackable></PropertyGroup></Project>",
1188                true,
1189            ),
1190            (
1191                "missing",
1192                "<Project><PropertyGroup><Version>1.0.0</Version></PropertyGroup></Project>",
1193                true,
1194            ),
1195            (
1196                "self closing",
1197                "<Project><PropertyGroup><IsPackable /></PropertyGroup></Project>",
1198                true,
1199            ),
1200            (
1201                "conditional property group",
1202                r#"<Project><PropertyGroup Condition="'$(Configuration)' == 'Release'"><IsPackable>false</IsPackable></PropertyGroup></Project>"#,
1203                true,
1204            ),
1205            (
1206                "computed",
1207                "<Project><PropertyGroup><IsPackable>$(Packable)</IsPackable></PropertyGroup></Project>",
1208                true,
1209            ),
1210            (
1211                "nested property group",
1212                "<Project><Target><PropertyGroup><IsPackable>false</IsPackable></PropertyGroup></Target></Project>",
1213                true,
1214            ),
1215        ];
1216
1217        for (label, content, expected) in cases {
1218            let publishable_by_default = CSharpProjectFinder::parse_csproj_metadata(content)
1219                .unwrap()
1220                .2;
1221            assert_eq!(publishable_by_default, expected, "{label}");
1222        }
1223    }
1224
1225    #[test]
1226    fn test_parse_csproj_metadata_scopes_is_packable_like_version() {
1227        let cases = [
1228            (
1229                "text",
1230                "<Root><Project><PropertyGroup><Version>1.2.3</Version><IsPackable>false</IsPackable></PropertyGroup></Project></Root>",
1231            ),
1232            (
1233                "cdata",
1234                "<Root><Project><PropertyGroup><Version>1.2.3</Version><IsPackable><![CDATA[false]]></IsPackable></PropertyGroup></Project></Root>",
1235            ),
1236        ];
1237
1238        for (label, content) in cases {
1239            let (version, _, publishable_by_default) =
1240                CSharpProjectFinder::parse_csproj_metadata(content).unwrap();
1241            assert_eq!(version.as_deref(), Some("1.2.3"), "{label}");
1242            assert!(!publishable_by_default, "{label}");
1243        }
1244    }
1245
1246    #[tokio::test]
1247    async fn test_visit_package_carries_is_packable_false_metadata() {
1248        let temp_dir = TempDir::new().unwrap();
1249        let csproj_path = temp_dir.path().join("Private.csproj");
1250        fs::write(
1251            &csproj_path,
1252            "<Project><PropertyGroup><IsPackable>false</IsPackable></PropertyGroup></Project>",
1253        )
1254        .unwrap();
1255
1256        let mut finder = CSharpProjectFinder::new();
1257        finder
1258            .visit(&csproj_path, Path::new("Private.csproj"))
1259            .await
1260            .unwrap();
1261
1262        let projects = finder.projects();
1263        assert_eq!(projects.len(), 1);
1264        assert!(!projects[0].is_publishable_by_default());
1265    }
1266
1267    // Happy-path anchor for the decode-error propagation change: a
1268    // well-formed manifest whose `<Version>` arrives as plain text and whose
1269    // `<IsPackable>` arrives as CDATA must still parse exactly as before.
1270    // Both values now flow through a `?` at their call sites, so this pins
1271    // that the added error path did not disturb the success path.
1272    #[test]
1273    fn test_parse_csproj_metadata_decodes_text_and_cdata_on_happy_path() {
1274        let content = "<Project><PropertyGroup><Version>1.2.3</Version><IsPackable><![CDATA[false]]></IsPackable></PropertyGroup></Project>";
1275
1276        let (version, refs, publishable_by_default) =
1277            CSharpProjectFinder::parse_csproj_metadata(content).unwrap();
1278
1279        assert_eq!(version.as_deref(), Some("1.2.3"));
1280        assert!(refs.is_empty());
1281        assert!(!publishable_by_default);
1282    }
1283
1284    // A failed `BytesText::decode` / `BytesCData::decode` must surface as a
1285    // contextual error rather than silently yielding `version = None` and
1286    // `publishable_by_default = true` — the latter makes changepacks treat a
1287    // versioned project as unversioned and bump it from `0.0.0`. The helper is
1288    // exercised directly because `Reader::from_str` can never produce this
1289    // error: a `&str` is valid UTF-8 by construction.
1290    #[test]
1291    fn test_record_decoded_csproj_text_propagates_decode_error() {
1292        // Half of a two-byte UTF-8 sequence — invalid on its own. Sliced at
1293        // runtime so the bytes are not a compile-time-known literal.
1294        let truncated = &"é".as_bytes()[..1];
1295        let utf8_error = std::str::from_utf8(truncated).unwrap_err();
1296        // The accumulator is a `String` (fragments are appended and committed
1297        // on `</Version>`); "nothing recorded" is therefore an empty buffer
1298        // instead of `None`, which still means `version = None` at the
1299        // `parse_csproj_metadata` boundary.
1300        let mut version_text = String::new();
1301        let mut publishable_by_default = true;
1302
1303        let error = super::record_decoded_csproj_text(
1304            Err(quick_xml::encoding::EncodingError::from(utf8_error)),
1305            true,
1306            true,
1307            &mut version_text,
1308            &mut publishable_by_default,
1309        )
1310        .unwrap_err();
1311
1312        assert!(
1313            format!("{error:#}").contains("Failed to decode .csproj text node"),
1314            "context missing from chain: {error:#}"
1315        );
1316        assert!(
1317            format!("{error:#}").contains("cannot decode input using UTF-8"),
1318            "root cause dropped from chain: {error:#}"
1319        );
1320        assert!(version_text.is_empty());
1321        assert!(publishable_by_default);
1322    }
1323
1324    #[test]
1325    fn test_extract_version_malformed_xml() {
1326        let content = "<Project><PropertyGroup><Version>1.0.0";
1327        assert!(CSharpProjectFinder::parse_csproj_metadata(content).is_err());
1328    }
1329
1330    // The mirror image of `test_extract_version_malformed_xml`: that one
1331    // pins truncation (a start tag that never closes), this one pins the
1332    // opposite imbalance — a document that opens with a CLOSING tag and so
1333    // has no matching start. The two failure shapes take different exits
1334    // out of `parse_csproj_metadata` (EOF-with-open-depth vs. the
1335    // `Event::End` arm / the reader's own end-tag check), so truncation
1336    // coverage alone does not guard this one. Asserted as `is_err()` only,
1337    // deliberately: which of the two exits fires is a quick-xml
1338    // configuration detail, and pinning its wording here would make the
1339    // test fail on a dependency bump that changes nothing we care about.
1340    #[test]
1341    fn test_parse_csproj_metadata_unmatched_end_tag_is_err() {
1342        assert!(CSharpProjectFinder::parse_csproj_metadata("</Project>").is_err());
1343    }
1344
1345    // Same input at the `visit` level, mirroring
1346    // `test_visit_malformed_xml_returns_path_context`: whichever exit the
1347    // unmatched end tag takes, `visit` must still wrap it with the
1348    // manifest path so the discovery caller can tell WHICH `.csproj` in a
1349    // monorepo is broken. Asserts the context message and the path, not the
1350    // root-cause wording, for the reason given on the unit test above.
1351    #[tokio::test]
1352    async fn test_visit_unmatched_end_tag_returns_path_context() {
1353        let temp_dir = TempDir::new().unwrap();
1354        let csproj_path = temp_dir.path().join("StrayEnd.csproj");
1355        fs::write(&csproj_path, "</Project>").unwrap();
1356
1357        let mut finder = CSharpProjectFinder::new();
1358        let error = finder
1359            .visit(&csproj_path, &PathBuf::from("StrayEnd.csproj"))
1360            .await
1361            .unwrap_err();
1362
1363        let chain = format!("{error:#}");
1364        assert!(
1365            chain.contains("Failed to parse C# project XML"),
1366            "context missing from chain: {chain}"
1367        );
1368        assert!(
1369            chain.contains(&csproj_path.display().to_string()),
1370            "offending manifest path missing from chain: {chain}"
1371        );
1372        assert_eq!(
1373            finder.projects().len(),
1374            0,
1375            "a manifest that failed to parse must not be registered"
1376        );
1377
1378        temp_dir.close().unwrap();
1379    }
1380
1381    // Pins the `with_context` wrapper `visit` puts around
1382    // `parse_csproj_metadata`: the top-level message must name the failure
1383    // and the manifest path, AND the underlying parser error must survive
1384    // in the anyhow chain. The chain assertion is what distinguishes a
1385    // context wrapper from a `map_err` that discards the root cause — the
1386    // extractor-level `test_extract_version_malformed_xml` only proves
1387    // `parse_csproj_metadata` returns `Err`, and asserting on
1388    // `error.to_string()` alone would still pass if `visit` replaced the
1389    // source instead of wrapping it.
1390    #[tokio::test]
1391    async fn test_visit_malformed_xml_returns_path_context() {
1392        let temp_dir = TempDir::new().unwrap();
1393        let csproj_path = temp_dir.path().join("Broken.csproj");
1394        fs::write(&csproj_path, "<Project><PropertyGroup><Version>1.0.0").unwrap();
1395
1396        let mut finder = CSharpProjectFinder::new();
1397        let error = finder
1398            .visit(&csproj_path, &PathBuf::from("Broken.csproj"))
1399            .await
1400            .unwrap_err();
1401        let message = error.to_string();
1402        assert!(message.contains("Failed to parse C# project XML"));
1403        assert!(message.contains("Broken.csproj"));
1404
1405        // Full alternate-Display chain: context + every source below it.
1406        let chain = format!("{error:#}");
1407        assert!(
1408            chain.contains("Failed to parse C# project XML"),
1409            "context missing from chain: {chain}"
1410        );
1411        assert!(
1412            chain.contains(&csproj_path.display().to_string()),
1413            "absolute manifest path missing from chain: {chain}"
1414        );
1415        assert!(
1416            chain.contains("unexpected end of XML document"),
1417            "root cause dropped from chain: {chain}"
1418        );
1419        assert_eq!(
1420            error.root_cause().to_string(),
1421            "unexpected end of XML document",
1422            "context must wrap the parser error, not replace it: {chain}"
1423        );
1424
1425        temp_dir.close().unwrap();
1426    }
1427
1428    #[test]
1429    fn test_extract_project_references() {
1430        let content = r#"<Project Sdk="Microsoft.NET.Sdk">
1431  <ItemGroup>
1432    <ProjectReference Include="..\CoreLib\CoreLib.csproj" />
1433    <ProjectReference Include="..\Utils\Utils.csproj" />
1434    <ProjectReference Update="..\Updated\Updated.csproj" />
1435  </ItemGroup>
1436</Project>"#;
1437        let refs = CSharpProjectFinder::parse_csproj_metadata(content)
1438            .unwrap()
1439            .1;
1440        assert_eq!(refs.len(), 3);
1441        assert!(refs.contains(&"CoreLib".to_string()));
1442        assert!(refs.contains(&"Utils".to_string()));
1443        assert!(refs.contains(&"Updated".to_string()));
1444    }
1445
1446    #[test]
1447    fn test_extract_project_references_prefers_include_over_update() {
1448        let content = r#"<Project Sdk="Microsoft.NET.Sdk">
1449  <ItemGroup>
1450    <ProjectReference Include="..\CoreLib\CoreLib.csproj" Update="..\Fallback\Fallback.csproj" />
1451  </ItemGroup>
1452</Project>"#;
1453        let refs = CSharpProjectFinder::parse_csproj_metadata(content)
1454            .unwrap()
1455            .1;
1456        assert_eq!(refs, vec!["CoreLib".to_string()]);
1457    }
1458
1459    #[test]
1460    fn test_extract_project_references_from_start_and_empty_elements() {
1461        let content = r#"<Project>
1462  <ItemGroup>
1463    <ProjectReference Include="..\Started\Started.csproj"></ProjectReference>
1464    <ProjectReference Include="..\Empty\Empty.csproj" />
1465  </ItemGroup>
1466</Project>"#;
1467
1468        let refs = CSharpProjectFinder::parse_csproj_metadata(content)
1469            .unwrap()
1470            .1;
1471
1472        assert_eq!(refs, vec!["Started".to_string(), "Empty".to_string()]);
1473    }
1474
1475    // Attributes other than `Include` / `Update` must be skipped outright:
1476    // `PrivateAssets` / `OutputItemType` are routine on a `ProjectReference`,
1477    // and neither may be mistaken for a project path. The reference is still
1478    // collected from its `Include`.
1479    #[test]
1480    fn test_extract_project_references_skips_unrelated_attributes() {
1481        let content = r#"<Project Sdk="Microsoft.NET.Sdk">
1482  <ItemGroup>
1483    <ProjectReference OutputItemType="Analyzer" Include="..\CoreLib\CoreLib.csproj" PrivateAssets="all" />
1484  </ItemGroup>
1485</Project>"#;
1486
1487        let refs = CSharpProjectFinder::parse_csproj_metadata(content)
1488            .unwrap()
1489            .1;
1490
1491        assert_eq!(refs, vec!["CoreLib".to_string()]);
1492    }
1493
1494    // An `Include` / `Update` value that is not a `.csproj` yields `None` from
1495    // `extract_project_name_from_path`, so the attribute contributes no name
1496    // and the element as a whole records nothing. `.vbproj` / `.fsproj`
1497    // siblings are real MSBuild references this finder does not manage, so
1498    // they must not leak into the dependency graph under a bogus name.
1499    #[test]
1500    fn test_extract_project_references_ignores_non_csproj_reference_paths() {
1501        let content = r#"<Project Sdk="Microsoft.NET.Sdk">
1502  <ItemGroup>
1503    <ProjectReference Include="..\Legacy\Legacy.vbproj" />
1504    <ProjectReference Update="..\Native\Native.vcxproj" />
1505    <ProjectReference Include="..\CoreLib\CoreLib.csproj" />
1506  </ItemGroup>
1507</Project>"#;
1508
1509        let refs = CSharpProjectFinder::parse_csproj_metadata(content)
1510            .unwrap()
1511            .1;
1512
1513        assert_eq!(refs, vec!["CoreLib".to_string()]);
1514    }
1515
1516    #[test]
1517    fn test_project_reference_malformed_attribute_returns_contextual_error() {
1518        let content = r#"<Project><ItemGroup><ProjectReference Include="Valid.csproj" Broken /></ItemGroup></Project>"#;
1519
1520        let error = CSharpProjectFinder::parse_csproj_metadata(content).unwrap_err();
1521
1522        assert!(
1523            format!("{error:#}").contains("Failed to parse ProjectReference attribute"),
1524            "unexpected error: {error:#}"
1525        );
1526    }
1527
1528    #[test]
1529    fn test_project_reference_malformed_entity_returns_contextual_error() {
1530        let content = r#"<Project><ItemGroup><ProjectReference Include="..\Bad&unknown;\Bad.csproj" /></ItemGroup></Project>"#;
1531
1532        let error = CSharpProjectFinder::parse_csproj_metadata(content).unwrap_err();
1533
1534        assert!(
1535            format!("{error:#}").contains("Failed to normalize ProjectReference attribute value"),
1536            "unexpected error: {error:#}"
1537        );
1538    }
1539
1540    // The unified `parse_csproj_metadata` MUST return both the version and
1541    // the `ProjectReference` list in a single walk. This test fixes that
1542    // contract on a fixture combining both elements (plus a
1543    // `PackageReference` decoy that must be ignored) so any future refactor
1544    // that reintroduces a second XML walk — or accidentally drops one of
1545    // the outputs — trips a failing test immediately. Serves as the
1546    // regression anchor for the single-pass metadata-parse consolidation.
1547    #[test]
1548    fn test_parse_csproj_metadata_returns_version_and_refs_in_one_pass() {
1549        let content = r#"<Project Sdk="Microsoft.NET.Sdk">
1550  <PropertyGroup>
1551    <Version>1.5.0</Version>
1552  </PropertyGroup>
1553  <ItemGroup>
1554    <PackageReference Include="Newtonsoft.Json" Version="13.0.1" />
1555  </ItemGroup>
1556  <ItemGroup>
1557    <ProjectReference Include="..\CoreLib\CoreLib.csproj" />
1558    <ProjectReference Include="..\Utils\Utils.csproj" />
1559  </ItemGroup>
1560</Project>"#;
1561        let (version, refs, publishable_by_default) =
1562            CSharpProjectFinder::parse_csproj_metadata(content).unwrap();
1563        assert_eq!(version, Some("1.5.0".to_string()));
1564        assert_eq!(refs.len(), 2);
1565        assert!(refs.contains(&"CoreLib".to_string()));
1566        assert!(refs.contains(&"Utils".to_string()));
1567        assert!(publishable_by_default);
1568    }
1569
1570    #[tokio::test]
1571    async fn test_discovery_and_rewrite_use_unconditional_top_level_property_groups() -> Result<()>
1572    {
1573        let cases = [
1574            VersionPolicyCase {
1575                name: "target-local",
1576                input: "<Project>\n  <Target Name=\"Build\">\n    <PropertyGroup>\n      <Version>7.0.0</Version>\n    </PropertyGroup>\n  </Target>\n  <PropertyGroup>\n    <TargetFramework>net8.0</TargetFramework>\n  </PropertyGroup>\n</Project>",
1577                discovered: None,
1578                expected: "<Project>\n  <Target Name=\"Build\">\n    <PropertyGroup>\n      <Version>7.0.0</Version>\n    </PropertyGroup>\n  </Target>\n  <PropertyGroup>\n    <TargetFramework>net8.0</TargetFramework>\n    <Version>0.0.1</Version>\n  </PropertyGroup>\n</Project>",
1579            },
1580            VersionPolicyCase {
1581                name: "conditional-only",
1582                input: "<Project>\n  <PropertyGroup Condition=\"'$(Configuration)' == 'Release'\">\n    <Version>7.0.0</Version>\n  </PropertyGroup>\n</Project>",
1583                discovered: None,
1584                expected: "<Project>\n  <PropertyGroup Condition=\"'$(Configuration)' == 'Release'\">\n    <Version>7.0.0</Version>\n  </PropertyGroup>\n  <PropertyGroup>\n    <Version>0.0.1</Version>\n  </PropertyGroup>\n</Project>",
1585            },
1586            VersionPolicyCase {
1587                name: "conditional-before-unconditional",
1588                input: "<Project>\n  <PropertyGroup Condition=\"'$(Configuration)' == 'Release'\">\n    <Version>7.0.0</Version>\n  </PropertyGroup>\n  <PropertyGroup>\n    <Version>1.2.3</Version>\n  </PropertyGroup>\n</Project>",
1589                discovered: Some("1.2.3"),
1590                expected: "<Project>\n  <PropertyGroup Condition=\"'$(Configuration)' == 'Release'\">\n    <Version>7.0.0</Version>\n  </PropertyGroup>\n  <PropertyGroup>\n    <Version>1.2.4</Version>\n  </PropertyGroup>\n</Project>",
1591            },
1592            VersionPolicyCase {
1593                name: "cdata",
1594                input: "<Project>\n  <PropertyGroup Condition=\"'$(Configuration)' == 'Release'\">\n    <Version><![CDATA[7.0.0]]></Version>\n  </PropertyGroup>\n  <PropertyGroup>\n    <Version><![CDATA[1.2.3]]></Version>\n  </PropertyGroup>\n</Project>",
1595                discovered: Some("1.2.3"),
1596                expected: "<Project>\n  <PropertyGroup Condition=\"'$(Configuration)' == 'Release'\">\n    <Version><![CDATA[7.0.0]]></Version>\n  </PropertyGroup>\n  <PropertyGroup>\n    <Version><![CDATA[1.2.4]]></Version>\n  </PropertyGroup>\n</Project>",
1597            },
1598            VersionPolicyCase {
1599                name: "self-closing",
1600                input: "<Project>\n  <Target Name=\"Build\">\n    <PropertyGroup>\n      <Version>7.0.0</Version>\n    </PropertyGroup>\n  </Target>\n  <PropertyGroup>\n    <Version/>\n  </PropertyGroup>\n</Project>",
1601                discovered: None,
1602                expected: "<Project>\n  <Target Name=\"Build\">\n    <PropertyGroup>\n      <Version>7.0.0</Version>\n    </PropertyGroup>\n  </Target>\n  <PropertyGroup>\n    <Version>0.0.1</Version>\n  </PropertyGroup>\n</Project>",
1603            },
1604            VersionPolicyCase {
1605                name: "namespaced",
1606                input: "<msb:Project xmlns:msb=\"urn:msbuild\">\n  <msb:PropertyGroup Condition=\"'$(Configuration)' == 'Release'\">\n    <msb:Version>7.0.0</msb:Version>\n  </msb:PropertyGroup>\n  <msb:PropertyGroup>\n    <msb:Version>1.2.3</msb:Version>\n  </msb:PropertyGroup>\n</msb:Project>",
1607                discovered: Some("1.2.3"),
1608                expected: "<msb:Project xmlns:msb=\"urn:msbuild\">\n  <msb:PropertyGroup Condition=\"'$(Configuration)' == 'Release'\">\n    <msb:Version>7.0.0</msb:Version>\n  </msb:PropertyGroup>\n  <msb:PropertyGroup>\n    <msb:Version>1.2.4</msb:Version>\n  </msb:PropertyGroup>\n</msb:Project>",
1609            },
1610            VersionPolicyCase {
1611                name: "crlf",
1612                input: "<Project>\r\n  <Target Name=\"Build\">\r\n    <PropertyGroup>\r\n      <Version>7.0.0</Version>\r\n    </PropertyGroup>\r\n  </Target>\r\n  <PropertyGroup>\r\n    <TargetFramework>net8.0</TargetFramework>\r\n  </PropertyGroup>\r\n</Project>\r\n",
1613                discovered: None,
1614                expected: "<Project>\r\n  <Target Name=\"Build\">\r\n    <PropertyGroup>\r\n      <Version>7.0.0</Version>\r\n    </PropertyGroup>\r\n  </Target>\r\n  <PropertyGroup>\r\n    <TargetFramework>net8.0</TargetFramework>\r\n    <Version>0.0.1</Version>\r\n  </PropertyGroup>\r\n</Project>\r\n",
1615            },
1616            VersionPolicyCase {
1617                name: "tab-indented",
1618                input: "<Project>\n\t<PropertyGroup Condition=\"'$(Configuration)' == 'Release'\">\n\t\t<Version>7.0.0</Version>\n\t</PropertyGroup>\n\t<PropertyGroup>\n\t\t<Version>1.2.3</Version>\n\t</PropertyGroup>\n</Project>",
1619                discovered: Some("1.2.3"),
1620                expected: "<Project>\n\t<PropertyGroup Condition=\"'$(Configuration)' == 'Release'\">\n\t\t<Version>7.0.0</Version>\n\t</PropertyGroup>\n\t<PropertyGroup>\n\t\t<Version>1.2.4</Version>\n\t</PropertyGroup>\n</Project>",
1621            },
1622        ];
1623
1624        for case in cases {
1625            let temp_dir = TempDir::new()?;
1626            let manifest = temp_dir.path().join("Test.csproj");
1627            async_fs::write(&manifest, case.input).await?;
1628            let mut finder = CSharpProjectFinder::new();
1629
1630            finder.visit(&manifest, Path::new("Test.csproj")).await?;
1631            {
1632                let mut projects = finder.projects_mut();
1633                let project = projects
1634                    .first_mut()
1635                    .context("finder did not return the C# fixture")?;
1636                assert_eq!(
1637                    project.version(),
1638                    case.discovered,
1639                    "{} discovery",
1640                    case.name
1641                );
1642                project.update_version(UpdateType::Patch).await?;
1643            }
1644            assert_eq!(
1645                async_fs::read_to_string(&manifest).await?,
1646                case.expected,
1647                "{} rewrite",
1648                case.name
1649            );
1650            temp_dir.close()?;
1651        }
1652
1653        Ok(())
1654    }
1655
1656    #[rstest]
1657    // Windows-style paths (both single and doubled `..`).
1658    #[case(r"..\CoreLib\CoreLib.csproj", Some("CoreLib"))]
1659    #[case(r"..\..\Utils\Utils.csproj", Some("Utils"))]
1660    // Unix-style paths.
1661    #[case("../CoreLib/CoreLib.csproj", Some("CoreLib"))]
1662    // Just filename — no separator at all.
1663    #[case("MyProject.csproj", Some("MyProject"))]
1664    // Invalid — the extension is the sole legit `None` source.
1665    #[case("MyProject.txt", None)]
1666    // Case-insensitive `.csproj` — mixed-case suffixes (common in Windows
1667    // shell / hand-written project files) resolve the same as lowercase.
1668    // Regression anchor for the switch from `strip_suffix(".csproj")`
1669    // to `eq_ignore_ascii_case`.
1670    #[case("MyProject.CSPROJ", Some("MyProject"))]
1671    #[case("MyProject.CsProj", Some("MyProject"))]
1672    #[case(r"..\CoreLib\CoreLib.CSPROJ", Some("CoreLib"))]
1673    // No extension at all → None (rsplit_once('.') fails, function returns
1674    // early via `?`). Locks in the "no dot means no extension" contract.
1675    #[case("MyProject", None)]
1676    // Multi-dot stem — the split is on the LAST `.`, so the dots inside the
1677    // stem are preserved. `Foo.Tests.csproj` is the standard .NET test-project
1678    // naming convention, so a `split_once` regression here would silently
1679    // rename every test project to `Foo` and break its dependency edges.
1680    #[case("Foo.Tests.csproj", Some("Foo.Tests"))]
1681    // Bare extension — the stem is empty but the extension gate still passes,
1682    // so the current contract yields `Some("")` rather than `None`.
1683    #[case(".csproj", Some(""))]
1684    // Trailing separator — the separator split yields an empty filename, which
1685    // has no `.`, so the `?` on `rsplit_once('.')` returns `None`.
1686    #[case(r"..\CoreLib\", None)]
1687    fn test_extract_project_name_from_path(#[case] input: &str, #[case] expected: Option<&str>) {
1688        assert_eq!(
1689            super::extract_project_name_from_path(input),
1690            expected.map(std::string::ToString::to_string)
1691        );
1692    }
1693}