//! Resolve the current [`ProjectWorkspace`] or [`Workspace`].

use std::collections::{BTreeMap, BTreeSet};
use std::path::{Path, PathBuf};
use std::sync::{Arc, Mutex};

use glob::{GlobError, PatternError, glob};
use itertools::Itertools;
use rustc_hash::{FxHashMap, FxHashSet};
use tracing::{debug, trace, warn};

use uv_configuration::DependencyGroupsWithDefaults;
use uv_distribution_types::{Index, Requirement, RequirementSource};
use uv_fs::{CWD, Simplified, normalize_path};
use uv_normalize::{DEV_DEPENDENCIES, GroupName, PackageName};
use uv_pep440::VersionSpecifiers;
use uv_pep508::{MarkerTree, VerbatimUrl};
use uv_pypi_types::{ConflictError, Conflicts, SupportedEnvironments, VerbatimParsedUrl};
use uv_static::EnvVars;
use uv_warnings::warn_user_once;

use crate::dependency_groups::{DependencyGroupError, FlatDependencyGroup, FlatDependencyGroups};
use crate::pyproject::{
    Project, PyProjectToml, PyprojectTomlError, Source, Sources, ToolUvSources, ToolUvWorkspace,
};

type WorkspaceMembers = Arc<BTreeMap<PackageName, WorkspaceMember>>;

/// Cache key for workspace discovery.
///
/// Given this key, the discovered workspace member list is the same.
#[derive(Debug, Default, Clone, Hash, PartialEq, Eq)]
struct WorkspaceCacheKey {
    workspace_root: PathBuf,
    discovery_options: DiscoveryOptions,
}

/// Cache for workspace discovery.
///
/// Avoid re-reading the `pyproject.toml` files in a workspace for each member by caching the
/// workspace members by their workspace root.
#[derive(Debug, Default, Clone)]
pub struct WorkspaceCache(Arc<Mutex<FxHashMap<WorkspaceCacheKey, WorkspaceMembers>>>);

