Skip to main content

uv_workspace/
workspace.rs

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