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    pub fn allows_implicit_workspace(&self) -> bool {
119        matches!(self, Self::Optional)
120    }
121
122    /// Whether a non-project workspace root is allowed.
123    pub 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    pub 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    pub 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    pub 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    pub fn update_member(
1401        self,
1402        pyproject_toml: PyProjectToml,
1403    ) -> Result<Option<Self>, WorkspaceError> {
1404        let Some(workspace) = self
1405            .workspace
1406            .update_member(&self.project_name, pyproject_toml)?
1407        else {
1408            return Ok(None);
1409        };
1410        Ok(Some(Self { workspace, ..self }))
1411    }
1412
1413    /// Find the workspace for a project.
1414    async fn from_project(
1415        install_path: &Path,
1416        project: &Project,
1417        project_pyproject_toml: &PyProjectToml,
1418        options: &DiscoveryOptions,
1419        cache: &WorkspaceCache,
1420    ) -> Result<Self, WorkspaceError> {
1421        let project_path = std::path::absolute(install_path)
1422            .map_err(WorkspaceError::Normalize)?
1423            .clone();
1424        let project_path = normalize_path(&project_path);
1425
1426        // Check if workspaces are explicitly disabled for the project.
1427        if project_pyproject_toml
1428            .tool
1429            .as_ref()
1430            .and_then(|tool| tool.uv.as_ref())
1431            .and_then(|uv| uv.managed)
1432            == Some(false)
1433        {
1434            debug!("Project `{}` is marked as unmanaged", project.name);
1435            return Err(WorkspaceError::NonWorkspace(project_path.to_path_buf()));
1436        }
1437
1438        // Check if the current project is also an explicit workspace root.
1439        let mut workspace = project_pyproject_toml
1440            .tool
1441            .as_ref()
1442            .and_then(|tool| tool.uv.as_ref())
1443            .and_then(|uv| uv.workspace.as_ref())
1444            .map(|workspace| {
1445                (
1446                    project_path.to_path_buf(),
1447                    workspace.clone(),
1448                    project_pyproject_toml.clone(),
1449                )
1450            });
1451
1452        if workspace.is_none() {
1453            // The project isn't an explicit workspace root, check if we're a regular workspace
1454            // member by looking for an explicit workspace root above.
1455            workspace = find_workspace(&project_path, options).await?;
1456        }
1457
1458        let current_project = WorkspaceMember {
1459            root: project_path.to_path_buf(),
1460            project: project.clone(),
1461            pyproject_toml: project_pyproject_toml.clone(),
1462        };
1463
1464        let Some((workspace_root, workspace_definition, workspace_pyproject_toml)) = workspace
1465        else {
1466            // The project isn't an explicit workspace root, but there's also no workspace root
1467            // above it, so the project is an implicit workspace root identical to the project root.
1468            debug!("No workspace root found, using project root");
1469
1470            let current_project_as_members = Arc::new(BTreeMap::from_iter([(
1471                project.name.clone(),
1472                current_project,
1473            )]));
1474            let workspace_sources = BTreeMap::default();
1475            let required_members = Workspace::collect_required_members(
1476                &current_project_as_members,
1477                &workspace_sources,
1478                project_pyproject_toml,
1479            )?;
1480
1481            return Ok(Self {
1482                project_root: project_path.to_path_buf(),
1483                project_name: project.name.clone(),
1484                workspace: Workspace {
1485                    install_path: project_path.to_path_buf(),
1486                    packages: current_project_as_members,
1487                    required_members,
1488                    // There may be package sources, but we don't need to duplicate them into the
1489                    // workspace sources.
1490                    sources: workspace_sources,
1491                    indexes: Vec::default(),
1492                    pyproject_toml: project_pyproject_toml.clone(),
1493                },
1494            });
1495        };
1496
1497        debug!(
1498            "Found workspace root: `{}`",
1499            workspace_root.simplified_display()
1500        );
1501
1502        let workspace = Workspace::collect_members(
1503            workspace_root,
1504            workspace_definition,
1505            workspace_pyproject_toml,
1506            Some(current_project),
1507            options,
1508            cache,
1509        )
1510        .await?;
1511
1512        Ok(Self {
1513            project_root: project_path.to_path_buf(),
1514            project_name: project.name.clone(),
1515            workspace,
1516        })
1517    }
1518}
1519
1520/// Find the workspace root above the current project, if any.
1521async fn find_workspace(
1522    project_root: &Path,
1523    options: &DiscoveryOptions,
1524) -> Result<Option<(PathBuf, ToolUvWorkspace, PyProjectToml)>, WorkspaceError> {
1525    // Skip 1 to ignore the current project itself.
1526    for workspace_root in project_root
1527        .ancestors()
1528        .take_while(|path| {
1529            // Only walk up the given directory, if any.
1530            options
1531                .stop_discovery_at
1532                .as_deref()
1533                .and_then(Path::parent)
1534                .map(|stop_discovery_at| stop_discovery_at != *path)
1535                .unwrap_or(true)
1536        })
1537        .skip(1)
1538    {
1539        let pyproject_path = workspace_root.join("pyproject.toml");
1540        if !pyproject_path.is_file() {
1541            continue;
1542        }
1543        trace!(
1544            "Found `pyproject.toml` at: `{}`",
1545            pyproject_path.simplified_display()
1546        );
1547
1548        // Read the `pyproject.toml`.
1549        let contents = fs_err::tokio::read_to_string(&pyproject_path).await?;
1550        let pyproject_toml = PyProjectToml::from_string(contents, &pyproject_path)
1551            .map_err(|err| WorkspaceError::Toml(pyproject_path.clone(), Box::new(err)))?;
1552
1553        return if let Some(workspace) = pyproject_toml
1554            .tool
1555            .as_ref()
1556            .and_then(|tool| tool.uv.as_ref())
1557            .and_then(|uv| uv.workspace.as_ref())
1558        {
1559            if !is_included_in_workspace(project_root, workspace_root, workspace)? {
1560                debug!(
1561                    "Found workspace root `{}`, but project is not included",
1562                    workspace_root.simplified_display()
1563                );
1564                return Ok(None);
1565            }
1566
1567            if is_excluded_from_workspace(project_root, workspace_root, workspace)? {
1568                debug!(
1569                    "Found workspace root `{}`, but project is excluded",
1570                    workspace_root.simplified_display()
1571                );
1572                return Ok(None);
1573            }
1574
1575            // We found a workspace root.
1576            Ok(Some((
1577                workspace_root.to_path_buf(),
1578                workspace.clone(),
1579                pyproject_toml,
1580            )))
1581        } else if pyproject_toml.project.is_some() {
1582            // We're in a directory of another project, e.g. tests or examples.
1583            // Example:
1584            // ```
1585            // albatross
1586            // ├── examples
1587            // │   └── bird-feeder [CURRENT DIRECTORY]
1588            // │       ├── pyproject.toml
1589            // │       └── src
1590            // │           └── bird_feeder
1591            // │               └── __init__.py
1592            // ├── pyproject.toml
1593            // └── src
1594            //     └── albatross
1595            //         └── __init__.py
1596            // ```
1597            // The current project is the example (non-workspace) `bird-feeder` in `albatross`,
1598            // we ignore all `albatross` is doing and any potential workspace it might be
1599            // contained in.
1600            debug!(
1601                "Project is contained in non-workspace project: `{}`",
1602                workspace_root.simplified_display()
1603            );
1604            Ok(None)
1605        } else {
1606            // We require that a `project.toml` file either declares a workspace or a project.
1607            warn!(
1608                "`pyproject.toml` does not contain a `project` table: `{}`",
1609                pyproject_path.simplified_display()
1610            );
1611            Ok(None)
1612        };
1613    }
1614
1615    Ok(None)
1616}
1617
1618/// Check if a directory only contains files that are ignored.
1619///
1620/// Returns `true` if walking the directory while respecting `.gitignore` and `.ignore` rules
1621/// yields no files, indicating that any files present (e.g., `__pycache__`) are all ignored.
1622fn has_only_gitignored_files(path: &Path) -> bool {
1623    let walker = ignore::WalkBuilder::new(path)
1624        .hidden(false)
1625        .parents(true)
1626        .ignore(true)
1627        .git_ignore(true)
1628        .git_global(true)
1629        .git_exclude(true)
1630        .build();
1631
1632    for entry in walker {
1633        let Ok(entry) = entry else {
1634            // If we can't read an entry, assume non-ignored content exists.
1635            return false;
1636        };
1637
1638        // Skip directories.
1639        if entry.path().is_dir() {
1640            continue;
1641        }
1642
1643        // A non-ignored entry exists.
1644        return false;
1645    }
1646
1647    true
1648}
1649
1650/// Check if we're in the `tool.uv.workspace.excluded` of a workspace.
1651fn is_excluded_from_workspace(
1652    project_path: &Path,
1653    workspace_root: &Path,
1654    workspace: &ToolUvWorkspace,
1655) -> Result<bool, WorkspaceError> {
1656    for exclude_glob in workspace.exclude.iter().flatten() {
1657        // Normalize the exclude glob to remove leading `./` and other relative path components
1658        let normalized_glob = normalize_path(Path::new(exclude_glob.as_str()));
1659        let absolute_glob = PathBuf::from(glob::Pattern::escape(
1660            workspace_root.simplified().to_string_lossy().as_ref(),
1661        ))
1662        .join(normalized_glob.as_ref());
1663        let absolute_glob = absolute_glob.to_string_lossy();
1664        let exclude_pattern = glob::Pattern::new(&absolute_glob)
1665            .map_err(|err| WorkspaceError::Pattern(absolute_glob.to_string(), err))?;
1666        if exclude_pattern.matches_path(project_path) {
1667            return Ok(true);
1668        }
1669    }
1670    Ok(false)
1671}
1672
1673/// Check if we're in the `tool.uv.workspace.members` of a workspace.
1674fn is_included_in_workspace(
1675    project_path: &Path,
1676    workspace_root: &Path,
1677    workspace: &ToolUvWorkspace,
1678) -> Result<bool, WorkspaceError> {
1679    for member_glob in workspace.members.iter().flatten() {
1680        // Normalize the member glob to remove leading `./` and other relative path components
1681        let normalized_glob = normalize_path(Path::new(member_glob.as_str()));
1682        let absolute_glob = PathBuf::from(glob::Pattern::escape(
1683            workspace_root.simplified().to_string_lossy().as_ref(),
1684        ))
1685        .join(normalized_glob);
1686        let absolute_glob = absolute_glob.to_string_lossy();
1687        let include_pattern = glob::Pattern::new(&absolute_glob)
1688            .map_err(|err| WorkspaceError::Pattern(absolute_glob.to_string(), err))?;
1689        if include_pattern.matches_path(project_path) {
1690            return Ok(true);
1691        }
1692    }
1693    Ok(false)
1694}
1695
1696/// A project that can be discovered.
1697///
1698/// The project could be a package within a workspace, a real workspace root, or a non-project
1699/// workspace root, which can define its own dev dependencies.
1700#[derive(Debug, Clone)]
1701pub enum VirtualProject {
1702    /// A project (which could be a workspace root or member).
1703    Project(ProjectWorkspace),
1704    /// A non-project workspace root.
1705    NonProject(Workspace),
1706}
1707
1708impl VirtualProject {
1709    /// Find the current project or virtual workspace root, given the current directory.
1710    ///
1711    /// Similar to calling [`ProjectWorkspace::discover`] with a fallback to [`Workspace::discover`],
1712    /// but avoids rereading the `pyproject.toml` (and relying on error-handling as control flow).
1713    ///
1714    /// This method requires an absolute path and panics otherwise, i.e. this method only supports
1715    /// discovering the main workspace.
1716    pub async fn discover(
1717        path: &Path,
1718        options: &DiscoveryOptions,
1719        cache: &WorkspaceCache,
1720    ) -> Result<Self, WorkspaceError> {
1721        assert!(
1722            path.is_absolute(),
1723            "virtual project discovery with relative path"
1724        );
1725        let project_root = path
1726            .ancestors()
1727            .take_while(|path| {
1728                // Only walk up the given directory, if any.
1729                options
1730                    .stop_discovery_at
1731                    .as_deref()
1732                    .and_then(Path::parent)
1733                    .map(|stop_discovery_at| stop_discovery_at != *path)
1734                    .unwrap_or(true)
1735            })
1736            .find(|path| path.join("pyproject.toml").is_file())
1737            .ok_or(WorkspaceError::MissingPyprojectToml)?;
1738
1739        debug!(
1740            "Found project root: `{}`",
1741            project_root.simplified_display()
1742        );
1743
1744        // Read the current `pyproject.toml`.
1745        let pyproject_path = project_root.join("pyproject.toml");
1746        let contents = fs_err::tokio::read_to_string(&pyproject_path).await?;
1747        let pyproject_toml = PyProjectToml::from_string(contents, &pyproject_path)
1748            .map_err(|err| WorkspaceError::Toml(pyproject_path.clone(), Box::new(err)))?;
1749
1750        if let Some(project) = pyproject_toml.project.as_ref() {
1751            // If the `pyproject.toml` contains a `[project]` table, it's a project.
1752            let project = ProjectWorkspace::from_project(
1753                project_root,
1754                project,
1755                &pyproject_toml,
1756                options,
1757                cache,
1758            )
1759            .await?;
1760            Ok(Self::Project(project))
1761        } else if let Some(workspace) = pyproject_toml
1762            .tool
1763            .as_ref()
1764            .and_then(|tool| tool.uv.as_ref())
1765            .and_then(|uv| uv.workspace.as_ref())
1766            .filter(|_| options.project.allows_non_project_workspace())
1767        {
1768            // Otherwise, if it contains a `tool.uv.workspace` table, it's a non-project workspace
1769            // root.
1770            let project_path = std::path::absolute(project_root)
1771                .map_err(WorkspaceError::Normalize)?
1772                .clone();
1773
1774            let workspace = Workspace::collect_members(
1775                project_path,
1776                workspace.clone(),
1777                pyproject_toml,
1778                None,
1779                options,
1780                cache,
1781            )
1782            .await?;
1783
1784            Ok(Self::NonProject(workspace))
1785        } else if options.project.allows_implicit_workspace() {
1786            // Otherwise it's a pyproject.toml that maybe contains dependency-groups
1787            // that we want to treat like a project/workspace to handle those uniformly
1788            let project_path = std::path::absolute(project_root)
1789                .map_err(WorkspaceError::Normalize)?
1790                .clone();
1791
1792            let workspace = Workspace::collect_members(
1793                project_path,
1794                ToolUvWorkspace::default(),
1795                pyproject_toml,
1796                None,
1797                options,
1798                cache,
1799            )
1800            .await?;
1801
1802            Ok(Self::NonProject(workspace))
1803        } else {
1804            Err(WorkspaceError::MissingProject(pyproject_path))
1805        }
1806    }
1807
1808    /// Discover a project workspace with the member package.
1809    pub async fn discover_with_package(
1810        path: &Path,
1811        options: &DiscoveryOptions,
1812        cache: &WorkspaceCache,
1813        package: PackageName,
1814    ) -> Result<Self, WorkspaceError> {
1815        let workspace = Workspace::discover(path, options, cache).await?;
1816        let project_workspace = Workspace::with_current_project(workspace.clone(), package.clone());
1817        Ok(Self::Project(project_workspace.ok_or_else(|| {
1818            WorkspaceError::NoSuchMember(package.clone(), workspace.install_path)
1819        })?))
1820    }
1821
1822    /// Update the `pyproject.toml` for the current project.
1823    ///
1824    /// Assumes that the project name is unchanged in the updated [`PyProjectToml`].
1825    pub fn update_member(
1826        self,
1827        pyproject_toml: PyProjectToml,
1828    ) -> Result<Option<Self>, WorkspaceError> {
1829        Ok(match self {
1830            Self::Project(project) => {
1831                let Some(project) = project.update_member(pyproject_toml)? else {
1832                    return Ok(None);
1833                };
1834                Some(Self::Project(project))
1835            }
1836            Self::NonProject(workspace) => {
1837                // If this is a non-project workspace root, then by definition the root isn't a
1838                // member, so we can just update the top-level `pyproject.toml`.
1839                Some(Self::NonProject(Workspace {
1840                    pyproject_toml,
1841                    ..workspace.clone()
1842                }))
1843            }
1844        })
1845    }
1846
1847    /// Return the root of the project.
1848    pub fn root(&self) -> &Path {
1849        match self {
1850            Self::Project(project) => project.project_root(),
1851            Self::NonProject(workspace) => workspace.install_path(),
1852        }
1853    }
1854
1855    /// Return the [`PyProjectToml`] of the project.
1856    pub fn pyproject_toml(&self) -> &PyProjectToml {
1857        match self {
1858            Self::Project(project) => project.current_project().pyproject_toml(),
1859            Self::NonProject(workspace) => &workspace.pyproject_toml,
1860        }
1861    }
1862
1863    /// Return the [`Workspace`] of the project.
1864    pub fn workspace(&self) -> &Workspace {
1865        match self {
1866            Self::Project(project) => project.workspace(),
1867            Self::NonProject(workspace) => workspace,
1868        }
1869    }
1870
1871    /// Return the [`PackageName`] of the project, if available.
1872    pub fn project_name(&self) -> Option<&PackageName> {
1873        match self {
1874            Self::Project(project) => Some(project.project_name()),
1875            Self::NonProject(_) => None,
1876        }
1877    }
1878
1879    /// Returns `true` if the project is a virtual workspace root.
1880    pub fn is_non_project(&self) -> bool {
1881        matches!(self, Self::NonProject(_))
1882    }
1883}
1884
1885#[cfg(test)]
1886#[cfg(unix)] // Avoid path escaping for the unit tests
1887mod tests {
1888    use std::env;
1889    use std::path::Path;
1890    use std::str::FromStr;
1891
1892    use anyhow::Result;
1893    use assert_fs::fixture::ChildPath;
1894    use assert_fs::prelude::*;
1895    use insta::{assert_json_snapshot, assert_snapshot};
1896
1897    use uv_normalize::GroupName;
1898    use uv_pypi_types::DependencyGroupSpecifier;
1899
1900    use crate::pyproject::PyProjectToml;
1901    use crate::workspace::{DiscoveryOptions, ProjectWorkspace};
1902    use crate::{WorkspaceCache, WorkspaceError};
1903
1904    async fn workspace_test(folder: &str) -> (ProjectWorkspace, String) {
1905        let root_dir = env::current_dir()
1906            .unwrap()
1907            .parent()
1908            .unwrap()
1909            .parent()
1910            .unwrap()
1911            .join("test")
1912            .join("workspaces");
1913        let project = ProjectWorkspace::discover(
1914            &root_dir.join(folder),
1915            &DiscoveryOptions::default(),
1916            &WorkspaceCache::default(),
1917        )
1918        .await
1919        .unwrap();
1920        let root_escaped = regex::escape(root_dir.to_string_lossy().as_ref());
1921        (project, root_escaped)
1922    }
1923
1924    async fn temporary_test(
1925        folder: &Path,
1926    ) -> Result<(ProjectWorkspace, String), (WorkspaceError, String)> {
1927        let root_escaped = regex::escape(folder.to_string_lossy().as_ref());
1928        let project = ProjectWorkspace::discover(
1929            folder,
1930            &DiscoveryOptions::default(),
1931            &WorkspaceCache::default(),
1932        )
1933        .await
1934        .map_err(|error| (error, root_escaped.clone()))?;
1935
1936        Ok((project, root_escaped))
1937    }
1938
1939    #[tokio::test]
1940    async fn albatross_in_example() {
1941        let (project, root_escaped) =
1942            workspace_test("albatross-in-example/examples/bird-feeder").await;
1943        let filters = vec![(root_escaped.as_str(), "[ROOT]")];
1944        insta::with_settings!({filters => filters}, {
1945        assert_json_snapshot!(
1946            project,
1947            {
1948                ".workspace.packages.*.pyproject_toml" => "[PYPROJECT_TOML]"
1949            },
1950            @r#"
1951        {
1952          "project_root": "[ROOT]/albatross-in-example/examples/bird-feeder",
1953          "project_name": "bird-feeder",
1954          "workspace": {
1955            "install_path": "[ROOT]/albatross-in-example/examples/bird-feeder",
1956            "packages": {
1957              "bird-feeder": {
1958                "root": "[ROOT]/albatross-in-example/examples/bird-feeder",
1959                "project": {
1960                  "name": "bird-feeder",
1961                  "version": "1.0.0",
1962                  "requires-python": ">=3.12",
1963                  "dependencies": [
1964                    "iniconfig>=2,<3"
1965                  ],
1966                  "optional-dependencies": null
1967                },
1968                "pyproject_toml": "[PYPROJECT_TOML]"
1969              }
1970            },
1971            "required_members": {},
1972            "sources": {},
1973            "indexes": [],
1974            "pyproject_toml": {
1975              "project": {
1976                "name": "bird-feeder",
1977                "version": "1.0.0",
1978                "requires-python": ">=3.12",
1979                "dependencies": [
1980                  "iniconfig>=2,<3"
1981                ],
1982                "optional-dependencies": null
1983              },
1984              "tool": null,
1985              "dependency-groups": null
1986            }
1987          }
1988        }
1989        "#);
1990        });
1991    }
1992
1993    #[tokio::test]
1994    async fn albatross_project_in_excluded() {
1995        let (project, root_escaped) =
1996            workspace_test("albatross-project-in-excluded/excluded/bird-feeder").await;
1997        let filters = vec![(root_escaped.as_str(), "[ROOT]")];
1998        insta::with_settings!({filters => filters}, {
1999            assert_json_snapshot!(
2000            project,
2001            {
2002                ".workspace.packages.*.pyproject_toml" => "[PYPROJECT_TOML]"
2003            },
2004            @r#"
2005            {
2006              "project_root": "[ROOT]/albatross-project-in-excluded/excluded/bird-feeder",
2007              "project_name": "bird-feeder",
2008              "workspace": {
2009                "install_path": "[ROOT]/albatross-project-in-excluded/excluded/bird-feeder",
2010                "packages": {
2011                  "bird-feeder": {
2012                    "root": "[ROOT]/albatross-project-in-excluded/excluded/bird-feeder",
2013                    "project": {
2014                      "name": "bird-feeder",
2015                      "version": "1.0.0",
2016                      "requires-python": ">=3.12",
2017                      "dependencies": [
2018                        "iniconfig>=2,<3"
2019                      ],
2020                      "optional-dependencies": null
2021                    },
2022                    "pyproject_toml": "[PYPROJECT_TOML]"
2023                  }
2024                },
2025                "required_members": {},
2026                "sources": {},
2027                "indexes": [],
2028                "pyproject_toml": {
2029                  "project": {
2030                    "name": "bird-feeder",
2031                    "version": "1.0.0",
2032                    "requires-python": ">=3.12",
2033                    "dependencies": [
2034                      "iniconfig>=2,<3"
2035                    ],
2036                    "optional-dependencies": null
2037                  },
2038                  "tool": null,
2039                  "dependency-groups": null
2040                }
2041              }
2042            }
2043            "#);
2044        });
2045    }
2046
2047    #[tokio::test]
2048    async fn albatross_root_workspace() {
2049        let (project, root_escaped) = workspace_test("albatross-root-workspace").await;
2050        let filters = vec![(root_escaped.as_str(), "[ROOT]")];
2051        insta::with_settings!({filters => filters}, {
2052            assert_json_snapshot!(
2053            project,
2054            {
2055                ".workspace.packages.*.pyproject_toml" => "[PYPROJECT_TOML]"
2056            },
2057            @r#"
2058            {
2059              "project_root": "[ROOT]/albatross-root-workspace",
2060              "project_name": "albatross",
2061              "workspace": {
2062                "install_path": "[ROOT]/albatross-root-workspace",
2063                "packages": {
2064                  "albatross": {
2065                    "root": "[ROOT]/albatross-root-workspace",
2066                    "project": {
2067                      "name": "albatross",
2068                      "version": "0.1.0",
2069                      "requires-python": ">=3.12",
2070                      "dependencies": [
2071                        "bird-feeder",
2072                        "iniconfig>=2,<3"
2073                      ],
2074                      "optional-dependencies": null
2075                    },
2076                    "pyproject_toml": "[PYPROJECT_TOML]"
2077                  },
2078                  "bird-feeder": {
2079                    "root": "[ROOT]/albatross-root-workspace/packages/bird-feeder",
2080                    "project": {
2081                      "name": "bird-feeder",
2082                      "version": "1.0.0",
2083                      "requires-python": ">=3.8",
2084                      "dependencies": [
2085                        "iniconfig>=2,<3",
2086                        "seeds"
2087                      ],
2088                      "optional-dependencies": null
2089                    },
2090                    "pyproject_toml": "[PYPROJECT_TOML]"
2091                  },
2092                  "seeds": {
2093                    "root": "[ROOT]/albatross-root-workspace/packages/seeds",
2094                    "project": {
2095                      "name": "seeds",
2096                      "version": "1.0.0",
2097                      "requires-python": ">=3.12",
2098                      "dependencies": [
2099                        "idna==3.6"
2100                      ],
2101                      "optional-dependencies": null
2102                    },
2103                    "pyproject_toml": "[PYPROJECT_TOML]"
2104                  }
2105                },
2106                "required_members": {
2107                  "bird-feeder": null,
2108                  "seeds": null
2109                },
2110                "sources": {
2111                  "bird-feeder": [
2112                    {
2113                      "workspace": true,
2114                      "editable": null,
2115                      "extra": null,
2116                      "group": null
2117                    }
2118                  ]
2119                },
2120                "indexes": [],
2121                "pyproject_toml": {
2122                  "project": {
2123                    "name": "albatross",
2124                    "version": "0.1.0",
2125                    "requires-python": ">=3.12",
2126                    "dependencies": [
2127                      "bird-feeder",
2128                      "iniconfig>=2,<3"
2129                    ],
2130                    "optional-dependencies": null
2131                  },
2132                  "tool": {
2133                    "uv": {
2134                      "sources": {
2135                        "bird-feeder": [
2136                          {
2137                            "workspace": true,
2138                            "editable": null,
2139                            "extra": null,
2140                            "group": null
2141                          }
2142                        ]
2143                      },
2144                      "index": null,
2145                      "workspace": {
2146                        "members": [
2147                          "packages/*"
2148                        ],
2149                        "exclude": null
2150                      },
2151                      "managed": null,
2152                      "package": null,
2153                      "default-groups": null,
2154                      "dependency-groups": null,
2155                      "dev-dependencies": null,
2156                      "override-dependencies": null,
2157                      "exclude-dependencies": null,
2158                      "constraint-dependencies": null,
2159                      "build-constraint-dependencies": null,
2160                      "environments": null,
2161                      "required-environments": null,
2162                      "conflicts": null,
2163                      "build-backend": null
2164                    }
2165                  },
2166                  "dependency-groups": null
2167                }
2168              }
2169            }
2170            "#);
2171        });
2172    }
2173
2174    #[tokio::test]
2175    async fn albatross_virtual_workspace() {
2176        let (project, root_escaped) =
2177            workspace_test("albatross-virtual-workspace/packages/albatross").await;
2178        let filters = vec![(root_escaped.as_str(), "[ROOT]")];
2179        insta::with_settings!({filters => filters}, {
2180            assert_json_snapshot!(
2181            project,
2182            {
2183                ".workspace.packages.*.pyproject_toml" => "[PYPROJECT_TOML]"
2184            },
2185            @r#"
2186            {
2187              "project_root": "[ROOT]/albatross-virtual-workspace/packages/albatross",
2188              "project_name": "albatross",
2189              "workspace": {
2190                "install_path": "[ROOT]/albatross-virtual-workspace",
2191                "packages": {
2192                  "albatross": {
2193                    "root": "[ROOT]/albatross-virtual-workspace/packages/albatross",
2194                    "project": {
2195                      "name": "albatross",
2196                      "version": "0.1.0",
2197                      "requires-python": ">=3.12",
2198                      "dependencies": [
2199                        "bird-feeder",
2200                        "iniconfig>=2,<3"
2201                      ],
2202                      "optional-dependencies": null
2203                    },
2204                    "pyproject_toml": "[PYPROJECT_TOML]"
2205                  },
2206                  "bird-feeder": {
2207                    "root": "[ROOT]/albatross-virtual-workspace/packages/bird-feeder",
2208                    "project": {
2209                      "name": "bird-feeder",
2210                      "version": "1.0.0",
2211                      "requires-python": ">=3.12",
2212                      "dependencies": [
2213                        "anyio>=4.3.0,<5",
2214                        "seeds"
2215                      ],
2216                      "optional-dependencies": null
2217                    },
2218                    "pyproject_toml": "[PYPROJECT_TOML]"
2219                  },
2220                  "seeds": {
2221                    "root": "[ROOT]/albatross-virtual-workspace/packages/seeds",
2222                    "project": {
2223                      "name": "seeds",
2224                      "version": "1.0.0",
2225                      "requires-python": ">=3.12",
2226                      "dependencies": [
2227                        "idna==3.6"
2228                      ],
2229                      "optional-dependencies": null
2230                    },
2231                    "pyproject_toml": "[PYPROJECT_TOML]"
2232                  }
2233                },
2234                "required_members": {
2235                  "bird-feeder": null,
2236                  "seeds": null
2237                },
2238                "sources": {},
2239                "indexes": [],
2240                "pyproject_toml": {
2241                  "project": null,
2242                  "tool": {
2243                    "uv": {
2244                      "sources": null,
2245                      "index": null,
2246                      "workspace": {
2247                        "members": [
2248                          "packages/*"
2249                        ],
2250                        "exclude": null
2251                      },
2252                      "managed": null,
2253                      "package": null,
2254                      "default-groups": null,
2255                      "dependency-groups": null,
2256                      "dev-dependencies": null,
2257                      "override-dependencies": null,
2258                      "exclude-dependencies": null,
2259                      "constraint-dependencies": null,
2260                      "build-constraint-dependencies": null,
2261                      "environments": null,
2262                      "required-environments": null,
2263                      "conflicts": null,
2264                      "build-backend": null
2265                    }
2266                  },
2267                  "dependency-groups": null
2268                }
2269              }
2270            }
2271            "#);
2272        });
2273    }
2274
2275    #[tokio::test]
2276    async fn albatross_just_project() {
2277        let (project, root_escaped) = workspace_test("albatross-just-project").await;
2278        let filters = vec![(root_escaped.as_str(), "[ROOT]")];
2279        insta::with_settings!({filters => filters}, {
2280            assert_json_snapshot!(
2281            project,
2282            {
2283                ".workspace.packages.*.pyproject_toml" => "[PYPROJECT_TOML]"
2284            },
2285            @r#"
2286            {
2287              "project_root": "[ROOT]/albatross-just-project",
2288              "project_name": "albatross",
2289              "workspace": {
2290                "install_path": "[ROOT]/albatross-just-project",
2291                "packages": {
2292                  "albatross": {
2293                    "root": "[ROOT]/albatross-just-project",
2294                    "project": {
2295                      "name": "albatross",
2296                      "version": "0.1.0",
2297                      "requires-python": ">=3.12",
2298                      "dependencies": [
2299                        "iniconfig>=2,<3"
2300                      ],
2301                      "optional-dependencies": null
2302                    },
2303                    "pyproject_toml": "[PYPROJECT_TOML]"
2304                  }
2305                },
2306                "required_members": {},
2307                "sources": {},
2308                "indexes": [],
2309                "pyproject_toml": {
2310                  "project": {
2311                    "name": "albatross",
2312                    "version": "0.1.0",
2313                    "requires-python": ">=3.12",
2314                    "dependencies": [
2315                      "iniconfig>=2,<3"
2316                    ],
2317                    "optional-dependencies": null
2318                  },
2319                  "tool": null,
2320                  "dependency-groups": null
2321                }
2322              }
2323            }
2324            "#);
2325        });
2326    }
2327
2328    #[tokio::test]
2329    async fn exclude_package() -> Result<()> {
2330        let root = tempfile::TempDir::new()?;
2331        let root = ChildPath::new(root.path());
2332
2333        // Create the root.
2334        root.child("pyproject.toml").write_str(
2335            r#"
2336            [project]
2337            name = "albatross"
2338            version = "0.1.0"
2339            requires-python = ">=3.12"
2340            dependencies = ["tqdm>=4,<5"]
2341
2342            [tool.uv.workspace]
2343            members = ["packages/*"]
2344            exclude = ["packages/bird-feeder"]
2345
2346            [build-system]
2347            requires = ["hatchling"]
2348            build-backend = "hatchling.build"
2349            "#,
2350        )?;
2351        root.child("albatross").child("__init__.py").touch()?;
2352
2353        // Create an included package (`seeds`).
2354        root.child("packages")
2355            .child("seeds")
2356            .child("pyproject.toml")
2357            .write_str(
2358                r#"
2359            [project]
2360            name = "seeds"
2361            version = "1.0.0"
2362            requires-python = ">=3.12"
2363            dependencies = ["idna==3.6"]
2364
2365            [build-system]
2366            requires = ["hatchling"]
2367            build-backend = "hatchling.build"
2368            "#,
2369            )?;
2370        root.child("packages")
2371            .child("seeds")
2372            .child("seeds")
2373            .child("__init__.py")
2374            .touch()?;
2375
2376        // Create an excluded package (`bird-feeder`).
2377        root.child("packages")
2378            .child("bird-feeder")
2379            .child("pyproject.toml")
2380            .write_str(
2381                r#"
2382            [project]
2383            name = "bird-feeder"
2384            version = "1.0.0"
2385            requires-python = ">=3.12"
2386            dependencies = ["anyio>=4.3.0,<5"]
2387
2388            [build-system]
2389            requires = ["hatchling"]
2390            build-backend = "hatchling.build"
2391            "#,
2392            )?;
2393        root.child("packages")
2394            .child("bird-feeder")
2395            .child("bird_feeder")
2396            .child("__init__.py")
2397            .touch()?;
2398
2399        let (project, root_escaped) = temporary_test(root.as_ref()).await.unwrap();
2400        let filters = vec![(root_escaped.as_str(), "[ROOT]")];
2401        insta::with_settings!({filters => filters}, {
2402            assert_json_snapshot!(
2403            project,
2404            {
2405                ".workspace.packages.*.pyproject_toml" => "[PYPROJECT_TOML]"
2406            },
2407            @r#"
2408            {
2409              "project_root": "[ROOT]",
2410              "project_name": "albatross",
2411              "workspace": {
2412                "install_path": "[ROOT]",
2413                "packages": {
2414                  "albatross": {
2415                    "root": "[ROOT]",
2416                    "project": {
2417                      "name": "albatross",
2418                      "version": "0.1.0",
2419                      "requires-python": ">=3.12",
2420                      "dependencies": [
2421                        "tqdm>=4,<5"
2422                      ],
2423                      "optional-dependencies": null
2424                    },
2425                    "pyproject_toml": "[PYPROJECT_TOML]"
2426                  },
2427                  "seeds": {
2428                    "root": "[ROOT]/packages/seeds",
2429                    "project": {
2430                      "name": "seeds",
2431                      "version": "1.0.0",
2432                      "requires-python": ">=3.12",
2433                      "dependencies": [
2434                        "idna==3.6"
2435                      ],
2436                      "optional-dependencies": null
2437                    },
2438                    "pyproject_toml": "[PYPROJECT_TOML]"
2439                  }
2440                },
2441                "required_members": {},
2442                "sources": {},
2443                "indexes": [],
2444                "pyproject_toml": {
2445                  "project": {
2446                    "name": "albatross",
2447                    "version": "0.1.0",
2448                    "requires-python": ">=3.12",
2449                    "dependencies": [
2450                      "tqdm>=4,<5"
2451                    ],
2452                    "optional-dependencies": null
2453                  },
2454                  "tool": {
2455                    "uv": {
2456                      "sources": null,
2457                      "index": null,
2458                      "workspace": {
2459                        "members": [
2460                          "packages/*"
2461                        ],
2462                        "exclude": [
2463                          "packages/bird-feeder"
2464                        ]
2465                      },
2466                      "managed": null,
2467                      "package": null,
2468                      "default-groups": null,
2469                      "dependency-groups": null,
2470                      "dev-dependencies": null,
2471                      "override-dependencies": null,
2472                      "exclude-dependencies": null,
2473                      "constraint-dependencies": null,
2474                      "build-constraint-dependencies": null,
2475                      "environments": null,
2476                      "required-environments": null,
2477                      "conflicts": null,
2478                      "build-backend": null
2479                    }
2480                  },
2481                  "dependency-groups": null
2482                }
2483              }
2484            }
2485            "#);
2486        });
2487
2488        // Rewrite the members to both include and exclude `bird-feeder` by name.
2489        root.child("pyproject.toml").write_str(
2490            r#"
2491            [project]
2492            name = "albatross"
2493            version = "0.1.0"
2494            requires-python = ">=3.12"
2495            dependencies = ["tqdm>=4,<5"]
2496
2497            [tool.uv.workspace]
2498            members = ["packages/seeds", "packages/bird-feeder"]
2499            exclude = ["packages/bird-feeder"]
2500
2501            [build-system]
2502            requires = ["hatchling"]
2503            build-backend = "hatchling.build"
2504            "#,
2505        )?;
2506
2507        // `bird-feeder` should still be excluded.
2508        let (project, root_escaped) = temporary_test(root.as_ref()).await.unwrap();
2509        let filters = vec![(root_escaped.as_str(), "[ROOT]")];
2510        insta::with_settings!({filters => filters}, {
2511            assert_json_snapshot!(
2512            project,
2513            {
2514                ".workspace.packages.*.pyproject_toml" => "[PYPROJECT_TOML]"
2515            },
2516            @r#"
2517            {
2518              "project_root": "[ROOT]",
2519              "project_name": "albatross",
2520              "workspace": {
2521                "install_path": "[ROOT]",
2522                "packages": {
2523                  "albatross": {
2524                    "root": "[ROOT]",
2525                    "project": {
2526                      "name": "albatross",
2527                      "version": "0.1.0",
2528                      "requires-python": ">=3.12",
2529                      "dependencies": [
2530                        "tqdm>=4,<5"
2531                      ],
2532                      "optional-dependencies": null
2533                    },
2534                    "pyproject_toml": "[PYPROJECT_TOML]"
2535                  },
2536                  "seeds": {
2537                    "root": "[ROOT]/packages/seeds",
2538                    "project": {
2539                      "name": "seeds",
2540                      "version": "1.0.0",
2541                      "requires-python": ">=3.12",
2542                      "dependencies": [
2543                        "idna==3.6"
2544                      ],
2545                      "optional-dependencies": null
2546                    },
2547                    "pyproject_toml": "[PYPROJECT_TOML]"
2548                  }
2549                },
2550                "required_members": {},
2551                "sources": {},
2552                "indexes": [],
2553                "pyproject_toml": {
2554                  "project": {
2555                    "name": "albatross",
2556                    "version": "0.1.0",
2557                    "requires-python": ">=3.12",
2558                    "dependencies": [
2559                      "tqdm>=4,<5"
2560                    ],
2561                    "optional-dependencies": null
2562                  },
2563                  "tool": {
2564                    "uv": {
2565                      "sources": null,
2566                      "index": null,
2567                      "workspace": {
2568                        "members": [
2569                          "packages/seeds",
2570                          "packages/bird-feeder"
2571                        ],
2572                        "exclude": [
2573                          "packages/bird-feeder"
2574                        ]
2575                      },
2576                      "managed": null,
2577                      "package": null,
2578                      "default-groups": null,
2579                      "dependency-groups": null,
2580                      "dev-dependencies": null,
2581                      "override-dependencies": null,
2582                      "exclude-dependencies": null,
2583                      "constraint-dependencies": null,
2584                      "build-constraint-dependencies": null,
2585                      "environments": null,
2586                      "required-environments": null,
2587                      "conflicts": null,
2588                      "build-backend": null
2589                    }
2590                  },
2591                  "dependency-groups": null
2592                }
2593              }
2594            }
2595            "#);
2596        });
2597
2598        // Rewrite the exclusion to use the top-level directory (`packages`).
2599        root.child("pyproject.toml").write_str(
2600            r#"
2601            [project]
2602            name = "albatross"
2603            version = "0.1.0"
2604            requires-python = ">=3.12"
2605            dependencies = ["tqdm>=4,<5"]
2606
2607            [tool.uv.workspace]
2608            members = ["packages/seeds", "packages/bird-feeder"]
2609            exclude = ["packages"]
2610
2611            [build-system]
2612            requires = ["hatchling"]
2613            build-backend = "hatchling.build"
2614            "#,
2615        )?;
2616
2617        // `bird-feeder` should now be included.
2618        let (project, root_escaped) = temporary_test(root.as_ref()).await.unwrap();
2619        let filters = vec![(root_escaped.as_str(), "[ROOT]")];
2620        insta::with_settings!({filters => filters}, {
2621            assert_json_snapshot!(
2622            project,
2623            {
2624                ".workspace.packages.*.pyproject_toml" => "[PYPROJECT_TOML]"
2625            },
2626            @r#"
2627            {
2628              "project_root": "[ROOT]",
2629              "project_name": "albatross",
2630              "workspace": {
2631                "install_path": "[ROOT]",
2632                "packages": {
2633                  "albatross": {
2634                    "root": "[ROOT]",
2635                    "project": {
2636                      "name": "albatross",
2637                      "version": "0.1.0",
2638                      "requires-python": ">=3.12",
2639                      "dependencies": [
2640                        "tqdm>=4,<5"
2641                      ],
2642                      "optional-dependencies": null
2643                    },
2644                    "pyproject_toml": "[PYPROJECT_TOML]"
2645                  },
2646                  "bird-feeder": {
2647                    "root": "[ROOT]/packages/bird-feeder",
2648                    "project": {
2649                      "name": "bird-feeder",
2650                      "version": "1.0.0",
2651                      "requires-python": ">=3.12",
2652                      "dependencies": [
2653                        "anyio>=4.3.0,<5"
2654                      ],
2655                      "optional-dependencies": null
2656                    },
2657                    "pyproject_toml": "[PYPROJECT_TOML]"
2658                  },
2659                  "seeds": {
2660                    "root": "[ROOT]/packages/seeds",
2661                    "project": {
2662                      "name": "seeds",
2663                      "version": "1.0.0",
2664                      "requires-python": ">=3.12",
2665                      "dependencies": [
2666                        "idna==3.6"
2667                      ],
2668                      "optional-dependencies": null
2669                    },
2670                    "pyproject_toml": "[PYPROJECT_TOML]"
2671                  }
2672                },
2673                "required_members": {},
2674                "sources": {},
2675                "indexes": [],
2676                "pyproject_toml": {
2677                  "project": {
2678                    "name": "albatross",
2679                    "version": "0.1.0",
2680                    "requires-python": ">=3.12",
2681                    "dependencies": [
2682                      "tqdm>=4,<5"
2683                    ],
2684                    "optional-dependencies": null
2685                  },
2686                  "tool": {
2687                    "uv": {
2688                      "sources": null,
2689                      "index": null,
2690                      "workspace": {
2691                        "members": [
2692                          "packages/seeds",
2693                          "packages/bird-feeder"
2694                        ],
2695                        "exclude": [
2696                          "packages"
2697                        ]
2698                      },
2699                      "managed": null,
2700                      "package": null,
2701                      "default-groups": null,
2702                      "dependency-groups": null,
2703                      "dev-dependencies": null,
2704                      "override-dependencies": null,
2705                      "exclude-dependencies": null,
2706                      "constraint-dependencies": null,
2707                      "build-constraint-dependencies": null,
2708                      "environments": null,
2709                      "required-environments": null,
2710                      "conflicts": null,
2711                      "build-backend": null
2712                    }
2713                  },
2714                  "dependency-groups": null
2715                }
2716              }
2717            }
2718            "#);
2719        });
2720
2721        // Rewrite the exclusion to use the top-level directory with a glob (`packages/*`).
2722        root.child("pyproject.toml").write_str(
2723            r#"
2724            [project]
2725            name = "albatross"
2726            version = "0.1.0"
2727            requires-python = ">=3.12"
2728            dependencies = ["tqdm>=4,<5"]
2729
2730            [tool.uv.workspace]
2731            members = ["packages/seeds", "packages/bird-feeder"]
2732            exclude = ["packages/*"]
2733
2734            [build-system]
2735            requires = ["hatchling"]
2736            build-backend = "hatchling.build"
2737            "#,
2738        )?;
2739
2740        // `bird-feeder` and `seeds` should now be excluded.
2741        let (project, root_escaped) = temporary_test(root.as_ref()).await.unwrap();
2742        let filters = vec![(root_escaped.as_str(), "[ROOT]")];
2743        insta::with_settings!({filters => filters}, {
2744            assert_json_snapshot!(
2745            project,
2746            {
2747                ".workspace.packages.*.pyproject_toml" => "[PYPROJECT_TOML]"
2748            },
2749            @r#"
2750            {
2751              "project_root": "[ROOT]",
2752              "project_name": "albatross",
2753              "workspace": {
2754                "install_path": "[ROOT]",
2755                "packages": {
2756                  "albatross": {
2757                    "root": "[ROOT]",
2758                    "project": {
2759                      "name": "albatross",
2760                      "version": "0.1.0",
2761                      "requires-python": ">=3.12",
2762                      "dependencies": [
2763                        "tqdm>=4,<5"
2764                      ],
2765                      "optional-dependencies": null
2766                    },
2767                    "pyproject_toml": "[PYPROJECT_TOML]"
2768                  }
2769                },
2770                "required_members": {},
2771                "sources": {},
2772                "indexes": [],
2773                "pyproject_toml": {
2774                  "project": {
2775                    "name": "albatross",
2776                    "version": "0.1.0",
2777                    "requires-python": ">=3.12",
2778                    "dependencies": [
2779                      "tqdm>=4,<5"
2780                    ],
2781                    "optional-dependencies": null
2782                  },
2783                  "tool": {
2784                    "uv": {
2785                      "sources": null,
2786                      "index": null,
2787                      "workspace": {
2788                        "members": [
2789                          "packages/seeds",
2790                          "packages/bird-feeder"
2791                        ],
2792                        "exclude": [
2793                          "packages/*"
2794                        ]
2795                      },
2796                      "managed": null,
2797                      "package": null,
2798                      "default-groups": null,
2799                      "dependency-groups": null,
2800                      "dev-dependencies": null,
2801                      "override-dependencies": null,
2802                      "exclude-dependencies": null,
2803                      "constraint-dependencies": null,
2804                      "build-constraint-dependencies": null,
2805                      "environments": null,
2806                      "required-environments": null,
2807                      "conflicts": null,
2808                      "build-backend": null
2809                    }
2810                  },
2811                  "dependency-groups": null
2812                }
2813              }
2814            }
2815            "#);
2816        });
2817
2818        Ok(())
2819    }
2820
2821    #[test]
2822    fn read_dependency_groups() {
2823        let toml = r#"
2824[dependency-groups]
2825foo = ["a", {include-group = "bar"}]
2826bar = ["b"]
2827"#;
2828
2829        let result = PyProjectToml::from_string(toml.to_string(), "pyproject.toml")
2830            .expect("Deserialization should succeed");
2831
2832        let groups = result
2833            .dependency_groups
2834            .expect("`dependency-groups` should be present");
2835        let foo = groups
2836            .get(&GroupName::from_str("foo").unwrap())
2837            .expect("Group `foo` should be present");
2838        assert_eq!(
2839            foo,
2840            &[
2841                DependencyGroupSpecifier::Requirement("a".to_string()),
2842                DependencyGroupSpecifier::IncludeGroup {
2843                    include_group: GroupName::from_str("bar").unwrap(),
2844                }
2845            ]
2846        );
2847
2848        let bar = groups
2849            .get(&GroupName::from_str("bar").unwrap())
2850            .expect("Group `bar` should be present");
2851        assert_eq!(
2852            bar,
2853            &[DependencyGroupSpecifier::Requirement("b".to_string())]
2854        );
2855    }
2856
2857    #[tokio::test]
2858    async fn nested_workspace() -> Result<()> {
2859        let root = tempfile::TempDir::new()?;
2860        let root = ChildPath::new(root.path());
2861
2862        // Create the root.
2863        root.child("pyproject.toml").write_str(
2864            r#"
2865            [project]
2866            name = "albatross"
2867            version = "0.1.0"
2868            requires-python = ">=3.12"
2869            dependencies = ["tqdm>=4,<5"]
2870
2871            [tool.uv.workspace]
2872            members = ["packages/*"]
2873            "#,
2874        )?;
2875
2876        // Create an included package (`seeds`).
2877        root.child("packages")
2878            .child("seeds")
2879            .child("pyproject.toml")
2880            .write_str(
2881                r#"
2882            [project]
2883            name = "seeds"
2884            version = "1.0.0"
2885            requires-python = ">=3.12"
2886            dependencies = ["idna==3.6"]
2887
2888            [tool.uv.workspace]
2889            members = ["nested_packages/*"]
2890            "#,
2891            )?;
2892
2893        let (error, root_escaped) = temporary_test(root.as_ref()).await.unwrap_err();
2894        let filters = vec![(root_escaped.as_str(), "[ROOT]")];
2895        insta::with_settings!({filters => filters}, {
2896            assert_snapshot!(
2897                error,
2898            @"Nested workspaces are not supported, but workspace member has a `tool.uv.workspace` table: [ROOT]/packages/seeds");
2899        });
2900
2901        Ok(())
2902    }
2903
2904    #[tokio::test]
2905    async fn duplicate_names() -> Result<()> {
2906        let root = tempfile::TempDir::new()?;
2907        let root = ChildPath::new(root.path());
2908
2909        // Create the root.
2910        root.child("pyproject.toml").write_str(
2911            r#"
2912            [project]
2913            name = "albatross"
2914            version = "0.1.0"
2915            requires-python = ">=3.12"
2916            dependencies = ["tqdm>=4,<5"]
2917
2918            [tool.uv.workspace]
2919            members = ["packages/*"]
2920            "#,
2921        )?;
2922
2923        // Create an included package (`seeds`).
2924        root.child("packages")
2925            .child("seeds")
2926            .child("pyproject.toml")
2927            .write_str(
2928                r#"
2929            [project]
2930            name = "seeds"
2931            version = "1.0.0"
2932            requires-python = ">=3.12"
2933            dependencies = ["idna==3.6"]
2934
2935            [tool.uv.workspace]
2936            members = ["nested_packages/*"]
2937            "#,
2938            )?;
2939
2940        // Create an included package (`seeds2`).
2941        root.child("packages")
2942            .child("seeds2")
2943            .child("pyproject.toml")
2944            .write_str(
2945                r#"
2946            [project]
2947            name = "seeds"
2948            version = "1.0.0"
2949            requires-python = ">=3.12"
2950            dependencies = ["idna==3.6"]
2951
2952            [tool.uv.workspace]
2953            members = ["nested_packages/*"]
2954            "#,
2955            )?;
2956
2957        let (error, root_escaped) = temporary_test(root.as_ref()).await.unwrap_err();
2958        let filters = vec![(root_escaped.as_str(), "[ROOT]")];
2959        insta::with_settings!({filters => filters}, {
2960            assert_snapshot!(
2961                error,
2962            @"Two workspace members are both named `seeds`: `[ROOT]/packages/seeds` and `[ROOT]/packages/seeds2`");
2963        });
2964
2965        Ok(())
2966    }
2967}