1use std::{collections::BTreeMap, marker::PhantomData};
7
8use anyhow::{Result, ensure};
9use serde::{Deserialize, Serialize};
10
11use crate::{
12 GeneratedFile,
13 engine::{Contract, Enforce, Language, Meta, Plugin, PluginContext, Provision},
14};
15
16pub const PACKAGE_METADATA_PATH: &str = ".poolster/package.json";
17pub const PACKAGE_METADATA_VERSION: u32 = 1;
18
19#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
20#[serde(deny_unknown_fields)]
21pub struct PackageCommand {
22 pub program: String,
23 #[serde(default)]
24 pub args: Vec<String>,
25}
26
27impl PackageCommand {
28 pub fn new(
29 program: impl Into<String>,
30 args: impl IntoIterator<Item = impl Into<String>>,
31 ) -> Self {
32 Self {
33 program: program.into(),
34 args: args.into_iter().map(Into::into).collect(),
35 }
36 }
37
38 pub fn validate(&self) -> Result<()> {
39 ensure!(
40 !self.program.is_empty() && !self.program.starts_with('-'),
41 "package command needs an executable name"
42 );
43 ensure!(
44 !self.program.contains(['\0', '\n', '\r']),
45 "package executable contains a control character"
46 );
47 ensure!(
48 self.args.iter().all(|arg| !arg.contains('\0')),
49 "package argument contains a NUL byte"
50 );
51 Ok(())
52 }
53}
54
55#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
58#[serde(deny_unknown_fields)]
59pub struct PackagePublisher {
60 pub registry: String,
61 pub release_type: String,
62 #[serde(default)]
63 pub commands: Vec<PackageCommand>,
64 #[serde(default)]
65 pub extra_files: Vec<String>,
66}
67
68#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
69#[serde(deny_unknown_fields)]
70pub struct PackageMetadata {
71 #[serde(default = "metadata_version")]
72 pub schema_version: u32,
73 #[serde(default)]
74 pub language: String,
75 #[serde(default)]
76 pub name: String,
77 #[serde(default)]
78 pub version: String,
79 #[serde(default)]
80 pub build: Vec<PackageCommand>,
81 #[serde(default)]
82 pub test: Vec<PackageCommand>,
83 #[serde(default, skip_serializing_if = "Option::is_none")]
84 pub publisher: Option<PackagePublisher>,
85 #[serde(default)]
87 pub capabilities: BTreeMap<String, String>,
88}
89
90fn metadata_version() -> u32 {
91 PACKAGE_METADATA_VERSION
92}
93
94impl PackageMetadata {
95 pub fn new(name: impl Into<String>) -> Self {
96 Self {
97 schema_version: PACKAGE_METADATA_VERSION,
98 language: String::new(),
99 name: name.into(),
100 version: String::new(),
101 build: vec![],
102 test: vec![],
103 publisher: None,
104 capabilities: BTreeMap::new(),
105 }
106 }
107
108 pub fn validate(&self) -> Result<()> {
109 ensure!(
110 self.schema_version == PACKAGE_METADATA_VERSION,
111 "unsupported package metadata version {}",
112 self.schema_version
113 );
114 for (label, value) in [
115 ("name", &self.name),
116 ("language", &self.language),
117 ("version", &self.version),
118 ] {
119 ensure!(
120 !value.trim().is_empty() && !value.contains(['\0', '\n', '\r']),
121 "invalid package {label}"
122 );
123 }
124 for command in self.build.iter().chain(&self.test).chain(
125 self.publisher
126 .iter()
127 .flat_map(|publisher| &publisher.commands),
128 ) {
129 command.validate()?;
130 }
131 if let Some(publisher) = &self.publisher {
132 ensure!(
133 !publisher.registry.trim().is_empty() && !publisher.release_type.trim().is_empty(),
134 "publisher requires registry and release_type"
135 );
136 ensure!(
137 !publisher.commands.is_empty()
138 || matches!(
139 publisher.registry.as_str(),
140 "npm" | "pypi" | "crates.io" | "go"
141 ),
142 "custom publisher requires at least one command"
143 );
144 for path in &publisher.extra_files {
145 GeneratedFile::new(path, "")?;
146 }
147 }
148 Ok(())
149 }
150
151 pub fn to_json(&self) -> Result<String> {
152 self.validate()?;
153 Ok(format!("{}\n", serde_json::to_string_pretty(self)?))
154 }
155}
156
157impl Contract for PackageMetadata {
158 const NAME: &'static str = "poolster.package-metadata";
159}
160
161pub struct Metadata<L: Language> {
162 meta: Meta,
163 value: PackageMetadata,
164 language: PhantomData<L>,
165}
166
167pub fn metadata<L: Language>(value: PackageMetadata) -> Metadata<L> {
170 Metadata {
171 meta: Meta::new(),
172 value,
173 language: PhantomData,
174 }
175}
176
177impl<L: Language> Plugin<L> for Metadata<L> {
178 fn supports_native_input(&self) -> bool {
179 true
180 }
181 fn kind(&self) -> &'static str {
182 "package-metadata"
183 }
184 fn meta(&self) -> &Meta {
185 &self.meta
186 }
187 fn enforce(&self) -> Enforce {
188 Enforce::Post
189 }
190 fn provides(&self) -> Vec<Provision> {
191 vec![Provision::of::<PackageMetadata>()]
192 }
193 fn generate(&self, cx: &mut PluginContext<'_, L>) -> Result<()> {
194 let mut value = self.value.clone();
195 if value.language.is_empty() {
196 value.language = L::NAME.into();
197 }
198 if value.version.is_empty() {
199 value.version = cx
200 .common
201 .package_version
202 .clone()
203 .unwrap_or_else(|| cx.api.version.clone());
204 }
205 cx.files
206 .emit(GeneratedFile::new(PACKAGE_METADATA_PATH, value.to_json()?)?)?;
207 cx.publish(value)
208 }
209}
210
211#[cfg(test)]
212mod tests {
213 use super::*;
214 use crate::{
215 Api,
216 engine::{Package, Packages},
217 };
218
219 struct Community;
220 impl Language for Community {
221 const NAME: &'static str = "community";
222 type Settings = ();
223 type Workspace = ();
224 }
225
226 #[test]
227 fn native_metadata_uses_explicit_package_version_without_http_input() {
228 let tree = Packages::new()
229 .package(
230 Package::<Community>::new("native")
231 .common(crate::engine::Common::default().package_version("1.2.3"))
232 .with(metadata::<Community>(PackageMetadata::new("native-sdk"))),
233 )
234 .generate_native()
235 .unwrap();
236 let value: PackageMetadata =
237 serde_json::from_str(tree.get("native/.poolster/package.json").unwrap()).unwrap();
238 assert_eq!(value.language, "community");
239 assert_eq!(value.version, "1.2.3");
240 }
241
242 #[test]
243 fn works_for_a_community_language_with_its_own_registry() {
244 let mut value = PackageMetadata::new("community-sdk");
245 value.build.push(PackageCommand::new(
246 "custom-compiler",
247 ["compile", "$(literal)"],
248 ));
249 value.publisher = Some(PackagePublisher {
250 registry: "community-registry".into(),
251 release_type: "simple".into(),
252 commands: vec![PackageCommand::new("community-publish", ["--release"])],
253 extra_files: vec!["version.txt".into()],
254 });
255 let tree = Packages::new()
256 .package(Package::<Community>::new("sdk").with(metadata(value)))
257 .generate(
258 &Api {
259 version: "1.2.3".into(),
260 ..Default::default()
261 },
262 None,
263 )
264 .unwrap();
265 let value: PackageMetadata =
266 serde_json::from_str(tree.get("sdk/.poolster/package.json").unwrap()).unwrap();
267 assert_eq!(value.language, "community");
268 assert_eq!(value.version, "1.2.3");
269 assert_eq!(value.build[0].args[1], "$(literal)");
270 assert_eq!(value.publisher.unwrap().registry, "community-registry");
271 }
272
273 #[test]
274 fn rejects_invalid_versions_commands_and_escaping_release_files() {
275 let mut value = PackageMetadata::new("sdk");
276 value.language = "custom".into();
277 value.version = "1.0.0".into();
278 value.schema_version = 2;
279 assert!(value.validate().is_err());
280 value.schema_version = 1;
281 value
282 .build
283 .push(PackageCommand::new("", Vec::<String>::new()));
284 assert!(value.validate().is_err());
285 value.build.clear();
286 value.publisher = Some(PackagePublisher {
287 registry: "custom".into(),
288 release_type: "simple".into(),
289 commands: vec![PackageCommand::new("publish", Vec::<String>::new())],
290 extra_files: vec!["../secret".into()],
291 });
292 assert!(value.validate().is_err());
293 }
294}