Skip to main content

poolster_core/
release.rs

1//! Optional package metadata for build and release orchestration.
2//!
3//! Commands are argument vectors, never interpolated shell programs. The plugin
4//! works with community languages and does not teach core about registries.
5
6use 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/// A registry integration declares its commands and release-please strategy.
56/// Third-party plugins may name any registry; core has no registry enum.
57#[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    /// Capability -> verification level, e.g. `serialization: wire-tested`.
86    #[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
167/// Attach metadata explicitly to any language package. API/package versions
168/// default from the normalized input after package settings have been applied.
169pub 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}