Skip to main content

npmgen_core/
pipeline.rs

1//! The generation pipeline over a resolved [`Project`]: check the tag, resolve
2//! targets, build (unless skipped), assemble the tree.
3//!
4//! Acquiring the project is a separate concern: build one in memory with
5//! [`Project::builder`](crate::Project::builder) or load it from a manifest with
6//! [`Project::load`](crate::Project::load). The engine itself reads no manifest.
7
8use std::path::PathBuf;
9
10use tracing::{info, warn};
11
12use crate::compile::{BuildDriver, CargoDriver, CompileError, Compiler};
13use crate::error::{Error, Result};
14use crate::npm::Assembler;
15use crate::project::Project;
16use crate::target::TargetResolver;
17
18/// Default output root for the generated tree.
19pub const DEFAULT_OUT: &str = "dist/npm";
20/// Default build driver command.
21pub const DEFAULT_DRIVER: &str = "cargo";
22
23/// Release-tag prefix that `--tag` is checked against (`v<version>`).
24const TAG_PREFIX: &str = "v";
25
26/// Generates the publish tree for one or more [`Project`]s into a shared output
27/// root, atomically. Construct with [`Generator::new`] (single) or
28/// [`Generator::for_projects`] (a workspace's bins) and configure with the
29/// chained setters.
30#[derive(Debug)]
31pub struct Generator<'a> {
32    projects: &'a [Project],
33    out: PathBuf,
34    tag: Option<String>,
35    no_build: bool,
36    driver: String,
37    targets: Vec<String>,
38    build_driver: Option<&'a dyn BuildDriver>,
39}
40
41impl<'a> Generator<'a> {
42    /// Start a generation for a single project.
43    pub fn new(project: &'a Project) -> Self {
44        Self::for_projects(std::slice::from_ref(project))
45    }
46
47    /// Start a generation for several projects (e.g. one per workspace bin)
48    /// sharing one output root; they are assembled and swapped together.
49    pub fn for_projects(projects: &'a [Project]) -> Self {
50        Self {
51            projects,
52            out: PathBuf::from(DEFAULT_OUT),
53            tag: None,
54            no_build: false,
55            driver: DEFAULT_DRIVER.to_owned(),
56            targets: Vec::new(),
57            build_driver: None,
58        }
59    }
60
61    /// Inject a build driver, overriding the default cargo command. Lets a
62    /// library consumer or test supply a custom [`BuildDriver`].
63    pub fn build_driver(mut self, driver: &'a dyn BuildDriver) -> Self {
64        self.build_driver = Some(driver);
65        self
66    }
67
68    /// Output root for the generated tree.
69    pub fn out(mut self, out: impl Into<PathBuf>) -> Self {
70        self.out = out.into();
71        self
72    }
73
74    /// Require the resolved version to equal `v<tag>`.
75    pub fn tag(mut self, tag: impl Into<String>) -> Self {
76        self.tag = Some(tag.into());
77        self
78    }
79
80    /// Skip the build phase and assemble from existing binaries.
81    pub fn no_build(mut self, no_build: bool) -> Self {
82        self.no_build = no_build;
83        self
84    }
85
86    /// Build driver invoked per target (e.g. `cargo`, `cross`, `cargo-zigbuild`).
87    pub fn driver(mut self, driver: impl Into<String>) -> Self {
88        self.driver = driver.into();
89        self
90    }
91
92    /// Restrict generation to these target keys; empty means all resolved.
93    pub fn targets(mut self, targets: impl IntoIterator<Item = impl Into<String>>) -> Self {
94        self.targets = targets.into_iter().map(Into::into).collect();
95        self
96    }
97
98    /// Build (unless skipped) and assemble every project, then atomically swap
99    /// the whole tree onto `out`. Either all projects land or none do.
100    pub fn run(&self) -> Result<()> {
101        let assembler = Assembler::new(&self.out)?;
102        if !self.no_build && self.build_driver.is_none() {
103            validate_driver(&self.driver)?;
104        }
105        let mut total_targets = 0;
106        let mut missing = Vec::new();
107
108        for project in self.projects {
109            if let Some(tag) = &self.tag {
110                let expected = format!("{TAG_PREFIX}{}", project.version);
111                if tag != &expected {
112                    return Err(Error::TagMismatch {
113                        tag: tag.clone(),
114                        expected,
115                    });
116                }
117            }
118
119            let targets = TargetResolver::new(&project.config, &project.workspace_root)
120                .resolve(&self.targets)?;
121
122            if !self.no_build {
123                let cargo = CargoDriver::new(&self.driver);
124                let driver: &dyn BuildDriver = match self.build_driver {
125                    Some(injected) => injected,
126                    None => &cargo,
127                };
128                Compiler::new(driver).compile_all(project, &targets)?;
129            }
130
131            total_targets += targets.len();
132            missing.extend(assembler.add(project, &targets)?);
133        }
134
135        assembler.commit()?;
136
137        if !missing.is_empty() {
138            warn!(
139                placed = total_targets - missing.len(),
140                total = total_targets,
141                missing = ?missing,
142                "platform packages have no binary yet; place them before publishing",
143            );
144        }
145        info!(
146            packages = self.projects.len(),
147            out = %self.out.display(),
148            "generated npm publish tree",
149        );
150        Ok(())
151    }
152}
153
154/// Reject a build driver that is a path rather than a bare command name, so a
155/// crafted `--builder` cannot point the build at an arbitrary binary; the
156/// command is resolved through `PATH` like any cargo subcommand.
157fn validate_driver(driver: &str) -> Result<()> {
158    if driver.is_empty() || driver.contains('/') || driver.contains('\\') {
159        return Err(CompileError::InvalidDriver {
160            driver: driver.to_owned(),
161        }
162        .into());
163    }
164    Ok(())
165}
166
167#[cfg(test)]
168mod tests {
169    use super::validate_driver;
170    use crate::error::Error;
171
172    #[test]
173    fn bare_command_drivers_are_accepted() {
174        assert!(validate_driver("cargo").is_ok());
175        assert!(validate_driver("cargo-zigbuild").is_ok());
176        assert!(validate_driver("cross").is_ok());
177    }
178
179    #[test]
180    fn path_like_or_empty_drivers_are_rejected() {
181        for bad in ["", "/tmp/evil", "../evil", "a/b", "a\\b"] {
182            assert!(matches!(
183                validate_driver(bad).unwrap_err(),
184                Error::Compile(_)
185            ));
186        }
187    }
188}