Skip to main content

changepacks_rust/
finder.rs

1use anyhow::{Context, Result};
2use async_trait::async_trait;
3use changepacks_core::{Package, Project, ProjectFinder};
4use std::{
5    collections::HashMap,
6    path::{Path, PathBuf},
7};
8use tokio::fs::read_to_string;
9
10use crate::{package::RustPackage, workspace::RustWorkspace};
11
12/// Package info deferred for workspace version resolution
13#[derive(Debug)]
14struct PendingWorkspacePackage {
15    name: Option<String>,
16    abs_path: PathBuf,
17    relative_path: PathBuf,
18    dependencies: Vec<String>,
19}
20
21#[derive(Debug)]
22pub struct RustProjectFinder {
23    projects: HashMap<PathBuf, Project>,
24    project_files: Vec<&'static str>,
25    workspace_package_version: Option<String>,
26    workspace_root_path: Option<PathBuf>,
27    pending_workspace_packages: Vec<PendingWorkspacePackage>,
28}
29
30impl Default for RustProjectFinder {
31    fn default() -> Self {
32        Self::new()
33    }
34}
35
36impl RustProjectFinder {
37    #[must_use]
38    pub fn new() -> Self {
39        Self {
40            projects: HashMap::new(),
41            project_files: vec!["Cargo.toml"],
42            workspace_package_version: None,
43            workspace_root_path: None,
44            pending_workspace_packages: Vec::new(),
45        }
46    }
47}
48
49#[async_trait]
50impl ProjectFinder for RustProjectFinder {
51    fn projects(&self) -> Vec<&Project> {
52        self.projects.values().collect::<Vec<_>>()
53    }
54    fn projects_mut(&mut self) -> Vec<&mut Project> {
55        self.projects.values_mut().collect::<Vec<_>>()
56    }
57
58    fn project_files(&self) -> &[&str] {
59        &self.project_files
60    }
61
62    async fn visit(&mut self, path: &Path, relative_path: &Path) -> Result<()> {
63        if path.is_file()
64            && self.project_files().contains(
65                &path
66                    .file_name()
67                    .context(format!("File name not found - {}", path.display()))?
68                    .to_str()
69                    .context(format!("File name not found - {}", path.display()))?,
70            )
71        {
72            if self.projects.contains_key(path) {
73                return Ok(());
74            }
75            // read Cargo.toml
76            let cargo_toml = read_to_string(path).await?;
77            let cargo_toml: toml::Value = toml::from_str(&cargo_toml)?;
78
79            // Collect workspace dependencies for this file
80            let mut dep_names = Vec::new();
81            if let Some(deps) = cargo_toml.get("dependencies").and_then(|d| d.as_table()) {
82                for (dep_name, value) in deps {
83                    if let Some(dep) = value.as_table()
84                        && let Some(workspace) = dep.get("workspace")
85                        && workspace.as_bool().unwrap_or(false)
86                    {
87                        dep_names.push(dep_name.clone());
88                    }
89                }
90            }
91
92            // if workspace
93            if cargo_toml.get("workspace").is_some() {
94                // Read [workspace.package].version if present
95                let ws_pkg_version = cargo_toml
96                    .get("workspace")
97                    .and_then(|w| w.get("package"))
98                    .and_then(|p| p.get("version"))
99                    .and_then(|v| v.as_str())
100                    .map(String::from);
101                if ws_pkg_version.is_some() {
102                    self.workspace_package_version = ws_pkg_version;
103                    self.workspace_root_path = Some(path.to_path_buf());
104                }
105
106                let version = cargo_toml
107                    .get("package")
108                    .and_then(|p| p.get("version"))
109                    .and_then(|v| v.as_str())
110                    .map(std::string::ToString::to_string);
111                let name = cargo_toml
112                    .get("package")
113                    .and_then(|p| p.get("name"))
114                    .and_then(|v| v.as_str())
115                    .map(std::string::ToString::to_string);
116                let mut project = Project::Workspace(Box::new(RustWorkspace::new(
117                    name,
118                    version,
119                    path.to_path_buf(),
120                    relative_path.to_path_buf(),
121                )));
122                for dep_name in &dep_names {
123                    project.add_dependency(dep_name);
124                }
125                self.projects.insert(path.to_path_buf(), project);
126
127                // Resolve any pending packages that were visited before this workspace
128                let pending = std::mem::take(&mut self.pending_workspace_packages);
129                for p in pending {
130                    let mut pkg = RustPackage::new_with_workspace_version(
131                        p.name,
132                        self.workspace_package_version.clone(),
133                        p.abs_path.clone(),
134                        p.relative_path,
135                        self.workspace_root_path.clone(),
136                    );
137                    for dep in &p.dependencies {
138                        pkg.add_dependency(dep);
139                    }
140                    self.projects
141                        .insert(p.abs_path, Project::Package(Box::new(pkg)));
142                }
143            } else {
144                // Check if version.workspace = true
145                let inherits_workspace = cargo_toml
146                    .get("package")
147                    .and_then(|p| p.get("version"))
148                    .and_then(|v| v.as_table())
149                    .and_then(|t| t.get("workspace"))
150                    .and_then(|w| w.as_bool())
151                    .unwrap_or(false);
152
153                let name = cargo_toml["package"]["name"]
154                    .as_str()
155                    .map(std::string::ToString::to_string);
156
157                if inherits_workspace {
158                    if self.workspace_package_version.is_some() {
159                        // Workspace already visited — resolve immediately
160                        let mut pkg = RustPackage::new_with_workspace_version(
161                            name,
162                            self.workspace_package_version.clone(),
163                            path.to_path_buf(),
164                            relative_path.to_path_buf(),
165                            self.workspace_root_path.clone(),
166                        );
167                        for dep_name in &dep_names {
168                            pkg.add_dependency(dep_name);
169                        }
170                        self.projects
171                            .insert(path.to_path_buf(), Project::Package(Box::new(pkg)));
172                    } else {
173                        // Workspace not yet visited — defer
174                        self.pending_workspace_packages
175                            .push(PendingWorkspacePackage {
176                                name,
177                                abs_path: path.to_path_buf(),
178                                relative_path: relative_path.to_path_buf(),
179                                dependencies: dep_names,
180                            });
181                    }
182                } else {
183                    let version = cargo_toml["package"]["version"]
184                        .as_str()
185                        .map(std::string::ToString::to_string);
186                    let mut project = Project::Package(Box::new(RustPackage::new(
187                        name,
188                        version,
189                        path.to_path_buf(),
190                        relative_path.to_path_buf(),
191                    )));
192                    for dep_name in &dep_names {
193                        project.add_dependency(dep_name);
194                    }
195                    self.projects.insert(path.to_path_buf(), project);
196                }
197            };
198        }
199        Ok(())
200    }
201
202    async fn finalize(&mut self) -> Result<()> {
203        // If workspace root was never visited (e.g. excluded by ignore patterns),
204        // walk up from the first pending package to find and read it
205        if self.workspace_package_version.is_none()
206            && !self.pending_workspace_packages.is_empty()
207            && let Some(first_pkg) = self.pending_workspace_packages.first()
208        {
209            // Derive git root from the first pending package's absolute/relative paths
210            // e.g. abs=<repo>/crates/foo/Cargo.toml, rel=crates/foo/Cargo.toml → git_root=<repo>
211            let rel_component_count = first_pkg.relative_path.components().count();
212            let mut git_root = first_pkg.abs_path.clone();
213            for _ in 0..rel_component_count {
214                git_root.pop();
215            }
216
217            let mut dir = first_pkg.abs_path.parent().and_then(Path::parent);
218            while let Some(parent) = dir {
219                let candidate = parent.join("Cargo.toml");
220                if candidate.is_file()
221                    && let Ok(content) = read_to_string(&candidate).await
222                    && let Ok(parsed) = toml::from_str::<toml::Value>(&content)
223                    && let Some(version) = parsed
224                        .get("workspace")
225                        .and_then(|w| w.get("package"))
226                        .and_then(|p| p.get("version"))
227                        .and_then(|v| v.as_str())
228                {
229                    self.workspace_package_version = Some(version.to_string());
230                    self.workspace_root_path = Some(candidate.clone());
231
232                    // Insert synthetic workspace project so apply_updates() can find it
233                    let ws_name = parsed
234                        .get("package")
235                        .and_then(|p| p.get("name"))
236                        .and_then(|v| v.as_str())
237                        .map(String::from);
238                    let ws_pkg_version = parsed
239                        .get("package")
240                        .and_then(|p| p.get("version"))
241                        .and_then(|v| v.as_str())
242                        .map(String::from);
243                    let ws_relative_path = candidate
244                        .strip_prefix(&git_root)
245                        .unwrap_or(Path::new("Cargo.toml"))
246                        .to_path_buf();
247
248                    let workspace = RustWorkspace::new(
249                        ws_name,
250                        // For virtual workspaces (no [package]), use [workspace.package].version
251                        ws_pkg_version.or_else(|| self.workspace_package_version.clone()),
252                        candidate,
253                        ws_relative_path,
254                    );
255                    self.projects.insert(
256                        self.workspace_root_path.clone().unwrap(),
257                        Project::Workspace(Box::new(workspace)),
258                    );
259                    break;
260                }
261                dir = parent.parent();
262            }
263        }
264
265        for pending in self.pending_workspace_packages.drain(..) {
266            let mut pkg = RustPackage::new_with_workspace_version(
267                pending.name,
268                self.workspace_package_version.clone(),
269                pending.abs_path.clone(),
270                pending.relative_path,
271                self.workspace_root_path.clone(),
272            );
273            for dep in &pending.dependencies {
274                pkg.add_dependency(dep);
275            }
276            self.projects
277                .insert(pending.abs_path, Project::Package(Box::new(pkg)));
278        }
279        Ok(())
280    }
281}
282
283#[cfg(test)]
284mod tests {
285    use super::*;
286    use changepacks_core::Project;
287    use std::fs;
288    use tempfile::TempDir;
289
290    #[test]
291    fn test_rust_project_finder_new() {
292        let finder = RustProjectFinder::new();
293        assert_eq!(finder.project_files(), &["Cargo.toml"]);
294        assert_eq!(finder.projects().len(), 0);
295    }
296
297    #[test]
298    fn test_rust_project_finder_default() {
299        let finder = RustProjectFinder::default();
300        assert_eq!(finder.project_files(), &["Cargo.toml"]);
301        assert_eq!(finder.projects().len(), 0);
302    }
303
304    #[tokio::test]
305    async fn test_rust_project_finder_visit_package() {
306        let temp_dir = TempDir::new().unwrap();
307        let cargo_toml = temp_dir.path().join("Cargo.toml");
308        fs::write(
309            &cargo_toml,
310            r#"[package]
311name = "test-package"
312version = "1.0.0"
313"#,
314        )
315        .unwrap();
316
317        let mut finder = RustProjectFinder::new();
318        finder
319            .visit(&cargo_toml, &PathBuf::from("Cargo.toml"))
320            .await
321            .unwrap();
322
323        let projects = finder.projects();
324        assert_eq!(projects.len(), 1);
325        match projects[0] {
326            Project::Package(pkg) => {
327                assert_eq!(pkg.name(), Some("test-package"));
328                assert_eq!(pkg.version(), Some("1.0.0"));
329            }
330            _ => panic!("Expected Package"),
331        }
332
333        temp_dir.close().unwrap();
334    }
335
336    #[tokio::test]
337    async fn test_rust_project_finder_visit_workspace() {
338        let temp_dir = TempDir::new().unwrap();
339        let cargo_toml = temp_dir.path().join("Cargo.toml");
340        fs::write(
341            &cargo_toml,
342            r#"[workspace]
343members = ["crates/*"]
344
345[package]
346name = "test-workspace"
347version = "1.0.0"
348"#,
349        )
350        .unwrap();
351
352        let mut finder = RustProjectFinder::new();
353        finder
354            .visit(&cargo_toml, &PathBuf::from("Cargo.toml"))
355            .await
356            .unwrap();
357
358        let projects = finder.projects();
359        assert_eq!(projects.len(), 1);
360        match projects[0] {
361            Project::Workspace(ws) => {
362                assert_eq!(ws.name(), Some("test-workspace"));
363                assert_eq!(ws.version(), Some("1.0.0"));
364            }
365            _ => panic!("Expected Workspace"),
366        }
367
368        temp_dir.close().unwrap();
369    }
370
371    #[tokio::test]
372    async fn test_rust_project_finder_visit_workspace_without_package() {
373        let temp_dir = TempDir::new().unwrap();
374        let cargo_toml = temp_dir.path().join("Cargo.toml");
375        fs::write(
376            &cargo_toml,
377            r#"[workspace]
378members = ["crates/*"]
379"#,
380        )
381        .unwrap();
382
383        let mut finder = RustProjectFinder::new();
384        finder
385            .visit(&cargo_toml, &PathBuf::from("Cargo.toml"))
386            .await
387            .unwrap();
388
389        let projects = finder.projects();
390        assert_eq!(projects.len(), 1);
391        match projects[0] {
392            Project::Workspace(ws) => {
393                assert_eq!(ws.name(), None);
394                assert_eq!(ws.version(), None);
395            }
396            _ => panic!("Expected Workspace"),
397        }
398
399        temp_dir.close().unwrap();
400    }
401
402    #[tokio::test]
403    async fn test_rust_project_finder_visit_non_cargo_file() {
404        let temp_dir = TempDir::new().unwrap();
405        let other_file = temp_dir.path().join("other.txt");
406        fs::write(&other_file, "some content").unwrap();
407
408        let mut finder = RustProjectFinder::new();
409        finder
410            .visit(&other_file, &PathBuf::from("other.txt"))
411            .await
412            .unwrap();
413
414        assert_eq!(finder.projects().len(), 0);
415
416        temp_dir.close().unwrap();
417    }
418
419    #[tokio::test]
420    async fn test_rust_project_finder_visit_directory() {
421        let temp_dir = TempDir::new().unwrap();
422        let cargo_toml = temp_dir.path().join("Cargo.toml");
423        fs::write(
424            &cargo_toml,
425            r#"[package]
426name = "test-package"
427version = "1.0.0"
428"#,
429        )
430        .unwrap();
431
432        let mut finder = RustProjectFinder::new();
433        // Pass directory instead of file
434        finder
435            .visit(temp_dir.path(), &PathBuf::from("."))
436            .await
437            .unwrap();
438
439        assert_eq!(finder.projects().len(), 0);
440
441        temp_dir.close().unwrap();
442    }
443
444    #[tokio::test]
445    async fn test_rust_project_finder_visit_duplicate() {
446        let temp_dir = TempDir::new().unwrap();
447        let cargo_toml = temp_dir.path().join("Cargo.toml");
448        fs::write(
449            &cargo_toml,
450            r#"[package]
451name = "test-package"
452version = "1.0.0"
453"#,
454        )
455        .unwrap();
456
457        let mut finder = RustProjectFinder::new();
458        finder
459            .visit(&cargo_toml, &PathBuf::from("Cargo.toml"))
460            .await
461            .unwrap();
462
463        assert_eq!(finder.projects().len(), 1);
464
465        // Visit again - should not add duplicate
466        finder
467            .visit(&cargo_toml, &PathBuf::from("Cargo.toml"))
468            .await
469            .unwrap();
470
471        assert_eq!(finder.projects().len(), 1);
472
473        temp_dir.close().unwrap();
474    }
475
476    #[tokio::test]
477    async fn test_rust_project_finder_visit_multiple_packages() {
478        let temp_dir = TempDir::new().unwrap();
479        let cargo_toml1 = temp_dir.path().join("package1").join("Cargo.toml");
480        fs::create_dir_all(cargo_toml1.parent().unwrap()).unwrap();
481        fs::write(
482            &cargo_toml1,
483            r#"[package]
484name = "package1"
485version = "1.0.0"
486"#,
487        )
488        .unwrap();
489
490        let cargo_toml2 = temp_dir.path().join("package2").join("Cargo.toml");
491        fs::create_dir_all(cargo_toml2.parent().unwrap()).unwrap();
492        fs::write(
493            &cargo_toml2,
494            r#"[package]
495name = "package2"
496version = "2.0.0"
497"#,
498        )
499        .unwrap();
500
501        let mut finder = RustProjectFinder::new();
502        finder
503            .visit(&cargo_toml1, &PathBuf::from("package1/Cargo.toml"))
504            .await
505            .unwrap();
506        finder
507            .visit(&cargo_toml2, &PathBuf::from("package2/Cargo.toml"))
508            .await
509            .unwrap();
510
511        let projects = finder.projects();
512        assert_eq!(projects.len(), 2);
513
514        temp_dir.close().unwrap();
515    }
516
517    #[tokio::test]
518    async fn test_rust_project_finder_projects_mut() {
519        let temp_dir = TempDir::new().unwrap();
520        let cargo_toml = temp_dir.path().join("Cargo.toml");
521        fs::write(
522            &cargo_toml,
523            r#"[package]
524name = "test-package"
525version = "1.0.0"
526"#,
527        )
528        .unwrap();
529
530        let mut finder = RustProjectFinder::new();
531        finder
532            .visit(&cargo_toml, &PathBuf::from("Cargo.toml"))
533            .await
534            .unwrap();
535
536        let mut_projects = finder.projects_mut();
537        assert_eq!(mut_projects.len(), 1);
538
539        temp_dir.close().unwrap();
540    }
541
542    #[tokio::test]
543    async fn test_rust_project_finder_visit_package_with_workspace_dependencies() {
544        let temp_dir = TempDir::new().unwrap();
545        let cargo_toml = temp_dir.path().join("Cargo.toml");
546        fs::write(
547            &cargo_toml,
548            r#"[package]
549name = "test-package"
550version = "1.0.0"
551
552[dependencies]
553core = { workspace = true }
554utils = { workspace = true }
555external = "1.0"
556"#,
557        )
558        .unwrap();
559
560        let mut finder = RustProjectFinder::new();
561        finder
562            .visit(&cargo_toml, &PathBuf::from("Cargo.toml"))
563            .await
564            .unwrap();
565
566        let projects = finder.projects();
567        assert_eq!(projects.len(), 1);
568        match projects[0] {
569            Project::Package(pkg) => {
570                assert_eq!(pkg.name(), Some("test-package"));
571                let deps = pkg.dependencies();
572                assert_eq!(deps.len(), 2);
573                assert!(deps.contains("core"));
574                assert!(deps.contains("utils"));
575                // external is not a workspace dependency
576                assert!(!deps.contains("external"));
577            }
578            _ => panic!("Expected Package"),
579        }
580
581        temp_dir.close().unwrap();
582    }
583
584    #[tokio::test]
585    async fn test_rust_project_finder_virtual_workspace_with_workspace_version() {
586        // Reproduces vespera-style virtual workspace (no [package] section)
587        let temp_dir = TempDir::new().unwrap();
588
589        let workspace_toml = temp_dir.path().join("Cargo.toml");
590        fs::write(
591            &workspace_toml,
592            r#"[workspace]
593resolver = "2"
594members = ["crates/*"]
595
596[workspace.package]
597version = "0.1.33"
598edition = "2024"
599"#,
600        )
601        .unwrap();
602
603        let pkg_dir = temp_dir.path().join("crates").join("vespera");
604        fs::create_dir_all(&pkg_dir).unwrap();
605        let pkg_toml = pkg_dir.join("Cargo.toml");
606        fs::write(
607            &pkg_toml,
608            r#"[package]
609name = "vespera"
610version.workspace = true
611edition.workspace = true
612
613[dependencies]
614vespera_core = { workspace = true }
615
616[lints]
617workspace = true
618"#,
619        )
620        .unwrap();
621
622        let mut finder = RustProjectFinder::new();
623        finder
624            .visit(&workspace_toml, &PathBuf::from("Cargo.toml"))
625            .await
626            .unwrap();
627        finder
628            .visit(&pkg_toml, &PathBuf::from("crates/vespera/Cargo.toml"))
629            .await
630            .unwrap();
631        finder.finalize().await.unwrap();
632
633        let projects = finder.projects();
634        // Virtual workspace (no [package]) + 1 member
635        assert_eq!(projects.len(), 2);
636
637        let pkg = projects
638            .iter()
639            .find(|p| p.name() == Some("vespera"))
640            .unwrap();
641        assert_eq!(pkg.version(), Some("0.1.33"));
642    }
643
644    #[tokio::test]
645    async fn test_rust_project_finder_visit_package_with_workspace_version() {
646        let temp_dir = TempDir::new().unwrap();
647
648        // Create workspace root
649        let workspace_toml = temp_dir.path().join("Cargo.toml");
650        fs::write(
651            &workspace_toml,
652            r#"[workspace]
653members = ["crates/*"]
654
655[workspace.package]
656version = "2.5.0"
657edition = "2024"
658
659[package]
660name = "my-workspace"
661version = "2.5.0"
662"#,
663        )
664        .unwrap();
665
666        // Create member package with version.workspace = true
667        let pkg_dir = temp_dir.path().join("crates").join("my-crate");
668        fs::create_dir_all(&pkg_dir).unwrap();
669        let pkg_toml = pkg_dir.join("Cargo.toml");
670        fs::write(
671            &pkg_toml,
672            r#"[package]
673name = "my-crate"
674version.workspace = true
675edition.workspace = true
676"#,
677        )
678        .unwrap();
679
680        let mut finder = RustProjectFinder::new();
681        // Visit workspace first (normal git index order)
682        finder
683            .visit(&workspace_toml, &PathBuf::from("Cargo.toml"))
684            .await
685            .unwrap();
686        finder
687            .visit(&pkg_toml, &PathBuf::from("crates/my-crate/Cargo.toml"))
688            .await
689            .unwrap();
690        finder.finalize().await.unwrap();
691
692        let projects = finder.projects();
693        assert_eq!(projects.len(), 2);
694
695        // Find the package
696        let pkg = projects
697            .iter()
698            .find(|p| p.name() == Some("my-crate"))
699            .unwrap();
700        assert_eq!(pkg.version(), Some("2.5.0")); // Should inherit workspace version
701    }
702
703    #[tokio::test]
704    async fn test_rust_project_finder_visit_package_before_workspace() {
705        let temp_dir = TempDir::new().unwrap();
706
707        // Create workspace root
708        let workspace_toml = temp_dir.path().join("Cargo.toml");
709        fs::write(
710            &workspace_toml,
711            r#"[workspace]
712members = ["crates/*"]
713
714[workspace.package]
715version = "3.0.0"
716
717[package]
718name = "my-workspace"
719version = "3.0.0"
720"#,
721        )
722        .unwrap();
723
724        // Create member package
725        let pkg_dir = temp_dir.path().join("crates").join("my-crate");
726        fs::create_dir_all(&pkg_dir).unwrap();
727        let pkg_toml = pkg_dir.join("Cargo.toml");
728        fs::write(
729            &pkg_toml,
730            r#"[package]
731name = "my-crate"
732version.workspace = true
733"#,
734        )
735        .unwrap();
736
737        let mut finder = RustProjectFinder::new();
738        // Visit package BEFORE workspace (reverse order)
739        finder
740            .visit(&pkg_toml, &PathBuf::from("crates/my-crate/Cargo.toml"))
741            .await
742            .unwrap();
743        finder
744            .visit(&workspace_toml, &PathBuf::from("Cargo.toml"))
745            .await
746            .unwrap();
747        finder.finalize().await.unwrap();
748
749        let projects = finder.projects();
750        assert_eq!(projects.len(), 2);
751
752        let pkg = projects
753            .iter()
754            .find(|p| p.name() == Some("my-crate"))
755            .unwrap();
756        assert_eq!(pkg.version(), Some("3.0.0")); // Should still resolve correctly
757    }
758
759    #[tokio::test]
760    async fn test_rust_project_finder_workspace_ignored_by_config() {
761        // Simulates when ignore patterns like ["**", "!crates/**"] skip the root Cargo.toml
762        let temp_dir = TempDir::new().unwrap();
763
764        // Create workspace root (won't be visited due to ignore)
765        let workspace_toml = temp_dir.path().join("Cargo.toml");
766        fs::write(
767            &workspace_toml,
768            r#"[workspace]
769resolver = "2"
770members = ["crates/*"]
771
772[workspace.package]
773version = "0.1.33"
774edition = "2024"
775"#,
776        )
777        .unwrap();
778
779        // Create 2 member packages
780        for name in ["vespera", "vespera_core"] {
781            let pkg_dir = temp_dir.path().join("crates").join(name);
782            fs::create_dir_all(&pkg_dir).unwrap();
783            fs::write(
784                pkg_dir.join("Cargo.toml"),
785                format!(
786                    r#"[package]
787name = "{name}"
788version.workspace = true
789edition.workspace = true
790
791[lints]
792workspace = true
793"#
794                ),
795            )
796            .unwrap();
797        }
798
799        let mut finder = RustProjectFinder::new();
800        // Only visit member packages (workspace root is ignored)
801        for name in ["vespera", "vespera_core"] {
802            let pkg_toml = temp_dir.path().join("crates").join(name).join("Cargo.toml");
803            finder
804                .visit(
805                    &pkg_toml,
806                    &PathBuf::from(format!("crates/{name}/Cargo.toml")),
807                )
808                .await
809                .unwrap();
810        }
811        // finalize should discover the workspace root by walking up
812        finder.finalize().await.unwrap();
813
814        let projects = finder.projects();
815        // 2 member packages + 1 synthetic workspace
816        assert_eq!(projects.len(), 3);
817
818        for name in ["vespera", "vespera_core"] {
819            let pkg = projects.iter().find(|p| p.name() == Some(name)).unwrap();
820            assert_eq!(
821                pkg.version(),
822                Some("0.1.33"),
823                "{name} should inherit workspace version"
824            );
825        }
826
827        // Synthetic workspace should exist with the workspace version
828        let ws = projects
829            .iter()
830            .find(|p| matches!(p, Project::Workspace(_)))
831            .expect("synthetic workspace should be created");
832        assert_eq!(ws.version(), Some("0.1.33"));
833        assert_eq!(ws.relative_path(), Path::new("Cargo.toml"));
834    }
835
836    #[tokio::test]
837    async fn test_rust_project_finder_finalize_discovers_workspace_with_package_section() {
838        // When finalize() walks up to discover the workspace root, and that root
839        // has a [package] section with name and version, lines 162-163 return Some(...)
840        let temp_dir = TempDir::new().unwrap();
841
842        let workspace_toml = temp_dir.path().join("Cargo.toml");
843        fs::write(
844            &workspace_toml,
845            r#"[workspace]
846resolver = "2"
847members = ["crates/*"]
848
849[workspace.package]
850version = "0.2.0"
851
852[package]
853name = "my-workspace-root"
854version = "0.2.0"
855"#,
856        )
857        .unwrap();
858
859        let pkg_dir = temp_dir.path().join("crates").join("my-crate");
860        fs::create_dir_all(&pkg_dir).unwrap();
861        fs::write(
862            pkg_dir.join("Cargo.toml"),
863            r#"[package]
864name = "my-crate"
865version.workspace = true
866"#,
867        )
868        .unwrap();
869
870        let mut finder = RustProjectFinder::new();
871        // Only visit member (workspace root is NOT visited — simulates ignore config)
872        let pkg_toml = pkg_dir.join("Cargo.toml");
873        finder
874            .visit(&pkg_toml, &PathBuf::from("crates/my-crate/Cargo.toml"))
875            .await
876            .unwrap();
877        finder.finalize().await.unwrap();
878
879        let projects = finder.projects();
880        assert_eq!(projects.len(), 2);
881
882        let ws = projects
883            .iter()
884            .find(|p| matches!(p, Project::Workspace(_)))
885            .unwrap();
886        assert_eq!(ws.name(), Some("my-workspace-root"));
887        assert_eq!(ws.version(), Some("0.2.0"));
888
889        let pkg = projects
890            .iter()
891            .find(|p| p.name() == Some("my-crate"))
892            .unwrap();
893        assert_eq!(pkg.version(), Some("0.2.0"));
894    }
895}