npmgen_core/config/
mod.rs1mod 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#[derive(Debug, Clone, Default, Deserialize)]
20#[serde(default, deny_unknown_fields)]
21pub struct Config {
22 pub scope: Option<String>,
24 pub license: Option<String>,
26 pub launcher: Option<Launcher>,
28 pub include: Vec<String>,
30 pub extra: serde_json::Map<String, serde_json::Value>,
32 pub manifests: Vec<ManifestSpec>,
34 pub targets: Vec<TargetSpec>,
36}
37
38#[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 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 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 assert_eq!(merged.scope.as_deref(), Some("@acme"));
131 assert_eq!(merged.targets[0].key, "linux-x64");
132 assert_eq!(merged.manifests[0].dest(), ".claude-plugin/plugin.json");
134 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 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}