Skip to main content

uv_resolver/lock/export/
metadata.rs

1use std::collections::{BTreeMap, VecDeque};
2use std::fmt::Display;
3use std::path::Path;
4
5use uv_distribution_filename::WheelFilename;
6use uv_distribution_types::{Name, Requirement, RequiresPython, ResolvedDist, UrlString};
7use uv_fs::PortablePathBuf;
8use uv_normalize::{ExtraName, GroupName, PackageName};
9use uv_pep440::Version;
10use uv_pep508::MarkerTree;
11use uv_pypi_types::{ConflictItem, ConflictKind, ConflictSet, Conflicts, ModuleName};
12use uv_workspace::Workspace;
13
14use crate::lock::{
15    Dependency, DirectSource, Package, PackageId, RegistrySource, Source, SourceDist,
16    SourceDistMetadata, Wheel, WheelWireSource, ZstdWheel,
17};
18use crate::{Lock, LockError};
19
20#[derive(Debug, thiserror::Error)]
21enum MetadataErrorKind {
22    #[error(transparent)]
23    Serialize(#[from] serde_json::error::Error),
24    #[error(transparent)]
25    Lock(#[from] LockError),
26}
27
28#[derive(Debug)]
29pub struct MetadataError {
30    kind: Box<MetadataErrorKind>,
31}
32
33impl std::error::Error for MetadataError {
34    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
35        self.kind.source()
36    }
37}
38
39impl std::fmt::Display for MetadataError {
40    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
41        write!(f, "{}", self.kind)?;
42        Ok(())
43    }
44}
45
46impl<E> From<E> for MetadataError
47where
48    MetadataErrorKind: From<E>,
49{
50    fn from(err: E) -> Self {
51        Self {
52            kind: Box::new(MetadataErrorKind::from(err)),
53        }
54    }
55}
56
57/// The full `uv workspace metadata` JSON object
58#[derive(Debug, serde::Serialize)]
59pub struct Metadata {
60    /// Format information
61    schema: SchemaReport,
62    /// Absolute path to the workspace root
63    ///
64    /// Ideally absolute paths to things that are found in subdirs of this should have exactly
65    /// this as a prefix so it can be stripped to get relative paths if one wants.
66    workspace_root: PortablePathBuf,
67    /// Information about the synchronized environment, when `--sync` was used.
68    #[serde(skip_serializing_if = "Option::is_none", default)]
69    environment: Option<MetadataEnvironment>,
70    /// Information about the script root, when metadata was requested for a script.
71    #[serde(skip_serializing_if = "Option::is_none", default)]
72    script: Option<MetadataScript>,
73    /// Information about the workspace root, when metadata was requested for a workspace.
74    #[serde(skip_serializing_if = "Option::is_none", default)]
75    workspace: Option<MetadataWorkspace>,
76    /// The version of python required by the workspace
77    ///
78    /// Every `marker` we emit implicitly assumes this constraint to keep things clean
79    requires_python: RequiresPython,
80    /// Info about conflicting packages
81    conflicts: MetadataConflicts,
82    /// A mapping from importable module names to the package nodes that provide them
83    #[serde(skip_serializing_if = "BTreeMap::is_empty", default)]
84    module_owners: BTreeMap<ModuleName, Vec<MetadataModuleOwner>>,
85    /// An index of which nodes are workspace members
86    ///
87    /// These entries are often what you should use as the entry-points into the `resolve` graph.
88    #[serde(skip_serializing_if = "Vec::is_empty", default)]
89    members: Vec<MetadataWorkspaceMember>,
90    /// The dependency graph
91    #[serde(skip_serializing_if = "BTreeMap::is_empty", default)]
92    resolution: BTreeMap<MetadataNodeIdFlat, MetadataNode>,
93}
94
95/// The schema version for the metadata report.
96#[derive(serde::Serialize, Debug, Default)]
97#[serde(rename_all = "snake_case")]
98enum SchemaVersion {
99    /// An unstable, experimental schema.
100    #[default]
101    Preview,
102}
103
104/// The schema metadata for the metadata report.
105#[derive(serde::Serialize, Debug, Default)]
106struct SchemaReport {
107    /// The version of the schema.
108    version: SchemaVersion,
109}
110
111/// Information about the environment synchronized for the workspace.
112#[derive(Debug, serde::Serialize)]
113struct MetadataEnvironment {
114    /// Absolute path to the environment root.
115    root: PortablePathBuf,
116}
117
118/// The script entry-point.
119#[derive(Debug, serde::Serialize)]
120pub(crate) struct MetadataScript {
121    /// Absolute path to the script.
122    path: PortablePathBuf,
123    /// Key for the script's node in the `resolution` graph.
124    id: MetadataNodeIdFlat,
125}
126
127impl MetadataScript {
128    pub(crate) fn new(path: PortablePathBuf, id: MetadataNodeIdFlat) -> Self {
129        Self { path, id }
130    }
131}
132
133/// The workspace entry-point.
134#[derive(Debug, serde::Serialize)]
135pub(crate) struct MetadataWorkspace {
136    /// Absolute path to the workspace root.
137    path: PortablePathBuf,
138    /// Key for the workspace's node in the `resolution` graph.
139    id: MetadataNodeIdFlat,
140}
141
142impl MetadataWorkspace {
143    pub(crate) fn new(path: PortablePathBuf, id: MetadataNodeIdFlat) -> Self {
144        Self { path, id }
145    }
146}
147
148/// Info for looking up workspace members, most information is stored in the node behind `id`
149#[derive(Debug, serde::Serialize)]
150pub(crate) struct MetadataWorkspaceMember {
151    /// Package name
152    name: PackageName,
153    /// Absolute path to the member
154    path: PortablePathBuf,
155    /// Key for the package's node in the `resolve` graph
156    id: MetadataNodeIdFlat,
157}
158
159impl MetadataWorkspaceMember {
160    /// Construct a workspace member from the local source recorded in the lockfile.
161    pub(crate) fn from_locked_package(
162        workspace_root: &PortablePathBuf,
163        package_id: &PackageId,
164    ) -> Option<Self> {
165        let path = match &package_id.source {
166            Source::Directory(path) | Source::Editable(path) | Source::Virtual(path) => path,
167            Source::Registry(_) | Source::Git(..) | Source::Direct(..) | Source::Path(_) => {
168                return None;
169            }
170        };
171        Some(Self {
172            name: package_id.name.clone(),
173            path: normalize_workspace_relative_path(workspace_root, path),
174            id: MetadataNodeId::from_package_id(
175                workspace_root,
176                package_id,
177                MetadataNodeKind::Package,
178            )
179            .to_flat(),
180        })
181    }
182}
183
184/// An installed distribution that provides an importable module.
185#[derive(Debug, serde::Serialize)]
186struct MetadataModuleOwner {
187    /// Key for the package node in the `resolution` graph.
188    package_id: MetadataNodeIdFlat,
189}
190
191/// A node in the dependency graph.
192///
193/// There are 6 kinds of nodes:
194///
195/// * workspaces: `workspace+/workspace`
196/// * scripts:    `script+/workspace/script.py`
197/// * packages:   `mypackage==1.0.0@registry+https://pypi.org/simple`
198/// * extras:     `mypackage[myextra]==1.0.0@registry+https://pypi.org/simple`
199/// * groups:     `mypackage:mygroup==1.0.0@registry+https://pypi.org/simple`
200/// * build:      `mypackage(build)==1.0.0@registry+https://pypi.org/simple`
201///
202/// Workspace and script nodes are special cases that only ever exist in the root of the graph.
203///
204/// A workspace node is only ever used to hang `dependency-groups` off of, to represent the fact
205/// that workspaces can define groups that aren't otherwise associated with any package
206/// (in this way there's technically two kinds of group nodes, since this changes their id format).
207///
208/// A script node is basically just a package, but, it's a script so it's identified by path
209/// instead of name/version.
210///
211/// Build nodes are stubbed out, but never actually used yet, so they're under-defined.
212///
213///
214/// # What's an Edge?
215///
216/// Strictly speaking the only edges of the graph are the `dependencies` field of each node.
217/// These are the things that must be installed for the node's requirements to be satisfied
218/// (possibly qualified by a marker).
219///
220/// `optional-dependencies`, `dependency-groups` and `build-system` define things that *look*
221/// like edges but aren't really -- there is no dependency from `mypackage` to
222/// `mypackage[extra]` or `mypackage:group`. There *is* a dependency from `mypackage[extra]`
223/// to `mypackage`, and that is expressed by including `mypackage` in the `dependencies` of
224/// `mypackage[extra]`.
225///
226/// The `optional_dependencies` entry on a `mypackage` node is essentially just a listing
227/// that the `mypackage[extra]` node *exists*. In this way if `mypackage` is a workspace
228/// member then `mypackage`, `mypackage[extra]`, and `mypackage:group` are all equally "roots"
229/// of the dependency graph (and arguably `workspace` isn't a root at all even though
230/// `workspace:group` is).
231///
232///
233/// # Simple Example
234///
235/// A package like this:
236///
237/// ```toml
238/// [project]
239/// name = "mypackage"
240/// version = 1.0.0
241///
242/// dependencies = ["httpx"]
243///
244/// [project.optional-dependencies]
245/// cli = ["rich"]
246///
247/// [dependency-groups]
248/// dev = ["typing-extensions"]
249///
250/// [build-system]
251/// requires = ["hatchling"]
252/// ```
253///
254/// will get 4 nodes with the following edges (Version and Source omitted here for brevity):
255///
256/// * `mypackage`
257///   * `httpx`
258/// * `mypackage(build)`
259///   * `hatchling`
260/// * `mypackage[cli]`
261///   * `mypackage`
262///   * `rich`
263/// * `mypackage:dev`
264///   * `typing-extensions`
265///
266/// Note that `mypackage[cli]` has a dependency edge on `mypackage` while `mypackage:dev` does not.
267/// This is because `mypackage[cli]` is fundamentally an augmentation of `mypackage` while `mypackage:dev`
268/// is just a list of packages that happens to be defined by `mypackage`'s pyproject.toml.
269///
270/// ---------
271///
272/// Workspace nodes and script nodes
273#[derive(Debug, Clone, serde::Serialize)]
274pub(crate) struct MetadataNode {
275    /// A unique id for this node that will be used to refer to it
276    #[serde(flatten)]
277    id: MetadataNodeId,
278    /// Dependencies of this node (the edges of The Graph)
279    dependencies: Vec<MetadataDependency>,
280    /// Extras
281    #[serde(skip_serializing_if = "Vec::is_empty", default)]
282    optional_dependencies: Vec<MetadataExtra>,
283    /// Groups
284    #[serde(skip_serializing_if = "Vec::is_empty", default)]
285    dependency_groups: Vec<MetadataGroup>,
286    /// The latest known version of this package, when requested by the caller.
287    #[serde(skip_serializing_if = "Option::is_none", default)]
288    latest_version: Option<Version>,
289    /// Info about building the package
290    #[serde(skip_serializing_if = "Option::is_none", default)]
291    build_system: Option<MetadataBuildSystem>,
292    /// The source distribution found
293    #[serde(skip_serializing_if = "Option::is_none", default)]
294    sdist: Option<MetadataSourceDist>,
295    /// Wheels we found
296    #[serde(skip_serializing_if = "Vec::is_empty", default)]
297    wheels: Vec<MetadataWheel>,
298}
299
300impl MetadataNode {
301    pub(crate) fn new(id: MetadataNodeId) -> Self {
302        Self {
303            id,
304            dependencies: Vec::new(),
305            dependency_groups: Vec::new(),
306            optional_dependencies: Vec::new(),
307            latest_version: None,
308            wheels: Vec::new(),
309            build_system: None,
310            sdist: None,
311        }
312    }
313
314    pub(crate) fn from_package_id(
315        workspace_root: &PortablePathBuf,
316        id: &PackageId,
317        kind: MetadataNodeKind,
318    ) -> Self {
319        Self::new(MetadataNodeId::from_package_id(workspace_root, id, kind))
320    }
321
322    fn from_script(path: PortablePathBuf, dependencies: Vec<MetadataDependency>) -> Self {
323        let mut node = Self::new(MetadataNodeId::from_script(path));
324        node.dependencies = dependencies;
325        node
326    }
327
328    fn from_workspace(path: PortablePathBuf, dependency_groups: Vec<MetadataGroup>) -> Self {
329        let mut node = Self::new(MetadataNodeId::from_workspace(path));
330        node.dependency_groups = dependency_groups;
331        node
332    }
333
334    fn from_workspace_group(
335        path: PortablePathBuf,
336        group: GroupName,
337        dependencies: Vec<MetadataDependency>,
338    ) -> Self {
339        let mut node = Self::new(MetadataNodeId::from_workspace_group(path, group));
340        node.dependencies = dependencies;
341        node
342    }
343
344    fn add_dependency(
345        &mut self,
346        workspace_root: &PortablePathBuf,
347        dependency: &Dependency,
348        parent_reachability: MarkerTree,
349    ) {
350        let mut marker = dependency.simplified_marker.as_simplified_marker_tree();
351        marker.and(parent_reachability);
352        let marker = marker.try_to_string();
353        let extras = dependency.extra();
354        if extras.is_empty() {
355            let id = MetadataNodeId::from_package_id(
356                workspace_root,
357                &dependency.package_id,
358                MetadataNodeKind::Package,
359            );
360            self.dependencies.push(MetadataDependency {
361                id: id.to_flat(),
362                marker,
363            });
364            return;
365        }
366        for extra in extras {
367            let id = MetadataNodeId::from_package_id(
368                workspace_root,
369                &dependency.package_id,
370                MetadataNodeKind::Extra(extra.clone()),
371            );
372            self.dependencies.push(MetadataDependency {
373                id: id.to_flat(),
374                marker: marker.clone(),
375            });
376        }
377    }
378
379    pub(crate) fn add_resolution_dependency(
380        &mut self,
381        id: MetadataNodeIdFlat,
382        marker: Option<MetadataMarker>,
383    ) {
384        self.dependencies.push(MetadataDependency { id, marker });
385    }
386
387    pub(crate) fn add_optional_dependency(&mut self, name: ExtraName, id: MetadataNodeIdFlat) {
388        self.optional_dependencies.push(MetadataExtra { name, id });
389    }
390
391    pub(crate) fn add_dependency_group(&mut self, name: GroupName, id: MetadataNodeIdFlat) {
392        self.dependency_groups.push(MetadataGroup { name, id });
393    }
394
395    pub(crate) fn set_latest_version(&mut self, version: Option<Version>) {
396        self.latest_version = version;
397    }
398
399    pub(crate) fn set_wheels_from_package(
400        &mut self,
401        workspace_root: &PortablePathBuf,
402        package: &Package,
403    ) {
404        self.wheels = package
405            .wheels
406            .iter()
407            .map(|wheel| MetadataWheel::from_wheel(workspace_root, wheel))
408            .collect();
409    }
410
411    pub(crate) fn normalize_resolution(&mut self) {
412        self.dependencies.sort();
413        self.dependencies.dedup();
414        self.optional_dependencies.sort();
415        self.optional_dependencies.dedup();
416        self.dependency_groups.sort();
417        self.dependency_groups.dedup();
418    }
419}
420
421#[derive(Debug, Clone, serde::Serialize)]
422#[serde(rename_all = "snake_case")]
423enum MetadataWorkspaceNodeKind {
424    Workspace,
425}
426
427#[derive(Debug, Clone, serde::Serialize)]
428#[serde(rename_all = "snake_case")]
429enum MetadataWorkspaceGroupNodeKind {
430    Group(GroupName),
431}
432#[derive(Debug, Clone, serde::Serialize)]
433#[serde(rename_all = "snake_case")]
434enum MetadataScriptNodeKind {
435    Script,
436}
437
438fn root_dependencies<'lock>(
439    workspace_root: &PortablePathBuf,
440    lock: &'lock Lock,
441    requirements: impl IntoIterator<Item = &'lock Requirement>,
442) -> Vec<MetadataDependency> {
443    let mut dependencies = Vec::new();
444
445    // Root requirements retain names, extras, and markers rather than resolved package IDs. Match
446    // them to the locked packages using the same name and fork-marker logic as lock export.
447    for requirement in requirements {
448        for package in lock
449            .packages()
450            .iter()
451            .filter(|package| package.name() == &requirement.name)
452        {
453            let Some(marker) = lock.root_requirement_marker(requirement, package) else {
454                continue;
455            };
456
457            let marker = marker.try_to_string();
458            let mut has_extra_node = false;
459            for extra in requirement
460                .extras
461                .iter()
462                .filter(|extra| package.optional_dependencies.contains_key(*extra))
463            {
464                let id = MetadataNodeId::from_package_id(
465                    workspace_root,
466                    &package.id,
467                    MetadataNodeKind::Extra(extra.clone()),
468                );
469                dependencies.push(MetadataDependency {
470                    id: id.to_flat(),
471                    marker: marker.clone(),
472                });
473                has_extra_node = true;
474            }
475
476            if !has_extra_node {
477                let id = MetadataNodeId::from_package_id(
478                    workspace_root,
479                    &package.id,
480                    MetadataNodeKind::Package,
481                );
482                dependencies.push(MetadataDependency {
483                    id: id.to_flat(),
484                    marker,
485                });
486            }
487        }
488    }
489
490    dependencies
491}
492
493/// Determine the standalone reachability marker for every package-derived metadata node.
494///
495/// Dependency markers in the lock are simplified under the conditions required to reach their
496/// parent. Metadata consumers evaluate each edge marker independently, so restore those conditions
497/// by propagating markers from the metadata graph's entry points.
498fn metadata_reachability(
499    workspace_root: &PortablePathBuf,
500    workspace: Option<&Workspace>,
501    lock: &Lock,
502) -> BTreeMap<MetadataNodeIdFlat, MarkerTree> {
503    let mut reachability = BTreeMap::new();
504    let mut queue = VecDeque::new();
505    let always = MarkerTree::TRUE;
506
507    if let Some(workspace) = workspace {
508        for package in lock
509            .packages()
510            .iter()
511            .filter(|package| workspace.packages().contains_key(package.name()))
512        {
513            add_metadata_reachability(
514                workspace_root,
515                &mut reachability,
516                &mut queue,
517                package,
518                MetadataNodeKind::Package,
519                always,
520            );
521            for extra in package.optional_dependencies.keys() {
522                add_metadata_reachability(
523                    workspace_root,
524                    &mut reachability,
525                    &mut queue,
526                    package,
527                    MetadataNodeKind::Extra(extra.clone()),
528                    always,
529                );
530            }
531            for group in package.dependency_groups.keys() {
532                add_metadata_reachability(
533                    workspace_root,
534                    &mut reachability,
535                    &mut queue,
536                    package,
537                    MetadataNodeKind::Group(group.clone()),
538                    always,
539                );
540            }
541        }
542    }
543
544    for requirement in lock
545        .requirements()
546        .iter()
547        .chain(lock.dependency_groups().values().flatten())
548    {
549        for package in lock
550            .packages()
551            .iter()
552            .filter(|package| package.name() == &requirement.name)
553        {
554            let Some(marker) = lock.root_requirement_marker(requirement, package) else {
555                continue;
556            };
557            let mut has_extra_node = false;
558            for extra in requirement
559                .extras
560                .iter()
561                .filter(|extra| package.optional_dependencies.contains_key(*extra))
562            {
563                add_metadata_reachability(
564                    workspace_root,
565                    &mut reachability,
566                    &mut queue,
567                    package,
568                    MetadataNodeKind::Extra(extra.clone()),
569                    marker,
570                );
571                has_extra_node = true;
572            }
573            if !has_extra_node {
574                add_metadata_reachability(
575                    workspace_root,
576                    &mut reachability,
577                    &mut queue,
578                    package,
579                    MetadataNodeKind::Package,
580                    marker,
581                );
582            }
583        }
584    }
585
586    while let Some((package, kind)) = queue.pop_front() {
587        let id =
588            MetadataNodeId::from_package_id(workspace_root, &package.id, kind.clone()).to_flat();
589        let Some(parent_reachability) = reachability.get(&id).copied() else {
590            continue;
591        };
592
593        if matches!(kind, MetadataNodeKind::Extra(_)) {
594            add_metadata_reachability(
595                workspace_root,
596                &mut reachability,
597                &mut queue,
598                package,
599                MetadataNodeKind::Package,
600                parent_reachability,
601            );
602        }
603
604        let dependencies: &[Dependency] = match &kind {
605            MetadataNodeKind::Package => package.dependencies.as_slice(),
606            MetadataNodeKind::Extra(extra) => package
607                .optional_dependencies
608                .get(extra)
609                .map_or(&[], Vec::as_slice),
610            MetadataNodeKind::Group(group) => package
611                .dependency_groups
612                .get(group)
613                .map_or(&[], Vec::as_slice),
614            MetadataNodeKind::Build => &[],
615        };
616        for dependency in dependencies {
617            let mut dependency_reachability =
618                dependency.simplified_marker.as_simplified_marker_tree();
619            dependency_reachability.and(parent_reachability);
620            let dependency_package = lock.find_by_id(&dependency.package_id);
621            if dependency.extra.is_empty() {
622                add_metadata_reachability(
623                    workspace_root,
624                    &mut reachability,
625                    &mut queue,
626                    dependency_package,
627                    MetadataNodeKind::Package,
628                    dependency_reachability,
629                );
630            } else {
631                for extra in &dependency.extra {
632                    add_metadata_reachability(
633                        workspace_root,
634                        &mut reachability,
635                        &mut queue,
636                        dependency_package,
637                        MetadataNodeKind::Extra(extra.clone()),
638                        dependency_reachability,
639                    );
640                }
641            }
642        }
643    }
644
645    reachability
646}
647
648fn add_metadata_reachability<'lock>(
649    workspace_root: &PortablePathBuf,
650    reachability: &mut BTreeMap<MetadataNodeIdFlat, MarkerTree>,
651    queue: &mut VecDeque<(&'lock Package, MetadataNodeKind)>,
652    package: &'lock Package,
653    kind: MetadataNodeKind,
654    marker: MarkerTree,
655) {
656    let id = MetadataNodeId::from_package_id(workspace_root, &package.id, kind.clone()).to_flat();
657    let changed = if let Some(existing) = reachability.get_mut(&id) {
658        let previous = *existing;
659        existing.or(marker);
660        *existing != previous
661    } else {
662        reachability.insert(id, marker);
663        true
664    };
665    if changed {
666        queue.push_back((package, kind));
667    }
668}
669
670/// The unique key for every node in the graph.
671#[derive(Debug, Clone, serde::Serialize)]
672#[serde(untagged)]
673pub(crate) enum MetadataNodeId {
674    Package(MetadataPackageNodeId),
675    Script(MetadataScriptNodeId),
676    Workspace(MetadataWorkspaceNodeId),
677    WorkspaceGroup(MetadataWorkspaceGroupNodeId),
678}
679
680/// The unique key for a package-derived node.
681///
682/// (It's not entirely clear to me that two nodes can differ only by `source` but it doesn't hurt.)
683#[derive(Debug, Clone, serde::Serialize)]
684pub(crate) struct MetadataPackageNodeId {
685    /// The name of the package
686    name: PackageName,
687    /// The version of the package, if any could be found (source trees may have no version)
688    #[serde(skip_serializing_if = "Option::is_none", default)]
689    version: Option<Version>,
690    /// The source of the package (directory, registry, URL...)
691    source: MetadataSource,
692    /// What kind of node is this?
693    kind: MetadataNodeKind,
694}
695
696/// The unique key for a script node.
697#[derive(Debug, Clone, serde::Serialize)]
698pub(crate) struct MetadataScriptNodeId {
699    kind: MetadataScriptNodeKind,
700    /// Absolute path to the script.
701    path: PortablePathBuf,
702}
703
704/// The unique key for a workspace node.
705#[derive(Debug, Clone, serde::Serialize)]
706pub(crate) struct MetadataWorkspaceNodeId {
707    kind: MetadataWorkspaceNodeKind,
708    /// Absolute path to the workspace root.
709    path: PortablePathBuf,
710}
711
712/// The unique key for a dependency group defined on the workspace root.
713#[derive(Debug, Clone, serde::Serialize)]
714pub(crate) struct MetadataWorkspaceGroupNodeId {
715    kind: MetadataWorkspaceGroupNodeKind,
716    /// Absolute path to the workspace root.
717    path: PortablePathBuf,
718}
719
720/// This is intended to be an opaque unique id for referring to a node
721///
722/// It's human readable for convenience but parsing it or relying on it is inadvisable.
723type MetadataNodeIdFlat = String;
724
725impl MetadataNodeId {
726    pub(crate) fn from_script(path: PortablePathBuf) -> Self {
727        Self::Script(MetadataScriptNodeId {
728            kind: MetadataScriptNodeKind::Script,
729            path,
730        })
731    }
732
733    pub(crate) fn from_workspace(path: PortablePathBuf) -> Self {
734        Self::Workspace(MetadataWorkspaceNodeId {
735            kind: MetadataWorkspaceNodeKind::Workspace,
736            path,
737        })
738    }
739
740    pub(crate) fn from_workspace_group(path: PortablePathBuf, group: GroupName) -> Self {
741        Self::WorkspaceGroup(MetadataWorkspaceGroupNodeId {
742            kind: MetadataWorkspaceGroupNodeKind::Group(group),
743            path,
744        })
745    }
746
747    pub(crate) fn from_package_id(
748        workspace_root: &PortablePathBuf,
749        id: &PackageId,
750        kind: MetadataNodeKind,
751    ) -> Self {
752        let name = id.name.clone();
753        let version = id.version.clone();
754        let source = MetadataSource::from_source(workspace_root, id.source.clone());
755
756        Self::Package(MetadataPackageNodeId {
757            name,
758            version,
759            source,
760            kind,
761        })
762    }
763
764    fn as_package(&self) -> Option<&MetadataPackageNodeId> {
765        match self {
766            Self::Package(package) => Some(package),
767            Self::Script(_) | Self::Workspace(_) | Self::WorkspaceGroup(_) => None,
768        }
769    }
770
771    pub(crate) fn to_flat(&self) -> MetadataNodeIdFlat {
772        self.to_string()
773    }
774}
775
776impl Display for MetadataNodeId {
777    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
778        match self {
779            Self::Package(package) => match &package.version {
780                Some(version) => write!(
781                    f,
782                    "{}{}=={version}@{}",
783                    package.name, package.kind, package.source
784                ),
785                None => write!(f, "{}{}@{}", package.name, package.kind, package.source),
786            },
787            Self::Script(script) => write!(f, "script+{}", script.path),
788            Self::Workspace(workspace) => write!(f, "workspace+{}", workspace.path),
789            Self::WorkspaceGroup(group) => {
790                let MetadataWorkspaceGroupNodeKind::Group(name) = &group.kind;
791                write!(f, "workspace+{}:{name}", group.path)
792            }
793        }
794    }
795}
796
797#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, serde::Serialize)]
798struct MetadataDependency {
799    id: MetadataNodeIdFlat,
800    #[serde(skip_serializing_if = "Option::is_none", default)]
801    marker: Option<MetadataMarker>,
802}
803
804type MetadataMarker = String;
805
806/// The kind a node can have in the dependency graph
807#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, serde::Serialize)]
808#[serde(rename_all = "snake_case")]
809pub(crate) enum MetadataNodeKind {
810    /// The node is the package itself
811    /// its edges are `project.dependencies`
812    Package,
813    /// The node is for building the package's sdist into a wheel
814    /// its edges are `build-system.requires`
815    #[expect(dead_code)]
816    Build,
817    /// The node is for an extra defined on the package
818    /// its edges are `project.optional-dependencies.myextra`
819    Extra(ExtraName),
820    /// The node is for a dependency-group defined on the package
821    /// its edges are `dependency-groups.mygroup`
822    Group(GroupName),
823}
824
825impl Display for MetadataNodeKind {
826    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
827        match self {
828            // Don't apply any special decoration, this is the default
829            Self::Package => Ok(()),
830            Self::Build => f.write_str("(build)"),
831            Self::Extra(extra_name) => write!(f, "[{extra_name}]"),
832            Self::Group(group_name) => write!(f, ":{group_name}"),
833        }
834    }
835}
836
837#[derive(Clone, Debug, serde::Serialize)]
838#[serde(untagged, rename_all = "snake_case")]
839enum MetadataSource {
840    Registry {
841        registry: MetadataRegistrySource,
842    },
843    Git {
844        git: UrlString,
845    },
846    Direct {
847        url: UrlString,
848        subdirectory: Option<PortablePathBuf>,
849    },
850    Path {
851        path: PortablePathBuf,
852    },
853    Directory {
854        directory: PortablePathBuf,
855    },
856    Editable {
857        editable: PortablePathBuf,
858    },
859    Virtual {
860        r#virtual: PortablePathBuf,
861    },
862}
863
864impl Display for MetadataSource {
865    fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
866        match self {
867            Self::Registry {
868                registry: MetadataRegistrySource::Url(url),
869            }
870            | Self::Git { git: url }
871            | Self::Direct { url, .. } => {
872                write!(f, "{}+{}", self.name(), url)
873            }
874            Self::Registry {
875                registry: MetadataRegistrySource::Path(path),
876            }
877            | Self::Path { path }
878            | Self::Directory { directory: path }
879            | Self::Editable { editable: path }
880            | Self::Virtual { r#virtual: path } => {
881                write!(f, "{}+{}", self.name(), path)
882            }
883        }
884    }
885}
886
887impl MetadataSource {
888    fn name(&self) -> &str {
889        match self {
890            Self::Registry { .. } => "registry",
891            Self::Git { .. } => "git",
892            Self::Direct { .. } => "direct",
893            Self::Path { .. } => "path",
894            Self::Directory { .. } => "directory",
895            Self::Editable { .. } => "editable",
896            Self::Virtual { .. } => "virtual",
897        }
898    }
899}
900
901impl MetadataSource {
902    fn from_source(workspace_root: &PortablePathBuf, source: Source) -> Self {
903        match source {
904            Source::Registry(source) => match source {
905                RegistrySource::Url(url) => Self::Registry {
906                    registry: MetadataRegistrySource::Url(url),
907                },
908                RegistrySource::Path(path) => Self::Registry {
909                    registry: MetadataRegistrySource::Path(normalize_workspace_relative_path(
910                        workspace_root,
911                        &path,
912                    )),
913                },
914            },
915            Source::Git(url, _) => Self::Git { git: url },
916            Source::Direct(url, DirectSource { subdirectory }) => Self::Direct {
917                url,
918                subdirectory: subdirectory
919                    .map(|path| normalize_workspace_relative_path(workspace_root, &path)),
920            },
921            Source::Path(path) => Self::Path {
922                path: normalize_workspace_relative_path(workspace_root, &path),
923            },
924            Source::Directory(path) => Self::Directory {
925                directory: normalize_workspace_relative_path(workspace_root, &path),
926            },
927            Source::Editable(path) => Self::Editable {
928                editable: normalize_workspace_relative_path(workspace_root, &path),
929            },
930            Source::Virtual(path) => Self::Virtual {
931                r#virtual: normalize_workspace_relative_path(workspace_root, &path),
932            },
933        }
934    }
935}
936
937fn normalize_workspace_relative_path(
938    workspace_root: &PortablePathBuf,
939    maybe_rel: &std::path::Path,
940) -> PortablePathBuf {
941    if maybe_rel.is_absolute() {
942        PortablePathBuf::from(maybe_rel)
943    } else {
944        PortablePathBuf::from(workspace_root.as_ref().join(maybe_rel).as_path())
945    }
946}
947
948#[derive(Clone, Debug, serde::Serialize)]
949#[serde(rename_all = "snake_case")]
950enum MetadataRegistrySource {
951    /// Ex) `https://pypi.org/simple`
952    Url(UrlString),
953    /// Ex) `/path/to/local/index`
954    Path(PortablePathBuf),
955}
956
957#[derive(Clone, Debug, serde::Serialize)]
958#[serde(untagged, rename_all = "snake_case")]
959enum MetadataSourceDist {
960    Url {
961        url: UrlString,
962        #[serde(flatten)]
963        metadata: MetadataSourceDistMetadata,
964    },
965    Path {
966        path: PortablePathBuf,
967        #[serde(flatten)]
968        metadata: MetadataSourceDistMetadata,
969    },
970    Metadata {
971        #[serde(flatten)]
972        metadata: MetadataSourceDistMetadata,
973    },
974}
975
976impl MetadataSourceDist {
977    fn from_sdist(workspace_root: &PortablePathBuf, sdist: &SourceDist) -> Self {
978        match sdist {
979            SourceDist::Url { url, metadata } => Self::Url {
980                url: url.clone(),
981                metadata: MetadataSourceDistMetadata::from_sdist(metadata),
982            },
983            SourceDist::Path { path, metadata } => Self::Path {
984                path: normalize_workspace_relative_path(workspace_root, path),
985                metadata: MetadataSourceDistMetadata::from_sdist(metadata),
986            },
987            SourceDist::Metadata { metadata } => Self::Metadata {
988                metadata: MetadataSourceDistMetadata::from_sdist(metadata),
989            },
990        }
991    }
992}
993
994#[derive(Clone, Debug, serde::Serialize)]
995#[serde(rename_all = "snake_case")]
996struct MetadataSourceDistMetadata {
997    /// A hash of the source distribution.
998    #[serde(skip_serializing_if = "BTreeMap::is_empty", default)]
999    hashes: BTreeMap<HashAlgorithm, Hash>,
1000    /// The size of the source distribution in bytes.
1001    ///
1002    /// This is only present for source distributions that come from registries.
1003    #[serde(skip_serializing_if = "Option::is_none", default)]
1004    size: Option<u64>,
1005    /// The upload time of the source distribution.
1006    #[serde(skip_serializing_if = "Option::is_none", default)]
1007    upload_time: Option<jiff::Timestamp>,
1008}
1009
1010/// The name of a hash algorithm ("sha256", "blake2b", "md5", etc)
1011type HashAlgorithm = String;
1012/// A hex encoded digest of the file
1013type Hash = String;
1014
1015/// Oh you wanted a hash map? No this is the hashes map, a sorted map of hashes!
1016///
1017/// We prefer matching PEP 691 (JSON-based Simple API for Python) here for future-proofing
1018/// and convenience of consumption.
1019fn hashes_map(hash: &crate::lock::Hash) -> BTreeMap<HashAlgorithm, Hash> {
1020    Some((hash.0.algorithm.to_string(), hash.0.digest.to_string()))
1021        .into_iter()
1022        .collect()
1023}
1024
1025impl MetadataSourceDistMetadata {
1026    fn from_sdist(sdist: &SourceDistMetadata) -> Self {
1027        Self {
1028            hashes: sdist.hash.as_ref().map(hashes_map).unwrap_or_default(),
1029            size: sdist.size,
1030            upload_time: sdist.upload_time,
1031        }
1032    }
1033}
1034#[derive(Clone, Debug, serde::Serialize)]
1035struct MetadataWheel {
1036    /// A URL or file path (via `file://`) where the wheel that was locked
1037    /// against was found. The location does not need to exist in the future,
1038    /// so this should be treated as only a hint to where to look and/or
1039    /// recording where the wheel file originally came from.
1040    #[serde(flatten)]
1041    source: Option<MetadataWheelWireSource>,
1042    /// A hash of the built distribution.
1043    ///
1044    /// This is only present for wheels that come from registries and direct
1045    /// URLs. Wheels from git or path dependencies do not have hashes
1046    /// associated with them.
1047    #[serde(skip_serializing_if = "BTreeMap::is_empty", default)]
1048    hashes: BTreeMap<HashAlgorithm, Hash>,
1049    /// The size of the built distribution in bytes.
1050    ///
1051    /// This is only present for wheels that come from registries.
1052    #[serde(skip_serializing_if = "Option::is_none", default)]
1053    size: Option<u64>,
1054    /// The upload time of the built distribution.
1055    ///
1056    /// This is only present for wheels that come from registries.
1057    #[serde(skip_serializing_if = "Option::is_none", default)]
1058    upload_time: Option<jiff::Timestamp>,
1059    /// The filename of the wheel.
1060    ///
1061    /// This isn't part of the wire format since it's redundant with the
1062    /// URL. But we do use it for various things, and thus compute it at
1063    /// deserialization time. Not being able to extract a wheel filename from a
1064    /// wheel URL is thus a deserialization error.
1065    filename: WheelFilename,
1066    /// The zstandard-compressed wheel metadata, if any.
1067    #[serde(skip_serializing_if = "Option::is_none", default)]
1068    zstd: Option<MetadataZstdWheel>,
1069}
1070
1071impl MetadataWheel {
1072    fn from_wheel(workspace_root: &PortablePathBuf, wheel: &Wheel) -> Self {
1073        Self {
1074            source: MetadataWheelWireSource::from_wheel(workspace_root, &wheel.url),
1075            hashes: wheel.hash.as_ref().map(hashes_map).unwrap_or_default(),
1076            size: wheel.size,
1077            upload_time: wheel.upload_time,
1078            filename: wheel.filename.clone(),
1079            zstd: wheel.zstd.as_ref().map(MetadataZstdWheel::from_wheel),
1080        }
1081    }
1082}
1083
1084#[derive(Clone, Debug, serde::Serialize)]
1085#[serde(untagged, rename_all = "snake_case")]
1086enum MetadataWheelWireSource {
1087    Url { url: UrlString },
1088    Path { path: PortablePathBuf },
1089}
1090
1091impl MetadataWheelWireSource {
1092    fn from_wheel(workspace_root: &PortablePathBuf, wheel: &WheelWireSource) -> Option<Self> {
1093        match wheel {
1094            WheelWireSource::Url { url } => Some(Self::Url { url: url.clone() }),
1095            WheelWireSource::Path { path } => Some(Self::Path {
1096                path: normalize_workspace_relative_path(workspace_root, path),
1097            }),
1098            // We guarantee this as a separate field so it's redundant
1099            WheelWireSource::Filename { .. } => None,
1100        }
1101    }
1102}
1103
1104#[derive(Clone, Debug, serde::Serialize)]
1105struct MetadataZstdWheel {
1106    #[serde(skip_serializing_if = "BTreeMap::is_empty", default)]
1107    hashes: BTreeMap<HashAlgorithm, Hash>,
1108    #[serde(skip_serializing_if = "Option::is_none", default)]
1109    size: Option<u64>,
1110}
1111
1112impl MetadataZstdWheel {
1113    fn from_wheel(wheel: &ZstdWheel) -> Self {
1114        Self {
1115            hashes: wheel.hash.as_ref().map(hashes_map).unwrap_or_default(),
1116            size: wheel.size,
1117        }
1118    }
1119}
1120
1121#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, serde::Serialize)]
1122struct MetadataExtra {
1123    name: ExtraName,
1124    id: MetadataNodeIdFlat,
1125}
1126
1127#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, serde::Serialize)]
1128struct MetadataGroup {
1129    name: GroupName,
1130    id: MetadataNodeIdFlat,
1131}
1132
1133#[derive(Clone, Debug, serde::Serialize)]
1134struct MetadataBuildSystem {
1135    /// The `build-backend` specified in the pyproject.toml
1136    build_backend: String,
1137    id: MetadataNodeIdFlat,
1138}
1139
1140/// Conflicts
1141#[derive(Clone, Debug, serde::Serialize)]
1142struct MetadataConflicts {
1143    sets: Vec<MetadataConflictSet>,
1144}
1145
1146impl MetadataConflicts {
1147    fn from_conflicts(
1148        members: &[MetadataWorkspaceMember],
1149        resolve: &BTreeMap<MetadataNodeIdFlat, MetadataNode>,
1150        conflicts: &Conflicts,
1151    ) -> Self {
1152        Self {
1153            sets: conflicts
1154                .iter()
1155                .map(|set| MetadataConflictSet::from_conflicts(members, resolve, set))
1156                .collect(),
1157        }
1158    }
1159}
1160
1161#[derive(Clone, Debug, serde::Serialize)]
1162struct MetadataConflictSet {
1163    items: Vec<MetadataConflictItem>,
1164}
1165
1166impl MetadataConflictSet {
1167    fn from_conflicts(
1168        members: &[MetadataWorkspaceMember],
1169        resolve: &BTreeMap<MetadataNodeIdFlat, MetadataNode>,
1170        set: &ConflictSet,
1171    ) -> Self {
1172        Self {
1173            items: set
1174                .iter()
1175                .map(|item| MetadataConflictItem::from_conflicts(members, resolve, item))
1176                .collect(),
1177        }
1178    }
1179}
1180
1181#[derive(Clone, Debug, serde::Serialize)]
1182struct MetadataConflictItem {
1183    /// These should always be names of packages referred to in [`Metadata::members`]
1184    package: PackageName,
1185    kind: MetadataConflictKind,
1186    /// This should never be None (should be a validation error way earlier in uv)
1187    /// ...but I'd rather not error if wrong.
1188    id: Option<MetadataNodeIdFlat>,
1189}
1190
1191impl MetadataConflictItem {
1192    fn from_conflicts(
1193        members: &[MetadataWorkspaceMember],
1194        resolve: &BTreeMap<MetadataNodeIdFlat, MetadataNode>,
1195        item: &ConflictItem,
1196    ) -> Self {
1197        let kind = MetadataConflictKind::from_conflicts(item.kind());
1198        let id = members
1199            .iter()
1200            .find(|member| &member.name == item.package())
1201            .and_then(|member| {
1202                let package_id = resolve.get(&member.id)?.id.as_package()?;
1203                let id = MetadataNodeId::Package(MetadataPackageNodeId {
1204                    kind: kind.to_node_kind(),
1205                    ..package_id.clone()
1206                });
1207                Some(id.to_flat())
1208            });
1209        Self {
1210            package: item.package().clone(),
1211            kind,
1212            id,
1213        }
1214    }
1215}
1216
1217#[derive(Clone, Debug, serde::Serialize)]
1218enum MetadataConflictKind {
1219    Group(GroupName),
1220    Extra(ExtraName),
1221    Project,
1222}
1223
1224impl MetadataConflictKind {
1225    fn from_conflicts(item: &ConflictKind) -> Self {
1226        match item {
1227            ConflictKind::Extra(name) => Self::Extra(name.clone()),
1228            ConflictKind::Group(name) => Self::Group(name.clone()),
1229            ConflictKind::Project => Self::Project,
1230        }
1231    }
1232
1233    fn to_node_kind(&self) -> MetadataNodeKind {
1234        match self {
1235            Self::Group(name) => MetadataNodeKind::Group(name.clone()),
1236            Self::Extra(name) => MetadataNodeKind::Extra(name.clone()),
1237            Self::Project => MetadataNodeKind::Package,
1238        }
1239    }
1240}
1241
1242impl Metadata {
1243    /// Construct [`Metadata`] for a workspace from a uv lockfile.
1244    pub fn from_lock(workspace: &Workspace, lock: &Lock) -> Result<Self, MetadataError> {
1245        Ok(Self::from_lock_target(
1246            workspace.install_path(),
1247            Some(workspace),
1248            None,
1249            lock,
1250        ))
1251    }
1252
1253    /// Construct [`Metadata`] for a script from a uv lockfile.
1254    pub fn from_script(script_path: &Path, lock: &Lock) -> Result<Self, MetadataError> {
1255        let workspace_root = script_path.parent().unwrap_or_else(|| Path::new(""));
1256        Ok(Self::from_lock_target(
1257            workspace_root,
1258            None,
1259            Some(script_path),
1260            lock,
1261        ))
1262    }
1263
1264    fn from_lock_target(
1265        workspace_root: &Path,
1266        workspace: Option<&Workspace>,
1267        script_path: Option<&Path>,
1268        lock: &Lock,
1269    ) -> Self {
1270        let mut resolve = BTreeMap::new();
1271        let mut members = Vec::new();
1272        let workspace_root = PortablePathBuf::from(workspace_root);
1273        let reachability = metadata_reachability(&workspace_root, workspace, lock);
1274
1275        for lock_package in lock.packages() {
1276            let mut meta_package = MetadataNode::from_package_id(
1277                &workspace_root,
1278                &lock_package.id,
1279                MetadataNodeKind::Package,
1280            );
1281            let package_reachability = reachability
1282                .get(&meta_package.id.to_flat())
1283                .copied()
1284                .unwrap_or(MarkerTree::FALSE);
1285
1286            // Direct dependencies go on the package node
1287            for dependency in &lock_package.dependencies {
1288                meta_package.add_dependency(&workspace_root, dependency, package_reachability);
1289            }
1290
1291            // Extras get their own nodes
1292            for (extra, dependencies) in &lock_package.optional_dependencies {
1293                let mut meta_extra = MetadataNode::from_package_id(
1294                    &workspace_root,
1295                    &lock_package.id,
1296                    MetadataNodeKind::Extra(extra.clone()),
1297                );
1298                let extra_reachability = reachability
1299                    .get(&meta_extra.id.to_flat())
1300                    .copied()
1301                    .unwrap_or(MarkerTree::FALSE);
1302                // Extras always depend on the base package
1303                meta_extra.dependencies.push(MetadataDependency {
1304                    id: meta_package.id.to_flat(),
1305                    marker: None,
1306                });
1307                for dependency in dependencies {
1308                    meta_extra.add_dependency(&workspace_root, dependency, extra_reachability);
1309                }
1310
1311                meta_package.optional_dependencies.push(MetadataExtra {
1312                    name: extra.clone(),
1313                    id: meta_extra.id.to_flat(),
1314                });
1315
1316                resolve.insert(meta_extra.id.to_flat(), meta_extra);
1317            }
1318
1319            // Groups get their own nodes
1320            for (group, dependencies) in &lock_package.dependency_groups {
1321                let mut meta_group = MetadataNode::from_package_id(
1322                    &workspace_root,
1323                    &lock_package.id,
1324                    MetadataNodeKind::Group(group.clone()),
1325                );
1326                let group_reachability = reachability
1327                    .get(&meta_group.id.to_flat())
1328                    .copied()
1329                    .unwrap_or(MarkerTree::FALSE);
1330                // Groups *do not* depend on the base package, so don't add that
1331                for dependency in dependencies {
1332                    meta_group.add_dependency(&workspace_root, dependency, group_reachability);
1333                }
1334
1335                meta_package.dependency_groups.push(MetadataGroup {
1336                    name: group.clone(),
1337                    id: meta_group.id.to_flat(),
1338                });
1339
1340                resolve.insert(meta_group.id.to_flat(), meta_group);
1341            }
1342
1343            // Register this package if it appears to be a workspace member
1344            if let Some(workspace_package) =
1345                workspace.and_then(|workspace| workspace.packages().get(lock_package.name()))
1346            {
1347                let member = MetadataWorkspaceMember {
1348                    name: lock_package.name().clone(),
1349                    path: normalize_workspace_relative_path(
1350                        &workspace_root,
1351                        workspace_package.root().as_path(),
1352                    ),
1353                    id: meta_package.id.to_flat(),
1354                };
1355                members.push(member);
1356            }
1357
1358            // Record sdist/wheel information
1359            if let Some(sdist) = &lock_package.sdist {
1360                meta_package.sdist = Some(MetadataSourceDist::from_sdist(&workspace_root, sdist));
1361            }
1362
1363            meta_package.set_wheels_from_package(&workspace_root, lock_package);
1364
1365            resolve.insert(meta_package.id.to_flat(), meta_package);
1366        }
1367
1368        let script = script_path.map(|path| {
1369            let path = PortablePathBuf::from(path);
1370            let node = MetadataNode::from_script(
1371                path.clone(),
1372                root_dependencies(&workspace_root, lock, lock.requirements()),
1373            );
1374            let id = node.id.to_flat();
1375            resolve.insert(id.clone(), node);
1376            MetadataScript::new(path, id)
1377        });
1378
1379        let workspace_metadata = workspace.map(|_| {
1380            let mut dependency_groups = Vec::new();
1381            for (group, requirements) in lock.dependency_groups() {
1382                let node = MetadataNode::from_workspace_group(
1383                    workspace_root.clone(),
1384                    group.clone(),
1385                    root_dependencies(&workspace_root, lock, requirements),
1386                );
1387                let id = node.id.to_flat();
1388                resolve.insert(id.clone(), node);
1389                dependency_groups.push(MetadataGroup {
1390                    name: group.clone(),
1391                    id,
1392                });
1393            }
1394
1395            let node = MetadataNode::from_workspace(workspace_root.clone(), dependency_groups);
1396            let id = node.id.to_flat();
1397            resolve.insert(id.clone(), node);
1398            MetadataWorkspace::new(workspace_root.clone(), id)
1399        });
1400        let conflicts = MetadataConflicts::from_conflicts(&members, &resolve, &lock.conflicts);
1401
1402        Self {
1403            schema: SchemaReport {
1404                version: SchemaVersion::Preview,
1405            },
1406            conflicts,
1407            environment: None,
1408            script,
1409            workspace: workspace_metadata,
1410            module_owners: BTreeMap::new(),
1411            workspace_root,
1412            requires_python: lock.requires_python.clone(),
1413            members,
1414            resolution: resolve,
1415        }
1416    }
1417
1418    pub fn package_node_id(
1419        workspace_root: &PortablePathBuf,
1420        dist: &ResolvedDist,
1421    ) -> Result<String, MetadataError> {
1422        let source = Source::from_resolved_dist(dist, workspace_root.as_ref())?;
1423        Ok(MetadataNodeId::Package(MetadataPackageNodeId {
1424            name: dist.name().clone(),
1425            version: dist.version().cloned(),
1426            source: MetadataSource::from_source(workspace_root, source),
1427            kind: MetadataNodeKind::Package,
1428        })
1429        .to_flat())
1430    }
1431
1432    #[must_use]
1433    pub fn with_environment_root(mut self, environment_root: &Path) -> Self {
1434        self.environment = Some(MetadataEnvironment {
1435            root: PortablePathBuf::from(environment_root),
1436        });
1437        self
1438    }
1439
1440    #[must_use]
1441    pub fn with_module_owners(mut self, module_owners: BTreeMap<ModuleName, Vec<String>>) -> Self {
1442        self.module_owners = module_owners
1443            .into_iter()
1444            .filter_map(|(module, owners)| {
1445                let owners = owners
1446                    .into_iter()
1447                    .filter(|package_id| self.resolution.contains_key(package_id))
1448                    .map(|package_id| MetadataModuleOwner { package_id })
1449                    .collect::<Vec<_>>();
1450                (!owners.is_empty()).then_some((module, owners))
1451            })
1452            .collect();
1453        self
1454    }
1455
1456    pub fn to_json(&self) -> Result<String, MetadataError> {
1457        Ok(serde_json::to_string_pretty(self)?)
1458    }
1459}