Skip to main content

npmgen_core/project/
builder.rs

1use std::path::PathBuf;
2
3use super::{Author, Identity, Project, ProjectError};
4use crate::config::Config;
5
6/// Programmatic, dependency-free construction of a [`Project`].
7///
8/// Unlike [`Project::load`](super::Project::load), this needs no `Cargo.toml`,
9/// no `cargo metadata`, and no TOML parsing: a caller that already holds the
10/// package facts supplies them directly. The scope, name and version are
11/// required; every other field defaults.
12#[derive(Debug, Clone)]
13pub struct ProjectBuilder {
14    scope: String,
15    name: String,
16    version: String,
17    git_url: String,
18    description: String,
19    author: String,
20    license: String,
21    repository: String,
22    bin: Option<String>,
23    package: Option<String>,
24    config: Config,
25    workspace_root: PathBuf,
26    target_directory: PathBuf,
27}
28
29impl ProjectBuilder {
30    pub(crate) fn new(
31        scope: impl Into<String>,
32        name: impl Into<String>,
33        version: impl Into<String>,
34    ) -> Self {
35        Self {
36            scope: scope.into(),
37            name: name.into(),
38            version: version.into(),
39            git_url: String::new(),
40            description: String::new(),
41            author: String::new(),
42            license: String::new(),
43            repository: String::new(),
44            bin: None,
45            package: None,
46            config: Config::default(),
47            workspace_root: PathBuf::from("."),
48            target_directory: PathBuf::from("target"),
49        }
50    }
51
52    /// npm git URL recorded in the meta `package.json` repository field.
53    pub fn git_url(mut self, git_url: impl Into<String>) -> Self {
54        self.git_url = git_url.into();
55        self
56    }
57
58    pub fn description(mut self, description: impl Into<String>) -> Self {
59        self.description = description.into();
60        self
61    }
62
63    /// Author entry in `Name <email>` form.
64    pub fn author(mut self, author: impl Into<String>) -> Self {
65        self.author = author.into();
66        self
67    }
68
69    pub fn license(mut self, license: impl Into<String>) -> Self {
70        self.license = license.into();
71        self
72    }
73
74    /// Raw repository URL exposed to manifest substitution.
75    pub fn repository(mut self, repository: impl Into<String>) -> Self {
76        self.repository = repository.into();
77        self
78    }
79
80    /// Cargo bin name to build and ship; defaults to the package name.
81    pub fn bin(mut self, bin: impl Into<String>) -> Self {
82        self.bin = Some(bin.into());
83        self
84    }
85
86    /// Cargo package passed as `--package` to the build.
87    pub fn package(mut self, package: impl Into<String>) -> Self {
88        self.package = Some(package.into());
89        self
90    }
91
92    /// Targets, payload and manifests to generate.
93    pub fn config(mut self, config: Config) -> Self {
94        self.config = config;
95        self
96    }
97
98    /// Root the payload and manifest sources are read from, and where the build runs.
99    pub fn workspace_root(mut self, workspace_root: impl Into<PathBuf>) -> Self {
100        self.workspace_root = workspace_root.into();
101        self
102    }
103
104    /// Cargo target directory the compiled binaries are copied from.
105    pub fn target_directory(mut self, target_directory: impl Into<PathBuf>) -> Self {
106        self.target_directory = target_directory.into();
107        self
108    }
109
110    /// Validate the required fields and assemble the [`Project`].
111    pub fn build(self) -> Result<Project, ProjectError> {
112        if self.scope.is_empty() || !self.scope.starts_with('@') {
113            return Err(ProjectError::InvalidField {
114                field: "scope",
115                reason: "must be a non-empty npm scope starting with '@'",
116            });
117        }
118        if self.name.is_empty() {
119            return Err(ProjectError::InvalidField {
120                field: "name",
121                reason: "must not be empty",
122            });
123        }
124        if self.version.is_empty() {
125            return Err(ProjectError::InvalidField {
126                field: "version",
127                reason: "must not be empty",
128            });
129        }
130
131        let bin = self.bin.unwrap_or_else(|| self.name.clone());
132        Ok(Project {
133            identity: Identity {
134                scope: self.scope,
135                name: self.name,
136                git_url: self.git_url,
137            },
138            version: self.version,
139            description: self.description,
140            author: Author::parse(&self.author),
141            license: self.license,
142            repository: self.repository,
143            bin,
144            package: self.package,
145            config: self.config,
146            workspace_root: self.workspace_root,
147            target_directory: self.target_directory,
148        })
149    }
150}
151
152#[cfg(test)]
153mod tests {
154    use super::ProjectBuilder;
155
156    #[test]
157    fn builds_identity_and_defaults_bin_to_name() {
158        let project = ProjectBuilder::new("@me", "tool", "1.2.3")
159            .git_url("git+https://example.test/me/tool.git")
160            .build()
161            .unwrap();
162        assert_eq!(project.package_name(), "@me/tool");
163        assert_eq!(project.version, "1.2.3");
164        assert_eq!(project.bin, "tool");
165        assert_eq!(
166            project.identity.git_url,
167            "git+https://example.test/me/tool.git"
168        );
169    }
170
171    #[test]
172    fn explicit_bin_overrides_the_name_default() {
173        let project = ProjectBuilder::new("@me", "tool", "1.2.3")
174            .bin("other")
175            .build()
176            .unwrap();
177        assert_eq!(project.bin, "other");
178    }
179
180    #[test]
181    fn rejects_empty_or_unscoped_required_fields() {
182        assert!(ProjectBuilder::new("", "tool", "1.0.0").build().is_err());
183        assert!(ProjectBuilder::new("me", "tool", "1.0.0").build().is_err());
184        assert!(ProjectBuilder::new("@me", "", "1.0.0").build().is_err());
185        assert!(ProjectBuilder::new("@me", "tool", "").build().is_err());
186        assert!(ProjectBuilder::new("@me", "tool", "1.0.0").build().is_ok());
187    }
188}