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