Skip to main content

npmgen_core/config/
mod.rs

1//! The `[package.metadata.npmgen]` (or `[workspace.metadata.npmgen]`) schema.
2//!
3//! Three payload classes flow from here: npmgen-owned manifests built in code
4//! (`package.json`), foreign manifests rendered by identity substitution
5//! ([`ManifestSpec`]), and verbatim copies ([`Config::include`]).
6
7mod launcher;
8mod manifest_spec;
9mod target_spec;
10
11pub use launcher::Launcher;
12pub use manifest_spec::ManifestSpec;
13pub use target_spec::TargetSpec;
14
15use serde::Deserialize;
16
17/// Deserialized `npmgen` metadata table. Every field is optional so a project
18/// publishing a plain cross-platform binary needs no configuration at all.
19#[derive(Debug, Clone, Default, Deserialize)]
20#[serde(default, deny_unknown_fields)]
21pub struct Config {
22    /// npm scope (`@owner`); defaults to the repository owner.
23    pub scope: Option<String>,
24    /// SPDX license override; defaults to the crate's `license`.
25    pub license: Option<String>,
26    /// Launcher file bundled into the meta package, optionally wired as `bin`.
27    pub launcher: Option<Launcher>,
28    /// Non-manifest files/dirs copied verbatim into the meta package.
29    pub include: Vec<String>,
30    /// Extra fields merged into the meta `package.json`.
31    pub extra: serde_json::Map<String, serde_json::Value>,
32    /// Foreign manifests rendered by `${var}` identity substitution.
33    pub manifests: Vec<ManifestSpec>,
34    /// Highest-precedence platform target list; empty means inherit from cargo or default.
35    pub targets: Vec<TargetSpec>,
36}
37
38/// Failure deserializing the `npmgen` metadata table.
39#[derive(Debug, thiserror::Error)]
40pub enum ConfigError {
41    #[error("[package.metadata.npmgen] is not valid")]
42    Deserialize {
43        #[source]
44        source: serde_json::Error,
45    },
46}
47
48impl Config {
49    /// Build from the `npmgen` sub-value of a `metadata` table. A `Null` value
50    /// (table absent) yields the all-defaults config.
51    pub fn from_metadata(value: &serde_json::Value) -> Result<Self, ConfigError> {
52        if value.is_null() {
53            return Ok(Self::default());
54        }
55        serde_json::from_value(value.clone()).map_err(|source| ConfigError::Deserialize { source })
56    }
57
58    /// Resolve this package-level config against the workspace-level `base`,
59    /// mirroring cargo's `[workspace.package]` inheritance: a field set here
60    /// wins, an unset one is inherited. Scalars inherit when `None`; lists
61    /// inherit when empty; `extra` maps merge key by key with this package's
62    /// entries taking precedence.
63    pub fn inherit(mut self, base: &Config) -> Config {
64        self.scope = self.scope.or_else(|| base.scope.clone());
65        self.license = self.license.or_else(|| base.license.clone());
66        self.launcher = self.launcher.or_else(|| base.launcher.clone());
67        if self.include.is_empty() {
68            self.include = base.include.clone();
69        }
70        if self.manifests.is_empty() {
71            self.manifests = base.manifests.clone();
72        }
73        if self.targets.is_empty() {
74            self.targets = base.targets.clone();
75        }
76        let mut extra = base.extra.clone();
77        extra.extend(std::mem::take(&mut self.extra));
78        self.extra = extra;
79        self
80    }
81}
82
83#[cfg(test)]
84mod tests {
85    use super::*;
86    use serde_json::json;
87
88    #[test]
89    fn absent_table_yields_defaults() {
90        let config = Config::from_metadata(&serde_json::Value::Null).unwrap();
91        assert!(config.scope.is_none());
92        assert!(config.targets.is_empty());
93        assert!(config.manifests.is_empty());
94    }
95
96    #[test]
97    fn deserializes_full_table() {
98        let value = json!({
99            "scope": "@gglinnk",
100            "launcher": { "file": "launch.mjs", "bin": "nocmd" },
101            "include": ["hooks"],
102            "manifests": [".claude-plugin/plugin.json"],
103            "extra": { "keywords": ["hook"] },
104            "targets": [{ "key": "win32-x64", "triple": "x86_64-pc-windows-msvc" }],
105        });
106        let config = Config::from_metadata(&value).unwrap();
107        assert_eq!(config.scope.as_deref(), Some("@gglinnk"));
108        assert_eq!(config.launcher.as_ref().unwrap().bin(), Some("nocmd"));
109        assert_eq!(config.manifests[0].dest(), ".claude-plugin/plugin.json");
110        assert_eq!(config.targets[0].key, "win32-x64");
111        assert!(config.extra.contains_key("keywords"));
112    }
113
114    #[test]
115    fn inherit_takes_workspace_defaults_then_package_overrides() {
116        let base = Config::from_metadata(&json!({
117            "scope": "@acme",
118            "targets": [{ "key": "linux-x64", "triple": "x86_64-unknown-linux-gnu" }],
119            "extra": { "keywords": ["shared"], "private": false },
120        }))
121        .unwrap();
122        let package = Config::from_metadata(&json!({
123            "manifests": [".claude-plugin/plugin.json"],
124            "extra": { "keywords": ["own"] },
125        }))
126        .unwrap();
127
128        let merged = package.inherit(&base);
129        // Unset fields inherit from the workspace.
130        assert_eq!(merged.scope.as_deref(), Some("@acme"));
131        assert_eq!(merged.targets[0].key, "linux-x64");
132        // Set fields win.
133        assert_eq!(merged.manifests[0].dest(), ".claude-plugin/plugin.json");
134        // Maps merge, package entries overriding.
135        assert_eq!(merged.extra["keywords"], json!(["own"]));
136        assert_eq!(merged.extra["private"], json!(false));
137    }
138
139    #[test]
140    fn accepts_launcher_and_manifest_shorthands() {
141        let value = json!({
142            "launcher": "launch.mjs",
143            "manifests": [{ "src": "tmpl/plugin.json", "dest": ".claude-plugin/plugin.json" }],
144        });
145        let config = Config::from_metadata(&value).unwrap();
146        let launcher = config.launcher.unwrap();
147        assert_eq!(launcher.output(), "launch.mjs");
148        assert_eq!(launcher.bin(), None);
149        assert!(!launcher.is_generated());
150        assert_eq!(config.manifests[0].src(), "tmpl/plugin.json");
151        assert_eq!(config.manifests[0].dest(), ".claude-plugin/plugin.json");
152    }
153
154    #[test]
155    fn launcher_without_a_file_is_generated() {
156        let value = json!({ "launcher": { "bin": "mytool", "fail_open": true } });
157        let launcher = Config::from_metadata(&value).unwrap().launcher.unwrap();
158        assert!(launcher.is_generated());
159        assert_eq!(launcher.bin(), Some("mytool"));
160        assert!(launcher.fail_open());
161        assert_eq!(launcher.output(), "launch.mjs");
162    }
163
164    #[test]
165    fn rejects_fail_open_on_a_copied_launcher() {
166        let value = json!({ "launcher": { "file": "launch.mjs", "fail_open": true } });
167        assert!(Config::from_metadata(&value).is_err());
168    }
169
170    #[test]
171    fn rejects_an_unknown_launcher_key() {
172        // A typo on `file` must error, not silently ship a generated launcher.
173        let value = json!({ "launcher": { "fial": "launch.mjs" } });
174        assert!(Config::from_metadata(&value).is_err());
175    }
176
177    #[test]
178    fn rejects_unknown_fields() {
179        let value = json!({ "nonsense": true });
180        assert!(Config::from_metadata(&value).is_err());
181    }
182}