Skip to main content

npmgen_core/project/
mod.rs

1//! The target crate(s), resolved via `cargo metadata`.
2//!
3//! npmgen mirrors cargo: it publishes the binaries cargo would build, one npm
4//! package per binary, named after the binary. Identity (version, description,
5//! author, repository, license) comes from each package with `[workspace.package]`
6//! inheritance already applied by cargo. npmgen-specific settings live in
7//! `[package.metadata.npmgen]`, inheriting `[workspace.metadata.npmgen]` the way
8//! cargo inherits `[workspace.package]`.
9
10mod author;
11mod builder;
12mod identity;
13mod workspace;
14
15pub use author::Author;
16pub use builder::ProjectBuilder;
17pub use identity::Identity;
18pub use workspace::Workspace;
19
20use std::collections::BTreeMap;
21use std::path::{Path, PathBuf};
22
23use cargo_metadata::Package;
24
25use crate::config::Config;
26
27/// Default manifest path for [`Project::discover`].
28pub const DEFAULT_MANIFEST_PATH: &str = "Cargo.toml";
29
30/// Metadata table key under `[package.metadata.*]` / `[workspace.metadata.*]`.
31pub(crate) const METADATA_KEY: &str = "npmgen";
32
33/// Command-line selection, mirroring `cargo`'s package/target flags. Empty
34/// vectors mean "no restriction"; the defaults match `cargo build`.
35#[derive(Debug, Clone, Default)]
36pub struct Overrides {
37    /// `-p/--package`: restrict to these workspace members (repeatable).
38    pub packages: Vec<String>,
39    /// `--workspace`: select every workspace member.
40    pub workspace: bool,
41    /// `--exclude`: drop these members from the selection (repeatable).
42    pub exclude: Vec<String>,
43    /// `--bin`: restrict to these binaries (repeatable).
44    pub bins: Vec<String>,
45    /// Override the package version for every selected bin.
46    pub version: Option<String>,
47}
48
49/// Everything the pipeline needs to ship one binary as an npm package.
50#[derive(Debug, Clone)]
51pub struct Project {
52    pub identity: Identity,
53    pub version: String,
54    pub description: String,
55    pub author: Author,
56    pub license: String,
57    pub repository: String,
58    /// Cargo bin name to build and ship; also the npm package name.
59    pub bin: String,
60    /// Owning cargo package, passed as `--package` to the build.
61    pub package: Option<String>,
62    pub config: Config,
63    pub workspace_root: PathBuf,
64    pub target_directory: PathBuf,
65}
66
67/// Failures loading and resolving the target crate(s).
68#[derive(Debug, thiserror::Error)]
69pub enum ProjectError {
70    #[error("running `cargo metadata`")]
71    Metadata {
72        #[source]
73        source: Box<cargo_metadata::Error>,
74    },
75
76    #[error("no workspace package named {name:?}")]
77    PackageNotFound { name: String },
78
79    #[error("package repository must be set to https://<host>/<owner>/<repo>")]
80    MissingRepository,
81
82    #[error("package {package:?} has no bin named {bin:?}")]
83    UnknownBin { package: String, bin: String },
84
85    #[error("no workspace bin named {bin:?}")]
86    BinNotInWorkspace { bin: String },
87
88    #[error("bin {bin:?} is defined by more than one package ({}); select one with --package", packages.join(", "))]
89    AmbiguousBin { bin: String, packages: Vec<String> },
90
91    #[error(
92        "nothing to publish: no selected package ships a binary (every match is a library or `publish = false`)"
93    )]
94    NothingToPublish,
95
96    #[error(
97        "{count} binaries match; narrow with --package/--bin, or use Project::discover for the full set"
98    )]
99    NotSingle { count: usize },
100
101    #[error("invalid project field {field}: {reason}")]
102    InvalidField {
103        field: &'static str,
104        reason: &'static str,
105    },
106
107    #[error(transparent)]
108    Config(#[from] crate::config::ConfigError),
109}
110
111impl Project {
112    /// Construct a project programmatically, with no `Cargo.toml`, `cargo
113    /// metadata`, or TOML parsing. The scope, name and version are required.
114    pub fn builder(
115        scope: impl Into<String>,
116        name: impl Into<String>,
117        version: impl Into<String>,
118    ) -> ProjectBuilder {
119        ProjectBuilder::new(scope, name, version)
120    }
121
122    /// Resolve every publishable binary at `manifest_path` into a project.
123    ///
124    /// Selection mirrors `cargo build`: by default the workspace's
125    /// default-members (or all members), each member's binaries, skipping
126    /// libraries and `publish = false` crates. `--package`/`--workspace`/
127    /// `--exclude`/`--bin` narrow the set exactly as cargo's flags do. Each
128    /// binary becomes one npm package named after the binary.
129    pub fn discover(
130        manifest_path: &Path,
131        overrides: &Overrides,
132    ) -> Result<Vec<Self>, ProjectError> {
133        let projects = Workspace::load(manifest_path)?.projects(overrides)?;
134        if projects.is_empty() {
135            return Err(ProjectError::NothingToPublish);
136        }
137        Ok(projects)
138    }
139
140    /// Resolve a single binary at `manifest_path`. A convenience over
141    /// [`discover`](Self::discover) for the common one-binary case; errors with
142    /// [`ProjectError::NotSingle`] when the selection matches more than one.
143    pub fn load(manifest_path: &Path, overrides: &Overrides) -> Result<Self, ProjectError> {
144        let mut projects = Self::discover(manifest_path, overrides)?;
145        match projects.len() {
146            1 => Ok(projects.pop().unwrap()),
147            count => Err(ProjectError::NotSingle { count }),
148        }
149    }
150
151    /// Build a per-bin project from a workspace member package. The npm name is
152    /// the bin name; scope and git URL come from the package's repository.
153    pub(crate) fn from_package_bin(
154        package: &Package,
155        bin: &str,
156        config: &Config,
157        overrides: &Overrides,
158        workspace_root: &Path,
159        target_directory: &Path,
160    ) -> Result<Self, ProjectError> {
161        let repository = package
162            .repository
163            .clone()
164            .ok_or(ProjectError::MissingRepository)?;
165        let base = Identity::from_repository(&repository, config.scope.as_deref())?;
166        let identity = Identity {
167            name: bin.to_owned(),
168            ..base
169        };
170        let version = overrides
171            .version
172            .clone()
173            .unwrap_or_else(|| package.version.to_string());
174        let license = config
175            .license
176            .clone()
177            .or_else(|| package.license.clone())
178            .unwrap_or_default();
179        Ok(Self {
180            identity,
181            version,
182            description: package.description.clone().unwrap_or_default(),
183            author: Author::parse(&package.authors.first().cloned().unwrap_or_default()),
184            license,
185            repository,
186            bin: bin.to_owned(),
187            package: Some(package.name.as_str().to_owned()),
188            config: config.clone(),
189            workspace_root: workspace_root.to_path_buf(),
190            target_directory: target_directory.to_path_buf(),
191        })
192    }
193
194    /// `@scope/name` meta package name.
195    pub fn package_name(&self) -> String {
196        format!("{}/{}", self.identity.scope, self.identity.name)
197    }
198
199    /// Identity values exposed to foreign-manifest substitution.
200    pub fn variables(&self) -> BTreeMap<String, String> {
201        BTreeMap::from([
202            ("name".to_owned(), self.identity.name.clone()),
203            ("scope".to_owned(), self.identity.scope.clone()),
204            ("package".to_owned(), self.package_name()),
205            ("version".to_owned(), self.version.clone()),
206            ("description".to_owned(), self.description.clone()),
207            ("license".to_owned(), self.license.clone()),
208            ("repository".to_owned(), self.repository.clone()),
209            ("git_url".to_owned(), self.identity.git_url.clone()),
210            ("bin".to_owned(), self.bin.clone()),
211            ("author".to_owned(), self.author.full.clone()),
212            ("author_name".to_owned(), self.author.name.clone()),
213            (
214                "author_email".to_owned(),
215                self.author.email.clone().unwrap_or_default(),
216            ),
217        ])
218    }
219}
220
221/// A nocmd-shaped [`Project`] for tests in this crate (no filesystem or cargo
222/// metadata needed). Override individual fields per test.
223#[cfg(test)]
224pub(crate) fn sample_project() -> Project {
225    Project {
226        identity: Identity {
227            scope: "@gglinnk".to_owned(),
228            name: "nocmd".to_owned(),
229            git_url: "git+https://github.com/gglinnk/nocmd.git".to_owned(),
230        },
231        version: "0.1.1".to_owned(),
232        description: "a hook".to_owned(),
233        author: Author::parse("Gabriel GRONDIN <gglinnk@protonmail.com>"),
234        license: "MIT".to_owned(),
235        repository: "https://github.com/gglinnk/nocmd".to_owned(),
236        bin: "nocmd".to_owned(),
237        package: Some("nocmd".to_owned()),
238        config: Config::default(),
239        workspace_root: PathBuf::from("."),
240        target_directory: PathBuf::from("target"),
241    }
242}