Skip to main content

changepacks_python/
finder.rs

1use anyhow::Result;
2use async_trait::async_trait;
3use changepacks_core::{Project, ProjectFinder};
4use std::{
5    collections::HashMap,
6    path::{Path, PathBuf},
7};
8
9use crate::{package::PythonPackage, read_and_parse_pyproject_toml, workspace::PythonWorkspace};
10
11/// Manifest filenames this finder recognizes. Static because the list is
12/// compile-time constant — no per-instance heap `Vec` is needed and the
13/// `ProjectFinder::project_files` return type (`&[&str]`) already accepts
14/// a `&'static [&'static str]`.
15const PROJECT_FILES: &[&str] = &["pyproject.toml"];
16
17#[derive(Debug, Default)]
18pub struct PythonProjectFinder {
19    projects: HashMap<PathBuf, Project>,
20}
21
22impl PythonProjectFinder {
23    #[must_use]
24    pub fn new() -> Self {
25        Self::default()
26    }
27}
28
29/// Register every local workspace dependency declared under `[tool.uv.sources]`.
30///
31/// `[tool.uv.sources]` is a TOML **table** keyed by dependency name
32/// (e.g. `pkg-a = { path = "../pkg-a" }`), not an array of strings, so it is
33/// iterated as a table — otherwise Python packages never register their
34/// workspace deps for topological publish ordering.
35///
36/// Takes the already-cached `[tool.uv]` item from `visit` so no extra manifest
37/// lookup or re-parse is introduced. Mirrors `add_workspace_dependencies` in
38/// the Node and Dart finders, which hoist the equivalent scan into a named
39/// free function.
40fn add_uv_source_dependencies(project: &mut Project, uv_table: Option<&toml_edit::Item>) {
41    let Some(sources) = uv_table
42        .and_then(|u| u.get("sources"))
43        .and_then(toml_edit::Item::as_table_like)
44    else {
45        return;
46    };
47    for (dep_name, source) in sources.iter() {
48        let is_local_source = source.as_table_like().is_some_and(|source| {
49            source.contains_key("path")
50                || source.get("workspace").and_then(toml_edit::Item::as_bool) == Some(true)
51        });
52        if is_local_source {
53            project.add_dependency(dep_name);
54        }
55    }
56}
57
58#[async_trait]
59impl ProjectFinder for PythonProjectFinder {
60    // `projects()` / `projects_mut()` share their byte-identical body with
61    // the Node and Dart finders (all three use a
62    // `HashMap<PathBuf, Project>` backing store). Consolidated via the
63    // `impl_projects_hashmap_accessors!()` macro in `changepacks-core` so
64    // future accessor tweaks land in one place — expansion is byte-
65    // identical to the previous hand-rolled bodies.
66    changepacks_core::impl_projects_hashmap_accessors!();
67
68    fn project_files(&self) -> &[&str] {
69        PROJECT_FILES
70    }
71
72    async fn visit(&mut self, path: &Path, relative_path: &Path) -> Result<()> {
73        // Parse this manifest if it is a recognized project file not already
74        // visited. Both guards live in `ProjectFinder::should_visit_manifest`
75        // (name/stat gate first, already-discovered map probe second) so the
76        // prelude is written once for every file-name-based finder.
77        if !self.should_visit_manifest(path).await? {
78            return Ok(());
79        }
80        // read and parse pyproject.toml
81        let (_raw, pyproject_toml) = read_and_parse_pyproject_toml(path).await?;
82        // `[project]` is OPTIONAL: uv workspace-only roots (the docs'
83        // canonical example) declare just `[tool.uv.workspace]` at the
84        // repo root and no `[project]` table. Match the tolerant
85        // extraction that `PythonWorkspace::update_version` already
86        // uses (see `write_pyproject_version` in `crates/python/src/lib.rs`).
87        // Both name and version fall through to `None` when the table
88        // is missing, exactly like the constructor arguments accept.
89        let project_table = pyproject_toml.get("project");
90
91        // Both branches use the same name/version and the same path;
92        // hoist so each branch collapses to a single constructor call.
93        // `toml_item_str` is the shared `<table>.<field>` string read, also
94        // used by the `changepacks-rust` finder's `[package]` /
95        // `[workspace.package]` readers; a non-string value (e.g. an
96        // inline-table inheritance marker) resolves to `None`, exactly as the
97        // local helper this replaces did.
98        let version = changepacks_utils::toml_item_str(project_table, "version");
99        let name = changepacks_utils::toml_item_str(project_table, "name");
100        let publishable_by_default = name.is_some();
101        let path_key = path.to_path_buf();
102        let relative_path_key = relative_path.to_path_buf();
103
104        // Hoist the `[tool.uv]` lookup ONCE: both the workspace guard
105        // below and the `[tool.uv.sources]` walk further down previously
106        // walked the identical `pyproject_toml.get("tool").and_then(|t|
107        // t.get("uv"))` chain independently. `Option<&Item>` is `Copy`,
108        // so caching the intermediate binding lets both call sites reuse
109        // it — saves one HashMap-style lookup per Python-project visit
110        // on every `check` / `update` / `publish` invocation. Behavior
111        // is byte-identical: both branches short-circuit on the same
112        // `None` positions.
113        let uv_table = pyproject_toml.get("tool").and_then(|t| t.get("uv"));
114
115        // The absent/valid/invalid triage is
116        // `changepacks_utils::ensure_declared_shape`, shared verbatim with the
117        // Node and Dart finders; only the shape predicate stays here because it
118        // is the one part that speaks `toml_edit::Item`.
119        let has_workspace_declaration = changepacks_utils::ensure_declared_shape(
120            uv_table
121                .and_then(|u| u.get("workspace"))
122                .map(|workspace| workspace.as_table_like().is_some()),
123            path,
124            "[tool.uv].workspace",
125            "a table or inline table",
126        )?;
127
128        let mut project = changepacks_core::discovered_project!(
129            has_workspace_declaration,
130            PythonWorkspace::new_discovered,
131            PythonPackage::new_discovered,
132            name,
133            version,
134            path_key.clone(),
135            relative_path_key,
136            publishable_by_default,
137        );
138
139        // read tool.uv.sources section — see `add_uv_source_dependencies`.
140        add_uv_source_dependencies(&mut project, uv_table);
141
142        self.projects.insert(path_key, project);
143        Ok(())
144    }
145}
146
147#[cfg(test)]
148mod tests {
149    use super::*;
150    use changepacks_core::Project;
151    use rstest::rstest;
152    use std::fs;
153    use tempfile::TempDir;
154
155    // Both `PythonProjectFinder::new()` and `PythonProjectFinder::default()`
156    // must yield the same empty, `pyproject.toml`-scoped finder.
157    #[rstest]
158    #[case(PythonProjectFinder::new())]
159    #[case(PythonProjectFinder::default())]
160    fn test_python_project_finder_construction(#[case] finder: PythonProjectFinder) {
161        assert_eq!(finder.project_files(), &["pyproject.toml"]);
162        assert_eq!(finder.projects().len(), 0);
163    }
164
165    #[tokio::test]
166    async fn test_python_project_finder_visit_package() {
167        let temp_dir = TempDir::new().unwrap();
168        let pyproject_toml = temp_dir.path().join("pyproject.toml");
169        fs::write(
170            &pyproject_toml,
171            r#"[project]
172name = "test-package"
173version = "1.0.0"
174"#,
175        )
176        .unwrap();
177
178        let mut finder = PythonProjectFinder::new();
179        finder
180            .visit(&pyproject_toml, &PathBuf::from("pyproject.toml"))
181            .await
182            .unwrap();
183
184        let projects = finder.projects();
185        assert_eq!(projects.len(), 1);
186        let pkg = projects[0].expect_package();
187        assert_eq!(pkg.name(), Some("test-package"));
188        assert_eq!(pkg.version(), Some("1.0.0"));
189        assert!(pkg.is_publishable_by_default());
190
191        temp_dir.close().unwrap();
192    }
193
194    #[rstest]
195    #[case(
196        r#"[tool.uv.workspace]
197members = ["packages/*"]
198"#
199    )]
200    #[case("[tool.uv.workspace]\n")]
201    #[case(
202        r#"[tool.uv]
203workspace = { members = ["packages/*"] }
204"#
205    )]
206    #[case("[tool.uv]\nworkspace = {}\n")]
207    #[tokio::test]
208    async fn test_python_project_finder_visit_workspace_with_table_like_declaration(
209        #[case] workspace: &str,
210    ) {
211        let temp_dir = TempDir::new().unwrap();
212        let pyproject_toml = temp_dir.path().join("pyproject.toml");
213        fs::write(
214            &pyproject_toml,
215            format!(
216                r#"{workspace}
217[project]
218name = "test-workspace"
219version = "1.0.0"
220"#
221            ),
222        )
223        .unwrap();
224
225        let mut finder = PythonProjectFinder::new();
226        finder
227            .visit(&pyproject_toml, &PathBuf::from("pyproject.toml"))
228            .await
229            .unwrap();
230
231        let projects = finder.projects();
232        assert_eq!(projects.len(), 1);
233        let ws = projects[0].expect_workspace();
234        assert_eq!(ws.name(), Some("test-workspace"));
235        assert_eq!(ws.version(), Some("1.0.0"));
236        assert!(ws.is_publishable_by_default());
237
238        temp_dir.close().unwrap();
239    }
240
241    #[rstest]
242    #[case("true")]
243    #[case(r#""packages/*""#)]
244    #[case("[]")]
245    #[tokio::test]
246    async fn test_python_project_finder_rejects_invalid_uv_workspace_declaration(
247        #[case] workspace: &str,
248    ) {
249        let temp_dir = TempDir::new().unwrap();
250        let pyproject_toml = temp_dir.path().join("pyproject.toml");
251        fs::write(
252            &pyproject_toml,
253            format!(
254                r#"[tool.uv]
255workspace = {workspace}
256
257[project]
258name = "test-package"
259version = "1.0.0"
260"#
261            ),
262        )
263        .unwrap();
264
265        let mut finder = PythonProjectFinder::new();
266        let result = finder
267            .visit(&pyproject_toml, &PathBuf::from("pyproject.toml"))
268            .await;
269
270        let error_msg = result
271            .expect_err("invalid uv workspace declaration should fail")
272            .to_string();
273        assert!(
274            error_msg.contains("Invalid `[tool.uv].workspace` declaration"),
275            "error message should explain the invalid declaration, got: {error_msg}"
276        );
277        assert!(
278            error_msg.contains(pyproject_toml.to_string_lossy().as_ref()),
279            "error message should contain the manifest path, got: {error_msg}"
280        );
281        assert!(finder.projects().is_empty());
282
283        temp_dir.close().unwrap();
284    }
285
286    #[tokio::test]
287    async fn test_python_project_finder_visit_workspace_without_version() {
288        let temp_dir = TempDir::new().unwrap();
289        let pyproject_toml = temp_dir.path().join("pyproject.toml");
290        fs::write(
291            &pyproject_toml,
292            r#"[tool.uv.workspace]
293members = ["packages/*"]
294
295[project]
296name = "test-workspace"
297"#,
298        )
299        .unwrap();
300
301        let mut finder = PythonProjectFinder::new();
302        finder
303            .visit(&pyproject_toml, &PathBuf::from("pyproject.toml"))
304            .await
305            .unwrap();
306
307        let projects = finder.projects();
308        assert_eq!(projects.len(), 1);
309        let ws = projects[0].expect_workspace();
310        assert_eq!(ws.name(), Some("test-workspace"));
311        assert_eq!(ws.version(), None);
312
313        temp_dir.close().unwrap();
314    }
315
316    #[tokio::test]
317    async fn test_python_project_finder_visit_non_pyproject_file() {
318        let temp_dir = TempDir::new().unwrap();
319        let other_file = temp_dir.path().join("other.txt");
320        fs::write(&other_file, "some content").unwrap();
321
322        let mut finder = PythonProjectFinder::new();
323        finder
324            .visit(&other_file, &PathBuf::from("other.txt"))
325            .await
326            .unwrap();
327
328        assert_eq!(finder.projects().len(), 0);
329
330        temp_dir.close().unwrap();
331    }
332
333    #[tokio::test]
334    async fn test_python_project_finder_visit_directory() {
335        let temp_dir = TempDir::new().unwrap();
336        let pyproject_toml = temp_dir.path().join("pyproject.toml");
337        fs::write(
338            &pyproject_toml,
339            r#"[project]
340name = "test-package"
341version = "1.0.0"
342"#,
343        )
344        .unwrap();
345
346        let mut finder = PythonProjectFinder::new();
347        // Pass directory instead of file
348        finder
349            .visit(temp_dir.path(), &PathBuf::from("."))
350            .await
351            .unwrap();
352
353        assert_eq!(finder.projects().len(), 0);
354
355        temp_dir.close().unwrap();
356    }
357
358    #[tokio::test]
359    async fn test_python_project_finder_visit_duplicate() {
360        let temp_dir = TempDir::new().unwrap();
361        let pyproject_toml = temp_dir.path().join("pyproject.toml");
362        fs::write(
363            &pyproject_toml,
364            r#"[project]
365name = "test-package"
366version = "1.0.0"
367"#,
368        )
369        .unwrap();
370
371        let mut finder = PythonProjectFinder::new();
372        finder
373            .visit(&pyproject_toml, &PathBuf::from("pyproject.toml"))
374            .await
375            .unwrap();
376
377        assert_eq!(finder.projects().len(), 1);
378
379        // Visit again - should not add duplicate
380        finder
381            .visit(&pyproject_toml, &PathBuf::from("pyproject.toml"))
382            .await
383            .unwrap();
384
385        assert_eq!(finder.projects().len(), 1);
386
387        temp_dir.close().unwrap();
388    }
389
390    #[tokio::test]
391    async fn test_python_project_finder_visit_multiple_packages() {
392        let temp_dir = TempDir::new().unwrap();
393        let pyproject_toml1 = temp_dir.path().join("package1").join("pyproject.toml");
394        fs::create_dir_all(pyproject_toml1.parent().unwrap()).unwrap();
395        fs::write(
396            &pyproject_toml1,
397            r#"[project]
398name = "package1"
399version = "1.0.0"
400"#,
401        )
402        .unwrap();
403
404        let pyproject_toml2 = temp_dir.path().join("package2").join("pyproject.toml");
405        fs::create_dir_all(pyproject_toml2.parent().unwrap()).unwrap();
406        fs::write(
407            &pyproject_toml2,
408            r#"[project]
409name = "package2"
410version = "2.0.0"
411"#,
412        )
413        .unwrap();
414
415        let mut finder = PythonProjectFinder::new();
416        finder
417            .visit(&pyproject_toml1, &PathBuf::from("package1/pyproject.toml"))
418            .await
419            .unwrap();
420        finder
421            .visit(&pyproject_toml2, &PathBuf::from("package2/pyproject.toml"))
422            .await
423            .unwrap();
424
425        let projects = finder.projects();
426        assert_eq!(projects.len(), 2);
427
428        temp_dir.close().unwrap();
429    }
430
431    #[tokio::test]
432    async fn test_python_project_finder_projects_mut() {
433        let temp_dir = TempDir::new().unwrap();
434        let pyproject_toml = temp_dir.path().join("pyproject.toml");
435        fs::write(
436            &pyproject_toml,
437            r#"[project]
438name = "test-package"
439version = "1.0.0"
440"#,
441        )
442        .unwrap();
443
444        let mut finder = PythonProjectFinder::new();
445        finder
446            .visit(&pyproject_toml, &PathBuf::from("pyproject.toml"))
447            .await
448            .unwrap();
449
450        let mut_projects = finder.projects_mut();
451        assert_eq!(mut_projects.len(), 1);
452
453        temp_dir.close().unwrap();
454    }
455
456    #[tokio::test]
457    async fn test_python_project_finder_visit_package_without_project_section() {
458        // Regression: a pyproject.toml with only `[build-system]` (no
459        // `[project]`, no `[tool.uv.workspace]`) is a legitimate PEP 517
460        // shape used e.g. by build-only backend configs. The finder must
461        // register it as a Package with `None` name/version rather than
462        // failing hard — `write_pyproject_version` already handles the
463        // missing-section case downstream (it creates `[project]` on
464        // demand), so the extraction here must be lenient too.
465        let temp_dir = TempDir::new().unwrap();
466        let pyproject_toml = temp_dir.path().join("pyproject.toml");
467        fs::write(
468            &pyproject_toml,
469            r#"[build-system]
470requires = ["setuptools"]
471"#,
472        )
473        .unwrap();
474
475        let mut finder = PythonProjectFinder::new();
476        finder
477            .visit(&pyproject_toml, &PathBuf::from("pyproject.toml"))
478            .await
479            .unwrap();
480
481        let mut projects = finder.projects_mut();
482        assert_eq!(projects.len(), 1);
483        let project = projects.pop().unwrap();
484        assert!(matches!(&project, Project::Package(_)));
485        assert_eq!(project.name(), None);
486        assert_eq!(project.version(), None);
487        assert!(!project.is_publishable_by_default());
488
489        project.set_name("repository-name".to_string());
490
491        assert_eq!(project.name(), Some("repository-name"));
492        assert!(!project.is_publishable_by_default());
493
494        temp_dir.close().unwrap();
495    }
496
497    #[tokio::test]
498    async fn test_python_project_finder_visit_workspace_without_project_section() {
499        // Regression: uv workspace-only roots (the docs' canonical
500        // example) declare just `[tool.uv.workspace]` at the repo root
501        // with no `[project]` table at all. Members supply their own
502        // `[project]` sections. The finder must register the root as a
503        // `Project::Workspace` with `None` name/version, mirroring how
504        // `PythonWorkspace::update_version` (see
505        // `test_python_workspace_update_version_without_project_section`)
506        // already handles the missing-section case downstream.
507        let temp_dir = TempDir::new().unwrap();
508        let pyproject_toml = temp_dir.path().join("pyproject.toml");
509        fs::write(
510            &pyproject_toml,
511            r#"[tool.uv.workspace]
512members = ["packages/*"]
513"#,
514        )
515        .unwrap();
516
517        let mut finder = PythonProjectFinder::new();
518        finder
519            .visit(&pyproject_toml, &PathBuf::from("pyproject.toml"))
520            .await
521            .unwrap();
522
523        let projects = finder.projects();
524        assert_eq!(projects.len(), 1);
525        let ws = projects[0].expect_workspace();
526        assert_eq!(ws.name(), None);
527        assert_eq!(ws.version(), None);
528        assert!(!ws.is_publishable_by_default());
529
530        temp_dir.close().unwrap();
531    }
532
533    #[tokio::test]
534    async fn test_workspace_only_root_fallback_name_does_not_enable_default_publish() {
535        let temp_dir = TempDir::new().unwrap();
536        let pyproject_toml = temp_dir.path().join("pyproject.toml");
537        fs::write(
538            &pyproject_toml,
539            r#"[tool.uv.workspace]
540members = ["packages/*"]
541"#,
542        )
543        .unwrap();
544
545        let mut finder = PythonProjectFinder::new();
546        finder
547            .visit(&pyproject_toml, &PathBuf::from("pyproject.toml"))
548            .await
549            .unwrap();
550
551        let mut projects = finder.projects_mut();
552        assert_eq!(projects.len(), 1);
553        let project = projects.pop().unwrap();
554        assert!(!project.is_publishable_by_default());
555
556        project.set_name("repository-name".to_string());
557
558        assert_eq!(project.name(), Some("repository-name"));
559        assert!(!project.is_publishable_by_default());
560
561        temp_dir.close().unwrap();
562    }
563
564    #[tokio::test]
565    async fn test_python_project_finder_visit_registers_uv_sources_dependencies() {
566        // Regression: `[tool.uv.sources]` is a TOML **table** keyed by
567        // dependency name (`pkg-a = { path = "..." }`), not an array of
568        // strings. The finder must iterate it as a table so Python
569        // workspaces feed real deps into `sort_by_dependencies` for
570        // topological publish order.
571        let temp_dir = TempDir::new().unwrap();
572        let pyproject_toml = temp_dir.path().join("pyproject.toml");
573        fs::write(
574            &pyproject_toml,
575            r#"[tool.uv.workspace]
576members = ["packages/*"]
577
578	[tool.uv.sources]
579		pkg-a = { path = "packages/pkg-a", editable = true }
580		pkg-b = { workspace = true }
581		pkg-c = { git = "https://example.com/pkg-c.git", editable = true }
582		pkg-d = { url = "https://example.com/pkg-d.tar.gz" }
583		pkg-e = { workspace = false }
584
585	[project]
586	name = "test-workspace"
587version = "1.0.0"
588"#,
589        )
590        .unwrap();
591
592        let mut finder = PythonProjectFinder::new();
593        finder
594            .visit(&pyproject_toml, &PathBuf::from("pyproject.toml"))
595            .await
596            .unwrap();
597
598        let projects = finder.projects();
599        assert_eq!(projects.len(), 1);
600        let deps = projects[0].expect_workspace().dependencies();
601        assert_eq!(
602            deps.len(),
603            2,
604            "expected only local tool.uv.sources entries, got {deps:?}"
605        );
606        assert!(deps.contains("pkg-a"), "missing pkg-a in {deps:?}");
607        assert!(deps.contains("pkg-b"), "missing pkg-b in {deps:?}");
608        assert!(!deps.contains("pkg-c"), "unexpected pkg-c in {deps:?}");
609        assert!(!deps.contains("pkg-d"), "unexpected pkg-d in {deps:?}");
610        assert!(!deps.contains("pkg-e"), "unexpected pkg-e in {deps:?}");
611
612        temp_dir.close().unwrap();
613    }
614
615    #[tokio::test]
616    async fn test_python_project_finder_skips_non_table_uv_sources() {
617        // Pins the outer shape guard in `add_uv_source_dependencies`:
618        // `[tool.uv].sources` that is NOT table-like (here a bare string)
619        // is skipped leniently instead of panicking or being iterated, so
620        // `visit` still succeeds and simply registers no dependencies.
621        let temp_dir = TempDir::new().unwrap();
622        let pyproject_toml = temp_dir.path().join("pyproject.toml");
623        fs::write(
624            &pyproject_toml,
625            r#"[tool.uv]
626sources = "packages/pkg-a"
627
628[project]
629name = "test-package"
630version = "1.0.0"
631"#,
632        )
633        .unwrap();
634
635        let mut finder = PythonProjectFinder::new();
636        finder
637            .visit(&pyproject_toml, &PathBuf::from("pyproject.toml"))
638            .await
639            .unwrap();
640
641        let projects = finder.projects();
642        assert_eq!(projects.len(), 1);
643        let deps = projects[0].dependencies();
644        assert!(
645            deps.is_empty(),
646            "scalar tool.uv.sources must register no dependencies, got {deps:?}"
647        );
648
649        temp_dir.close().unwrap();
650    }
651
652    #[tokio::test]
653    async fn test_python_project_finder_skips_non_table_uv_source_entry() {
654        // Pins the per-entry shape guard in `add_uv_source_dependencies`:
655        // inside a valid `[tool.uv.sources]` table, an entry whose value is
656        // not table-like (`pkg-f`) is skipped while well-formed sibling
657        // entries (`pkg-a`) are still registered.
658        let temp_dir = TempDir::new().unwrap();
659        let pyproject_toml = temp_dir.path().join("pyproject.toml");
660        fs::write(
661            &pyproject_toml,
662            r#"[project]
663name = "test-package"
664version = "1.0.0"
665
666[tool.uv.sources]
667pkg-a = { path = "packages/pkg-a" }
668pkg-f = "packages/pkg-f"
669"#,
670        )
671        .unwrap();
672
673        let mut finder = PythonProjectFinder::new();
674        finder
675            .visit(&pyproject_toml, &PathBuf::from("pyproject.toml"))
676            .await
677            .unwrap();
678
679        let projects = finder.projects();
680        assert_eq!(projects.len(), 1);
681        let deps = projects[0].dependencies();
682        assert_eq!(
683            deps.len(),
684            1,
685            "expected only the table-like source entry, got {deps:?}"
686        );
687        assert!(deps.contains("pkg-a"), "missing pkg-a in {deps:?}");
688        assert!(!deps.contains("pkg-f"), "unexpected pkg-f in {deps:?}");
689
690        temp_dir.close().unwrap();
691    }
692
693    #[tokio::test]
694    async fn test_uv_sources_feed_local_graphs_and_ignore_registry_only_names() {
695        // End-to-end counterpart to the Node and Dart finder graph tests: the
696        // per-manifest assertions above only prove which names
697        // `add_uv_source_dependencies` registers, not that the registered set
698        // actually drives publish ordering. Discover two real manifests where
699        // `app` declares one local `[tool.uv.sources]` entry (`core`, a
700        // relative `path`) plus one registry-only entry (`registry-only`,
701        // pinned to a named index), then push both projects through
702        // `sort_by_dependencies`. Inverting the local-source predicate in
703        // `add_uv_source_dependencies` flips the registered set and drops the
704        // `core -> app` edge, so the graph keeps the `[app, core]` input order
705        // and both assertions below fail.
706        use changepacks_utils::sort_by_dependencies;
707
708        let temp_dir = TempDir::new().unwrap();
709        let manifests = [
710            (
711                "core",
712                r#"[project]
713name = "core"
714version = "1.0.0"
715"#,
716            ),
717            (
718                "app",
719                r#"[project]
720name = "app"
721version = "1.0.0"
722dependencies = ["core", "registry-only"]
723
724[tool.uv.sources]
725core = { path = "../core" }
726registry-only = { index = "internal-index" }
727"#,
728            ),
729        ];
730
731        let mut finder = PythonProjectFinder::new();
732        for (directory, contents) in manifests {
733            let path = temp_dir.path().join(directory).join("pyproject.toml");
734            fs::create_dir_all(path.parent().unwrap()).unwrap();
735            fs::write(&path, contents).unwrap();
736            finder
737                .visit(&path, &PathBuf::from(directory).join("pyproject.toml"))
738                .await
739                .unwrap();
740        }
741
742        let projects = finder.projects();
743        assert_eq!(projects.len(), 2);
744        let by_name = |name: &str| {
745            *projects
746                .iter()
747                .find(|project| project.name() == Some(name))
748                .unwrap()
749        };
750        let app = by_name("app");
751        let app_deps = app.dependencies();
752        assert_eq!(
753            app_deps.len(),
754            1,
755            "only the local path source must register, got {app_deps:?}"
756        );
757        assert!(app_deps.contains("core"), "missing core in {app_deps:?}");
758
759        let sorted =
760            sort_by_dependencies(vec![app, by_name("core")]).expect("fixture graph is a DAG");
761        assert_eq!(sorted[0].name(), Some("core"));
762        assert_eq!(sorted[1].name(), Some("app"));
763
764        temp_dir.close().unwrap();
765    }
766
767    #[tokio::test]
768    async fn test_python_project_finder_visit_malformed_manifest() {
769        // Regression: malformed pyproject.toml must fail with path-aware
770        // error context. The error message must include both the manifest
771        // path and "Failed to parse pyproject.toml".
772        let temp_dir = TempDir::new().unwrap();
773        let pyproject_toml = temp_dir.path().join("pyproject.toml");
774        fs::write(&pyproject_toml, "invalid toml [[[").unwrap();
775
776        let mut finder = PythonProjectFinder::new();
777        let result = finder
778            .visit(&pyproject_toml, &PathBuf::from("pyproject.toml"))
779            .await;
780
781        assert!(result.is_err(), "Expected error for malformed manifest");
782        let error_msg = result.unwrap_err().to_string();
783        assert!(
784            error_msg.contains("Failed to parse pyproject.toml"),
785            "Error message missing 'Failed to parse pyproject.toml': {error_msg}"
786        );
787        assert!(
788            error_msg.contains(pyproject_toml.to_string_lossy().as_ref()),
789            "Error message missing path: {error_msg}"
790        );
791
792        temp_dir.close().unwrap();
793    }
794}