Skip to main content

uv_workspace/
workspace.rs

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