#[derive(thiserror::Error, Debug)]
pub enum WorkspaceError {
    // Workspace structure errors.
    #[error("No `pyproject.toml` found in current directory or any parent directory")]
    MissingPyprojectToml,
    #[error("Workspace member `{}` is missing a `pyproject.toml` (matches: `{}`)", _0.simplified_display(), _1)]
    MissingPyprojectTomlMember(PathBuf, String),
    #[error("No `project` table found in: {}", _0.simplified_display())]
    MissingProject(PathBuf),
    #[error("No workspace found for: {}", _0.simplified_display())]
    MissingWorkspace(PathBuf),
    #[error("The project is marked as unmanaged: {}", _0.simplified_display())]
    NonWorkspace(PathBuf),
    #[error("Nested workspaces are not supported, but workspace member has a `tool.uv.workspace` table: {}", _0.simplified_display())]
    NestedWorkspace(PathBuf),
    #[error("The workspace does not have a member {}: {}", _0, _1.simplified_display())]
    NoSuchMember(PackageName, PathBuf),
    #[error("Two workspace members are both named `{name}`: `{}` and `{}`", first.simplified_display(), second.simplified_display())]
    DuplicatePackage {
        name: PackageName,
        first: PathBuf,
        second: PathBuf,
    },
    #[error("pyproject.toml section is declared as dynamic, but must be static: `{0}`")]
    DynamicNotAllowed(&'static str),
    #[error(
        "Workspace member `{}` was requested as both `editable = true` and `editable = false`",
        _0
    )]
    EditableConflict(PackageName),
    #[error("Failed to find directories for glob: `{0}`")]
    Pattern(String, #[source] PatternError),
    // Syntax and other errors.
    #[error("Directory walking failed for `tool.uv.workspace.members` glob: `{0}`")]
    GlobWalk(String, #[source] GlobError),
    #[error(transparent)]
    Io(#[from] std::io::Error),
    #[error("Failed to parse: `{}`", _0.user_display())]
    Toml(PathBuf, #[source] Box<PyprojectTomlError>),
    #[error(transparent)]
    Conflicts(#[from] ConflictError),
    #[error("Failed to normalize workspace member path")]
    Normalize(#[source] std::io::Error),
}

#[derive(Debug, Default, Clone, Hash, PartialEq, Eq)]
pub enum MemberDiscovery {
    /// Discover all workspace members.
    #[default]
    All,
    /// Discover workspace members that are present, but ignore missing members.
    Existing,
    /// Don't discover any workspace members.
    None,
    /// Discover workspace members, but ignore the given paths.
    Ignore(BTreeSet<PathBuf>),
}

/// Whether a "project" must be defined via a `[project]` table.
#[derive(Debug, Default, Clone, Hash, PartialEq, Eq)]
pub enum ProjectDiscovery {
    /// The `[project]` table is optional; when missing, the target is treated as a virtual
    /// project with only dependency groups.
    #[default]
    Optional,
    /// A `[project]` table must be defined.
    ///
    /// If not defined, discovery will fail.
    Required,
}

impl ProjectDiscovery {
    /// Whether a `[project]` table is required.
    fn allows_implicit_workspace(&self) -> bool {
        matches!(self, Self::Optional)
    }

    /// Whether a non-project workspace root is allowed.
    fn allows_non_project_workspace(&self) -> bool {
        matches!(self, Self::Optional)
    }
}

#[derive(Debug, Default, Clone, Hash, PartialEq, Eq)]
pub struct DiscoveryOptions {
    /// The path to stop discovery at.
    pub stop_discovery_at: Option<PathBuf>,
    /// The strategy to use when discovering workspace members.
    pub members: MemberDiscovery,
    /// The strategy to use when discovering the project.
    pub project: ProjectDiscovery,
}

pub type RequiresPythonSources = BTreeMap<(PackageName, Option<GroupName>), VersionSpecifiers>;

pub type Editability = Option<bool>;

/// A workspace, consisting of a root directory and members. See [`ProjectWorkspace`].
#[derive(Debug, Clone)]
#[cfg_attr(test, derive(serde::Serialize))]
pub struct Workspace {
    /// The path to the workspace root.
    ///
    /// The workspace root is the directory containing the top level `pyproject.toml` with
    /// the `uv.tool.workspace`, or the `pyproject.toml` in an implicit single workspace project.
    install_path: PathBuf,
    /// The members of the workspace.
    packages: WorkspaceMembers,
    /// The workspace members that are required by other members, and whether they were requested
    /// as editable.
    required_members: BTreeMap<PackageName, Editability>,
    /// The sources table from the workspace `pyproject.toml`.
    ///
    /// This table is overridden by the project sources.
    sources: BTreeMap<PackageName, Sources>,
    /// The index table from the workspace `pyproject.toml`.
    ///
    /// This table is overridden by the project indexes.
    indexes: Vec<Index>,
    /// The `pyproject.toml` of the workspace root.
    pyproject_toml: PyProjectToml,
}

impl Workspace {
    /// Find the workspace containing the given path.
    ///
    /// Unlike the [`ProjectWorkspace`] discovery, this does not require a current project. It also
    /// always uses absolute path, i.e., this method only supports discovering the main workspace.
    ///
    /// Steps of workspace discovery: Start by looking at the closest `pyproject.toml`:
    /// * If it's an explicit workspace root: Collect workspace from this root, we're done.
    /// * If it's also not a project: Error, must be either a workspace root or a project.
    /// * Otherwise, try to find an explicit workspace root above:
    ///   * If an explicit workspace root exists: Collect workspace from this root, we're done.
    ///   * If there is no explicit workspace: We have a single project workspace, we're done.
    ///
    /// Note that there are two kinds of workspace roots: projects, and non-project roots.
    /// The non-project roots lack a `[project]` table, and so are not themselves projects, as in:
    /// ```toml
    /// [tool.uv.workspace]
    /// members = ["packages/*"]
    ///
    /// [tool.uv]
    /// dev-dependencies = ["ruff"]
    /// ```
    pub async fn discover(
        path: &Path,
        options: &DiscoveryOptions,
        cache: &WorkspaceCache,
    ) -> Result<Self, WorkspaceError> {
        let path = std::path::absolute(path)
            .map_err(WorkspaceError::Normalize)?
            .clone();
        let path = normalize_path(&path);

        let project_path = path
            .ancestors()
            .find(|path| path.join("pyproject.toml").is_file())
            .ok_or(WorkspaceError::MissingPyprojectToml)?
            .to_path_buf();

        let pyproject_path = project_path.join("pyproject.toml");
        let contents = fs_err::tokio::read_to_string(&pyproject_path).await?;
        let pyproject_toml = PyProjectToml::from_string(contents, &pyproject_path)
            .map_err(|err| WorkspaceError::Toml(pyproject_path.clone(), Box::new(err)))?;

        // Check if the project is explicitly marked as unmanaged.
        if pyproject_toml
            .tool
            .as_ref()
            .and_then(|tool| tool.uv.as_ref())
            .and_then(|uv| uv.managed)
            == Some(false)
        {
            debug!(
                "Project `{}` is marked as unmanaged",
                project_path.simplified_display()
            );
            return Err(WorkspaceError::NonWorkspace(project_path));
        }

        // Check if the current project is also an explicit workspace root.
        let explicit_root = pyproject_toml
            .tool
            .as_ref()
            .and_then(|tool| tool.uv.as_ref())
            .and_then(|uv| uv.workspace.as_ref())
            .map(|workspace| {
                (
                    project_path.clone(),
                    workspace.clone(),
                    pyproject_toml.clone(),
                )
            });

        let (workspace_root, workspace_definition, workspace_pyproject_toml) =
            if let Some(workspace) = explicit_root {
                // We have found the explicit root immediately.
                workspace
            } else if pyproject_toml.project.is_none() {
                // Without a project, it can't be an implicit root
                return Err(WorkspaceError::MissingProject(pyproject_path));
            } else if let Some(workspace) = find_workspace(&project_path, options).await? {
                // We have found an explicit root above.
                workspace
            } else {
                // Support implicit single project workspaces.
                (
                    project_path.clone(),
                    ToolUvWorkspace::default(),
                    pyproject_toml.clone(),
                )
            };

        debug!(
            "Found workspace root: `{}`",
            workspace_root.simplified_display()
        );

        // Unlike in `ProjectWorkspace` discovery, we might be in a non-project root without
        // being in any specific project.
        let current_project = pyproject_toml
            .project
            .clone()
            .map(|project| WorkspaceMember {
                root: project_path,
                project,
                pyproject_toml,
            });

        Self::collect_members(
            workspace_root.clone(),
            workspace_definition,
            workspace_pyproject_toml,
            current_project,
            options,
            cache,
        )
        .await
    }

    /// Set the current project to the given workspace member.
    ///
    /// Returns `None` if the package is not part of the workspace.
    fn with_current_project(self, package_name: PackageName) -> Option<ProjectWorkspace> {
        let member = self.packages.get(&package_name)?;
        Some(ProjectWorkspace {
            project_root: member.root().clone(),
            project_name: package_name,
            workspace: self,
        })
    }

    /// Set the [`ProjectWorkspace`] for a given workspace member.
    ///
    /// Assumes that the project name is unchanged in the updated [`PyProjectToml`].
    fn update_member(
        self,
        package_name: &PackageName,
        pyproject_toml: PyProjectToml,
    ) -> Result<Option<Self>, WorkspaceError> {
        let mut packages = self.packages;

        let Some(member) = Arc::make_mut(&mut packages).get_mut(package_name) else {
            return Ok(None);
        };

        if member.root == self.install_path {
            // If the member is also the workspace root, update _both_ the member entry and the
            // root `pyproject.toml`.
            let workspace_pyproject_toml = pyproject_toml.clone();

            // Refresh the workspace sources.
            let workspace_sources = workspace_pyproject_toml
                .tool
                .clone()
                .and_then(|tool| tool.uv)
                .and_then(|uv| uv.sources)
                .map(ToolUvSources::into_inner)
                .unwrap_or_default();

            // Set the `pyproject.toml` for the member.
            member.pyproject_toml = pyproject_toml;

            // Recompute required_members with the updated data
            let required_members = Self::collect_required_members(
                &packages,
                &workspace_sources,
                &workspace_pyproject_toml,
            )?;

            Ok(Some(Self {
                pyproject_toml: workspace_pyproject_toml,
                sources: workspace_sources,
                packages,
                required_members,
                ..self
            }))
        } else {
            // Set the `pyproject.toml` for the member.
            member.pyproject_toml = pyproject_toml;

            // Recompute required_members with the updated member data
            let required_members =
                Self::collect_required_members(&packages, &self.sources, &self.pyproject_toml)?;

            Ok(Some(Self {
                packages,
                required_members,
                ..self
            }))
        }
    }

    /// Returns `true` if the workspace has a non-project root.
    pub fn is_non_project(&self) -> bool {
        !self
            .packages
            .values()
            .any(|member| *member.root() == self.install_path)
    }

    /// Returns the set of all workspace members.
    pub fn members_requirements(&self) -> impl Iterator<Item = Requirement> + '_ {
        self.packages.iter().filter_map(|(name, member)| {
            let url = VerbatimUrl::from_absolute_path(&member.root).expect("path is valid URL");
            Some(Requirement {
                name: member.pyproject_toml.project.as_ref()?.name.clone(),
                extras: Box::new([]),
                groups: Box::new([]),
                marker: MarkerTree::TRUE,
                source: if member
                    .pyproject_toml()
                    .is_package(!self.is_required_member(name))
                {
                    RequirementSource::Directory {
                        install_path: member.root.clone().into_boxed_path(),
                        editable: Some(
                            self.required_members
                                .get(name)
                                .copied()
                                .flatten()
                                .unwrap_or(true),
                        ),
                        r#virtual: Some(false),
                        url,
                    }
                } else {
                    RequirementSource::Directory {
                        install_path: member.root.clone().into_boxed_path(),
                        editable: Some(false),
                        r#virtual: Some(true),
                        url,
                    }
                },
                origin: None,
            })
        })
    }

    /// The workspace members that are required my another member of the workspace.
    pub fn required_members(&self) -> &BTreeMap<PackageName, Editability> {
        &self.required_members
    }

    /// Compute the workspace members that are required by another member of the workspace, and
    /// determine whether they should be installed as editable or non-editable.
    ///
    /// N.B. this checks if a workspace member is required by inspecting `tool.uv.source` entries,
    /// but does not actually check if the source is _used_, which could result in false positives
    /// but is easier to compute.
    fn collect_required_members(
        packages: &BTreeMap<PackageName, WorkspaceMember>,
        sources: &BTreeMap<PackageName, Sources>,
        pyproject_toml: &PyProjectToml,
    ) -> Result<BTreeMap<PackageName, Editability>, WorkspaceError> {
        let mut required_members = BTreeMap::new();

        for (package, sources) in sources
            .iter()
            .filter(|(name, _)| {
                pyproject_toml
                    .project
                    .as_ref()
                    .is_none_or(|project| project.name != **name)
            })
            .chain(
                packages
                    .iter()
                    .filter_map(|(name, member)| {
                        member
                            .pyproject_toml
                            .tool
                            .as_ref()
                            .and_then(|tool| tool.uv.as_ref())
                            .and_then(|uv| uv.sources.as_ref())
                            .map(ToolUvSources::inner)
                            .map(move |sources| {
                                sources
                                    .iter()
                                    .filter(move |(source_name, _)| name != *source_name)
                            })
                    })
                    .flatten(),
            )
        {
            for source in sources.iter() {
                let Source::Workspace { editable, .. } = &source else {
                    continue;
                };
                let existing = required_members.insert(package.clone(), *editable);
                if let Some(Some(existing)) = existing {
                    if let Some(editable) = editable {
                        // If there are conflicting `editable` values, raise an error.
                        if existing != *editable {
                            return Err(WorkspaceError::EditableConflict(package.clone()));
                        }
                    }
                }
            }
        }

        Ok(required_members)
    }

    /// Whether a given workspace member is required by another member.
    fn is_required_member(&self, name: &PackageName) -> bool {
        self.required_members().contains_key(name)
    }

    /// Returns the set of all workspace member dependency groups.
    pub fn group_requirements(&self) -> impl Iterator<Item = Requirement> + '_ {
        self.packages.iter().filter_map(|(name, member)| {
            let url = VerbatimUrl::from_absolute_path(&member.root).expect("path is valid URL");

            let groups = {
                let mut groups = member
                    .pyproject_toml
                    .dependency_groups
                    .as_ref()
                    .map(|groups| groups.keys().cloned().collect::<Vec<_>>())
                    .unwrap_or_default();
                if member
                    .pyproject_toml
                    .tool
                    .as_ref()
                    .and_then(|tool| tool.uv.as_ref())
                    .and_then(|uv| uv.dev_dependencies.as_ref())
                    .is_some()
                {
                    groups.push(DEV_DEPENDENCIES.clone());
                    groups.sort_unstable();
                }
                groups
            };
            if groups.is_empty() {
                return None;
            }

            let value = self.required_members.get(name);
            let is_required_member = value.is_some();
            let editability = value.copied().flatten();

            Some(Requirement {
                name: member.pyproject_toml.project.as_ref()?.name.clone(),
                extras: Box::new([]),
                groups: groups.into_boxed_slice(),
                marker: MarkerTree::TRUE,
                source: if member.pyproject_toml().is_package(!is_required_member) {
                    RequirementSource::Directory {
                        install_path: member.root.clone().into_boxed_path(),
                        editable: Some(editability.unwrap_or(true)),
                        r#virtual: Some(false),
                        url,
                    }
                } else {
                    RequirementSource::Directory {
                        install_path: member.root.clone().into_boxed_path(),
                        editable: Some(false),
                        r#virtual: Some(true),
                        url,
                    }
                },
                origin: None,
            })
        })
    }

    /// Returns the set of supported environments for the workspace.
    pub fn environments(&self) -> Option<&SupportedEnvironments> {
        self.pyproject_toml
            .tool
            .as_ref()
            .and_then(|tool| tool.uv.as_ref())
            .and_then(|uv| uv.environments.as_ref())
    }

    /// Returns the set of required platforms for the workspace.
    pub fn required_environments(&self) -> Option<&SupportedEnvironments> {
        self.pyproject_toml
            .tool
            .as_ref()
            .and_then(|tool| tool.uv.as_ref())
            .and_then(|uv| uv.required_environments.as_ref())
    }

    /// Returns the set of conflicts for the workspace.
    pub fn conflicts(&self) -> Result<Conflicts, WorkspaceError> {
        let mut conflicting = Conflicts::empty();
        if self.is_non_project() {
            if let Some(root_conflicts) = self
                .pyproject_toml
                .tool
                .as_ref()
                .and_then(|tool| tool.uv.as_ref())
                .and_then(|uv| uv.conflicts.as_ref())
            {
                let mut root_conflicts = root_conflicts.to_conflicts()?;
                conflicting.append(&mut root_conflicts);
            }
        }
        for member in self.packages.values() {
            conflicting.append(&mut member.pyproject_toml.conflicts());
        }
        Ok(conflicting)
    }

    /// Returns an iterator over the `requires-python` values for each member of the workspace.
    pub fn requires_python(
        &self,
        groups: &DependencyGroupsWithDefaults,
    ) -> Result<RequiresPythonSources, DependencyGroupError> {
        let mut requires = RequiresPythonSources::new();
        for (name, member) in self.packages() {
            // Get the top-level requires-python for this package, which is always active
            //
            // Arguably we could check groups.prod() to disable this, since, the requires-python
            // of the project is *technically* not relevant if you're doing `--only-group`, but,
            // that would be a big surprising change so let's *not* do that until someone asks!
            let top_requires = member
                .pyproject_toml()
                .project
                .as_ref()
                .and_then(|project| project.requires_python.as_ref())
                .map(|requires_python| ((name.to_owned(), None), requires_python.clone()));
            requires.extend(top_requires);

            // Get the requires-python for each enabled group on this package
            // We need to do full flattening here because include-group can transfer requires-python
            let dependency_groups =
                FlatDependencyGroups::from_pyproject_toml(member.root(), &member.pyproject_toml)?;
            let group_requires =
                dependency_groups
                    .into_iter()
                    .filter_map(move |(group_name, flat_group)| {
                        if groups.contains(&group_name) {
                            flat_group.requires_python.map(|requires_python| {
                                ((name.to_owned(), Some(group_name)), requires_python)
                            })
                        } else {
                            None
                        }
                    });
            requires.extend(group_requires);
        }
        Ok(requires)
    }

    /// Returns any requirements that are exclusive to the workspace root, i.e., not included in
    /// any of the workspace members.
    ///
    /// For now, there are no such requirements.
    pub fn requirements(&self) -> Vec<uv_pep508::Requirement<VerbatimParsedUrl>> {
        Vec::new()
    }

    /// Returns any dependency groups that are exclusive to the workspace root, i.e., not included
    /// in any of the workspace members.
    ///
    /// For workspaces with non-`[project]` roots, returns the dependency groups defined in the
    /// corresponding `pyproject.toml`.
    ///
    /// Otherwise, returns an empty list.
    pub fn workspace_dependency_groups(
        &self,
    ) -> Result<BTreeMap<GroupName, FlatDependencyGroup>, DependencyGroupError> {
        if self
            .packages
            .values()
            .any(|member| *member.root() == self.install_path)
        {
            // If the workspace has an explicit root, the root is a member, so we don't need to
            // include any root-only requirements.
            Ok(BTreeMap::default())
        } else {
            // Otherwise, return the dependency groups in the non-project workspace root.
            let dependency_groups = FlatDependencyGroups::from_pyproject_toml(
                &self.install_path,
                &self.pyproject_toml,
            )?;
            Ok(dependency_groups.into_inner())
        }
    }

    /// Returns the set of overrides for the workspace.
    pub fn overrides(&self) -> Vec<uv_pep508::Requirement<VerbatimParsedUrl>> {
        let Some(overrides) = self
            .pyproject_toml
            .tool
            .as_ref()
            .and_then(|tool| tool.uv.as_ref())
            .and_then(|uv| uv.override_dependencies.as_ref())
        else {
            return vec![];
        };
        overrides.clone()
    }

    /// Returns the set of dependency exclusions for the workspace.
    pub fn exclude_dependencies(&self) -> Vec<uv_normalize::PackageName> {
        let Some(excludes) = self
            .pyproject_toml
            .tool
            .as_ref()
            .and_then(|tool| tool.uv.as_ref())
            .and_then(|uv| uv.exclude_dependencies.as_ref())
        else {
            return vec![];
        };
        excludes.clone()
    }

    /// Returns the set of constraints for the workspace.
    pub fn constraints(&self) -> Vec<uv_pep508::Requirement<VerbatimParsedUrl>> {
        let Some(constraints) = self
            .pyproject_toml
            .tool
            .as_ref()
            .and_then(|tool| tool.uv.as_ref())
            .and_then(|uv| uv.constraint_dependencies.as_ref())
        else {
            return vec![];
        };
        constraints.clone()
    }

    /// Returns the set of build constraints for the workspace.
    pub fn build_constraints(&self) -> Vec<uv_pep508::Requirement<VerbatimParsedUrl>> {
        let Some(build_constraints) = self
            .pyproject_toml
            .tool
            .as_ref()
            .and_then(|tool| tool.uv.as_ref())
            .and_then(|uv| uv.build_constraint_dependencies.as_ref())
        else {
            return vec![];
        };
        build_constraints.clone()
    }

    /// The path to the workspace root, the directory containing the top level `pyproject.toml` with
    /// the `uv.tool.workspace`, or the `pyproject.toml` in an implicit single workspace project.
    pub fn install_path(&self) -> &PathBuf {
        &self.install_path
    }

    /// The path to the workspace virtual environment.
    ///
    /// Uses `.venv` in the install path directory by default.
    ///
    /// If `UV_PROJECT_ENVIRONMENT` is set, it will take precedence. If a relative path is provided,
    /// it is resolved relative to the install path.
    ///
    /// If `active` is `true`, the `VIRTUAL_ENV` variable will be preferred. If it is `false`, any
    /// warnings about mismatch between the active environment and the project environment will be
    /// silenced.
    pub fn venv(&self, active: Option<bool>) -> PathBuf {
        /// Resolve the `UV_PROJECT_ENVIRONMENT` value, if any.
        fn from_project_environment_variable(workspace: &Workspace) -> Option<PathBuf> {
            let value = std::env::var_os(EnvVars::UV_PROJECT_ENVIRONMENT)?;

            if value.is_empty() {
                return None;
            }

            let path = PathBuf::from(value);
            if path.is_absolute() {
                return Some(path);
            }

            // Resolve the path relative to the install path.
            Some(workspace.install_path.join(path))
        }

        /// Resolve the `VIRTUAL_ENV` variable, if any.
        fn from_virtual_env_variable() -> Option<PathBuf> {
            let value = std::env::var_os(EnvVars::VIRTUAL_ENV)?;

            if value.is_empty() {
                return None;
            }

            let path = PathBuf::from(value);
            if path.is_absolute() {
                return Some(path);
            }

            // Resolve the path relative to current directory.
            // Note this differs from `UV_PROJECT_ENVIRONMENT`
            Some(CWD.join(path))
        }

        // Determine the default value
        let project_env = from_project_environment_variable(self)
            .unwrap_or_else(|| self.install_path.join(".venv"));

        // Warn if it conflicts with `VIRTUAL_ENV`
        if let Some(from_virtual_env) = from_virtual_env_variable() {
            if !uv_fs::is_same_file_allow_missing(&from_virtual_env, &project_env).unwrap_or(false)
            {
                match active {
                    Some(true) => {
                        debug!(
                            "Using active virtual environment `{}` instead of project environment `{}`",
                            from_virtual_env.user_display(),
                            project_env.user_display()
                        );
                        return from_virtual_env;
                    }
                    Some(false) => {}
                    None => {
                        warn_user_once!(
                            "`VIRTUAL_ENV={}` does not match the project environment path `{}` and will be ignored; use `--active` to target the active environment instead",
                            from_virtual_env.user_display(),
                            project_env.user_display()
                        );
                    }
                }
            }
        } else {
            if active.unwrap_or_default() {
                debug!(
                    "Use of the active virtual environment was requested, but `VIRTUAL_ENV` is not set"
                );
            }
        }

        project_env
    }

    /// The members of the workspace.
    pub fn packages(&self) -> &BTreeMap<PackageName, WorkspaceMember> {
        &self.packages
    }

    /// The sources table from the workspace `pyproject.toml`.
    pub fn sources(&self) -> &BTreeMap<PackageName, Sources> {
        &self.sources
    }

    /// The index table from the workspace `pyproject.toml`.
    pub fn indexes(&self) -> &[Index] {
        &self.indexes
    }

    /// The `pyproject.toml` of the workspace.
    pub fn pyproject_toml(&self) -> &PyProjectToml {
        &self.pyproject_toml
    }

    /// Returns `true` if the path is excluded by the workspace.
    pub fn excludes(&self, project_path: &Path) -> Result<bool, WorkspaceError> {
        if let Some(workspace) = self
            .pyproject_toml
            .tool
            .as_ref()
            .and_then(|tool| tool.uv.as_ref())
            .and_then(|uv| uv.workspace.as_ref())
        {
            is_excluded_from_workspace(project_path, &self.install_path, workspace)
        } else {
            Ok(false)
        }
    }

    /// Returns `true` if the path is included by the workspace.
    pub fn includes(&self, project_path: &Path) -> Result<bool, WorkspaceError> {
        if let Some(workspace) = self
            .pyproject_toml
            .tool
            .as_ref()
            .and_then(|tool| tool.uv.as_ref())
            .and_then(|uv| uv.workspace.as_ref())
        {
            is_included_in_workspace(project_path, &self.install_path, workspace)
        } else {
            Ok(false)
        }
    }

    /// Collect the workspace member projects from the `members` and `excludes` entries.
    async fn collect_members(
        workspace_root: PathBuf,
        workspace_definition: ToolUvWorkspace,
        workspace_pyproject_toml: PyProjectToml,
        current_project: Option<WorkspaceMember>,
        options: &DiscoveryOptions,
        cache: &WorkspaceCache,
    ) -> Result<Self, WorkspaceError> {
        let cache_key = WorkspaceCacheKey {
            workspace_root: workspace_root.clone(),
            discovery_options: options.clone(),
        };
        let cache_entry = {
            // Acquire the lock for the minimal required region
            let cache = cache.0.lock().expect("there was a panic in another thread");
            cache.get(&cache_key).cloned()
        };
        let mut workspace_members = if let Some(workspace_members) = cache_entry {
            trace!(
                "Cached workspace members for: `{}`",
                &workspace_root.simplified_display()
            );
            workspace_members
        } else {
            trace!(
                "Discovering workspace members for: `{}`",
                &workspace_root.simplified_display()
            );
            let workspace_members = Self::collect_members_only(
                &workspace_root,
                &workspace_definition,
                &workspace_pyproject_toml,
                options,
            )
            .await?;
            {
                // Acquire the lock for the minimal required region
                let mut cache = cache.0.lock().expect("there was a panic in another thread");
                cache.insert(cache_key, Arc::new(workspace_members.clone()));
            }
            Arc::new(workspace_members)
        };

        // For the cases such as `MemberDiscovery::None`, add the current project if missing.
        if let Some(root_member) = current_project {
            if !workspace_members.contains_key(&root_member.project.name) {
                debug!(
                    "Adding current workspace member: `{}`",
                    root_member.root.simplified_display()
                );

                Arc::make_mut(&mut workspace_members)
                    .insert(root_member.project.name.clone(), root_member);
            }
        }

        let workspace_sources = workspace_pyproject_toml
            .tool
            .clone()
            .and_then(|tool| tool.uv)
            .and_then(|uv| uv.sources)
            .map(ToolUvSources::into_inner)
            .unwrap_or_default();

        let workspace_indexes = workspace_pyproject_toml
            .tool
            .clone()
            .and_then(|tool| tool.uv)
            .and_then(|uv| uv.index)
            .unwrap_or_default();

        let required_members = Self::collect_required_members(
            &workspace_members,
            &workspace_sources,
            &workspace_pyproject_toml,
        )?;

        let dev_dependencies_members = workspace_members
            .values()
            .filter_map(|member| {
                member
                    .pyproject_toml
                    .tool
                    .as_ref()
                    .and_then(|tool| tool.uv.as_ref())
                    .and_then(|uv| uv.dev_dependencies.as_ref())
                    .map(|_| format!("`{}`", member.root().join("pyproject.toml").user_display()))
            })
            .join(", ");
        if !dev_dependencies_members.is_empty() {
            warn_user_once!(
                "The `tool.uv.dev-dependencies` field (used in {}) is deprecated and will be removed in a future release; use `dependency-groups.dev` instead",
                dev_dependencies_members
            );
        }

        Ok(Self {
            install_path: workspace_root,
            packages: workspace_members,
            required_members,
            sources: workspace_sources,
            indexes: workspace_indexes,
            pyproject_toml: workspace_pyproject_toml,
        })
    }

    async fn collect_members_only(
        workspace_root: &PathBuf,
        workspace_definition: &ToolUvWorkspace,
        workspace_pyproject_toml: &PyProjectToml,
        options: &DiscoveryOptions,
    ) -> Result<BTreeMap<PackageName, WorkspaceMember>, WorkspaceError> {
        let mut workspace_members = BTreeMap::new();
        // Avoid reading a `pyproject.toml` more than once.
        let mut seen = FxHashSet::default();

        // Add the project at the workspace root, if it exists and if it's distinct from the current
        // project. If it is the current project, it is added as such in the next step.
        if let Some(project) = &workspace_pyproject_toml.project {
            let pyproject_path = workspace_root.join("pyproject.toml");
            let contents = fs_err::read_to_string(&pyproject_path)?;
            let pyproject_toml = PyProjectToml::from_string(contents, &pyproject_path)
                .map_err(|err| WorkspaceError::Toml(pyproject_path.clone(), Box::new(err)))?;

            debug!(
                "Adding root workspace member: `{}`",
                workspace_root.simplified_display()
            );

            seen.insert(workspace_root.clone());
            workspace_members.insert(
                project.name.clone(),
                WorkspaceMember {
                    root: workspace_root.clone(),
                    project: project.clone(),
                    pyproject_toml,
                },
            );
        }

        // Add all other workspace members.
        for member_glob in workspace_definition.clone().members.unwrap_or_default() {
            // Normalize the member glob to remove leading `./` and other relative path components
            let normalized_glob = normalize_path(Path::new(member_glob.as_str()));
            let absolute_glob = PathBuf::from(glob::Pattern::escape(
                workspace_root.simplified().to_string_lossy().as_ref(),
            ))
            .join(normalized_glob.as_ref())
            .to_string_lossy()
            .to_string();
            for member_root in glob(&absolute_glob)
                .map_err(|err| WorkspaceError::Pattern(absolute_glob.clone(), err))?
            {
                let member_root = member_root
                    .map_err(|err| WorkspaceError::GlobWalk(absolute_glob.clone(), err))?;
                if !seen.insert(member_root.clone()) {
                    continue;
                }
                let member_root = std::path::absolute(&member_root)
                    .map_err(WorkspaceError::Normalize)?
                    .clone();

                // If the directory is explicitly ignored, skip it.
                let skip = match &options.members {
                    MemberDiscovery::All | MemberDiscovery::Existing => false,
                    MemberDiscovery::None => true,
                    MemberDiscovery::Ignore(ignore) => ignore.contains(member_root.as_path()),
                };
                if skip {
                    debug!(
                        "Ignoring workspace member: `{}`",
                        member_root.simplified_display()
                    );
                    continue;
                }

                // If the member is excluded, ignore it.
                if is_excluded_from_workspace(&member_root, workspace_root, workspace_definition)? {
                    debug!(
                        "Ignoring workspace member: `{}`",
                        member_root.simplified_display()
                    );
                    continue;
                }

                trace!(
                    "Processing workspace member: `{}`",
                    member_root.user_display()
                );

                // Read the member `pyproject.toml`.
                let pyproject_path = member_root.join("pyproject.toml");
                let contents = match fs_err::tokio::read_to_string(&pyproject_path).await {
                    Ok(contents) => contents,
                    Err(err) => {
                        let metadata = match fs_err::metadata(&member_root) {
                            Ok(metadata) => metadata,
                            Err(err)
                                if matches!(options.members, MemberDiscovery::Existing)
                                    && err.kind() == std::io::ErrorKind::NotFound =>
                            {
                                debug!(
                                    "Ignoring missing workspace member: `{}`",
                                    member_root.simplified_display()
                                );
                                continue;
                            }
                            Err(err) => return Err(err.into()),
                        };
                        if !metadata.is_dir() {
                            warn!(
                                "Ignoring non-directory workspace member: `{}`",
                                member_root.simplified_display()
                            );
                            continue;
                        }

                        // A directory exists, but it doesn't contain a `pyproject.toml`.
                        if err.kind() == std::io::ErrorKind::NotFound {
                            // If the directory is hidden, skip it.
                            if member_root
                                .file_name()
                                .map(|name| name.as_encoded_bytes().starts_with(b"."))
                                .unwrap_or(false)
                            {
                                debug!(
                                    "Ignoring hidden workspace member: `{}`",
                                    member_root.simplified_display()
                                );
                                continue;
                            }

                            // If the directory only contains gitignored files
                            // (e.g., `__pycache__`), skip it.
                            if has_only_gitignored_files(&member_root) {
                                debug!(
                                    "Ignoring workspace member with only gitignored files: `{}`",
                                    member_root.simplified_display()
                                );
                                continue;
                            }

                            if matches!(options.members, MemberDiscovery::Existing) {
                                debug!(
                                    "Ignoring missing workspace member: `{}`",
                                    member_root.simplified_display()
                                );
                                continue;
                            }

                            return Err(WorkspaceError::MissingPyprojectTomlMember(
                                member_root,
                                member_glob.to_string(),
                            ));
                        }

                        return Err(err.into());
                    }
                };
                let pyproject_toml = PyProjectToml::from_string(contents, &pyproject_path)
                    .map_err(|err| WorkspaceError::Toml(pyproject_path.clone(), Box::new(err)))?;

                // Check if the current project is explicitly marked as unmanaged.
                if pyproject_toml
                    .tool
                    .as_ref()
                    .and_then(|tool| tool.uv.as_ref())
                    .and_then(|uv| uv.managed)
                    == Some(false)
                {
                    if let Some(project) = pyproject_toml.project.as_ref() {
                        debug!(
                            "Project `{}` is marked as unmanaged; omitting from workspace members",
                            project.name
                        );
                    } else {
                        debug!(
                            "Workspace member at `{}` is marked as unmanaged; omitting from workspace members",
                            member_root.simplified_display()
                        );
                    }
                    continue;
                }

                // Extract the package name.
                let Some(project) = pyproject_toml.project.clone() else {
                    return Err(WorkspaceError::MissingProject(pyproject_path));
                };

                debug!(
                    "Adding discovered workspace member: `{}`",
                    member_root.simplified_display()
                );

                if let Some(existing) = workspace_members.insert(
                    project.name.clone(),
                    WorkspaceMember {
                        root: member_root.clone(),
                        project,
                        pyproject_toml,
                    },
                ) {
                    return Err(WorkspaceError::DuplicatePackage {
                        name: existing.project.name,
                        first: existing.root.clone(),
                        second: member_root,
                    });
                }
            }
        }

        // Test for nested workspaces.
        for member in workspace_members.values() {
            if member.root() != workspace_root
                && member
                    .pyproject_toml
                    .tool
                    .as_ref()
                    .and_then(|tool| tool.uv.as_ref())
                    .and_then(|uv| uv.workspace.as_ref())
                    .is_some()
            {
                return Err(WorkspaceError::NestedWorkspace(member.root.clone()));
            }
        }
        Ok(workspace_members)
    }
}

/// A project in a workspace.
#[derive(Debug, Clone, PartialEq)]
#[cfg_attr(test, derive(serde::Serialize))]
pub struct WorkspaceMember {
    /// The path to the project root.
    root: PathBuf,
    /// The `[project]` table, from the `pyproject.toml` of the project found at
    /// `<root>/pyproject.toml`.
    project: Project,
    /// The `pyproject.toml` of the project, found at `<root>/pyproject.toml`.
    pyproject_toml: PyProjectToml,
}

impl WorkspaceMember {
    /// The path to the project root.
    pub fn root(&self) -> &PathBuf {
        &self.root
    }

    /// The `[project]` table, from the `pyproject.toml` of the project found at
    /// `<root>/pyproject.toml`.
    pub fn project(&self) -> &Project {
        &self.project
    }

    /// The `pyproject.toml` of the project, found at `<root>/pyproject.toml`.
    pub fn pyproject_toml(&self) -> &PyProjectToml {
        &self.pyproject_toml
    }
}

/// The current project and the workspace it is part of, with all of the workspace members.
///
/// # Structure
///
/// The workspace root is a directory with a `pyproject.toml`, all members need to be below that
/// directory. The workspace root defines members and exclusions. All packages below it must either
/// be a member or excluded. The workspace root can be a package itself or a virtual manifest.
///
/// For a simple single package project, the workspace root is implicitly the current project root
/// and the workspace has only this single member. Otherwise, a workspace root is declared through
/// a `tool.uv.workspace` section.
///
/// A workspace itself does not declare dependencies, instead one member is the current project used
/// as main requirement.
///
/// Each member is a directory with a `pyproject.toml` that contains a `[project]` section. Each
/// member is a Python package, with a name, a version and dependencies. Workspace members can
/// depend on other workspace members (`foo = { workspace = true }`). You can consider the
/// workspace another package source or index, similar to `--find-links`.
///
/// # Usage
///
/// There a two main usage patterns: A root package and helpers, and the flat workspace.
///
/// Root package and helpers:
///
/// ```text
/// albatross
/// ├── packages
/// │   ├── provider_a
/// │   │   ├── pyproject.toml
/// │   │   └── src
/// │   │       └── provider_a
/// │   │           ├── __init__.py
/// │   │           └── foo.py
/// │   └── provider_b
/// │       ├── pyproject.toml
/// │       └── src
/// │           └── provider_b
/// │               ├── __init__.py
/// │               └── bar.py
/// ├── pyproject.toml
/// ├── Readme.md
/// ├── uv.lock
/// └── src
///     └── albatross
///         ├── __init__.py
///         └── main.py
/// ```
///
/// Flat workspace:
///
/// ```text
/// albatross
/// ├── packages
/// │   ├── albatross
/// │   │   ├── pyproject.toml
/// │   │   └── src
/// │   │       └── albatross
/// │   │           ├── __init__.py
/// │   │           └── main.py
/// │   ├── provider_a
/// │   │   ├── pyproject.toml
/// │   │   └── src
/// │   │       └── provider_a
/// │   │           ├── __init__.py
/// │   │           └── foo.py
/// │   └── provider_b
/// │       ├── pyproject.toml
/// │       └── src
/// │           └── provider_b
/// │               ├── __init__.py
/// │               └── bar.py
/// ├── pyproject.toml
/// ├── Readme.md
/// └── uv.lock
/// ```
#[derive(Debug, Clone)]
#[cfg_attr(test, derive(serde::Serialize))]
pub struct ProjectWorkspace {
    /// The path to the project root.
    project_root: PathBuf,
    /// The name of the package.
    project_name: PackageName,
    /// The workspace the project is part of.
    workspace: Workspace,
}

impl ProjectWorkspace {
    /// Find the current project and workspace, given the current directory.
    ///
    /// `stop_discovery_at` must be either `None` or an ancestor of the current directory. If set,
    /// only directories between the current path and `stop_discovery_at` are considered.
    pub async fn discover(
        path: &Path,
        options: &DiscoveryOptions,
        cache: &WorkspaceCache,
    ) -> Result<Self, WorkspaceError> {
        let project_root = path
            .ancestors()
            .take_while(|path| {
                // Only walk up the given directory, if any.
                options
                    .stop_discovery_at
                    .as_deref()
                    .and_then(Path::parent)
                    .map(|stop_discovery_at| stop_discovery_at != *path)
                    .unwrap_or(true)
            })
            .find(|path| path.join("pyproject.toml").is_file())
            .ok_or(WorkspaceError::MissingPyprojectToml)?;

        debug!(
            "Found project root: `{}`",
            project_root.simplified_display()
        );

        Self::from_project_root(project_root, options, cache).await
    }

    /// Discover the workspace starting from the directory containing the `pyproject.toml`.
    async fn from_project_root(
        project_root: &Path,
        options: &DiscoveryOptions,
        cache: &WorkspaceCache,
    ) -> Result<Self, WorkspaceError> {
        // Read the current `pyproject.toml`.
        let pyproject_path = project_root.join("pyproject.toml");
        let contents = fs_err::tokio::read_to_string(&pyproject_path).await?;
        let pyproject_toml = PyProjectToml::from_string(contents, &pyproject_path)
            .map_err(|err| WorkspaceError::Toml(pyproject_path.clone(), Box::new(err)))?;

        // It must have a `[project]` table.
        let project = pyproject_toml
            .project
            .clone()
            .ok_or(WorkspaceError::MissingProject(pyproject_path))?;

        Self::from_project(project_root, &project, &pyproject_toml, options, cache).await
    }

    /// If the current directory contains a `pyproject.toml` with a `project` table, discover the
    /// workspace and return it, otherwise it is a dynamic path dependency and we return `Ok(None)`.
    pub async fn from_maybe_project_root(
        install_path: &Path,
        options: &DiscoveryOptions,
        cache: &WorkspaceCache,
    ) -> Result<Option<Self>, WorkspaceError> {
        // Read the `pyproject.toml`.
        let pyproject_path = install_path.join("pyproject.toml");
        let Ok(contents) = fs_err::tokio::read_to_string(&pyproject_path).await else {
            // No `pyproject.toml`, but there may still be a `setup.py` or `setup.cfg`.
            return Ok(None);
        };
        let pyproject_toml = PyProjectToml::from_string(contents, &pyproject_path)
            .map_err(|err| WorkspaceError::Toml(pyproject_path.clone(), Box::new(err)))?;

        // Extract the `[project]` metadata.
        let Some(project) = pyproject_toml.project.clone() else {
            // We have to build to get the metadata.
            return Ok(None);
        };

        match Self::from_project(install_path, &project, &pyproject_toml, options, cache).await {
            Ok(workspace) => Ok(Some(workspace)),
            Err(WorkspaceError::NonWorkspace(_)) => Ok(None),
            Err(err) => Err(err),
        }
    }

    /// Returns the directory containing the closest `pyproject.toml` that defines the current
    /// project.
    pub fn project_root(&self) -> &Path {
        &self.project_root
    }

    /// Returns the [`PackageName`] of the current project.
    pub fn project_name(&self) -> &PackageName {
        &self.project_name
    }

    /// Returns the [`Workspace`] containing the current project.
    pub fn workspace(&self) -> &Workspace {
        &self.workspace
    }

    /// Returns the current project as a [`WorkspaceMember`].
    pub fn current_project(&self) -> &WorkspaceMember {
        &self.workspace().packages[&self.project_name]
    }

    /// Set the `pyproject.toml` for the current project.
    ///
    /// Assumes that the project name is unchanged in the updated [`PyProjectToml`].
    fn update_member(self, pyproject_toml: PyProjectToml) -> Result<Option<Self>, WorkspaceError> {
        let Some(workspace) = self
            .workspace
            .update_member(&self.project_name, pyproject_toml)?
        else {
            return Ok(None);
        };
        Ok(Some(Self { workspace, ..self }))
    }

    /// Find the workspace for a project.
    async fn from_project(
        install_path: &Path,
        project: &Project,
        project_pyproject_toml: &PyProjectToml,
        options: &DiscoveryOptions,
        cache: &WorkspaceCache,
    ) -> Result<Self, WorkspaceError> {
        let project_path = std::path::absolute(install_path)
            .map_err(WorkspaceError::Normalize)?
            .clone();
        let project_path = normalize_path(&project_path);

        // Check if workspaces are explicitly disabled for the project.
        if project_pyproject_toml
            .tool
            .as_ref()
            .and_then(|tool| tool.uv.as_ref())
            .and_then(|uv| uv.managed)
            == Some(false)
        {
            debug!("Project `{}` is marked as unmanaged", project.name);
            return Err(WorkspaceError::NonWorkspace(project_path.to_path_buf()));
        }

        // Check if the current project is also an explicit workspace root.
        let mut workspace = project_pyproject_toml
            .tool
            .as_ref()
            .and_then(|tool| tool.uv.as_ref())
            .and_then(|uv| uv.workspace.as_ref())
            .map(|workspace| {
                (
                    project_path.to_path_buf(),
                    workspace.clone(),
                    project_pyproject_toml.clone(),
                )
            });

        if workspace.is_none() {
            // The project isn't an explicit workspace root, check if we're a regular workspace
            // member by looking for an explicit workspace root above.
            workspace = find_workspace(&project_path, options).await?;
        }

        let current_project = WorkspaceMember {
            root: project_path.to_path_buf(),
            project: project.clone(),
            pyproject_toml: project_pyproject_toml.clone(),
        };

        let Some((workspace_root, workspace_definition, workspace_pyproject_toml)) = workspace
        else {
            // The project isn't an explicit workspace root, but there's also no workspace root
            // above it, so the project is an implicit workspace root identical to the project root.
            debug!("No workspace root found, using project root");

            let current_project_as_members = Arc::new(BTreeMap::from_iter([(
                project.name.clone(),
                current_project,
            )]));
            let workspace_sources = BTreeMap::default();
            let required_members = Workspace::collect_required_members(
                &current_project_as_members,
                &workspace_sources,
                project_pyproject_toml,
            )?;

            return Ok(Self {
                project_root: project_path.to_path_buf(),
                project_name: project.name.clone(),
                workspace: Workspace {
                    install_path: project_path.to_path_buf(),
                    packages: current_project_as_members,
                    required_members,
                    // There may be package sources, but we don't need to duplicate them into the
                    // workspace sources.
                    sources: workspace_sources,
                    indexes: Vec::default(),
                    pyproject_toml: project_pyproject_toml.clone(),
                },
            });
        };

        debug!(
            "Found workspace root: `{}`",
            workspace_root.simplified_display()
        );

        let workspace = Workspace::collect_members(
            workspace_root,
            workspace_definition,
            workspace_pyproject_toml,
            Some(current_project),
            options,
            cache,
        )
        .await?;

        Ok(Self {
            project_root: project_path.to_path_buf(),
            project_name: project.name.clone(),
            workspace,
        })
    }
}

/// Find the workspace root above the current project, if any.
async fn find_workspace(
    project_root: &Path,
    options: &DiscoveryOptions,
) -> Result<Option<(PathBuf, ToolUvWorkspace, PyProjectToml)>, WorkspaceError> {
    // Skip 1 to ignore the current project itself.
    for workspace_root in project_root
        .ancestors()
        .take_while(|path| {
            // Only walk up the given directory, if any.
            options
                .stop_discovery_at
                .as_deref()
                .and_then(Path::parent)
                .map(|stop_discovery_at| stop_discovery_at != *path)
                .unwrap_or(true)
        })
        .skip(1)
    {
        let pyproject_path = workspace_root.join("pyproject.toml");
        if !pyproject_path.is_file() {
            continue;
        }
        trace!(
            "Found `pyproject.toml` at: `{}`",
            pyproject_path.simplified_display()
        );

        // Read the `pyproject.toml`.
        let contents = fs_err::tokio::read_to_string(&pyproject_path).await?;
        let pyproject_toml = PyProjectToml::from_string(contents, &pyproject_path)
            .map_err(|err| WorkspaceError::Toml(pyproject_path.clone(), Box::new(err)))?;

        return if let Some(workspace) = pyproject_toml
            .tool
            .as_ref()
            .and_then(|tool| tool.uv.as_ref())
            .and_then(|uv| uv.workspace.as_ref())
        {
            if !is_included_in_workspace(project_root, workspace_root, workspace)? {
                debug!(
                    "Found workspace root `{}`, but project is not included",
                    workspace_root.simplified_display()
                );
                return Ok(None);
            }

            if is_excluded_from_workspace(project_root, workspace_root, workspace)? {
                debug!(
                    "Found workspace root `{}`, but project is excluded",
                    workspace_root.simplified_display()
                );
                return Ok(None);
            }

            // We found a workspace root.
            Ok(Some((
                workspace_root.to_path_buf(),
                workspace.clone(),
                pyproject_toml,
            )))
        } else if pyproject_toml.project.is_some() {
            // We're in a directory of another project, e.g. tests or examples.
            // Example:
            // ```
            // albatross
            // ├── examples
            // │   └── bird-feeder [CURRENT DIRECTORY]
            // │       ├── pyproject.toml
            // │       └── src
            // │           └── bird_feeder
            // │               └── __init__.py
            // ├── pyproject.toml
            // └── src
            //     └── albatross
            //         └── __init__.py
            // ```
            // The current project is the example (non-workspace) `bird-feeder` in `albatross`,
            // we ignore all `albatross` is doing and any potential workspace it might be
            // contained in.
            debug!(
                "Project is contained in non-workspace project: `{}`",
                workspace_root.simplified_display()
            );
            Ok(None)
        } else {
            // We require that a `project.toml` file either declares a workspace or a project.
            warn!(
                "`pyproject.toml` does not contain a `project` table: `{}`",
                pyproject_path.simplified_display()
            );
            Ok(None)
        };
    }

    Ok(None)
}

/// Check if a directory only contains files that are ignored.
///
/// Returns `true` if walking the directory while respecting `.gitignore` and `.ignore` rules
/// yields no files, indicating that any files present (e.g., `__pycache__`) are all ignored.
fn has_only_gitignored_files(path: &Path) -> bool {
    let walker = ignore::WalkBuilder::new(path)
        .hidden(false)
        .parents(true)
        .ignore(true)
        .git_ignore(true)
        .git_global(true)
        .git_exclude(true)
        .build();

    for entry in walker {
        let Ok(entry) = entry else {
            // If we can't read an entry, assume non-ignored content exists.
            return false;
        };

        // Skip directories.
        if entry.path().is_dir() {
            continue;
        }

        // A non-ignored entry exists.
        return false;
    }

    true
}

/// Check if we're in the `tool.uv.workspace.excluded` of a workspace.
fn is_excluded_from_workspace(
    project_path: &Path,
    workspace_root: &Path,
    workspace: &ToolUvWorkspace,
) -> Result<bool, WorkspaceError> {
    for exclude_glob in workspace.exclude.iter().flatten() {
        // Normalize the exclude glob to remove leading `./` and other relative path components
        let normalized_glob = normalize_path(Path::new(exclude_glob.as_str()));
        let absolute_glob = PathBuf::from(glob::Pattern::escape(
            workspace_root.simplified().to_string_lossy().as_ref(),
        ))
        .join(normalized_glob.as_ref());
        let absolute_glob = absolute_glob.to_string_lossy();
        let exclude_pattern = glob::Pattern::new(&absolute_glob)
            .map_err(|err| WorkspaceError::Pattern(absolute_glob.to_string(), err))?;
        if exclude_pattern.matches_path(project_path) {
            return Ok(true);
        }
    }
    Ok(false)
}

/// Check if we're in the `tool.uv.workspace.members` of a workspace.
fn is_included_in_workspace(
    project_path: &Path,
    workspace_root: &Path,
    workspace: &ToolUvWorkspace,
) -> Result<bool, WorkspaceError> {
    for member_glob in workspace.members.iter().flatten() {
        // Normalize the member glob to remove leading `./` and other relative path components
        let normalized_glob = normalize_path(Path::new(member_glob.as_str()));
        let absolute_glob = PathBuf::from(glob::Pattern::escape(
            workspace_root.simplified().to_string_lossy().as_ref(),
        ))
        .join(normalized_glob);
        let absolute_glob = absolute_glob.to_string_lossy();
        let include_pattern = glob::Pattern::new(&absolute_glob)
            .map_err(|err| WorkspaceError::Pattern(absolute_glob.to_string(), err))?;
        if include_pattern.matches_path(project_path) {
            return Ok(true);
        }
    }
    Ok(false)
}

/// A project that can be discovered.
///
/// The project could be a package within a workspace, a real workspace root, or a non-project
/// workspace root, which can define its own dev dependencies.
#[derive(Debug, Clone)]
pub enum VirtualProject {
    /// A project (which could be a workspace root or member).
    Project(ProjectWorkspace),
    /// A non-project workspace root.
    NonProject(Workspace),
}

impl VirtualProject {
    /// Find the current project or virtual workspace root, given the current directory.
    ///
    /// Similar to calling [`ProjectWorkspace::discover`] with a fallback to [`Workspace::discover`],
    /// but avoids rereading the `pyproject.toml` (and relying on error-handling as control flow).
    ///
    /// This method requires an absolute path and panics otherwise, i.e. this method only supports
    /// discovering the main workspace.
    pub async fn discover(
        path: &Path,
        options: &DiscoveryOptions,
        cache: &WorkspaceCache,
    ) -> Result<Self, WorkspaceError> {
        assert!(
            path.is_absolute(),
            "virtual project discovery with relative path"
        );
        let project_root = path
            .ancestors()
            .take_while(|path| {
                // Only walk up the given directory, if any.
                options
                    .stop_discovery_at
                    .as_deref()
                    .and_then(Path::parent)
                    .map(|stop_discovery_at| stop_discovery_at != *path)
                    .unwrap_or(true)
            })
            .find(|path| path.join("pyproject.toml").is_file())
            .ok_or(WorkspaceError::MissingPyprojectToml)?;

        debug!(
            "Found project root: `{}`",
            project_root.simplified_display()
        );

        // Read the current `pyproject.toml`.
        let pyproject_path = project_root.join("pyproject.toml");
        let contents = fs_err::tokio::read_to_string(&pyproject_path).await?;
        let pyproject_toml = PyProjectToml::from_string(contents, &pyproject_path)
            .map_err(|err| WorkspaceError::Toml(pyproject_path.clone(), Box::new(err)))?;

        if let Some(project) = pyproject_toml.project.as_ref() {
            // If the `pyproject.toml` contains a `[project]` table, it's a project.
            let project = ProjectWorkspace::from_project(
                project_root,
                project,
                &pyproject_toml,
                options,
                cache,
            )
            .await?;
            Ok(Self::Project(project))
        } else if let Some(workspace) = pyproject_toml
            .tool
            .as_ref()
            .and_then(|tool| tool.uv.as_ref())
            .and_then(|uv| uv.workspace.as_ref())
            .filter(|_| options.project.allows_non_project_workspace())
        {
            // Otherwise, if it contains a `tool.uv.workspace` table, it's a non-project workspace
            // root.
            let project_path = std::path::absolute(project_root)
                .map_err(WorkspaceError::Normalize)?
                .clone();

            let workspace = Workspace::collect_members(
                project_path,
                workspace.clone(),
                pyproject_toml,
                None,
                options,
                cache,
            )
            .await?;

            Ok(Self::NonProject(workspace))
        } else if options.project.allows_implicit_workspace() {
            // Otherwise it's a pyproject.toml that maybe contains dependency-groups
            // that we want to treat like a project/workspace to handle those uniformly
            let project_path = std::path::absolute(project_root)
                .map_err(WorkspaceError::Normalize)?
                .clone();

            let workspace = Workspace::collect_members(
                project_path,
                ToolUvWorkspace::default(),
                pyproject_toml,
                None,
                options,
                cache,
            )
            .await?;

            Ok(Self::NonProject(workspace))
        } else {
            Err(WorkspaceError::MissingProject(pyproject_path))
        }
    }

    /// Discover a project workspace with the member package.
    pub async fn discover_with_package(
        path: &Path,
        options: &DiscoveryOptions,
        cache: &WorkspaceCache,
        package: PackageName,
    ) -> Result<Self, WorkspaceError> {
        let workspace = Workspace::discover(path, options, cache).await?;
        let project_workspace = Workspace::with_current_project(workspace.clone(), package.clone());
        Ok(Self::Project(project_workspace.ok_or_else(|| {
            WorkspaceError::NoSuchMember(package.clone(), workspace.install_path)
        })?))
    }

    /// Update the `pyproject.toml` for the current project.
    ///
    /// Assumes that the project name is unchanged in the updated [`PyProjectToml`].
    pub fn update_member(
        self,
        pyproject_toml: PyProjectToml,
    ) -> Result<Option<Self>, WorkspaceError> {
        Ok(match self {
            Self::Project(project) => {
                let Some(project) = project.update_member(pyproject_toml)? else {
                    return Ok(None);
                };
                Some(Self::Project(project))
            }
            Self::NonProject(workspace) => {
                // If this is a non-project workspace root, then by definition the root isn't a
                // member, so we can just update the top-level `pyproject.toml`.
                Some(Self::NonProject(Workspace {
                    pyproject_toml,
                    ..workspace.clone()
                }))
            }
        })
    }

    /// Return the root of the project.
    pub fn root(&self) -> &Path {
        match self {
            Self::Project(project) => project.project_root(),
            Self::NonProject(workspace) => workspace.install_path(),
        }
    }

    /// Return the [`PyProjectToml`] of the project.
    pub fn pyproject_toml(&self) -> &PyProjectToml {
        match self {
            Self::Project(project) => project.current_project().pyproject_toml(),
            Self::NonProject(workspace) => &workspace.pyproject_toml,
        }
    }

    /// Return the [`Workspace`] of the project.
    pub fn workspace(&self) -> &Workspace {
        match self {
            Self::Project(project) => project.workspace(),
            Self::NonProject(workspace) => workspace,
        }
    }

    /// Return the [`PackageName`] of the project, if available.
    pub fn project_name(&self) -> Option<&PackageName> {
        match self {
            Self::Project(project) => Some(project.project_name()),
            Self::NonProject(_) => None,
        }
    }

    /// Returns `true` if the project is a virtual workspace root.
    pub fn is_non_project(&self) -> bool {
        matches!(self, Self::NonProject(_))
    }
}

#[cfg(test)]
#[cfg(unix)] // Avoid path escaping for the unit tests
mod tests {
    use std::env;
    use std::path::Path;
    use std::str::FromStr;

    use anyhow::Result;
    use assert_fs::fixture::ChildPath;
    use assert_fs::prelude::*;
    use insta::{assert_json_snapshot, assert_snapshot};

    use uv_normalize::GroupName;
    use uv_pypi_types::DependencyGroupSpecifier;

    use crate::pyproject::PyProjectToml;
    use crate::workspace::{DiscoveryOptions, ProjectWorkspace};
    use crate::{WorkspaceCache, WorkspaceError};

    async fn workspace_test(folder: &str) -> (ProjectWorkspace, String) {
        let root_dir = env::current_dir()
            .unwrap()
            .parent()
            .unwrap()
            .parent()
            .unwrap()
            .join("test")
            .join("workspaces");
        let project = ProjectWorkspace::discover(
            &root_dir.join(folder),
            &DiscoveryOptions::default(),
            &WorkspaceCache::default(),
        )
        .await
        .unwrap();
        let root_escaped = regex::escape(root_dir.to_string_lossy().as_ref());
        (project, root_escaped)
    }

    async fn temporary_test(
        folder: &Path,
    ) -> Result<(ProjectWorkspace, String), (WorkspaceError, String)> {
        let root_escaped = regex::escape(folder.to_string_lossy().as_ref());
        let project = ProjectWorkspace::discover(
            folder,
            &DiscoveryOptions::default(),
            &WorkspaceCache::default(),
        )
        .await
        .map_err(|error| (error, root_escaped.clone()))?;

        Ok((project, root_escaped))
    }

    #[tokio::test]
    async fn albatross_in_example() {
        let (project, root_escaped) =
            workspace_test("albatross-in-example/examples/bird-feeder").await;
        let filters = vec![(root_escaped.as_str(), "[ROOT]")];
        insta::with_settings!({filters => filters}, {
        assert_json_snapshot!(
            project,
            {
                ".workspace.packages.*.pyproject_toml" => "[PYPROJECT_TOML]"
            },
            @r#"
        {
          "project_root": "[ROOT]/albatross-in-example/examples/bird-feeder",
          "project_name": "bird-feeder",
          "workspace": {
            "install_path": "[ROOT]/albatross-in-example/examples/bird-feeder",
            "packages": {
              "bird-feeder": {
                "root": "[ROOT]/albatross-in-example/examples/bird-feeder",
                "project": {
                  "name": "bird-feeder",
                  "version": "1.0.0",
                  "requires-python": ">=3.12",
                  "dependencies": [
                    "iniconfig>=2,<3"
                  ],
                  "optional-dependencies": null
                },
                "pyproject_toml": "[PYPROJECT_TOML]"
              }
            },
            "required_members": {},
            "sources": {},
            "indexes": [],
            "pyproject_toml": {
              "project": {
                "name": "bird-feeder",
                "version": "1.0.0",
                "requires-python": ">=3.12",
                "dependencies": [
                  "iniconfig>=2,<3"
                ],
                "optional-dependencies": null
              },
              "tool": null,
              "dependency-groups": null
            }
          }
        }
        "#);
        });
    }

    #[tokio::test]
    async fn albatross_project_in_excluded() {
        let (project, root_escaped) =
            workspace_test("albatross-project-in-excluded/excluded/bird-feeder").await;
        let filters = vec![(root_escaped.as_str(), "[ROOT]")];
        insta::with_settings!({filters => filters}, {
            assert_json_snapshot!(
            project,
            {
                ".workspace.packages.*.pyproject_toml" => "[PYPROJECT_TOML]"
            },
            @r#"
            {
              "project_root": "[ROOT]/albatross-project-in-excluded/excluded/bird-feeder",
              "project_name": "bird-feeder",
              "workspace": {
                "install_path": "[ROOT]/albatross-project-in-excluded/excluded/bird-feeder",
                "packages": {
                  "bird-feeder": {
                    "root": "[ROOT]/albatross-project-in-excluded/excluded/bird-feeder",
                    "project": {
                      "name": "bird-feeder",
                      "version": "1.0.0",
                      "requires-python": ">=3.12",
                      "dependencies": [
                        "iniconfig>=2,<3"
                      ],
                      "optional-dependencies": null
                    },
                    "pyproject_toml": "[PYPROJECT_TOML]"
                  }
                },
                "required_members": {},
                "sources": {},
                "indexes": [],
                "pyproject_toml": {
                  "project": {
                    "name": "bird-feeder",
                    "version": "1.0.0",
                    "requires-python": ">=3.12",
                    "dependencies": [
                      "iniconfig>=2,<3"
                    ],
                    "optional-dependencies": null
                  },
                  "tool": null,
                  "dependency-groups": null
                }
              }
            }
            "#);
        });
    }

    #[tokio::test]
    async fn albatross_root_workspace() {
        let (project, root_escaped) = workspace_test("albatross-root-workspace").await;
        let filters = vec![(root_escaped.as_str(), "[ROOT]")];
        insta::with_settings!({filters => filters}, {
            assert_json_snapshot!(
            project,
            {
                ".workspace.packages.*.pyproject_toml" => "[PYPROJECT_TOML]"
            },
            @r#"
            {
              "project_root": "[ROOT]/albatross-root-workspace",
              "project_name": "albatross",
              "workspace": {
                "install_path": "[ROOT]/albatross-root-workspace",
                "packages": {
                  "albatross": {
                    "root": "[ROOT]/albatross-root-workspace",
                    "project": {
                      "name": "albatross",
                      "version": "0.1.0",
                      "requires-python": ">=3.12",
                      "dependencies": [
                        "bird-feeder",
                        "iniconfig>=2,<3"
                      ],
                      "optional-dependencies": null
                    },
                    "pyproject_toml": "[PYPROJECT_TOML]"
                  },
                  "bird-feeder": {
                    "root": "[ROOT]/albatross-root-workspace/packages/bird-feeder",
                    "project": {
                      "name": "bird-feeder",
                      "version": "1.0.0",
                      "requires-python": ">=3.8",
                      "dependencies": [
                        "iniconfig>=2,<3",
                        "seeds"
                      ],
                      "optional-dependencies": null
                    },
                    "pyproject_toml": "[PYPROJECT_TOML]"
                  },
                  "seeds": {
                    "root": "[ROOT]/albatross-root-workspace/packages/seeds",
                    "project": {
                      "name": "seeds",
                      "version": "1.0.0",
                      "requires-python": ">=3.12",
                      "dependencies": [
                        "idna==3.6"
                      ],
                      "optional-dependencies": null
                    },
                    "pyproject_toml": "[PYPROJECT_TOML]"
                  }
                },
                "required_members": {
                  "bird-feeder": null,
                  "seeds": null
                },
                "sources": {
                  "bird-feeder": [
                    {
                      "workspace": true,
                      "editable": null,
                      "extra": null,
                      "group": null
                    }
                  ]
                },
                "indexes": [],
                "pyproject_toml": {
                  "project": {
                    "name": "albatross",
                    "version": "0.1.0",
                    "requires-python": ">=3.12",
                    "dependencies": [
                      "bird-feeder",
                      "iniconfig>=2,<3"
                    ],
                    "optional-dependencies": null
                  },
                  "tool": {
                    "uv": {
                      "sources": {
                        "bird-feeder": [
                          {
                            "workspace": true,
                            "editable": null,
                            "extra": null,
                            "group": null
                          }
                        ]
                      },
                      "index": null,
                      "workspace": {
                        "members": [
                          "packages/*"
                        ],
                        "exclude": null
                      },
                      "managed": null,
                      "package": null,
                      "default-groups": null,
                      "dependency-groups": null,
                      "dev-dependencies": null,
                      "override-dependencies": null,
                      "exclude-dependencies": null,
                      "constraint-dependencies": null,
                      "build-constraint-dependencies": null,
                      "environments": null,
                      "required-environments": null,
                      "conflicts": null,
                      "build-backend": null
                    }
                  },
                  "dependency-groups": null
                }
              }
            }
            "#);
        });
    }

    #[tokio::test]
    async fn albatross_virtual_workspace() {
        let (project, root_escaped) =
            workspace_test("albatross-virtual-workspace/packages/albatross").await;
        let filters = vec![(root_escaped.as_str(), "[ROOT]")];
        insta::with_settings!({filters => filters}, {
            assert_json_snapshot!(
            project,
            {
                ".workspace.packages.*.pyproject_toml" => "[PYPROJECT_TOML]"
            },
            @r#"
            {
              "project_root": "[ROOT]/albatross-virtual-workspace/packages/albatross",
              "project_name": "albatross",
              "workspace": {
                "install_path": "[ROOT]/albatross-virtual-workspace",
                "packages": {
                  "albatross": {
                    "root": "[ROOT]/albatross-virtual-workspace/packages/albatross",
                    "project": {
                      "name": "albatross",
                      "version": "0.1.0",
                      "requires-python": ">=3.12",
                      "dependencies": [
                        "bird-feeder",
                        "iniconfig>=2,<3"
                      ],
                      "optional-dependencies": null
                    },
                    "pyproject_toml": "[PYPROJECT_TOML]"
                  },
                  "bird-feeder": {
                    "root": "[ROOT]/albatross-virtual-workspace/packages/bird-feeder",
                    "project": {
                      "name": "bird-feeder",
                      "version": "1.0.0",
                      "requires-python": ">=3.12",
                      "dependencies": [
                        "anyio>=4.3.0,<5",
                        "seeds"
                      ],
                      "optional-dependencies": null
                    },
                    "pyproject_toml": "[PYPROJECT_TOML]"
                  },
                  "seeds": {
                    "root": "[ROOT]/albatross-virtual-workspace/packages/seeds",
                    "project": {
                      "name": "seeds",
                      "version": "1.0.0",
                      "requires-python": ">=3.12",
                      "dependencies": [
                        "idna==3.6"
                      ],
                      "optional-dependencies": null
                    },
                    "pyproject_toml": "[PYPROJECT_TOML]"
                  }
                },
                "required_members": {
                  "bird-feeder": null,
                  "seeds": null
                },
                "sources": {},
                "indexes": [],
                "pyproject_toml": {
                  "project": null,
                  "tool": {
                    "uv": {
                      "sources": null,
                      "index": null,
                      "workspace": {
                        "members": [
                          "packages/*"
                        ],
                        "exclude": null
                      },
                      "managed": null,
                      "package": null,
                      "default-groups": null,
                      "dependency-groups": null,
                      "dev-dependencies": null,
                      "override-dependencies": null,
                      "exclude-dependencies": null,
                      "constraint-dependencies": null,
                      "build-constraint-dependencies": null,
                      "environments": null,
                      "required-environments": null,
                      "conflicts": null,
                      "build-backend": null
                    }
                  },
                  "dependency-groups": null
                }
              }
            }
            "#);
        });
    }

    #[tokio::test]
    async fn albatross_just_project() {
        let (project, root_escaped) = workspace_test("albatross-just-project").await;
        let filters = vec![(root_escaped.as_str(), "[ROOT]")];
        insta::with_settings!({filters => filters}, {
            assert_json_snapshot!(
            project,
            {
                ".workspace.packages.*.pyproject_toml" => "[PYPROJECT_TOML]"
            },
            @r#"
            {
              "project_root": "[ROOT]/albatross-just-project",
              "project_name": "albatross",
              "workspace": {
                "install_path": "[ROOT]/albatross-just-project",
                "packages": {
                  "albatross": {
                    "root": "[ROOT]/albatross-just-project",
                    "project": {
                      "name": "albatross",
                      "version": "0.1.0",
                      "requires-python": ">=3.12",
                      "dependencies": [
                        "iniconfig>=2,<3"
                      ],
                      "optional-dependencies": null
                    },
                    "pyproject_toml": "[PYPROJECT_TOML]"
                  }
                },
                "required_members": {},
                "sources": {},
                "indexes": [],
                "pyproject_toml": {
                  "project": {
                    "name": "albatross",
                    "version": "0.1.0",
                    "requires-python": ">=3.12",
                    "dependencies": [
                      "iniconfig>=2,<3"
                    ],
                    "optional-dependencies": null
                  },
                  "tool": null,
                  "dependency-groups": null
                }
              }
            }
            "#);
        });
    }

    #[tokio::test]
    async fn exclude_package() -> Result<()> {
        let root = tempfile::TempDir::new()?;
        let root = ChildPath::new(root.path());

        // Create the root.
        root.child("pyproject.toml").write_str(
            r#"
            [project]
            name = "albatross"
            version = "0.1.0"
            requires-python = ">=3.12"
            dependencies = ["tqdm>=4,<5"]

            [tool.uv.workspace]
            members = ["packages/*"]
            exclude = ["packages/bird-feeder"]

            [build-system]
            requires = ["hatchling"]
            build-backend = "hatchling.build"
            "#,
        )?;
        root.child("albatross").child("__init__.py").touch()?;

        // Create an included package (`seeds`).
        root.child("packages")
            .child("seeds")
            .child("pyproject.toml")
            .write_str(
                r#"
            [project]
            name = "seeds"
            version = "1.0.0"
            requires-python = ">=3.12"
            dependencies = ["idna==3.6"]

            [build-system]
            requires = ["hatchling"]
            build-backend = "hatchling.build"
            "#,
            )?;
        root.child("packages")
            .child("seeds")
            .child("seeds")
            .child("__init__.py")
            .touch()?;

        // Create an excluded package (`bird-feeder`).
        root.child("packages")
            .child("bird-feeder")
            .child("pyproject.toml")
            .write_str(
                r#"
            [project]
            name = "bird-feeder"
            version = "1.0.0"
            requires-python = ">=3.12"
            dependencies = ["anyio>=4.3.0,<5"]

            [build-system]
            requires = ["hatchling"]
            build-backend = "hatchling.build"
            "#,
            )?;
        root.child("packages")
            .child("bird-feeder")
            .child("bird_feeder")
            .child("__init__.py")
            .touch()?;

        let (project, root_escaped) = temporary_test(root.as_ref()).await.unwrap();
        let filters = vec![(root_escaped.as_str(), "[ROOT]")];
        insta::with_settings!({filters => filters}, {
            assert_json_snapshot!(
            project,
            {
                ".workspace.packages.*.pyproject_toml" => "[PYPROJECT_TOML]"
            },
            @r#"
            {
              "project_root": "[ROOT]",
              "project_name": "albatross",
              "workspace": {
                "install_path": "[ROOT]",
                "packages": {
                  "albatross": {
                    "root": "[ROOT]",
                    "project": {
                      "name": "albatross",
                      "version": "0.1.0",
                      "requires-python": ">=3.12",
                      "dependencies": [
                        "tqdm>=4,<5"
                      ],
                      "optional-dependencies": null
                    },
                    "pyproject_toml": "[PYPROJECT_TOML]"
                  },
                  "seeds": {
                    "root": "[ROOT]/packages/seeds",
                    "project": {
                      "name": "seeds",
                      "version": "1.0.0",
                      "requires-python": ">=3.12",
                      "dependencies": [
                        "idna==3.6"
                      ],
                      "optional-dependencies": null
                    },
                    "pyproject_toml": "[PYPROJECT_TOML]"
                  }
                },
                "required_members": {},
                "sources": {},
                "indexes": [],
                "pyproject_toml": {
                  "project": {
                    "name": "albatross",
                    "version": "0.1.0",
                    "requires-python": ">=3.12",
                    "dependencies": [
                      "tqdm>=4,<5"
                    ],
                    "optional-dependencies": null
                  },
                  "tool": {
                    "uv": {
                      "sources": null,
                      "index": null,
                      "workspace": {
                        "members": [
                          "packages/*"
                        ],
                        "exclude": [
                          "packages/bird-feeder"
                        ]
                      },
                      "managed": null,
                      "package": null,
                      "default-groups": null,
                      "dependency-groups": null,
                      "dev-dependencies": null,
                      "override-dependencies": null,
                      "exclude-dependencies": null,
                      "constraint-dependencies": null,
                      "build-constraint-dependencies": null,
                      "environments": null,
                      "required-environments": null,
                      "conflicts": null,
                      "build-backend": null
                    }
                  },
                  "dependency-groups": null
                }
              }
            }
            "#);
        });

        // Rewrite the members to both include and exclude `bird-feeder` by name.
        root.child("pyproject.toml").write_str(
            r#"
            [project]
            name = "albatross"
            version = "0.1.0"
            requires-python = ">=3.12"
            dependencies = ["tqdm>=4,<5"]

            [tool.uv.workspace]
            members = ["packages/seeds", "packages/bird-feeder"]
            exclude = ["packages/bird-feeder"]

            [build-system]
            requires = ["hatchling"]
            build-backend = "hatchling.build"
            "#,
        )?;

        // `bird-feeder` should still be excluded.
        let (project, root_escaped) = temporary_test(root.as_ref()).await.unwrap();
        let filters = vec![(root_escaped.as_str(), "[ROOT]")];
        insta::with_settings!({filters => filters}, {
            assert_json_snapshot!(
            project,
            {
                ".workspace.packages.*.pyproject_toml" => "[PYPROJECT_TOML]"
            },
            @r#"
            {
              "project_root": "[ROOT]",
              "project_name": "albatross",
              "workspace": {
                "install_path": "[ROOT]",
                "packages": {
                  "albatross": {
                    "root": "[ROOT]",
                    "project": {
                      "name": "albatross",
                      "version": "0.1.0",
                      "requires-python": ">=3.12",
                      "dependencies": [
                        "tqdm>=4,<5"
                      ],
                      "optional-dependencies": null
                    },
                    "pyproject_toml": "[PYPROJECT_TOML]"
                  },
                  "seeds": {
                    "root": "[ROOT]/packages/seeds",
                    "project": {
                      "name": "seeds",
                      "version": "1.0.0",
                      "requires-python": ">=3.12",
                      "dependencies": [
                        "idna==3.6"
                      ],
                      "optional-dependencies": null
                    },
                    "pyproject_toml": "[PYPROJECT_TOML]"
                  }
                },
                "required_members": {},
                "sources": {},
                "indexes": [],
                "pyproject_toml": {
                  "project": {
                    "name": "albatross",
                    "version": "0.1.0",
                    "requires-python": ">=3.12",
                    "dependencies": [
                      "tqdm>=4,<5"
                    ],
                    "optional-dependencies": null
                  },
                  "tool": {
                    "uv": {
                      "sources": null,
                      "index": null,
                      "workspace": {
                        "members": [
                          "packages/seeds",
                          "packages/bird-feeder"
                        ],
                        "exclude": [
                          "packages/bird-feeder"
                        ]
                      },
                      "managed": null,
                      "package": null,
                      "default-groups": null,
                      "dependency-groups": null,
                      "dev-dependencies": null,
                      "override-dependencies": null,
                      "exclude-dependencies": null,
                      "constraint-dependencies": null,
                      "build-constraint-dependencies": null,
                      "environments": null,
                      "required-environments": null,
                      "conflicts": null,
                      "build-backend": null
                    }
                  },
                  "dependency-groups": null
                }
              }
            }
            "#);
        });

        // Rewrite the exclusion to use the top-level directory (`packages`).
        root.child("pyproject.toml").write_str(
            r#"
            [project]
            name = "albatross"
            version = "0.1.0"
            requires-python = ">=3.12"
            dependencies = ["tqdm>=4,<5"]

            [tool.uv.workspace]
            members = ["packages/seeds", "packages/bird-feeder"]
            exclude = ["packages"]

            [build-system]
            requires = ["hatchling"]
            build-backend = "hatchling.build"
            "#,
        )?;

        // `bird-feeder` should now be included.
        let (project, root_escaped) = temporary_test(root.as_ref()).await.unwrap();
        let filters = vec![(root_escaped.as_str(), "[ROOT]")];
        insta::with_settings!({filters => filters}, {
            assert_json_snapshot!(
            project,
            {
                ".workspace.packages.*.pyproject_toml" => "[PYPROJECT_TOML]"
            },
            @r#"
            {
              "project_root": "[ROOT]",
              "project_name": "albatross",
              "workspace": {
                "install_path": "[ROOT]",
                "packages": {
                  "albatross": {
                    "root": "[ROOT]",
                    "project": {
                      "name": "albatross",
                      "version": "0.1.0",
                      "requires-python": ">=3.12",
                      "dependencies": [
                        "tqdm>=4,<5"
                      ],
                      "optional-dependencies": null
                    },
                    "pyproject_toml": "[PYPROJECT_TOML]"
                  },
                  "bird-feeder": {
                    "root": "[ROOT]/packages/bird-feeder",
                    "project": {
                      "name": "bird-feeder",
                      "version": "1.0.0",
                      "requires-python": ">=3.12",
                      "dependencies": [
                        "anyio>=4.3.0,<5"
                      ],
                      "optional-dependencies": null
                    },
                    "pyproject_toml": "[PYPROJECT_TOML]"
                  },
                  "seeds": {
                    "root": "[ROOT]/packages/seeds",
                    "project": {
                      "name": "seeds",
                      "version": "1.0.0",
                      "requires-python": ">=3.12",
                      "dependencies": [
                        "idna==3.6"
                      ],
                      "optional-dependencies": null
                    },
                    "pyproject_toml": "[PYPROJECT_TOML]"
                  }
                },
                "required_members": {},
                "sources": {},
                "indexes": [],
                "pyproject_toml": {
                  "project": {
                    "name": "albatross",
                    "version": "0.1.0",
                    "requires-python": ">=3.12",
                    "dependencies": [
                      "tqdm>=4,<5"
                    ],
                    "optional-dependencies": null
                  },
                  "tool": {
                    "uv": {
                      "sources": null,
                      "index": null,
                      "workspace": {
                        "members": [
                          "packages/seeds",
                          "packages/bird-feeder"
                        ],
                        "exclude": [
                          "packages"
                        ]
                      },
                      "managed": null,
                      "package": null,
                      "default-groups": null,
                      "dependency-groups": null,
                      "dev-dependencies": null,
                      "override-dependencies": null,
                      "exclude-dependencies": null,
                      "constraint-dependencies": null,
                      "build-constraint-dependencies": null,
                      "environments": null,
                      "required-environments": null,
                      "conflicts": null,
                      "build-backend": null
                    }
                  },
                  "dependency-groups": null
                }
              }
            }
            "#);
        });

        // Rewrite the exclusion to use the top-level directory with a glob (`packages/*`).
        root.child("pyproject.toml").write_str(
            r#"
            [project]
            name = "albatross"
            version = "0.1.0"
            requires-python = ">=3.12"
            dependencies = ["tqdm>=4,<5"]

            [tool.uv.workspace]
            members = ["packages/seeds", "packages/bird-feeder"]
            exclude = ["packages/*"]

            [build-system]
            requires = ["hatchling"]
            build-backend = "hatchling.build"
            "#,
        )?;

        // `bird-feeder` and `seeds` should now be excluded.
        let (project, root_escaped) = temporary_test(root.as_ref()).await.unwrap();
        let filters = vec![(root_escaped.as_str(), "[ROOT]")];
        insta::with_settings!({filters => filters}, {
            assert_json_snapshot!(
            project,
            {
                ".workspace.packages.*.pyproject_toml" => "[PYPROJECT_TOML]"
            },
            @r#"
            {
              "project_root": "[ROOT]",
              "project_name": "albatross",
              "workspace": {
                "install_path": "[ROOT]",
                "packages": {
                  "albatross": {
                    "root": "[ROOT]",
                    "project": {
                      "name": "albatross",
                      "version": "0.1.0",
                      "requires-python": ">=3.12",
                      "dependencies": [
                        "tqdm>=4,<5"
                      ],
                      "optional-dependencies": null
                    },
                    "pyproject_toml": "[PYPROJECT_TOML]"
                  }
                },
                "required_members": {},
                "sources": {},
                "indexes": [],
                "pyproject_toml": {
                  "project": {
                    "name": "albatross",
                    "version": "0.1.0",
                    "requires-python": ">=3.12",
                    "dependencies": [
                      "tqdm>=4,<5"
                    ],
                    "optional-dependencies": null
                  },
                  "tool": {
                    "uv": {
                      "sources": null,
                      "index": null,
                      "workspace": {
                        "members": [
                          "packages/seeds",
                          "packages/bird-feeder"
                        ],
                        "exclude": [
                          "packages/*"
                        ]
                      },
                      "managed": null,
                      "package": null,
                      "default-groups": null,
                      "dependency-groups": null,
                      "dev-dependencies": null,
                      "override-dependencies": null,
                      "exclude-dependencies": null,
                      "constraint-dependencies": null,
                      "build-constraint-dependencies": null,
                      "environments": null,
                      "required-environments": null,
                      "conflicts": null,
                      "build-backend": null
                    }
                  },
                  "dependency-groups": null
                }
              }
            }
            "#);
        });

        Ok(())
    }

    #[test]
    fn read_dependency_groups() {
        let toml = r#"
[dependency-groups]
foo = ["a", {include-group = "bar"}]
bar = ["b"]
"#;

        let result = PyProjectToml::from_string(toml.to_string(), "pyproject.toml")
            .expect("Deserialization should succeed");

        let groups = result
            .dependency_groups
            .expect("`dependency-groups` should be present");
        let foo = groups
            .get(&GroupName::from_str("foo").unwrap())
            .expect("Group `foo` should be present");
        assert_eq!(
            foo,
            &[
                DependencyGroupSpecifier::Requirement("a".to_string()),
                DependencyGroupSpecifier::IncludeGroup {
                    include_group: GroupName::from_str("bar").unwrap(),
                }
            ]
        );

        let bar = groups
            .get(&GroupName::from_str("bar").unwrap())
            .expect("Group `bar` should be present");
        assert_eq!(
            bar,
            &[DependencyGroupSpecifier::Requirement("b".to_string())]
        );
    }

    #[tokio::test]
    async fn nested_workspace() -> Result<()> {
        let root = tempfile::TempDir::new()?;
        let root = ChildPath::new(root.path());

        // Create the root.
        root.child("pyproject.toml").write_str(
            r#"
            [project]
            name = "albatross"
            version = "0.1.0"
            requires-python = ">=3.12"
            dependencies = ["tqdm>=4,<5"]

            [tool.uv.workspace]
            members = ["packages/*"]
            "#,
        )?;

        // Create an included package (`seeds`).
        root.child("packages")
            .child("seeds")
            .child("pyproject.toml")
            .write_str(
                r#"
            [project]
            name = "seeds"
            version = "1.0.0"
            requires-python = ">=3.12"
            dependencies = ["idna==3.6"]

            [tool.uv.workspace]
            members = ["nested_packages/*"]
            "#,
            )?;

        let (error, root_escaped) = temporary_test(root.as_ref()).await.unwrap_err();
        let filters = vec![(root_escaped.as_str(), "[ROOT]")];
        insta::with_settings!({filters => filters}, {
            assert_snapshot!(
                error,
            @"Nested workspaces are not supported, but workspace member has a `tool.uv.workspace` table: [ROOT]/packages/seeds");
        });

        Ok(())
    }

    #[tokio::test]
    async fn duplicate_names() -> Result<()> {
        let root = tempfile::TempDir::new()?;
        let root = ChildPath::new(root.path());

        // Create the root.
        root.child("pyproject.toml").write_str(
            r#"
            [project]
            name = "albatross"
            version = "0.1.0"
            requires-python = ">=3.12"
            dependencies = ["tqdm>=4,<5"]

            [tool.uv.workspace]
            members = ["packages/*"]
            "#,
        )?;

        // Create an included package (`seeds`).
        root.child("packages")
            .child("seeds")
            .child("pyproject.toml")
            .write_str(
                r#"
            [project]
            name = "seeds"
            version = "1.0.0"
            requires-python = ">=3.12"
            dependencies = ["idna==3.6"]

            [tool.uv.workspace]
            members = ["nested_packages/*"]
            "#,
            )?;

        // Create an included package (`seeds2`).
        root.child("packages")
            .child("seeds2")
            .child("pyproject.toml")
            .write_str(
                r#"
            [project]
            name = "seeds"
            version = "1.0.0"
            requires-python = ">=3.12"
            dependencies = ["idna==3.6"]

            [tool.uv.workspace]
            members = ["nested_packages/*"]
            "#,
            )?;

        let (error, root_escaped) = temporary_test(root.as_ref()).await.unwrap_err();
        let filters = vec![(root_escaped.as_str(), "[ROOT]")];
        insta::with_settings!({filters => filters}, {
            assert_snapshot!(
                error,
            @"Two workspace members are both named `seeds`: `[ROOT]/packages/seeds` and `[ROOT]/packages/seeds2`");
        });

        Ok(())
    }
}
