Skip to main content

uv_workspace/
workspace.rs

1//! Resolve the current [`ProjectWorkspace`] or [`Workspace`].
2
3use std::collections::{BTreeMap, BTreeSet};
4use std::path::{Path, PathBuf};
5use std::sync::{Arc, Mutex};
6
7use glob::{GlobError, PatternError, glob};
8use itertools::Itertools;
9use rustc_hash::{FxHashMap, FxHashSet};
10use tracing::{debug, trace, warn};
11
12use uv_configuration::DependencyGroupsWithDefaults;
13use uv_distribution_types::{Index, Requirement, RequirementSource};
14use uv_fs::{CWD, Simplified};
15use uv_normalize::{DEV_DEPENDENCIES, GroupName, PackageName};
16use uv_pep440::VersionSpecifiers;
17use uv_pep508::{MarkerTree, VerbatimUrl};
18use uv_pypi_types::{Conflicts, SupportedEnvironments, VerbatimParsedUrl};
19use uv_static::EnvVars;
20use uv_warnings::warn_user_once;
21
22use crate::dependency_groups::{DependencyGroupError, FlatDependencyGroup, FlatDependencyGroups};
23use crate::pyproject::{
24    Project, PyProjectToml, PyprojectTomlError, Source, Sources, ToolUvSources, ToolUvWorkspace,
25};
26
27type WorkspaceMembers = Arc<BTreeMap<PackageName, WorkspaceMember>>;
28
29/// Cache key for workspace discovery.
30///
31/// Given this key, the discovered workspace member list is the same.
32#[derive(Debug, Default, Clone, Hash, PartialEq, Eq)]
33struct WorkspaceCacheKey {
34    workspace_root: PathBuf,
35    discovery_options: DiscoveryOptions,
36}
37
38/// Cache for workspace discovery.
39///
40/// Avoid re-reading the `pyproject.toml` files in a workspace for each member by caching the
41/// workspace members by their workspace root.
42#[derive(Debug, Default, Clone)]
43pub struct WorkspaceCache(Arc<Mutex<FxHashMap<WorkspaceCacheKey, WorkspaceMembers>>>);
44
45#[derive(thiserror::Error, Debug)]
46pub enum WorkspaceError {
47    // Workspace structure errors.
48    #[error("No `pyproject.toml` found in current directory or any parent directory")]
49    MissingPyprojectToml,
50    #[error("Workspace member `{}` is missing a `pyproject.toml` (matches: `{}`)", _0.simplified_display(), _1)]
51    MissingPyprojectTomlMember(PathBuf, String),
52    #[error("No `project` table found in: {}", _0.simplified_display())]
53    MissingProject(PathBuf),
54    #[error("No workspace found for: {}", _0.simplified_display())]
55    MissingWorkspace(PathBuf),
56    #[error("The project is marked as unmanaged: {}", _0.simplified_display())]
57    NonWorkspace(PathBuf),
58    #[error("Nested workspaces are not supported, but workspace member has a `tool.uv.workspace` table: {}", _0.simplified_display())]
59    NestedWorkspace(PathBuf),
60    #[error("The workspace does not have a member {}: {}", _0, _1.simplified_display())]
61    NoSuchMember(PackageName, PathBuf),
62    #[error("Two workspace members are both named `{name}`: `{}` and `{}`", first.simplified_display(), second.simplified_display())]
63    DuplicatePackage {
64        name: PackageName,
65        first: PathBuf,
66        second: PathBuf,
67    },
68    #[error("pyproject.toml section is declared as dynamic, but must be static: `{0}`")]
69    DynamicNotAllowed(&'static str),
70    #[error(
71        "Workspace member `{}` was requested as both `editable = true` and `editable = false`",
72        _0
73    )]
74    EditableConflict(PackageName),
75    #[error("Failed to find directories for glob: `{0}`")]
76    Pattern(String, #[source] PatternError),
77    // Syntax and other errors.
78    #[error("Directory walking failed for `tool.uv.workspace.members` glob: `{0}`")]
79    GlobWalk(String, #[source] GlobError),
80    #[error(transparent)]
81    Io(#[from] std::io::Error),
82    #[error("Failed to parse: `{}`", _0.user_display())]
83    Toml(PathBuf, #[source] Box<PyprojectTomlError>),
84    #[error("Failed to normalize workspace member path")]
85    Normalize(#[source] std::io::Error),
86}
87
88#[derive(Debug, Default, Clone, Hash, PartialEq, Eq)]
89pub enum MemberDiscovery {
90    /// Discover all workspace members.
91    #[default]
92    All,
93    /// Don't discover any workspace members.
94    None,
95    /// Discover workspace members, but ignore the given paths.
96    Ignore(BTreeSet<PathBuf>),
97}
98
99/// Whether a "project" must be defined via a `[project]` table.
100#[derive(Debug, Default, Clone, Hash, PartialEq, Eq)]
101pub enum ProjectDiscovery {
102    /// The `[project]` table is optional; when missing, the target is treated as a virtual
103    /// project with only dependency groups.
104    #[default]
105    Optional,
106    /// A `[project]` table must be defined.
107    ///
108    /// If not defined, discovery will fail.
109    Required,
110}
111
112impl ProjectDiscovery {
113    /// Whether a `[project]` table is required.
114    pub fn allows_implicit_workspace(&self) -> bool {
115        matches!(self, Self::Optional)
116    }
117
118    /// Whether a non-project workspace root is allowed.
119    pub fn allows_non_project_workspace(&self) -> bool {
120        matches!(self, Self::Optional)
121    }
122}
123
124#[derive(Debug, Default, Clone, Hash, PartialEq, Eq)]
125pub struct DiscoveryOptions {
126    /// The path to stop discovery at.
127    pub stop_discovery_at: Option<PathBuf>,
128    /// The strategy to use when discovering workspace members.
129    pub members: MemberDiscovery,
130    /// The strategy to use when discovering the project.
131    pub project: ProjectDiscovery,
132}
133
134pub type RequiresPythonSources = BTreeMap<(PackageName, Option<GroupName>), VersionSpecifiers>;
135
136pub type Editability = Option<bool>;
137
138/// A workspace, consisting of a root directory and members. See [`ProjectWorkspace`].
139#[derive(Debug, Clone)]
140#[cfg_attr(test, derive(serde::Serialize))]
141pub struct Workspace {
142    /// The path to the workspace root.
143    ///
144    /// The workspace root is the directory containing the top level `pyproject.toml` with
145    /// the `uv.tool.workspace`, or the `pyproject.toml` in an implicit single workspace project.
146    install_path: PathBuf,
147    /// The members of the workspace.
148    packages: WorkspaceMembers,
149    /// The workspace members that are required by other members, and whether they were requested
150    /// as editable.
151    required_members: BTreeMap<PackageName, Editability>,
152    /// The sources table from the workspace `pyproject.toml`.
153    ///
154    /// This table is overridden by the project sources.
155    sources: BTreeMap<PackageName, Sources>,
156    /// The index table from the workspace `pyproject.toml`.
157    ///
158    /// This table is overridden by the project indexes.
159    indexes: Vec<Index>,
160    /// The `pyproject.toml` of the workspace root.
161    pyproject_toml: PyProjectToml,
162}
163
164impl Workspace {
165    /// Find the workspace containing the given path.
166    ///
167    /// Unlike the [`ProjectWorkspace`] discovery, this does not require a current project. It also
168    /// always uses absolute path, i.e., this method only supports discovering the main workspace.
169    ///
170    /// Steps of workspace discovery: Start by looking at the closest `pyproject.toml`:
171    /// * If it's an explicit workspace root: Collect workspace from this root, we're done.
172    /// * If it's also not a project: Error, must be either a workspace root or a project.
173    /// * Otherwise, try to find an explicit workspace root above:
174    ///   * If an explicit workspace root exists: Collect workspace from this root, we're done.
175    ///   * If there is no explicit workspace: We have a single project workspace, we're done.
176    ///
177    /// Note that there are two kinds of workspace roots: projects, and non-project roots.
178    /// The non-project roots lack a `[project]` table, and so are not themselves projects, as in:
179    /// ```toml
180    /// [tool.uv.workspace]
181    /// members = ["packages/*"]
182    ///
183    /// [tool.uv]
184    /// dev-dependencies = ["ruff"]
185    /// ```
186    pub async fn discover(
187        path: &Path,
188        options: &DiscoveryOptions,
189        cache: &WorkspaceCache,
190    ) -> Result<Self, WorkspaceError> {
191        let path = std::path::absolute(path)
192            .map_err(WorkspaceError::Normalize)?
193            .clone();
194        // Remove `.` and `..`
195        let path = uv_fs::normalize_path(&path);
196        // Trim trailing slashes.
197        let path = path.components().collect::<PathBuf>();
198
199        let project_path = path
200            .ancestors()
201            .find(|path| path.join("pyproject.toml").is_file())
202            .ok_or(WorkspaceError::MissingPyprojectToml)?
203            .to_path_buf();
204
205        let pyproject_path = project_path.join("pyproject.toml");
206        let contents = fs_err::tokio::read_to_string(&pyproject_path).await?;
207        let pyproject_toml = PyProjectToml::from_string(contents, &pyproject_path)
208            .map_err(|err| WorkspaceError::Toml(pyproject_path.clone(), Box::new(err)))?;
209
210        // Check if the project is explicitly marked as unmanaged.
211        if pyproject_toml
212            .tool
213            .as_ref()
214            .and_then(|tool| tool.uv.as_ref())
215            .and_then(|uv| uv.managed)
216            == Some(false)
217        {
218            debug!(
219                "Project `{}` is marked as unmanaged",
220                project_path.simplified_display()
221            );
222            return Err(WorkspaceError::NonWorkspace(project_path));
223        }
224
225        // Check if the current project is also an explicit workspace root.
226        let explicit_root = pyproject_toml
227            .tool
228            .as_ref()
229            .and_then(|tool| tool.uv.as_ref())
230            .and_then(|uv| uv.workspace.as_ref())
231            .map(|workspace| {
232                (
233                    project_path.clone(),
234                    workspace.clone(),
235                    pyproject_toml.clone(),
236                )
237            });
238
239        let (workspace_root, workspace_definition, workspace_pyproject_toml) =
240            if let Some(workspace) = explicit_root {
241                // We have found the explicit root immediately.
242                workspace
243            } else if pyproject_toml.project.is_none() {
244                // Without a project, it can't be an implicit root
245                return Err(WorkspaceError::MissingProject(pyproject_path));
246            } else if let Some(workspace) = find_workspace(&project_path, options).await? {
247                // We have found an explicit root above.
248                workspace
249            } else {
250                // Support implicit single project workspaces.
251                (
252                    project_path.clone(),
253                    ToolUvWorkspace::default(),
254                    pyproject_toml.clone(),
255                )
256            };
257
258        debug!(
259            "Found workspace root: `{}`",
260            workspace_root.simplified_display()
261        );
262
263        // Unlike in `ProjectWorkspace` discovery, we might be in a non-project root without
264        // being in any specific project.
265        let current_project = pyproject_toml
266            .project
267            .clone()
268            .map(|project| WorkspaceMember {
269                root: project_path,
270                project,
271                pyproject_toml,
272            });
273
274        Self::collect_members(
275            workspace_root.clone(),
276            workspace_definition,
277            workspace_pyproject_toml,
278            current_project,
279            options,
280            cache,
281        )
282        .await
283    }
284
285    /// Set the current project to the given workspace member.
286    ///
287    /// Returns `None` if the package is not part of the workspace.
288    pub fn with_current_project(self, package_name: PackageName) -> Option<ProjectWorkspace> {
289        let member = self.packages.get(&package_name)?;
290        Some(ProjectWorkspace {
291            project_root: member.root().clone(),
292            project_name: package_name,
293            workspace: self,
294        })
295    }
296
297    /// Set the [`ProjectWorkspace`] for a given workspace member.
298    ///
299    /// Assumes that the project name is unchanged in the updated [`PyProjectToml`].
300    pub fn update_member(
301        self,
302        package_name: &PackageName,
303        pyproject_toml: PyProjectToml,
304    ) -> Result<Option<Self>, WorkspaceError> {
305        let mut packages = self.packages;
306
307        let Some(member) = Arc::make_mut(&mut packages).get_mut(package_name) else {
308            return Ok(None);
309        };
310
311        if member.root == self.install_path {
312            // If the member is also the workspace root, update _both_ the member entry and the
313            // root `pyproject.toml`.
314            let workspace_pyproject_toml = pyproject_toml.clone();
315
316            // Refresh the workspace sources.
317            let workspace_sources = workspace_pyproject_toml
318                .tool
319                .clone()
320                .and_then(|tool| tool.uv)
321                .and_then(|uv| uv.sources)
322                .map(ToolUvSources::into_inner)
323                .unwrap_or_default();
324
325            // Set the `pyproject.toml` for the member.
326            member.pyproject_toml = pyproject_toml;
327
328            // Recompute required_members with the updated data
329            let required_members = Self::collect_required_members(
330                &packages,
331                &workspace_sources,
332                &workspace_pyproject_toml,
333            )?;
334
335            Ok(Some(Self {
336                pyproject_toml: workspace_pyproject_toml,
337                sources: workspace_sources,
338                packages,
339                required_members,
340                ..self
341            }))
342        } else {
343            // Set the `pyproject.toml` for the member.
344            member.pyproject_toml = pyproject_toml;
345
346            // Recompute required_members with the updated member data
347            let required_members =
348                Self::collect_required_members(&packages, &self.sources, &self.pyproject_toml)?;
349
350            Ok(Some(Self {
351                packages,
352                required_members,
353                ..self
354            }))
355        }
356    }
357
358    /// Returns `true` if the workspace has a non-project root.
359    pub fn is_non_project(&self) -> bool {
360        !self
361            .packages
362            .values()
363            .any(|member| *member.root() == self.install_path)
364    }
365
366    /// Returns the set of all workspace members.
367    pub fn members_requirements(&self) -> impl Iterator<Item = Requirement> + '_ {
368        self.packages.iter().filter_map(|(name, member)| {
369            let url = VerbatimUrl::from_absolute_path(&member.root)
370                .expect("path is valid URL")
371                .with_given(member.root.to_string_lossy());
372            Some(Requirement {
373                name: member.pyproject_toml.project.as_ref()?.name.clone(),
374                extras: Box::new([]),
375                groups: Box::new([]),
376                marker: MarkerTree::TRUE,
377                source: if member
378                    .pyproject_toml()
379                    .is_package(!self.is_required_member(name))
380                {
381                    RequirementSource::Directory {
382                        install_path: member.root.clone().into_boxed_path(),
383                        editable: Some(
384                            self.required_members
385                                .get(name)
386                                .copied()
387                                .flatten()
388                                .unwrap_or(true),
389                        ),
390                        r#virtual: Some(false),
391                        url,
392                    }
393                } else {
394                    RequirementSource::Directory {
395                        install_path: member.root.clone().into_boxed_path(),
396                        editable: Some(false),
397                        r#virtual: Some(true),
398                        url,
399                    }
400                },
401                origin: None,
402            })
403        })
404    }
405
406    /// The workspace members that are required my another member of the workspace.
407    pub fn required_members(&self) -> &BTreeMap<PackageName, Editability> {
408        &self.required_members
409    }
410
411    /// Compute the workspace members that are required by another member of the workspace, and
412    /// determine whether they should be installed as editable or non-editable.
413    ///
414    /// N.B. this checks if a workspace member is required by inspecting `tool.uv.source` entries,
415    /// but does not actually check if the source is _used_, which could result in false positives
416    /// but is easier to compute.
417    fn collect_required_members(
418        packages: &BTreeMap<PackageName, WorkspaceMember>,
419        sources: &BTreeMap<PackageName, Sources>,
420        pyproject_toml: &PyProjectToml,
421    ) -> Result<BTreeMap<PackageName, Editability>, WorkspaceError> {
422        let mut required_members = BTreeMap::new();
423
424        for (package, sources) in sources
425            .iter()
426            .filter(|(name, _)| {
427                pyproject_toml
428                    .project
429                    .as_ref()
430                    .is_none_or(|project| project.name != **name)
431            })
432            .chain(
433                packages
434                    .iter()
435                    .filter_map(|(name, member)| {
436                        member
437                            .pyproject_toml
438                            .tool
439                            .as_ref()
440                            .and_then(|tool| tool.uv.as_ref())
441                            .and_then(|uv| uv.sources.as_ref())
442                            .map(ToolUvSources::inner)
443                            .map(move |sources| {
444                                sources
445                                    .iter()
446                                    .filter(move |(source_name, _)| name != *source_name)
447                            })
448                    })
449                    .flatten(),
450            )
451        {
452            for source in sources.iter() {
453                let Source::Workspace { editable, .. } = &source else {
454                    continue;
455                };
456                let existing = required_members.insert(package.clone(), *editable);
457                if let Some(Some(existing)) = existing {
458                    if let Some(editable) = editable {
459                        // If there are conflicting `editable` values, raise an error.
460                        if existing != *editable {
461                            return Err(WorkspaceError::EditableConflict(package.clone()));
462                        }
463                    }
464                }
465            }
466        }
467
468        Ok(required_members)
469    }
470
471    /// Whether a given workspace member is required by another member.
472    pub fn is_required_member(&self, name: &PackageName) -> bool {
473        self.required_members().contains_key(name)
474    }
475
476    /// Returns the set of all workspace member dependency groups.
477    pub fn group_requirements(&self) -> impl Iterator<Item = Requirement> + '_ {
478        self.packages.iter().filter_map(|(name, member)| {
479            let url = VerbatimUrl::from_absolute_path(&member.root)
480                .expect("path is valid URL")
481                .with_given(member.root.to_string_lossy());
482
483            let groups = {
484                let mut groups = member
485                    .pyproject_toml
486                    .dependency_groups
487                    .as_ref()
488                    .map(|groups| groups.keys().cloned().collect::<Vec<_>>())
489                    .unwrap_or_default();
490                if member
491                    .pyproject_toml
492                    .tool
493                    .as_ref()
494                    .and_then(|tool| tool.uv.as_ref())
495                    .and_then(|uv| uv.dev_dependencies.as_ref())
496                    .is_some()
497                {
498                    groups.push(DEV_DEPENDENCIES.clone());
499                    groups.sort_unstable();
500                }
501                groups
502            };
503            if groups.is_empty() {
504                return None;
505            }
506
507            let value = self.required_members.get(name);
508            let is_required_member = value.is_some();
509            let editability = value.copied().flatten();
510
511            Some(Requirement {
512                name: member.pyproject_toml.project.as_ref()?.name.clone(),
513                extras: Box::new([]),
514                groups: groups.into_boxed_slice(),
515                marker: MarkerTree::TRUE,
516                source: if member.pyproject_toml().is_package(!is_required_member) {
517                    RequirementSource::Directory {
518                        install_path: member.root.clone().into_boxed_path(),
519                        editable: Some(editability.unwrap_or(true)),
520                        r#virtual: Some(false),
521                        url,
522                    }
523                } else {
524                    RequirementSource::Directory {
525                        install_path: member.root.clone().into_boxed_path(),
526                        editable: Some(false),
527                        r#virtual: Some(true),
528                        url,
529                    }
530                },
531                origin: None,
532            })
533        })
534    }
535
536    /// Returns the set of supported environments for the workspace.
537    pub fn environments(&self) -> Option<&SupportedEnvironments> {
538        self.pyproject_toml
539            .tool
540            .as_ref()
541            .and_then(|tool| tool.uv.as_ref())
542            .and_then(|uv| uv.environments.as_ref())
543    }
544
545    /// Returns the set of required platforms for the workspace.
546    pub fn required_environments(&self) -> Option<&SupportedEnvironments> {
547        self.pyproject_toml
548            .tool
549            .as_ref()
550            .and_then(|tool| tool.uv.as_ref())
551            .and_then(|uv| uv.required_environments.as_ref())
552    }
553
554    /// Returns the set of conflicts for the workspace.
555    pub fn conflicts(&self) -> Conflicts {
556        let mut conflicting = Conflicts::empty();
557        for member in self.packages.values() {
558            conflicting.append(&mut member.pyproject_toml.conflicts());
559        }
560        conflicting
561    }
562
563    /// Returns an iterator over the `requires-python` values for each member of the workspace.
564    pub fn requires_python(
565        &self,
566        groups: &DependencyGroupsWithDefaults,
567    ) -> Result<RequiresPythonSources, DependencyGroupError> {
568        let mut requires = RequiresPythonSources::new();
569        for (name, member) in self.packages() {
570            // Get the top-level requires-python for this package, which is always active
571            //
572            // Arguably we could check groups.prod() to disable this, since, the requires-python
573            // of the project is *technically* not relevant if you're doing `--only-group`, but,
574            // that would be a big surprising change so let's *not* do that until someone asks!
575            let top_requires = member
576                .pyproject_toml()
577                .project
578                .as_ref()
579                .and_then(|project| project.requires_python.as_ref())
580                .map(|requires_python| ((name.to_owned(), None), requires_python.clone()));
581            requires.extend(top_requires);
582
583            // Get the requires-python for each enabled group on this package
584            // We need to do full flattening here because include-group can transfer requires-python
585            let dependency_groups =
586                FlatDependencyGroups::from_pyproject_toml(member.root(), &member.pyproject_toml)?;
587            let group_requires =
588                dependency_groups
589                    .into_iter()
590                    .filter_map(move |(group_name, flat_group)| {
591                        if groups.contains(&group_name) {
592                            flat_group.requires_python.map(|requires_python| {
593                                ((name.to_owned(), Some(group_name)), requires_python)
594                            })
595                        } else {
596                            None
597                        }
598                    });
599            requires.extend(group_requires);
600        }
601        Ok(requires)
602    }
603
604    /// Returns any requirements that are exclusive to the workspace root, i.e., not included in
605    /// any of the workspace members.
606    ///
607    /// For now, there are no such requirements.
608    pub fn requirements(&self) -> Vec<uv_pep508::Requirement<VerbatimParsedUrl>> {
609        Vec::new()
610    }
611
612    /// Returns any dependency groups that are exclusive to the workspace root, i.e., not included
613    /// in any of the workspace members.
614    ///
615    /// For workspaces with non-`[project]` roots, returns the dependency groups defined in the
616    /// corresponding `pyproject.toml`.
617    ///
618    /// Otherwise, returns an empty list.
619    pub fn workspace_dependency_groups(
620        &self,
621    ) -> Result<BTreeMap<GroupName, FlatDependencyGroup>, DependencyGroupError> {
622        if self
623            .packages
624            .values()
625            .any(|member| *member.root() == self.install_path)
626        {
627            // If the workspace has an explicit root, the root is a member, so we don't need to
628            // include any root-only requirements.
629            Ok(BTreeMap::default())
630        } else {
631            // Otherwise, return the dependency groups in the non-project workspace root.
632            let dependency_groups = FlatDependencyGroups::from_pyproject_toml(
633                &self.install_path,
634                &self.pyproject_toml,
635            )?;
636            Ok(dependency_groups.into_inner())
637        }
638    }
639
640    /// Returns the set of overrides for the workspace.
641    pub fn overrides(&self) -> Vec<uv_pep508::Requirement<VerbatimParsedUrl>> {
642        let Some(overrides) = self
643            .pyproject_toml
644            .tool
645            .as_ref()
646            .and_then(|tool| tool.uv.as_ref())
647            .and_then(|uv| uv.override_dependencies.as_ref())
648        else {
649            return vec![];
650        };
651        overrides.clone()
652    }
653
654    /// Returns the set of dependency exclusions for the workspace.
655    pub fn exclude_dependencies(&self) -> Vec<uv_normalize::PackageName> {
656        let Some(excludes) = self
657            .pyproject_toml
658            .tool
659            .as_ref()
660            .and_then(|tool| tool.uv.as_ref())
661            .and_then(|uv| uv.exclude_dependencies.as_ref())
662        else {
663            return vec![];
664        };
665        excludes.clone()
666    }
667
668    /// Returns the set of constraints for the workspace.
669    pub fn constraints(&self) -> Vec<uv_pep508::Requirement<VerbatimParsedUrl>> {
670        let Some(constraints) = self
671            .pyproject_toml
672            .tool
673            .as_ref()
674            .and_then(|tool| tool.uv.as_ref())
675            .and_then(|uv| uv.constraint_dependencies.as_ref())
676        else {
677            return vec![];
678        };
679        constraints.clone()
680    }
681
682    /// Returns the set of build constraints for the workspace.
683    pub fn build_constraints(&self) -> Vec<uv_pep508::Requirement<VerbatimParsedUrl>> {
684        let Some(build_constraints) = self
685            .pyproject_toml
686            .tool
687            .as_ref()
688            .and_then(|tool| tool.uv.as_ref())
689            .and_then(|uv| uv.build_constraint_dependencies.as_ref())
690        else {
691            return vec![];
692        };
693        build_constraints.clone()
694    }
695
696    /// The path to the workspace root, the directory containing the top level `pyproject.toml` with
697    /// the `uv.tool.workspace`, or the `pyproject.toml` in an implicit single workspace project.
698    pub fn install_path(&self) -> &PathBuf {
699        &self.install_path
700    }
701
702    /// The path to the workspace virtual environment.
703    ///
704    /// Uses `.venv` in the install path directory by default.
705    ///
706    /// If `UV_PROJECT_ENVIRONMENT` is set, it will take precedence. If a relative path is provided,
707    /// it is resolved relative to the install path.
708    ///
709    /// If `active` is `true`, the `VIRTUAL_ENV` variable will be preferred. If it is `false`, any
710    /// warnings about mismatch between the active environment and the project environment will be
711    /// silenced.
712    pub fn venv(&self, active: Option<bool>) -> PathBuf {
713        /// Resolve the `UV_PROJECT_ENVIRONMENT` value, if any.
714        fn from_project_environment_variable(workspace: &Workspace) -> Option<PathBuf> {
715            let value = std::env::var_os(EnvVars::UV_PROJECT_ENVIRONMENT)?;
716
717            if value.is_empty() {
718                return None;
719            }
720
721            let path = PathBuf::from(value);
722            if path.is_absolute() {
723                return Some(path);
724            }
725
726            // Resolve the path relative to the install path.
727            Some(workspace.install_path.join(path))
728        }
729
730        /// Resolve the `VIRTUAL_ENV` variable, if any.
731        fn from_virtual_env_variable() -> Option<PathBuf> {
732            let value = std::env::var_os(EnvVars::VIRTUAL_ENV)?;
733
734            if value.is_empty() {
735                return None;
736            }
737
738            let path = PathBuf::from(value);
739            if path.is_absolute() {
740                return Some(path);
741            }
742
743            // Resolve the path relative to current directory.
744            // Note this differs from `UV_PROJECT_ENVIRONMENT`
745            Some(CWD.join(path))
746        }
747
748        // Determine the default value
749        let project_env = from_project_environment_variable(self)
750            .unwrap_or_else(|| self.install_path.join(".venv"));
751
752        // Warn if it conflicts with `VIRTUAL_ENV`
753        if let Some(from_virtual_env) = from_virtual_env_variable() {
754            if !uv_fs::is_same_file_allow_missing(&from_virtual_env, &project_env).unwrap_or(false)
755            {
756                match active {
757                    Some(true) => {
758                        debug!(
759                            "Using active virtual environment `{}` instead of project environment `{}`",
760                            from_virtual_env.user_display(),
761                            project_env.user_display()
762                        );
763                        return from_virtual_env;
764                    }
765                    Some(false) => {}
766                    None => {
767                        warn_user_once!(
768                            "`VIRTUAL_ENV={}` does not match the project environment path `{}` and will be ignored; use `--active` to target the active environment instead",
769                            from_virtual_env.user_display(),
770                            project_env.user_display()
771                        );
772                    }
773                }
774            }
775        } else {
776            if active.unwrap_or_default() {
777                debug!(
778                    "Use of the active virtual environment was requested, but `VIRTUAL_ENV` is not set"
779                );
780            }
781        }
782
783        project_env
784    }
785
786    /// The members of the workspace.
787    pub fn packages(&self) -> &BTreeMap<PackageName, WorkspaceMember> {
788        &self.packages
789    }
790
791    /// The sources table from the workspace `pyproject.toml`.
792    pub fn sources(&self) -> &BTreeMap<PackageName, Sources> {
793        &self.sources
794    }
795
796    /// The index table from the workspace `pyproject.toml`.
797    pub fn indexes(&self) -> &[Index] {
798        &self.indexes
799    }
800
801    /// The `pyproject.toml` of the workspace.
802    pub fn pyproject_toml(&self) -> &PyProjectToml {
803        &self.pyproject_toml
804    }
805
806    /// Returns `true` if the path is excluded by the workspace.
807    pub fn excludes(&self, project_path: &Path) -> Result<bool, WorkspaceError> {
808        if let Some(workspace) = self
809            .pyproject_toml
810            .tool
811            .as_ref()
812            .and_then(|tool| tool.uv.as_ref())
813            .and_then(|uv| uv.workspace.as_ref())
814        {
815            is_excluded_from_workspace(project_path, &self.install_path, workspace)
816        } else {
817            Ok(false)
818        }
819    }
820
821    /// Returns `true` if the path is included by the workspace.
822    pub fn includes(&self, project_path: &Path) -> Result<bool, WorkspaceError> {
823        if let Some(workspace) = self
824            .pyproject_toml
825            .tool
826            .as_ref()
827            .and_then(|tool| tool.uv.as_ref())
828            .and_then(|uv| uv.workspace.as_ref())
829        {
830            is_included_in_workspace(project_path, &self.install_path, workspace)
831        } else {
832            Ok(false)
833        }
834    }
835
836    /// Collect the workspace member projects from the `members` and `excludes` entries.
837    async fn collect_members(
838        workspace_root: PathBuf,
839        workspace_definition: ToolUvWorkspace,
840        workspace_pyproject_toml: PyProjectToml,
841        current_project: Option<WorkspaceMember>,
842        options: &DiscoveryOptions,
843        cache: &WorkspaceCache,
844    ) -> Result<Self, WorkspaceError> {
845        let cache_key = WorkspaceCacheKey {
846            workspace_root: workspace_root.clone(),
847            discovery_options: options.clone(),
848        };
849        let cache_entry = {
850            // Acquire the lock for the minimal required region
851            let cache = cache.0.lock().expect("there was a panic in another thread");
852            cache.get(&cache_key).cloned()
853        };
854        let mut workspace_members = if let Some(workspace_members) = cache_entry {
855            trace!(
856                "Cached workspace members for: `{}`",
857                &workspace_root.simplified_display()
858            );
859            workspace_members
860        } else {
861            trace!(
862                "Discovering workspace members for: `{}`",
863                &workspace_root.simplified_display()
864            );
865            let workspace_members = Self::collect_members_only(
866                &workspace_root,
867                &workspace_definition,
868                &workspace_pyproject_toml,
869                options,
870            )
871            .await?;
872            {
873                // Acquire the lock for the minimal required region
874                let mut cache = cache.0.lock().expect("there was a panic in another thread");
875                cache.insert(cache_key, Arc::new(workspace_members.clone()));
876            }
877            Arc::new(workspace_members)
878        };
879
880        // For the cases such as `MemberDiscovery::None`, add the current project if missing.
881        if let Some(root_member) = current_project {
882            if !workspace_members.contains_key(&root_member.project.name) {
883                debug!(
884                    "Adding current workspace member: `{}`",
885                    root_member.root.simplified_display()
886                );
887
888                Arc::make_mut(&mut workspace_members)
889                    .insert(root_member.project.name.clone(), root_member);
890            }
891        }
892
893        let workspace_sources = workspace_pyproject_toml
894            .tool
895            .clone()
896            .and_then(|tool| tool.uv)
897            .and_then(|uv| uv.sources)
898            .map(ToolUvSources::into_inner)
899            .unwrap_or_default();
900
901        let workspace_indexes = workspace_pyproject_toml
902            .tool
903            .clone()
904            .and_then(|tool| tool.uv)
905            .and_then(|uv| uv.index)
906            .unwrap_or_default();
907
908        let required_members = Self::collect_required_members(
909            &workspace_members,
910            &workspace_sources,
911            &workspace_pyproject_toml,
912        )?;
913
914        let dev_dependencies_members = workspace_members
915            .iter()
916            .filter_map(|(_, member)| {
917                member
918                    .pyproject_toml
919                    .tool
920                    .as_ref()
921                    .and_then(|tool| tool.uv.as_ref())
922                    .and_then(|uv| uv.dev_dependencies.as_ref())
923                    .map(|_| format!("`{}`", member.root().join("pyproject.toml").user_display()))
924            })
925            .join(", ");
926        if !dev_dependencies_members.is_empty() {
927            warn_user_once!(
928                "The `tool.uv.dev-dependencies` field (used in {}) is deprecated and will be removed in a future release; use `dependency-groups.dev` instead",
929                dev_dependencies_members
930            );
931        }
932
933        Ok(Self {
934            install_path: workspace_root,
935            packages: workspace_members,
936            required_members,
937            sources: workspace_sources,
938            indexes: workspace_indexes,
939            pyproject_toml: workspace_pyproject_toml,
940        })
941    }
942
943    async fn collect_members_only(
944        workspace_root: &PathBuf,
945        workspace_definition: &ToolUvWorkspace,
946        workspace_pyproject_toml: &PyProjectToml,
947        options: &DiscoveryOptions,
948    ) -> Result<BTreeMap<PackageName, WorkspaceMember>, WorkspaceError> {
949        let mut workspace_members = BTreeMap::new();
950        // Avoid reading a `pyproject.toml` more than once.
951        let mut seen = FxHashSet::default();
952
953        // Add the project at the workspace root, if it exists and if it's distinct from the current
954        // project. If it is the current project, it is added as such in the next step.
955        if let Some(project) = &workspace_pyproject_toml.project {
956            let pyproject_path = workspace_root.join("pyproject.toml");
957            let contents = fs_err::read_to_string(&pyproject_path)?;
958            let pyproject_toml = PyProjectToml::from_string(contents, &pyproject_path)
959                .map_err(|err| WorkspaceError::Toml(pyproject_path.clone(), Box::new(err)))?;
960
961            debug!(
962                "Adding root workspace member: `{}`",
963                workspace_root.simplified_display()
964            );
965
966            seen.insert(workspace_root.clone());
967            workspace_members.insert(
968                project.name.clone(),
969                WorkspaceMember {
970                    root: workspace_root.clone(),
971                    project: project.clone(),
972                    pyproject_toml,
973                },
974            );
975        }
976
977        // Add all other workspace members.
978        for member_glob in workspace_definition.clone().members.unwrap_or_default() {
979            // Normalize the member glob to remove leading `./` and other relative path components
980            let normalized_glob = uv_fs::normalize_path(Path::new(member_glob.as_str()));
981            let absolute_glob = PathBuf::from(glob::Pattern::escape(
982                workspace_root.simplified().to_string_lossy().as_ref(),
983            ))
984            .join(normalized_glob.as_ref())
985            .to_string_lossy()
986            .to_string();
987            for member_root in glob(&absolute_glob)
988                .map_err(|err| WorkspaceError::Pattern(absolute_glob.clone(), err))?
989            {
990                let member_root = member_root
991                    .map_err(|err| WorkspaceError::GlobWalk(absolute_glob.clone(), err))?;
992                if !seen.insert(member_root.clone()) {
993                    continue;
994                }
995                let member_root = std::path::absolute(&member_root)
996                    .map_err(WorkspaceError::Normalize)?
997                    .clone();
998
999                // If the directory is explicitly ignored, skip it.
1000                let skip = match &options.members {
1001                    MemberDiscovery::All => false,
1002                    MemberDiscovery::None => true,
1003                    MemberDiscovery::Ignore(ignore) => ignore.contains(member_root.as_path()),
1004                };
1005                if skip {
1006                    debug!(
1007                        "Ignoring workspace member: `{}`",
1008                        member_root.simplified_display()
1009                    );
1010                    continue;
1011                }
1012
1013                // If the member is excluded, ignore it.
1014                if is_excluded_from_workspace(&member_root, workspace_root, workspace_definition)? {
1015                    debug!(
1016                        "Ignoring workspace member: `{}`",
1017                        member_root.simplified_display()
1018                    );
1019                    continue;
1020                }
1021
1022                trace!(
1023                    "Processing workspace member: `{}`",
1024                    member_root.user_display()
1025                );
1026
1027                // Read the member `pyproject.toml`.
1028                let pyproject_path = member_root.join("pyproject.toml");
1029                let contents = match fs_err::tokio::read_to_string(&pyproject_path).await {
1030                    Ok(contents) => contents,
1031                    Err(err) => {
1032                        if !fs_err::metadata(&member_root)?.is_dir() {
1033                            warn!(
1034                                "Ignoring non-directory workspace member: `{}`",
1035                                member_root.simplified_display()
1036                            );
1037                            continue;
1038                        }
1039
1040                        // A directory exists, but it doesn't contain a `pyproject.toml`.
1041                        if err.kind() == std::io::ErrorKind::NotFound {
1042                            // If the directory is hidden, skip it.
1043                            if member_root
1044                                .file_name()
1045                                .map(|name| name.as_encoded_bytes().starts_with(b"."))
1046                                .unwrap_or(false)
1047                            {
1048                                debug!(
1049                                    "Ignoring hidden workspace member: `{}`",
1050                                    member_root.simplified_display()
1051                                );
1052                                continue;
1053                            }
1054
1055                            // If the directory only contains gitignored files
1056                            // (e.g., `__pycache__`), skip it.
1057                            if has_only_gitignored_files(&member_root) {
1058                                debug!(
1059                                    "Ignoring workspace member with only gitignored files: `{}`",
1060                                    member_root.simplified_display()
1061                                );
1062                                continue;
1063                            }
1064
1065                            return Err(WorkspaceError::MissingPyprojectTomlMember(
1066                                member_root,
1067                                member_glob.to_string(),
1068                            ));
1069                        }
1070
1071                        return Err(err.into());
1072                    }
1073                };
1074                let pyproject_toml = PyProjectToml::from_string(contents, &pyproject_path)
1075                    .map_err(|err| WorkspaceError::Toml(pyproject_path.clone(), Box::new(err)))?;
1076
1077                // Check if the current project is explicitly marked as unmanaged.
1078                if pyproject_toml
1079                    .tool
1080                    .as_ref()
1081                    .and_then(|tool| tool.uv.as_ref())
1082                    .and_then(|uv| uv.managed)
1083                    == Some(false)
1084                {
1085                    if let Some(project) = pyproject_toml.project.as_ref() {
1086                        debug!(
1087                            "Project `{}` is marked as unmanaged; omitting from workspace members",
1088                            project.name
1089                        );
1090                    } else {
1091                        debug!(
1092                            "Workspace member at `{}` is marked as unmanaged; omitting from workspace members",
1093                            member_root.simplified_display()
1094                        );
1095                    }
1096                    continue;
1097                }
1098
1099                // Extract the package name.
1100                let Some(project) = pyproject_toml.project.clone() else {
1101                    return Err(WorkspaceError::MissingProject(pyproject_path));
1102                };
1103
1104                debug!(
1105                    "Adding discovered workspace member: `{}`",
1106                    member_root.simplified_display()
1107                );
1108
1109                if let Some(existing) = workspace_members.insert(
1110                    project.name.clone(),
1111                    WorkspaceMember {
1112                        root: member_root.clone(),
1113                        project,
1114                        pyproject_toml,
1115                    },
1116                ) {
1117                    return Err(WorkspaceError::DuplicatePackage {
1118                        name: existing.project.name,
1119                        first: existing.root.clone(),
1120                        second: member_root,
1121                    });
1122                }
1123            }
1124        }
1125
1126        // Test for nested workspaces.
1127        for member in workspace_members.values() {
1128            if member.root() != workspace_root
1129                && member
1130                    .pyproject_toml
1131                    .tool
1132                    .as_ref()
1133                    .and_then(|tool| tool.uv.as_ref())
1134                    .and_then(|uv| uv.workspace.as_ref())
1135                    .is_some()
1136            {
1137                return Err(WorkspaceError::NestedWorkspace(member.root.clone()));
1138            }
1139        }
1140        Ok(workspace_members)
1141    }
1142}
1143
1144/// A project in a workspace.
1145#[derive(Debug, Clone, PartialEq)]
1146#[cfg_attr(test, derive(serde::Serialize))]
1147pub struct WorkspaceMember {
1148    /// The path to the project root.
1149    root: PathBuf,
1150    /// The `[project]` table, from the `pyproject.toml` of the project found at
1151    /// `<root>/pyproject.toml`.
1152    project: Project,
1153    /// The `pyproject.toml` of the project, found at `<root>/pyproject.toml`.
1154    pyproject_toml: PyProjectToml,
1155}
1156
1157impl WorkspaceMember {
1158    /// The path to the project root.
1159    pub fn root(&self) -> &PathBuf {
1160        &self.root
1161    }
1162
1163    /// The `[project]` table, from the `pyproject.toml` of the project found at
1164    /// `<root>/pyproject.toml`.
1165    pub fn project(&self) -> &Project {
1166        &self.project
1167    }
1168
1169    /// The `pyproject.toml` of the project, found at `<root>/pyproject.toml`.
1170    pub fn pyproject_toml(&self) -> &PyProjectToml {
1171        &self.pyproject_toml
1172    }
1173}
1174
1175/// The current project and the workspace it is part of, with all of the workspace members.
1176///
1177/// # Structure
1178///
1179/// The workspace root is a directory with a `pyproject.toml`, all members need to be below that
1180/// directory. The workspace root defines members and exclusions. All packages below it must either
1181/// be a member or excluded. The workspace root can be a package itself or a virtual manifest.
1182///
1183/// For a simple single package project, the workspace root is implicitly the current project root
1184/// and the workspace has only this single member. Otherwise, a workspace root is declared through
1185/// a `tool.uv.workspace` section.
1186///
1187/// A workspace itself does not declare dependencies, instead one member is the current project used
1188/// as main requirement.
1189///
1190/// Each member is a directory with a `pyproject.toml` that contains a `[project]` section. Each
1191/// member is a Python package, with a name, a version and dependencies. Workspace members can
1192/// depend on other workspace members (`foo = { workspace = true }`). You can consider the
1193/// workspace another package source or index, similar to `--find-links`.
1194///
1195/// # Usage
1196///
1197/// There a two main usage patterns: A root package and helpers, and the flat workspace.
1198///
1199/// Root package and helpers:
1200///
1201/// ```text
1202/// albatross
1203/// ├── packages
1204/// │   ├── provider_a
1205/// │   │   ├── pyproject.toml
1206/// │   │   └── src
1207/// │   │       └── provider_a
1208/// │   │           ├── __init__.py
1209/// │   │           └── foo.py
1210/// │   └── provider_b
1211/// │       ├── pyproject.toml
1212/// │       └── src
1213/// │           └── provider_b
1214/// │               ├── __init__.py
1215/// │               └── bar.py
1216/// ├── pyproject.toml
1217/// ├── Readme.md
1218/// ├── uv.lock
1219/// └── src
1220///     └── albatross
1221///         ├── __init__.py
1222///         └── main.py
1223/// ```
1224///
1225/// Flat workspace:
1226///
1227/// ```text
1228/// albatross
1229/// ├── packages
1230/// │   ├── albatross
1231/// │   │   ├── pyproject.toml
1232/// │   │   └── src
1233/// │   │       └── albatross
1234/// │   │           ├── __init__.py
1235/// │   │           └── main.py
1236/// │   ├── provider_a
1237/// │   │   ├── pyproject.toml
1238/// │   │   └── src
1239/// │   │       └── provider_a
1240/// │   │           ├── __init__.py
1241/// │   │           └── foo.py
1242/// │   └── provider_b
1243/// │       ├── pyproject.toml
1244/// │       └── src
1245/// │           └── provider_b
1246/// │               ├── __init__.py
1247/// │               └── bar.py
1248/// ├── pyproject.toml
1249/// ├── Readme.md
1250/// └── uv.lock
1251/// ```
1252#[derive(Debug, Clone)]
1253#[cfg_attr(test, derive(serde::Serialize))]
1254pub struct ProjectWorkspace {
1255    /// The path to the project root.
1256    project_root: PathBuf,
1257    /// The name of the package.
1258    project_name: PackageName,
1259    /// The workspace the project is part of.
1260    workspace: Workspace,
1261}
1262
1263impl ProjectWorkspace {
1264    /// Find the current project and workspace, given the current directory.
1265    ///
1266    /// `stop_discovery_at` must be either `None` or an ancestor of the current directory. If set,
1267    /// only directories between the current path and `stop_discovery_at` are considered.
1268    pub async fn discover(
1269        path: &Path,
1270        options: &DiscoveryOptions,
1271        cache: &WorkspaceCache,
1272    ) -> Result<Self, WorkspaceError> {
1273        let project_root = path
1274            .ancestors()
1275            .take_while(|path| {
1276                // Only walk up the given directory, if any.
1277                options
1278                    .stop_discovery_at
1279                    .as_deref()
1280                    .and_then(Path::parent)
1281                    .map(|stop_discovery_at| stop_discovery_at != *path)
1282                    .unwrap_or(true)
1283            })
1284            .find(|path| path.join("pyproject.toml").is_file())
1285            .ok_or(WorkspaceError::MissingPyprojectToml)?;
1286
1287        debug!(
1288            "Found project root: `{}`",
1289            project_root.simplified_display()
1290        );
1291
1292        Self::from_project_root(project_root, options, cache).await
1293    }
1294
1295    /// Discover the workspace starting from the directory containing the `pyproject.toml`.
1296    async fn from_project_root(
1297        project_root: &Path,
1298        options: &DiscoveryOptions,
1299        cache: &WorkspaceCache,
1300    ) -> Result<Self, WorkspaceError> {
1301        // Read the current `pyproject.toml`.
1302        let pyproject_path = project_root.join("pyproject.toml");
1303        let contents = fs_err::tokio::read_to_string(&pyproject_path).await?;
1304        let pyproject_toml = PyProjectToml::from_string(contents, &pyproject_path)
1305            .map_err(|err| WorkspaceError::Toml(pyproject_path.clone(), Box::new(err)))?;
1306
1307        // It must have a `[project]` table.
1308        let project = pyproject_toml
1309            .project
1310            .clone()
1311            .ok_or(WorkspaceError::MissingProject(pyproject_path))?;
1312
1313        Self::from_project(project_root, &project, &pyproject_toml, options, cache).await
1314    }
1315
1316    /// If the current directory contains a `pyproject.toml` with a `project` table, discover the
1317    /// workspace and return it, otherwise it is a dynamic path dependency and we return `Ok(None)`.
1318    pub async fn from_maybe_project_root(
1319        install_path: &Path,
1320        options: &DiscoveryOptions,
1321        cache: &WorkspaceCache,
1322    ) -> Result<Option<Self>, WorkspaceError> {
1323        // Read the `pyproject.toml`.
1324        let pyproject_path = install_path.join("pyproject.toml");
1325        let Ok(contents) = fs_err::tokio::read_to_string(&pyproject_path).await else {
1326            // No `pyproject.toml`, but there may still be a `setup.py` or `setup.cfg`.
1327            return Ok(None);
1328        };
1329        let pyproject_toml = PyProjectToml::from_string(contents, &pyproject_path)
1330            .map_err(|err| WorkspaceError::Toml(pyproject_path.clone(), Box::new(err)))?;
1331
1332        // Extract the `[project]` metadata.
1333        let Some(project) = pyproject_toml.project.clone() else {
1334            // We have to build to get the metadata.
1335            return Ok(None);
1336        };
1337
1338        match Self::from_project(install_path, &project, &pyproject_toml, options, cache).await {
1339            Ok(workspace) => Ok(Some(workspace)),
1340            Err(WorkspaceError::NonWorkspace(_)) => Ok(None),
1341            Err(err) => Err(err),
1342        }
1343    }
1344
1345    /// Returns the directory containing the closest `pyproject.toml` that defines the current
1346    /// project.
1347    pub fn project_root(&self) -> &Path {
1348        &self.project_root
1349    }
1350
1351    /// Returns the [`PackageName`] of the current project.
1352    pub fn project_name(&self) -> &PackageName {
1353        &self.project_name
1354    }
1355
1356    /// Returns the [`Workspace`] containing the current project.
1357    pub fn workspace(&self) -> &Workspace {
1358        &self.workspace
1359    }
1360
1361    /// Returns the current project as a [`WorkspaceMember`].
1362    pub fn current_project(&self) -> &WorkspaceMember {
1363        &self.workspace().packages[&self.project_name]
1364    }
1365
1366    /// Set the `pyproject.toml` for the current project.
1367    ///
1368    /// Assumes that the project name is unchanged in the updated [`PyProjectToml`].
1369    pub fn update_member(
1370        self,
1371        pyproject_toml: PyProjectToml,
1372    ) -> Result<Option<Self>, WorkspaceError> {
1373        let Some(workspace) = self
1374            .workspace
1375            .update_member(&self.project_name, pyproject_toml)?
1376        else {
1377            return Ok(None);
1378        };
1379        Ok(Some(Self { workspace, ..self }))
1380    }
1381
1382    /// Find the workspace for a project.
1383    async fn from_project(
1384        install_path: &Path,
1385        project: &Project,
1386        project_pyproject_toml: &PyProjectToml,
1387        options: &DiscoveryOptions,
1388        cache: &WorkspaceCache,
1389    ) -> Result<Self, WorkspaceError> {
1390        let project_path = std::path::absolute(install_path)
1391            .map_err(WorkspaceError::Normalize)?
1392            .clone();
1393        // Remove `.` and `..`
1394        let project_path = uv_fs::normalize_path(&project_path);
1395        // Trim trailing slashes.
1396        let project_path = project_path.components().collect::<PathBuf>();
1397
1398        // Check if workspaces are explicitly disabled for the project.
1399        if project_pyproject_toml
1400            .tool
1401            .as_ref()
1402            .and_then(|tool| tool.uv.as_ref())
1403            .and_then(|uv| uv.managed)
1404            == Some(false)
1405        {
1406            debug!("Project `{}` is marked as unmanaged", project.name);
1407            return Err(WorkspaceError::NonWorkspace(project_path));
1408        }
1409
1410        // Check if the current project is also an explicit workspace root.
1411        let mut workspace = project_pyproject_toml
1412            .tool
1413            .as_ref()
1414            .and_then(|tool| tool.uv.as_ref())
1415            .and_then(|uv| uv.workspace.as_ref())
1416            .map(|workspace| {
1417                (
1418                    project_path.clone(),
1419                    workspace.clone(),
1420                    project_pyproject_toml.clone(),
1421                )
1422            });
1423
1424        if workspace.is_none() {
1425            // The project isn't an explicit workspace root, check if we're a regular workspace
1426            // member by looking for an explicit workspace root above.
1427            workspace = find_workspace(&project_path, options).await?;
1428        }
1429
1430        let current_project = WorkspaceMember {
1431            root: project_path.clone(),
1432            project: project.clone(),
1433            pyproject_toml: project_pyproject_toml.clone(),
1434        };
1435
1436        let Some((workspace_root, workspace_definition, workspace_pyproject_toml)) = workspace
1437        else {
1438            // The project isn't an explicit workspace root, but there's also no workspace root
1439            // above it, so the project is an implicit workspace root identical to the project root.
1440            debug!("No workspace root found, using project root");
1441
1442            let current_project_as_members = Arc::new(BTreeMap::from_iter([(
1443                project.name.clone(),
1444                current_project,
1445            )]));
1446            let workspace_sources = BTreeMap::default();
1447            let required_members = Workspace::collect_required_members(
1448                &current_project_as_members,
1449                &workspace_sources,
1450                project_pyproject_toml,
1451            )?;
1452
1453            return Ok(Self {
1454                project_root: project_path.clone(),
1455                project_name: project.name.clone(),
1456                workspace: Workspace {
1457                    install_path: project_path.clone(),
1458                    packages: current_project_as_members,
1459                    required_members,
1460                    // There may be package sources, but we don't need to duplicate them into the
1461                    // workspace sources.
1462                    sources: workspace_sources,
1463                    indexes: Vec::default(),
1464                    pyproject_toml: project_pyproject_toml.clone(),
1465                },
1466            });
1467        };
1468
1469        debug!(
1470            "Found workspace root: `{}`",
1471            workspace_root.simplified_display()
1472        );
1473
1474        let workspace = Workspace::collect_members(
1475            workspace_root,
1476            workspace_definition,
1477            workspace_pyproject_toml,
1478            Some(current_project),
1479            options,
1480            cache,
1481        )
1482        .await?;
1483
1484        Ok(Self {
1485            project_root: project_path,
1486            project_name: project.name.clone(),
1487            workspace,
1488        })
1489    }
1490}
1491
1492/// Find the workspace root above the current project, if any.
1493async fn find_workspace(
1494    project_root: &Path,
1495    options: &DiscoveryOptions,
1496) -> Result<Option<(PathBuf, ToolUvWorkspace, PyProjectToml)>, WorkspaceError> {
1497    // Skip 1 to ignore the current project itself.
1498    for workspace_root in project_root
1499        .ancestors()
1500        .take_while(|path| {
1501            // Only walk up the given directory, if any.
1502            options
1503                .stop_discovery_at
1504                .as_deref()
1505                .and_then(Path::parent)
1506                .map(|stop_discovery_at| stop_discovery_at != *path)
1507                .unwrap_or(true)
1508        })
1509        .skip(1)
1510    {
1511        let pyproject_path = workspace_root.join("pyproject.toml");
1512        if !pyproject_path.is_file() {
1513            continue;
1514        }
1515        trace!(
1516            "Found `pyproject.toml` at: `{}`",
1517            pyproject_path.simplified_display()
1518        );
1519
1520        // Read the `pyproject.toml`.
1521        let contents = fs_err::tokio::read_to_string(&pyproject_path).await?;
1522        let pyproject_toml = PyProjectToml::from_string(contents, &pyproject_path)
1523            .map_err(|err| WorkspaceError::Toml(pyproject_path.clone(), Box::new(err)))?;
1524
1525        return if let Some(workspace) = pyproject_toml
1526            .tool
1527            .as_ref()
1528            .and_then(|tool| tool.uv.as_ref())
1529            .and_then(|uv| uv.workspace.as_ref())
1530        {
1531            if !is_included_in_workspace(project_root, workspace_root, workspace)? {
1532                debug!(
1533                    "Found workspace root `{}`, but project is not included",
1534                    workspace_root.simplified_display()
1535                );
1536                return Ok(None);
1537            }
1538
1539            if is_excluded_from_workspace(project_root, workspace_root, workspace)? {
1540                debug!(
1541                    "Found workspace root `{}`, but project is excluded",
1542                    workspace_root.simplified_display()
1543                );
1544                return Ok(None);
1545            }
1546
1547            // We found a workspace root.
1548            Ok(Some((
1549                workspace_root.to_path_buf(),
1550                workspace.clone(),
1551                pyproject_toml,
1552            )))
1553        } else if pyproject_toml.project.is_some() {
1554            // We're in a directory of another project, e.g. tests or examples.
1555            // Example:
1556            // ```
1557            // albatross
1558            // ├── examples
1559            // │   └── bird-feeder [CURRENT DIRECTORY]
1560            // │       ├── pyproject.toml
1561            // │       └── src
1562            // │           └── bird_feeder
1563            // │               └── __init__.py
1564            // ├── pyproject.toml
1565            // └── src
1566            //     └── albatross
1567            //         └── __init__.py
1568            // ```
1569            // The current project is the example (non-workspace) `bird-feeder` in `albatross`,
1570            // we ignore all `albatross` is doing and any potential workspace it might be
1571            // contained in.
1572            debug!(
1573                "Project is contained in non-workspace project: `{}`",
1574                workspace_root.simplified_display()
1575            );
1576            Ok(None)
1577        } else {
1578            // We require that a `project.toml` file either declares a workspace or a project.
1579            warn!(
1580                "`pyproject.toml` does not contain a `project` table: `{}`",
1581                pyproject_path.simplified_display()
1582            );
1583            Ok(None)
1584        };
1585    }
1586
1587    Ok(None)
1588}
1589
1590/// Check if a directory only contains files that are ignored.
1591///
1592/// Returns `true` if walking the directory while respecting `.gitignore` and `.ignore` rules
1593/// yields no files, indicating that any files present (e.g., `__pycache__`) are all ignored.
1594fn has_only_gitignored_files(path: &Path) -> bool {
1595    let walker = ignore::WalkBuilder::new(path)
1596        .hidden(false)
1597        .parents(true)
1598        .ignore(true)
1599        .git_ignore(true)
1600        .git_global(true)
1601        .git_exclude(true)
1602        .build();
1603
1604    for entry in walker {
1605        let Ok(entry) = entry else {
1606            // If we can't read an entry, assume non-ignored content exists.
1607            return false;
1608        };
1609
1610        // Skip directories.
1611        if entry.path().is_dir() {
1612            continue;
1613        }
1614
1615        // A non-ignored entry exists.
1616        return false;
1617    }
1618
1619    true
1620}
1621
1622/// Check if we're in the `tool.uv.workspace.excluded` of a workspace.
1623fn is_excluded_from_workspace(
1624    project_path: &Path,
1625    workspace_root: &Path,
1626    workspace: &ToolUvWorkspace,
1627) -> Result<bool, WorkspaceError> {
1628    for exclude_glob in workspace.exclude.iter().flatten() {
1629        // Normalize the exclude glob to remove leading `./` and other relative path components
1630        let normalized_glob = uv_fs::normalize_path(Path::new(exclude_glob.as_str()));
1631        let absolute_glob = PathBuf::from(glob::Pattern::escape(
1632            workspace_root.simplified().to_string_lossy().as_ref(),
1633        ))
1634        .join(normalized_glob.as_ref());
1635        let absolute_glob = absolute_glob.to_string_lossy();
1636        let exclude_pattern = glob::Pattern::new(&absolute_glob)
1637            .map_err(|err| WorkspaceError::Pattern(absolute_glob.to_string(), err))?;
1638        if exclude_pattern.matches_path(project_path) {
1639            return Ok(true);
1640        }
1641    }
1642    Ok(false)
1643}
1644
1645/// Check if we're in the `tool.uv.workspace.members` of a workspace.
1646fn is_included_in_workspace(
1647    project_path: &Path,
1648    workspace_root: &Path,
1649    workspace: &ToolUvWorkspace,
1650) -> Result<bool, WorkspaceError> {
1651    for member_glob in workspace.members.iter().flatten() {
1652        // Normalize the member glob to remove leading `./` and other relative path components
1653        let normalized_glob = uv_fs::normalize_path(Path::new(member_glob.as_str()));
1654        let absolute_glob = PathBuf::from(glob::Pattern::escape(
1655            workspace_root.simplified().to_string_lossy().as_ref(),
1656        ))
1657        .join(normalized_glob.as_ref());
1658        let absolute_glob = absolute_glob.to_string_lossy();
1659        let include_pattern = glob::Pattern::new(&absolute_glob)
1660            .map_err(|err| WorkspaceError::Pattern(absolute_glob.to_string(), err))?;
1661        if include_pattern.matches_path(project_path) {
1662            return Ok(true);
1663        }
1664    }
1665    Ok(false)
1666}
1667
1668/// A project that can be discovered.
1669///
1670/// The project could be a package within a workspace, a real workspace root, or a non-project
1671/// workspace root, which can define its own dev dependencies.
1672#[derive(Debug, Clone)]
1673pub enum VirtualProject {
1674    /// A project (which could be a workspace root or member).
1675    Project(ProjectWorkspace),
1676    /// A non-project workspace root.
1677    NonProject(Workspace),
1678}
1679
1680impl VirtualProject {
1681    /// Find the current project or virtual workspace root, given the current directory.
1682    ///
1683    /// Similar to calling [`ProjectWorkspace::discover`] with a fallback to [`Workspace::discover`],
1684    /// but avoids rereading the `pyproject.toml` (and relying on error-handling as control flow).
1685    ///
1686    /// This method requires an absolute path and panics otherwise, i.e. this method only supports
1687    /// discovering the main workspace.
1688    pub async fn discover(
1689        path: &Path,
1690        options: &DiscoveryOptions,
1691        cache: &WorkspaceCache,
1692    ) -> Result<Self, WorkspaceError> {
1693        assert!(
1694            path.is_absolute(),
1695            "virtual project discovery with relative path"
1696        );
1697        let project_root = path
1698            .ancestors()
1699            .take_while(|path| {
1700                // Only walk up the given directory, if any.
1701                options
1702                    .stop_discovery_at
1703                    .as_deref()
1704                    .and_then(Path::parent)
1705                    .map(|stop_discovery_at| stop_discovery_at != *path)
1706                    .unwrap_or(true)
1707            })
1708            .find(|path| path.join("pyproject.toml").is_file())
1709            .ok_or(WorkspaceError::MissingPyprojectToml)?;
1710
1711        debug!(
1712            "Found project root: `{}`",
1713            project_root.simplified_display()
1714        );
1715
1716        // Read the current `pyproject.toml`.
1717        let pyproject_path = project_root.join("pyproject.toml");
1718        let contents = fs_err::tokio::read_to_string(&pyproject_path).await?;
1719        let pyproject_toml = PyProjectToml::from_string(contents, &pyproject_path)
1720            .map_err(|err| WorkspaceError::Toml(pyproject_path.clone(), Box::new(err)))?;
1721
1722        if let Some(project) = pyproject_toml.project.as_ref() {
1723            // If the `pyproject.toml` contains a `[project]` table, it's a project.
1724            let project = ProjectWorkspace::from_project(
1725                project_root,
1726                project,
1727                &pyproject_toml,
1728                options,
1729                cache,
1730            )
1731            .await?;
1732            Ok(Self::Project(project))
1733        } else if let Some(workspace) = pyproject_toml
1734            .tool
1735            .as_ref()
1736            .and_then(|tool| tool.uv.as_ref())
1737            .and_then(|uv| uv.workspace.as_ref())
1738            .filter(|_| options.project.allows_non_project_workspace())
1739        {
1740            // Otherwise, if it contains a `tool.uv.workspace` table, it's a non-project workspace
1741            // root.
1742            let project_path = std::path::absolute(project_root)
1743                .map_err(WorkspaceError::Normalize)?
1744                .clone();
1745
1746            let workspace = Workspace::collect_members(
1747                project_path,
1748                workspace.clone(),
1749                pyproject_toml,
1750                None,
1751                options,
1752                cache,
1753            )
1754            .await?;
1755
1756            Ok(Self::NonProject(workspace))
1757        } else if options.project.allows_implicit_workspace() {
1758            // Otherwise it's a pyproject.toml that maybe contains dependency-groups
1759            // that we want to treat like a project/workspace to handle those uniformly
1760            let project_path = std::path::absolute(project_root)
1761                .map_err(WorkspaceError::Normalize)?
1762                .clone();
1763
1764            let workspace = Workspace::collect_members(
1765                project_path,
1766                ToolUvWorkspace::default(),
1767                pyproject_toml,
1768                None,
1769                options,
1770                cache,
1771            )
1772            .await?;
1773
1774            Ok(Self::NonProject(workspace))
1775        } else {
1776            Err(WorkspaceError::MissingProject(pyproject_path))
1777        }
1778    }
1779
1780    /// Discover a project workspace with the member package.
1781    pub async fn discover_with_package(
1782        path: &Path,
1783        options: &DiscoveryOptions,
1784        cache: &WorkspaceCache,
1785        package: PackageName,
1786    ) -> Result<Self, WorkspaceError> {
1787        let workspace = Workspace::discover(path, options, cache).await?;
1788        let project_workspace = Workspace::with_current_project(workspace.clone(), package.clone());
1789        Ok(Self::Project(project_workspace.ok_or_else(|| {
1790            WorkspaceError::NoSuchMember(package.clone(), workspace.install_path)
1791        })?))
1792    }
1793
1794    /// Update the `pyproject.toml` for the current project.
1795    ///
1796    /// Assumes that the project name is unchanged in the updated [`PyProjectToml`].
1797    pub fn update_member(
1798        self,
1799        pyproject_toml: PyProjectToml,
1800    ) -> Result<Option<Self>, WorkspaceError> {
1801        Ok(match self {
1802            Self::Project(project) => {
1803                let Some(project) = project.update_member(pyproject_toml)? else {
1804                    return Ok(None);
1805                };
1806                Some(Self::Project(project))
1807            }
1808            Self::NonProject(workspace) => {
1809                // If this is a non-project workspace root, then by definition the root isn't a
1810                // member, so we can just update the top-level `pyproject.toml`.
1811                Some(Self::NonProject(Workspace {
1812                    pyproject_toml,
1813                    ..workspace.clone()
1814                }))
1815            }
1816        })
1817    }
1818
1819    /// Return the root of the project.
1820    pub fn root(&self) -> &Path {
1821        match self {
1822            Self::Project(project) => project.project_root(),
1823            Self::NonProject(workspace) => workspace.install_path(),
1824        }
1825    }
1826
1827    /// Return the [`PyProjectToml`] of the project.
1828    pub fn pyproject_toml(&self) -> &PyProjectToml {
1829        match self {
1830            Self::Project(project) => project.current_project().pyproject_toml(),
1831            Self::NonProject(workspace) => &workspace.pyproject_toml,
1832        }
1833    }
1834
1835    /// Return the [`Workspace`] of the project.
1836    pub fn workspace(&self) -> &Workspace {
1837        match self {
1838            Self::Project(project) => project.workspace(),
1839            Self::NonProject(workspace) => workspace,
1840        }
1841    }
1842
1843    /// Return the [`PackageName`] of the project, if available.
1844    pub fn project_name(&self) -> Option<&PackageName> {
1845        match self {
1846            Self::Project(project) => Some(project.project_name()),
1847            Self::NonProject(_) => None,
1848        }
1849    }
1850
1851    /// Returns `true` if the project is a virtual workspace root.
1852    pub fn is_non_project(&self) -> bool {
1853        matches!(self, Self::NonProject(_))
1854    }
1855}
1856
1857#[cfg(test)]
1858#[cfg(unix)] // Avoid path escaping for the unit tests
1859mod tests {
1860    use std::env;
1861    use std::path::Path;
1862    use std::str::FromStr;
1863
1864    use anyhow::Result;
1865    use assert_fs::fixture::ChildPath;
1866    use assert_fs::prelude::*;
1867    use insta::{assert_json_snapshot, assert_snapshot};
1868
1869    use uv_normalize::GroupName;
1870    use uv_pypi_types::DependencyGroupSpecifier;
1871
1872    use crate::pyproject::PyProjectToml;
1873    use crate::workspace::{DiscoveryOptions, ProjectWorkspace};
1874    use crate::{WorkspaceCache, WorkspaceError};
1875
1876    async fn workspace_test(folder: &str) -> (ProjectWorkspace, String) {
1877        let root_dir = env::current_dir()
1878            .unwrap()
1879            .parent()
1880            .unwrap()
1881            .parent()
1882            .unwrap()
1883            .join("test")
1884            .join("workspaces");
1885        let project = ProjectWorkspace::discover(
1886            &root_dir.join(folder),
1887            &DiscoveryOptions::default(),
1888            &WorkspaceCache::default(),
1889        )
1890        .await
1891        .unwrap();
1892        let root_escaped = regex::escape(root_dir.to_string_lossy().as_ref());
1893        (project, root_escaped)
1894    }
1895
1896    async fn temporary_test(
1897        folder: &Path,
1898    ) -> Result<(ProjectWorkspace, String), (WorkspaceError, String)> {
1899        let root_escaped = regex::escape(folder.to_string_lossy().as_ref());
1900        let project = ProjectWorkspace::discover(
1901            folder,
1902            &DiscoveryOptions::default(),
1903            &WorkspaceCache::default(),
1904        )
1905        .await
1906        .map_err(|error| (error, root_escaped.clone()))?;
1907
1908        Ok((project, root_escaped))
1909    }
1910
1911    #[tokio::test]
1912    async fn albatross_in_example() {
1913        let (project, root_escaped) =
1914            workspace_test("albatross-in-example/examples/bird-feeder").await;
1915        let filters = vec![(root_escaped.as_str(), "[ROOT]")];
1916        insta::with_settings!({filters => filters}, {
1917        assert_json_snapshot!(
1918            project,
1919            {
1920                ".workspace.packages.*.pyproject_toml" => "[PYPROJECT_TOML]"
1921            },
1922            @r#"
1923        {
1924          "project_root": "[ROOT]/albatross-in-example/examples/bird-feeder",
1925          "project_name": "bird-feeder",
1926          "workspace": {
1927            "install_path": "[ROOT]/albatross-in-example/examples/bird-feeder",
1928            "packages": {
1929              "bird-feeder": {
1930                "root": "[ROOT]/albatross-in-example/examples/bird-feeder",
1931                "project": {
1932                  "name": "bird-feeder",
1933                  "version": "1.0.0",
1934                  "requires-python": ">=3.12",
1935                  "dependencies": [
1936                    "iniconfig>=2,<3"
1937                  ],
1938                  "optional-dependencies": null
1939                },
1940                "pyproject_toml": "[PYPROJECT_TOML]"
1941              }
1942            },
1943            "required_members": {},
1944            "sources": {},
1945            "indexes": [],
1946            "pyproject_toml": {
1947              "project": {
1948                "name": "bird-feeder",
1949                "version": "1.0.0",
1950                "requires-python": ">=3.12",
1951                "dependencies": [
1952                  "iniconfig>=2,<3"
1953                ],
1954                "optional-dependencies": null
1955              },
1956              "tool": null,
1957              "dependency-groups": null
1958            }
1959          }
1960        }
1961        "#);
1962        });
1963    }
1964
1965    #[tokio::test]
1966    async fn albatross_project_in_excluded() {
1967        let (project, root_escaped) =
1968            workspace_test("albatross-project-in-excluded/excluded/bird-feeder").await;
1969        let filters = vec![(root_escaped.as_str(), "[ROOT]")];
1970        insta::with_settings!({filters => filters}, {
1971            assert_json_snapshot!(
1972            project,
1973            {
1974                ".workspace.packages.*.pyproject_toml" => "[PYPROJECT_TOML]"
1975            },
1976            @r#"
1977            {
1978              "project_root": "[ROOT]/albatross-project-in-excluded/excluded/bird-feeder",
1979              "project_name": "bird-feeder",
1980              "workspace": {
1981                "install_path": "[ROOT]/albatross-project-in-excluded/excluded/bird-feeder",
1982                "packages": {
1983                  "bird-feeder": {
1984                    "root": "[ROOT]/albatross-project-in-excluded/excluded/bird-feeder",
1985                    "project": {
1986                      "name": "bird-feeder",
1987                      "version": "1.0.0",
1988                      "requires-python": ">=3.12",
1989                      "dependencies": [
1990                        "iniconfig>=2,<3"
1991                      ],
1992                      "optional-dependencies": null
1993                    },
1994                    "pyproject_toml": "[PYPROJECT_TOML]"
1995                  }
1996                },
1997                "required_members": {},
1998                "sources": {},
1999                "indexes": [],
2000                "pyproject_toml": {
2001                  "project": {
2002                    "name": "bird-feeder",
2003                    "version": "1.0.0",
2004                    "requires-python": ">=3.12",
2005                    "dependencies": [
2006                      "iniconfig>=2,<3"
2007                    ],
2008                    "optional-dependencies": null
2009                  },
2010                  "tool": null,
2011                  "dependency-groups": null
2012                }
2013              }
2014            }
2015            "#);
2016        });
2017    }
2018
2019    #[tokio::test]
2020    async fn albatross_root_workspace() {
2021        let (project, root_escaped) = workspace_test("albatross-root-workspace").await;
2022        let filters = vec![(root_escaped.as_str(), "[ROOT]")];
2023        insta::with_settings!({filters => filters}, {
2024            assert_json_snapshot!(
2025            project,
2026            {
2027                ".workspace.packages.*.pyproject_toml" => "[PYPROJECT_TOML]"
2028            },
2029            @r#"
2030            {
2031              "project_root": "[ROOT]/albatross-root-workspace",
2032              "project_name": "albatross",
2033              "workspace": {
2034                "install_path": "[ROOT]/albatross-root-workspace",
2035                "packages": {
2036                  "albatross": {
2037                    "root": "[ROOT]/albatross-root-workspace",
2038                    "project": {
2039                      "name": "albatross",
2040                      "version": "0.1.0",
2041                      "requires-python": ">=3.12",
2042                      "dependencies": [
2043                        "bird-feeder",
2044                        "iniconfig>=2,<3"
2045                      ],
2046                      "optional-dependencies": null
2047                    },
2048                    "pyproject_toml": "[PYPROJECT_TOML]"
2049                  },
2050                  "bird-feeder": {
2051                    "root": "[ROOT]/albatross-root-workspace/packages/bird-feeder",
2052                    "project": {
2053                      "name": "bird-feeder",
2054                      "version": "1.0.0",
2055                      "requires-python": ">=3.8",
2056                      "dependencies": [
2057                        "iniconfig>=2,<3",
2058                        "seeds"
2059                      ],
2060                      "optional-dependencies": null
2061                    },
2062                    "pyproject_toml": "[PYPROJECT_TOML]"
2063                  },
2064                  "seeds": {
2065                    "root": "[ROOT]/albatross-root-workspace/packages/seeds",
2066                    "project": {
2067                      "name": "seeds",
2068                      "version": "1.0.0",
2069                      "requires-python": ">=3.12",
2070                      "dependencies": [
2071                        "idna==3.6"
2072                      ],
2073                      "optional-dependencies": null
2074                    },
2075                    "pyproject_toml": "[PYPROJECT_TOML]"
2076                  }
2077                },
2078                "required_members": {
2079                  "bird-feeder": null,
2080                  "seeds": null
2081                },
2082                "sources": {
2083                  "bird-feeder": [
2084                    {
2085                      "workspace": true,
2086                      "editable": null,
2087                      "extra": null,
2088                      "group": null
2089                    }
2090                  ]
2091                },
2092                "indexes": [],
2093                "pyproject_toml": {
2094                  "project": {
2095                    "name": "albatross",
2096                    "version": "0.1.0",
2097                    "requires-python": ">=3.12",
2098                    "dependencies": [
2099                      "bird-feeder",
2100                      "iniconfig>=2,<3"
2101                    ],
2102                    "optional-dependencies": null
2103                  },
2104                  "tool": {
2105                    "uv": {
2106                      "sources": {
2107                        "bird-feeder": [
2108                          {
2109                            "workspace": true,
2110                            "editable": null,
2111                            "extra": null,
2112                            "group": null
2113                          }
2114                        ]
2115                      },
2116                      "index": null,
2117                      "workspace": {
2118                        "members": [
2119                          "packages/*"
2120                        ],
2121                        "exclude": null
2122                      },
2123                      "managed": null,
2124                      "package": null,
2125                      "default-groups": null,
2126                      "dependency-groups": null,
2127                      "dev-dependencies": null,
2128                      "override-dependencies": null,
2129                      "exclude-dependencies": null,
2130                      "constraint-dependencies": null,
2131                      "build-constraint-dependencies": null,
2132                      "environments": null,
2133                      "required-environments": null,
2134                      "conflicts": null,
2135                      "build-backend": null
2136                    }
2137                  },
2138                  "dependency-groups": null
2139                }
2140              }
2141            }
2142            "#);
2143        });
2144    }
2145
2146    #[tokio::test]
2147    async fn albatross_virtual_workspace() {
2148        let (project, root_escaped) =
2149            workspace_test("albatross-virtual-workspace/packages/albatross").await;
2150        let filters = vec![(root_escaped.as_str(), "[ROOT]")];
2151        insta::with_settings!({filters => filters}, {
2152            assert_json_snapshot!(
2153            project,
2154            {
2155                ".workspace.packages.*.pyproject_toml" => "[PYPROJECT_TOML]"
2156            },
2157            @r#"
2158            {
2159              "project_root": "[ROOT]/albatross-virtual-workspace/packages/albatross",
2160              "project_name": "albatross",
2161              "workspace": {
2162                "install_path": "[ROOT]/albatross-virtual-workspace",
2163                "packages": {
2164                  "albatross": {
2165                    "root": "[ROOT]/albatross-virtual-workspace/packages/albatross",
2166                    "project": {
2167                      "name": "albatross",
2168                      "version": "0.1.0",
2169                      "requires-python": ">=3.12",
2170                      "dependencies": [
2171                        "bird-feeder",
2172                        "iniconfig>=2,<3"
2173                      ],
2174                      "optional-dependencies": null
2175                    },
2176                    "pyproject_toml": "[PYPROJECT_TOML]"
2177                  },
2178                  "bird-feeder": {
2179                    "root": "[ROOT]/albatross-virtual-workspace/packages/bird-feeder",
2180                    "project": {
2181                      "name": "bird-feeder",
2182                      "version": "1.0.0",
2183                      "requires-python": ">=3.12",
2184                      "dependencies": [
2185                        "anyio>=4.3.0,<5",
2186                        "seeds"
2187                      ],
2188                      "optional-dependencies": null
2189                    },
2190                    "pyproject_toml": "[PYPROJECT_TOML]"
2191                  },
2192                  "seeds": {
2193                    "root": "[ROOT]/albatross-virtual-workspace/packages/seeds",
2194                    "project": {
2195                      "name": "seeds",
2196                      "version": "1.0.0",
2197                      "requires-python": ">=3.12",
2198                      "dependencies": [
2199                        "idna==3.6"
2200                      ],
2201                      "optional-dependencies": null
2202                    },
2203                    "pyproject_toml": "[PYPROJECT_TOML]"
2204                  }
2205                },
2206                "required_members": {
2207                  "bird-feeder": null,
2208                  "seeds": null
2209                },
2210                "sources": {},
2211                "indexes": [],
2212                "pyproject_toml": {
2213                  "project": null,
2214                  "tool": {
2215                    "uv": {
2216                      "sources": null,
2217                      "index": null,
2218                      "workspace": {
2219                        "members": [
2220                          "packages/*"
2221                        ],
2222                        "exclude": null
2223                      },
2224                      "managed": null,
2225                      "package": null,
2226                      "default-groups": null,
2227                      "dependency-groups": null,
2228                      "dev-dependencies": null,
2229                      "override-dependencies": null,
2230                      "exclude-dependencies": null,
2231                      "constraint-dependencies": null,
2232                      "build-constraint-dependencies": null,
2233                      "environments": null,
2234                      "required-environments": null,
2235                      "conflicts": null,
2236                      "build-backend": null
2237                    }
2238                  },
2239                  "dependency-groups": null
2240                }
2241              }
2242            }
2243            "#);
2244        });
2245    }
2246
2247    #[tokio::test]
2248    async fn albatross_just_project() {
2249        let (project, root_escaped) = workspace_test("albatross-just-project").await;
2250        let filters = vec![(root_escaped.as_str(), "[ROOT]")];
2251        insta::with_settings!({filters => filters}, {
2252            assert_json_snapshot!(
2253            project,
2254            {
2255                ".workspace.packages.*.pyproject_toml" => "[PYPROJECT_TOML]"
2256            },
2257            @r#"
2258            {
2259              "project_root": "[ROOT]/albatross-just-project",
2260              "project_name": "albatross",
2261              "workspace": {
2262                "install_path": "[ROOT]/albatross-just-project",
2263                "packages": {
2264                  "albatross": {
2265                    "root": "[ROOT]/albatross-just-project",
2266                    "project": {
2267                      "name": "albatross",
2268                      "version": "0.1.0",
2269                      "requires-python": ">=3.12",
2270                      "dependencies": [
2271                        "iniconfig>=2,<3"
2272                      ],
2273                      "optional-dependencies": null
2274                    },
2275                    "pyproject_toml": "[PYPROJECT_TOML]"
2276                  }
2277                },
2278                "required_members": {},
2279                "sources": {},
2280                "indexes": [],
2281                "pyproject_toml": {
2282                  "project": {
2283                    "name": "albatross",
2284                    "version": "0.1.0",
2285                    "requires-python": ">=3.12",
2286                    "dependencies": [
2287                      "iniconfig>=2,<3"
2288                    ],
2289                    "optional-dependencies": null
2290                  },
2291                  "tool": null,
2292                  "dependency-groups": null
2293                }
2294              }
2295            }
2296            "#);
2297        });
2298    }
2299
2300    #[tokio::test]
2301    async fn exclude_package() -> Result<()> {
2302        let root = tempfile::TempDir::new()?;
2303        let root = ChildPath::new(root.path());
2304
2305        // Create the root.
2306        root.child("pyproject.toml").write_str(
2307            r#"
2308            [project]
2309            name = "albatross"
2310            version = "0.1.0"
2311            requires-python = ">=3.12"
2312            dependencies = ["tqdm>=4,<5"]
2313
2314            [tool.uv.workspace]
2315            members = ["packages/*"]
2316            exclude = ["packages/bird-feeder"]
2317
2318            [build-system]
2319            requires = ["hatchling"]
2320            build-backend = "hatchling.build"
2321            "#,
2322        )?;
2323        root.child("albatross").child("__init__.py").touch()?;
2324
2325        // Create an included package (`seeds`).
2326        root.child("packages")
2327            .child("seeds")
2328            .child("pyproject.toml")
2329            .write_str(
2330                r#"
2331            [project]
2332            name = "seeds"
2333            version = "1.0.0"
2334            requires-python = ">=3.12"
2335            dependencies = ["idna==3.6"]
2336
2337            [build-system]
2338            requires = ["hatchling"]
2339            build-backend = "hatchling.build"
2340            "#,
2341            )?;
2342        root.child("packages")
2343            .child("seeds")
2344            .child("seeds")
2345            .child("__init__.py")
2346            .touch()?;
2347
2348        // Create an excluded package (`bird-feeder`).
2349        root.child("packages")
2350            .child("bird-feeder")
2351            .child("pyproject.toml")
2352            .write_str(
2353                r#"
2354            [project]
2355            name = "bird-feeder"
2356            version = "1.0.0"
2357            requires-python = ">=3.12"
2358            dependencies = ["anyio>=4.3.0,<5"]
2359
2360            [build-system]
2361            requires = ["hatchling"]
2362            build-backend = "hatchling.build"
2363            "#,
2364            )?;
2365        root.child("packages")
2366            .child("bird-feeder")
2367            .child("bird_feeder")
2368            .child("__init__.py")
2369            .touch()?;
2370
2371        let (project, root_escaped) = temporary_test(root.as_ref()).await.unwrap();
2372        let filters = vec![(root_escaped.as_str(), "[ROOT]")];
2373        insta::with_settings!({filters => filters}, {
2374            assert_json_snapshot!(
2375            project,
2376            {
2377                ".workspace.packages.*.pyproject_toml" => "[PYPROJECT_TOML]"
2378            },
2379            @r#"
2380            {
2381              "project_root": "[ROOT]",
2382              "project_name": "albatross",
2383              "workspace": {
2384                "install_path": "[ROOT]",
2385                "packages": {
2386                  "albatross": {
2387                    "root": "[ROOT]",
2388                    "project": {
2389                      "name": "albatross",
2390                      "version": "0.1.0",
2391                      "requires-python": ">=3.12",
2392                      "dependencies": [
2393                        "tqdm>=4,<5"
2394                      ],
2395                      "optional-dependencies": null
2396                    },
2397                    "pyproject_toml": "[PYPROJECT_TOML]"
2398                  },
2399                  "seeds": {
2400                    "root": "[ROOT]/packages/seeds",
2401                    "project": {
2402                      "name": "seeds",
2403                      "version": "1.0.0",
2404                      "requires-python": ">=3.12",
2405                      "dependencies": [
2406                        "idna==3.6"
2407                      ],
2408                      "optional-dependencies": null
2409                    },
2410                    "pyproject_toml": "[PYPROJECT_TOML]"
2411                  }
2412                },
2413                "required_members": {},
2414                "sources": {},
2415                "indexes": [],
2416                "pyproject_toml": {
2417                  "project": {
2418                    "name": "albatross",
2419                    "version": "0.1.0",
2420                    "requires-python": ">=3.12",
2421                    "dependencies": [
2422                      "tqdm>=4,<5"
2423                    ],
2424                    "optional-dependencies": null
2425                  },
2426                  "tool": {
2427                    "uv": {
2428                      "sources": null,
2429                      "index": null,
2430                      "workspace": {
2431                        "members": [
2432                          "packages/*"
2433                        ],
2434                        "exclude": [
2435                          "packages/bird-feeder"
2436                        ]
2437                      },
2438                      "managed": null,
2439                      "package": null,
2440                      "default-groups": null,
2441                      "dependency-groups": null,
2442                      "dev-dependencies": null,
2443                      "override-dependencies": null,
2444                      "exclude-dependencies": null,
2445                      "constraint-dependencies": null,
2446                      "build-constraint-dependencies": null,
2447                      "environments": null,
2448                      "required-environments": null,
2449                      "conflicts": null,
2450                      "build-backend": null
2451                    }
2452                  },
2453                  "dependency-groups": null
2454                }
2455              }
2456            }
2457            "#);
2458        });
2459
2460        // Rewrite the members to both include and exclude `bird-feeder` by name.
2461        root.child("pyproject.toml").write_str(
2462            r#"
2463            [project]
2464            name = "albatross"
2465            version = "0.1.0"
2466            requires-python = ">=3.12"
2467            dependencies = ["tqdm>=4,<5"]
2468
2469            [tool.uv.workspace]
2470            members = ["packages/seeds", "packages/bird-feeder"]
2471            exclude = ["packages/bird-feeder"]
2472
2473            [build-system]
2474            requires = ["hatchling"]
2475            build-backend = "hatchling.build"
2476            "#,
2477        )?;
2478
2479        // `bird-feeder` should still be excluded.
2480        let (project, root_escaped) = temporary_test(root.as_ref()).await.unwrap();
2481        let filters = vec![(root_escaped.as_str(), "[ROOT]")];
2482        insta::with_settings!({filters => filters}, {
2483            assert_json_snapshot!(
2484            project,
2485            {
2486                ".workspace.packages.*.pyproject_toml" => "[PYPROJECT_TOML]"
2487            },
2488            @r#"
2489            {
2490              "project_root": "[ROOT]",
2491              "project_name": "albatross",
2492              "workspace": {
2493                "install_path": "[ROOT]",
2494                "packages": {
2495                  "albatross": {
2496                    "root": "[ROOT]",
2497                    "project": {
2498                      "name": "albatross",
2499                      "version": "0.1.0",
2500                      "requires-python": ">=3.12",
2501                      "dependencies": [
2502                        "tqdm>=4,<5"
2503                      ],
2504                      "optional-dependencies": null
2505                    },
2506                    "pyproject_toml": "[PYPROJECT_TOML]"
2507                  },
2508                  "seeds": {
2509                    "root": "[ROOT]/packages/seeds",
2510                    "project": {
2511                      "name": "seeds",
2512                      "version": "1.0.0",
2513                      "requires-python": ">=3.12",
2514                      "dependencies": [
2515                        "idna==3.6"
2516                      ],
2517                      "optional-dependencies": null
2518                    },
2519                    "pyproject_toml": "[PYPROJECT_TOML]"
2520                  }
2521                },
2522                "required_members": {},
2523                "sources": {},
2524                "indexes": [],
2525                "pyproject_toml": {
2526                  "project": {
2527                    "name": "albatross",
2528                    "version": "0.1.0",
2529                    "requires-python": ">=3.12",
2530                    "dependencies": [
2531                      "tqdm>=4,<5"
2532                    ],
2533                    "optional-dependencies": null
2534                  },
2535                  "tool": {
2536                    "uv": {
2537                      "sources": null,
2538                      "index": null,
2539                      "workspace": {
2540                        "members": [
2541                          "packages/seeds",
2542                          "packages/bird-feeder"
2543                        ],
2544                        "exclude": [
2545                          "packages/bird-feeder"
2546                        ]
2547                      },
2548                      "managed": null,
2549                      "package": null,
2550                      "default-groups": null,
2551                      "dependency-groups": null,
2552                      "dev-dependencies": null,
2553                      "override-dependencies": null,
2554                      "exclude-dependencies": null,
2555                      "constraint-dependencies": null,
2556                      "build-constraint-dependencies": null,
2557                      "environments": null,
2558                      "required-environments": null,
2559                      "conflicts": null,
2560                      "build-backend": null
2561                    }
2562                  },
2563                  "dependency-groups": null
2564                }
2565              }
2566            }
2567            "#);
2568        });
2569
2570        // Rewrite the exclusion to use the top-level directory (`packages`).
2571        root.child("pyproject.toml").write_str(
2572            r#"
2573            [project]
2574            name = "albatross"
2575            version = "0.1.0"
2576            requires-python = ">=3.12"
2577            dependencies = ["tqdm>=4,<5"]
2578
2579            [tool.uv.workspace]
2580            members = ["packages/seeds", "packages/bird-feeder"]
2581            exclude = ["packages"]
2582
2583            [build-system]
2584            requires = ["hatchling"]
2585            build-backend = "hatchling.build"
2586            "#,
2587        )?;
2588
2589        // `bird-feeder` should now be included.
2590        let (project, root_escaped) = temporary_test(root.as_ref()).await.unwrap();
2591        let filters = vec![(root_escaped.as_str(), "[ROOT]")];
2592        insta::with_settings!({filters => filters}, {
2593            assert_json_snapshot!(
2594            project,
2595            {
2596                ".workspace.packages.*.pyproject_toml" => "[PYPROJECT_TOML]"
2597            },
2598            @r#"
2599            {
2600              "project_root": "[ROOT]",
2601              "project_name": "albatross",
2602              "workspace": {
2603                "install_path": "[ROOT]",
2604                "packages": {
2605                  "albatross": {
2606                    "root": "[ROOT]",
2607                    "project": {
2608                      "name": "albatross",
2609                      "version": "0.1.0",
2610                      "requires-python": ">=3.12",
2611                      "dependencies": [
2612                        "tqdm>=4,<5"
2613                      ],
2614                      "optional-dependencies": null
2615                    },
2616                    "pyproject_toml": "[PYPROJECT_TOML]"
2617                  },
2618                  "bird-feeder": {
2619                    "root": "[ROOT]/packages/bird-feeder",
2620                    "project": {
2621                      "name": "bird-feeder",
2622                      "version": "1.0.0",
2623                      "requires-python": ">=3.12",
2624                      "dependencies": [
2625                        "anyio>=4.3.0,<5"
2626                      ],
2627                      "optional-dependencies": null
2628                    },
2629                    "pyproject_toml": "[PYPROJECT_TOML]"
2630                  },
2631                  "seeds": {
2632                    "root": "[ROOT]/packages/seeds",
2633                    "project": {
2634                      "name": "seeds",
2635                      "version": "1.0.0",
2636                      "requires-python": ">=3.12",
2637                      "dependencies": [
2638                        "idna==3.6"
2639                      ],
2640                      "optional-dependencies": null
2641                    },
2642                    "pyproject_toml": "[PYPROJECT_TOML]"
2643                  }
2644                },
2645                "required_members": {},
2646                "sources": {},
2647                "indexes": [],
2648                "pyproject_toml": {
2649                  "project": {
2650                    "name": "albatross",
2651                    "version": "0.1.0",
2652                    "requires-python": ">=3.12",
2653                    "dependencies": [
2654                      "tqdm>=4,<5"
2655                    ],
2656                    "optional-dependencies": null
2657                  },
2658                  "tool": {
2659                    "uv": {
2660                      "sources": null,
2661                      "index": null,
2662                      "workspace": {
2663                        "members": [
2664                          "packages/seeds",
2665                          "packages/bird-feeder"
2666                        ],
2667                        "exclude": [
2668                          "packages"
2669                        ]
2670                      },
2671                      "managed": null,
2672                      "package": null,
2673                      "default-groups": null,
2674                      "dependency-groups": null,
2675                      "dev-dependencies": null,
2676                      "override-dependencies": null,
2677                      "exclude-dependencies": null,
2678                      "constraint-dependencies": null,
2679                      "build-constraint-dependencies": null,
2680                      "environments": null,
2681                      "required-environments": null,
2682                      "conflicts": null,
2683                      "build-backend": null
2684                    }
2685                  },
2686                  "dependency-groups": null
2687                }
2688              }
2689            }
2690            "#);
2691        });
2692
2693        // Rewrite the exclusion to use the top-level directory with a glob (`packages/*`).
2694        root.child("pyproject.toml").write_str(
2695            r#"
2696            [project]
2697            name = "albatross"
2698            version = "0.1.0"
2699            requires-python = ">=3.12"
2700            dependencies = ["tqdm>=4,<5"]
2701
2702            [tool.uv.workspace]
2703            members = ["packages/seeds", "packages/bird-feeder"]
2704            exclude = ["packages/*"]
2705
2706            [build-system]
2707            requires = ["hatchling"]
2708            build-backend = "hatchling.build"
2709            "#,
2710        )?;
2711
2712        // `bird-feeder` and `seeds` should now be excluded.
2713        let (project, root_escaped) = temporary_test(root.as_ref()).await.unwrap();
2714        let filters = vec![(root_escaped.as_str(), "[ROOT]")];
2715        insta::with_settings!({filters => filters}, {
2716            assert_json_snapshot!(
2717            project,
2718            {
2719                ".workspace.packages.*.pyproject_toml" => "[PYPROJECT_TOML]"
2720            },
2721            @r#"
2722            {
2723              "project_root": "[ROOT]",
2724              "project_name": "albatross",
2725              "workspace": {
2726                "install_path": "[ROOT]",
2727                "packages": {
2728                  "albatross": {
2729                    "root": "[ROOT]",
2730                    "project": {
2731                      "name": "albatross",
2732                      "version": "0.1.0",
2733                      "requires-python": ">=3.12",
2734                      "dependencies": [
2735                        "tqdm>=4,<5"
2736                      ],
2737                      "optional-dependencies": null
2738                    },
2739                    "pyproject_toml": "[PYPROJECT_TOML]"
2740                  }
2741                },
2742                "required_members": {},
2743                "sources": {},
2744                "indexes": [],
2745                "pyproject_toml": {
2746                  "project": {
2747                    "name": "albatross",
2748                    "version": "0.1.0",
2749                    "requires-python": ">=3.12",
2750                    "dependencies": [
2751                      "tqdm>=4,<5"
2752                    ],
2753                    "optional-dependencies": null
2754                  },
2755                  "tool": {
2756                    "uv": {
2757                      "sources": null,
2758                      "index": null,
2759                      "workspace": {
2760                        "members": [
2761                          "packages/seeds",
2762                          "packages/bird-feeder"
2763                        ],
2764                        "exclude": [
2765                          "packages/*"
2766                        ]
2767                      },
2768                      "managed": null,
2769                      "package": null,
2770                      "default-groups": null,
2771                      "dependency-groups": null,
2772                      "dev-dependencies": null,
2773                      "override-dependencies": null,
2774                      "exclude-dependencies": null,
2775                      "constraint-dependencies": null,
2776                      "build-constraint-dependencies": null,
2777                      "environments": null,
2778                      "required-environments": null,
2779                      "conflicts": null,
2780                      "build-backend": null
2781                    }
2782                  },
2783                  "dependency-groups": null
2784                }
2785              }
2786            }
2787            "#);
2788        });
2789
2790        Ok(())
2791    }
2792
2793    #[test]
2794    fn read_dependency_groups() {
2795        let toml = r#"
2796[dependency-groups]
2797foo = ["a", {include-group = "bar"}]
2798bar = ["b"]
2799"#;
2800
2801        let result = PyProjectToml::from_string(toml.to_string(), "pyproject.toml")
2802            .expect("Deserialization should succeed");
2803
2804        let groups = result
2805            .dependency_groups
2806            .expect("`dependency-groups` should be present");
2807        let foo = groups
2808            .get(&GroupName::from_str("foo").unwrap())
2809            .expect("Group `foo` should be present");
2810        assert_eq!(
2811            foo,
2812            &[
2813                DependencyGroupSpecifier::Requirement("a".to_string()),
2814                DependencyGroupSpecifier::IncludeGroup {
2815                    include_group: GroupName::from_str("bar").unwrap(),
2816                }
2817            ]
2818        );
2819
2820        let bar = groups
2821            .get(&GroupName::from_str("bar").unwrap())
2822            .expect("Group `bar` should be present");
2823        assert_eq!(
2824            bar,
2825            &[DependencyGroupSpecifier::Requirement("b".to_string())]
2826        );
2827    }
2828
2829    #[tokio::test]
2830    async fn nested_workspace() -> Result<()> {
2831        let root = tempfile::TempDir::new()?;
2832        let root = ChildPath::new(root.path());
2833
2834        // Create the root.
2835        root.child("pyproject.toml").write_str(
2836            r#"
2837            [project]
2838            name = "albatross"
2839            version = "0.1.0"
2840            requires-python = ">=3.12"
2841            dependencies = ["tqdm>=4,<5"]
2842
2843            [tool.uv.workspace]
2844            members = ["packages/*"]
2845            "#,
2846        )?;
2847
2848        // Create an included package (`seeds`).
2849        root.child("packages")
2850            .child("seeds")
2851            .child("pyproject.toml")
2852            .write_str(
2853                r#"
2854            [project]
2855            name = "seeds"
2856            version = "1.0.0"
2857            requires-python = ">=3.12"
2858            dependencies = ["idna==3.6"]
2859
2860            [tool.uv.workspace]
2861            members = ["nested_packages/*"]
2862            "#,
2863            )?;
2864
2865        let (error, root_escaped) = temporary_test(root.as_ref()).await.unwrap_err();
2866        let filters = vec![(root_escaped.as_str(), "[ROOT]")];
2867        insta::with_settings!({filters => filters}, {
2868            assert_snapshot!(
2869                error,
2870            @"Nested workspaces are not supported, but workspace member has a `tool.uv.workspace` table: [ROOT]/packages/seeds");
2871        });
2872
2873        Ok(())
2874    }
2875
2876    #[tokio::test]
2877    async fn duplicate_names() -> Result<()> {
2878        let root = tempfile::TempDir::new()?;
2879        let root = ChildPath::new(root.path());
2880
2881        // Create the root.
2882        root.child("pyproject.toml").write_str(
2883            r#"
2884            [project]
2885            name = "albatross"
2886            version = "0.1.0"
2887            requires-python = ">=3.12"
2888            dependencies = ["tqdm>=4,<5"]
2889
2890            [tool.uv.workspace]
2891            members = ["packages/*"]
2892            "#,
2893        )?;
2894
2895        // Create an included package (`seeds`).
2896        root.child("packages")
2897            .child("seeds")
2898            .child("pyproject.toml")
2899            .write_str(
2900                r#"
2901            [project]
2902            name = "seeds"
2903            version = "1.0.0"
2904            requires-python = ">=3.12"
2905            dependencies = ["idna==3.6"]
2906
2907            [tool.uv.workspace]
2908            members = ["nested_packages/*"]
2909            "#,
2910            )?;
2911
2912        // Create an included package (`seeds2`).
2913        root.child("packages")
2914            .child("seeds2")
2915            .child("pyproject.toml")
2916            .write_str(
2917                r#"
2918            [project]
2919            name = "seeds"
2920            version = "1.0.0"
2921            requires-python = ">=3.12"
2922            dependencies = ["idna==3.6"]
2923
2924            [tool.uv.workspace]
2925            members = ["nested_packages/*"]
2926            "#,
2927            )?;
2928
2929        let (error, root_escaped) = temporary_test(root.as_ref()).await.unwrap_err();
2930        let filters = vec![(root_escaped.as_str(), "[ROOT]")];
2931        insta::with_settings!({filters => filters}, {
2932            assert_snapshot!(
2933                error,
2934            @"Two workspace members are both named `seeds`: `[ROOT]/packages/seeds` and `[ROOT]/packages/seeds2`");
2935        });
2936
2937        Ok(())
2938    }
2939}