Skip to main content

npmgen_core/project/
workspace.rs

1use std::collections::BTreeMap;
2use std::path::Path;
3
4use cargo_metadata::{Metadata, MetadataCommand, Package};
5
6use super::{METADATA_KEY, Overrides, Project, ProjectError};
7use crate::config::Config;
8
9/// The target cargo workspace, resolved once via `cargo metadata`, and the
10/// source for per-binary discovery.
11pub struct Workspace {
12    metadata: Metadata,
13}
14
15impl Workspace {
16    /// Run `cargo metadata` for the workspace containing `manifest_path`.
17    pub fn load(manifest_path: &Path) -> Result<Self, ProjectError> {
18        let metadata = MetadataCommand::new()
19            .manifest_path(manifest_path)
20            .exec()
21            .map_err(|source| ProjectError::Metadata {
22                source: Box::new(source),
23            })?;
24        Ok(Self { metadata })
25    }
26
27    /// One project per published binary, named after the binary, selected like
28    /// `cargo build`.
29    ///
30    /// The package set is the default-members (or all members), or the explicit
31    /// `--package`/`--workspace` set, minus `--exclude` and minus
32    /// `publish = false` crates (kept only when named explicitly). Each selected
33    /// package contributes all its binaries, or just those named by `--bin`. A
34    /// member's `[package.metadata.npmgen]` inherits `[workspace.metadata.npmgen]`
35    /// and is parsed only when the member actually ships a binary, so an
36    /// unrelated member's malformed config never aborts the run.
37    pub fn projects(&self, overrides: &Overrides) -> Result<Vec<Project>, ProjectError> {
38        let workspace_root = self.metadata.workspace_root.as_std_path();
39        let target_directory = self.metadata.target_directory.as_std_path();
40        let workspace_config = self.workspace_config()?;
41
42        let mut projects = Vec::new();
43        for package in self.selected_packages(overrides)? {
44            let bins = Self::selected_bins(package, overrides);
45            if bins.is_empty() {
46                continue;
47            }
48            let config = Self::package_config(package)?.inherit(&workspace_config);
49            for bin in bins {
50                projects.push(Project::from_package_bin(
51                    package,
52                    bin,
53                    &config,
54                    overrides,
55                    workspace_root,
56                    target_directory,
57                )?);
58            }
59        }
60
61        self.reject_unmatched_bins(overrides, &projects)?;
62        Self::reject_duplicate_names(&projects)?;
63        Ok(projects)
64    }
65
66    /// The packages in scope, following cargo's selection precedence.
67    fn selected_packages(&self, overrides: &Overrides) -> Result<Vec<&Package>, ProjectError> {
68        let mut packages = if !overrides.packages.is_empty() {
69            // Explicit `--package`: resolve each by name. Honor it even for a
70            // `publish = false` crate, since the user asked for it by name.
71            let mut picked = Vec::new();
72            for name in &overrides.packages {
73                picked.push(self.package_named(name)?);
74            }
75            picked
76        } else {
77            let base = if overrides.workspace {
78                self.metadata.workspace_packages()
79            } else {
80                self.default_packages()
81            };
82            base.into_iter().filter(|p| Self::publishable(p)).collect()
83        };
84
85        if !overrides.exclude.is_empty() {
86            packages.retain(|package| {
87                !overrides
88                    .exclude
89                    .iter()
90                    .any(|name| name == package.name.as_str())
91            });
92        }
93        Ok(packages)
94    }
95
96    /// The default-members of the workspace (or all members when none are
97    /// configured, or the running cargo is too old to report them).
98    fn default_packages(&self) -> Vec<&Package> {
99        if self.metadata.workspace_default_members.is_available() {
100            self.metadata.workspace_default_packages()
101        } else {
102            self.metadata.workspace_packages()
103        }
104    }
105
106    fn package_named(&self, name: &str) -> Result<&Package, ProjectError> {
107        self.metadata
108            .workspace_packages()
109            .into_iter()
110            .find(|package| package.name.as_str() == name)
111            .ok_or_else(|| ProjectError::PackageNotFound {
112                name: name.to_owned(),
113            })
114    }
115
116    /// A `publish = false` crate is private and never published (like cargo).
117    fn publishable(package: &Package) -> bool {
118        !package
119            .publish
120            .as_ref()
121            .is_some_and(|registries| registries.is_empty())
122    }
123
124    /// The package's binaries, narrowed to `--bin` when given.
125    fn selected_bins<'a>(package: &'a Package, overrides: &Overrides) -> Vec<&'a str> {
126        Self::bin_names(package)
127            .filter(|name| {
128                overrides.bins.is_empty() || overrides.bins.iter().any(|bin| bin == name)
129            })
130            .collect()
131    }
132
133    fn workspace_config(&self) -> Result<Config, ProjectError> {
134        match self.metadata.workspace_metadata.get(METADATA_KEY) {
135            Some(value) => Ok(Config::from_metadata(value)?),
136            None => Ok(Config::default()),
137        }
138    }
139
140    fn package_config(package: &Package) -> Result<Config, ProjectError> {
141        match package.metadata.get(METADATA_KEY) {
142            Some(value) => Ok(Config::from_metadata(value)?),
143            None => Ok(Config::default()),
144        }
145    }
146
147    /// Each `--bin` must match at least one shipped binary.
148    fn reject_unmatched_bins(
149        &self,
150        overrides: &Overrides,
151        projects: &[Project],
152    ) -> Result<(), ProjectError> {
153        for wanted in &overrides.bins {
154            if !projects.iter().any(|project| &project.bin == wanted) {
155                return Err(if overrides.packages.is_empty() {
156                    ProjectError::BinNotInWorkspace {
157                        bin: wanted.clone(),
158                    }
159                } else {
160                    ProjectError::UnknownBin {
161                        package: overrides.packages.join(", "),
162                        bin: wanted.clone(),
163                    }
164                });
165            }
166        }
167        Ok(())
168    }
169
170    /// Two members can legally declare a bin with the same name; published under
171    /// that name they would collide. Reject it so the user disambiguates (e.g.
172    /// with `--package`) instead of one silently winning.
173    fn reject_duplicate_names(projects: &[Project]) -> Result<(), ProjectError> {
174        let mut by_name: BTreeMap<&str, &str> = BTreeMap::new();
175        for project in projects {
176            let package = project.package.as_deref().unwrap_or_default();
177            if let Some(other) = by_name.insert(&project.identity.name, package) {
178                return Err(ProjectError::AmbiguousBin {
179                    bin: project.identity.name.clone(),
180                    packages: vec![other.to_owned(), package.to_owned()],
181                });
182            }
183        }
184        Ok(())
185    }
186
187    fn bin_names(package: &Package) -> impl Iterator<Item = &str> {
188        package
189            .targets
190            .iter()
191            .filter(|target| target.is_bin())
192            .map(|target| target.name.as_str())
193    }
194}