Skip to main content

shipwright_manifest/
lib.rs

1//! Manifest and binary version-output primitives for shipwright libraries.
2//!
3//! This crate intentionally has no Nimblesite-specific package name. Product
4//! manifests and schema identifiers may be Nimblesite-specific while the public
5//! reusable library stays generic.
6
7use serde::{Deserialize, Serialize};
8use std::collections::BTreeMap;
9
10/// Schema version emitted in every `VersionOutput` JSON payload.
11pub const MANIFEST_VERSION: u32 = 1;
12
13/// Errors produced when constructing or serializing version/manifest types.
14#[derive(Debug, thiserror::Error)]
15pub enum ManifestError {
16    /// A binary or product name failed the naming rules (lowercase alphanumeric + hyphens, 2–64 chars).
17    #[error("invalid binary name `{0}`")]
18    InvalidName(String),
19    /// A version string is not a valid semantic version.
20    #[error("invalid semver `{0}`")]
21    InvalidVersion(String),
22    /// JSON serialization failed.
23    #[error("json serialization failed: {0}")]
24    Json(#[from] serde_json::Error),
25}
26
27/// The role a binary plays within a product.
28#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
29#[serde(rename_all = "kebab-case")]
30pub enum ExecutableKind {
31    /// Command-line interface binary.
32    Cli,
33    /// Language server protocol binary.
34    Lsp,
35    /// Model context protocol binary.
36    Mcp,
37    /// Long-running background process.
38    Sidecar,
39    /// Debug adapter protocol binary.
40    Dap,
41    /// General-purpose tool binary.
42    Tool,
43}
44
45/// The implementation language of a component.
46#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
47#[serde(rename_all = "kebab-case")]
48pub enum Language {
49    /// Rust native binary.
50    Rust,
51    /// .NET / C# binary.
52    Dotnet,
53    /// Dart binary.
54    Dart,
55    /// TypeScript / Node.js binary.
56    Typescript,
57    /// Kotlin / JVM binary.
58    Kotlin,
59    /// Plain JavaScript binary.
60    Javascript,
61}
62
63/// The structured JSON payload emitted by `binary --version --json`.
64#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
65#[serde(rename_all = "camelCase")]
66pub struct VersionOutput {
67    /// Schema version; always [`MANIFEST_VERSION`].
68    pub manifest_version: u32,
69    /// Stable binary name (lowercase alphanumeric + hyphens).
70    pub name: String,
71    /// Semantic version of this binary build.
72    pub version: String,
73    /// Role this binary plays.
74    pub kind: ExecutableKind,
75    /// Implementation language.
76    pub language: Language,
77    /// ISO-8601 build timestamp, if embedded at build time.
78    #[serde(skip_serializing_if = "Option::is_none")]
79    pub build_time: Option<String>,
80    /// Full git commit SHA, if embedded at build time.
81    #[serde(skip_serializing_if = "Option::is_none")]
82    pub git_sha: Option<String>,
83    /// Whether the working tree was dirty at build time.
84    #[serde(skip_serializing_if = "Option::is_none")]
85    pub git_dirty: Option<bool>,
86    /// Rust target triple (e.g. `x86_64-unknown-linux-gnu`).
87    #[serde(skip_serializing_if = "Option::is_none")]
88    pub target: Option<String>,
89    /// Rust toolchain channel (e.g. `stable`, `1.75.0`).
90    #[serde(skip_serializing_if = "Option::is_none")]
91    pub toolchain: Option<String>,
92    /// Optional feature flags or protocol extensions advertised by this binary.
93    #[serde(default, skip_serializing_if = "Vec::is_empty")]
94    pub capabilities: Vec<String>,
95    /// Owning product name, if this binary is product-specific.
96    #[serde(skip_serializing_if = "Option::is_none")]
97    pub product: Option<String>,
98}
99
100impl VersionOutput {
101    /// Construct a minimal [`VersionOutput`], validating `name` and `version`.
102    ///
103    /// # Errors
104    ///
105    /// Returns [`ManifestError::InvalidName`] if `name` violates naming rules,
106    /// or [`ManifestError::InvalidVersion`] if `version` is not a valid semver.
107    pub fn new(
108        name: impl Into<String>,
109        version: impl Into<String>,
110        kind: ExecutableKind,
111        language: Language,
112    ) -> Result<Self, ManifestError> {
113        let name = name.into();
114        let version = version.into();
115        validate_name(&name)?;
116        validate_semver(&version)?;
117
118        Ok(Self {
119            manifest_version: MANIFEST_VERSION,
120            name,
121            version,
122            kind,
123            language,
124            build_time: None,
125            git_sha: None,
126            git_dirty: None,
127            target: None,
128            toolchain: None,
129            capabilities: Vec::new(),
130            product: None,
131        })
132    }
133
134    /// Attach a product name, validating it against the naming rules.
135    ///
136    /// # Errors
137    ///
138    /// Returns [`ManifestError::InvalidName`] if `product` violates naming rules.
139    pub fn with_product(mut self, product: impl Into<String>) -> Result<Self, ManifestError> {
140        let product = product.into();
141        validate_name(&product)?;
142        self.product = Some(product);
143        Ok(self)
144    }
145
146    /// Append a capability string.
147    #[must_use]
148    pub fn with_capability(mut self, capability: impl Into<String>) -> Self {
149        self.capabilities.push(capability.into());
150        self
151    }
152}
153
154/// The top-level deployment manifest (`shipwright.json`).
155#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
156#[serde(rename_all = "camelCase")]
157pub struct ProductManifest {
158    /// Schema version; always [`MANIFEST_VERSION`].
159    pub manifest_version: u32,
160    /// Product metadata.
161    pub product: Product,
162    /// Ordered list of components managed by this manifest.
163    pub components: Vec<Component>,
164    /// Per-host override policies, keyed by host identifier.
165    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
166    pub hosts: BTreeMap<String, HostPolicy>,
167}
168
169/// Product-level metadata within a [`ProductManifest`].
170#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
171#[serde(rename_all = "camelCase")]
172pub struct Product {
173    /// Stable product identifier.
174    pub id: String,
175    /// Human-readable product name shown in IDE UI.
176    #[serde(skip_serializing_if = "Option::is_none")]
177    pub display_name: Option<String>,
178    /// Semantic version of this manifest.
179    pub version: String,
180    /// Source repository URL.
181    #[serde(skip_serializing_if = "Option::is_none")]
182    pub repository: Option<String>,
183    /// Product homepage URL.
184    #[serde(skip_serializing_if = "Option::is_none")]
185    pub homepage: Option<String>,
186}
187
188/// The role a component plays in the IDE extension host.
189#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
190#[serde(rename_all = "kebab-case")]
191pub enum ComponentKind {
192    /// Command-line interface binary.
193    Cli,
194    /// Language server protocol binary.
195    Lsp,
196    /// Model context protocol binary.
197    Mcp,
198    /// Long-running background process.
199    Sidecar,
200    /// Debug adapter protocol binary.
201    Dap,
202    /// General-purpose tool binary.
203    Tool,
204    /// VS Code extension package.
205    ExtensionVscode,
206    /// `JetBrains` plugin package.
207    ExtensionJetbrains,
208    /// Zed extension package.
209    ExtensionZed,
210    /// Static downloadable asset.
211    Asset,
212}
213
214/// A single managed component within a [`ProductManifest`].
215#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
216#[serde(rename_all = "camelCase")]
217pub struct Component {
218    /// Stable component identifier.
219    pub id: String,
220    /// Role this component plays.
221    pub kind: ComponentKind,
222    /// Implementation language, if applicable.
223    #[serde(skip_serializing_if = "Option::is_none")]
224    pub language: Option<Language>,
225    /// Filesystem name of the binary executable.
226    #[serde(skip_serializing_if = "Option::is_none")]
227    pub binary_name: Option<String>,
228    /// Expected semantic version; used by the host resolution algorithm.
229    #[serde(skip_serializing_if = "Option::is_none")]
230    pub expected_version: Option<String>,
231    /// Platforms this component targets (e.g. `darwin-arm64`).
232    #[serde(default, skip_serializing_if = "Vec::is_empty")]
233    pub platforms: Vec<String>,
234    /// Download/install source URIs.
235    #[serde(default, skip_serializing_if = "Vec::is_empty")]
236    pub sources: Vec<String>,
237    /// Whether the IDE extension should refuse to start if this component is absent.
238    #[serde(default = "default_required")]
239    pub required: bool,
240}
241
242/// Per-host override policy within a [`ProductManifest`].
243#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
244#[serde(rename_all = "camelCase")]
245pub struct HostPolicy {
246    /// Override artifact path for this host.
247    #[serde(skip_serializing_if = "Option::is_none")]
248    pub artifact: Option<String>,
249    /// Component IDs whose version must be verified on host activation.
250    #[serde(default, skip_serializing_if = "Vec::is_empty")]
251    pub activation_verifies: Vec<String>,
252    /// Action to take when a version mismatch is detected.
253    #[serde(skip_serializing_if = "Option::is_none")]
254    pub on_mismatch: Option<String>,
255}
256
257/// Format the plain-text `--version` line: `"{name} {version}"`.
258///
259/// # Errors
260///
261/// Returns [`ManifestError::InvalidName`] or [`ManifestError::InvalidVersion`] if inputs are invalid.
262pub fn plain_version_line(name: &str, version: &str) -> Result<String, ManifestError> {
263    validate_name(name)?;
264    validate_semver(version)?;
265    Ok(format!("{name} {version}"))
266}
267
268/// Serialize a [`VersionOutput`] to pretty-printed JSON with a trailing newline.
269///
270/// Re-validates `name`, `version`, and optional `product` before serializing.
271///
272/// # Errors
273///
274/// Returns [`ManifestError`] if any field fails validation or JSON serialization fails.
275pub fn version_output_json(output: &VersionOutput) -> Result<String, ManifestError> {
276    validate_name(&output.name)?;
277    validate_semver(&output.version)?;
278    if let Some(product) = &output.product {
279        validate_name(product)?;
280    }
281    Ok(format!("{}\n", serde_json::to_string_pretty(output)?))
282}
283
284/// Serde default: components are required unless explicitly set to `false`.
285fn default_required() -> bool {
286    true
287}
288
289/// Returns `true` if `value` satisfies binary/product naming rules.
290fn validate_name(value: &str) -> Result<(), ManifestError> {
291    let mut chars = value.chars();
292    let Some(first) = chars.next() else {
293        return Err(ManifestError::InvalidName(value.to_string()));
294    };
295    if !first.is_ascii_lowercase() && !first.is_ascii_digit() {
296        return Err(ManifestError::InvalidName(value.to_string()));
297    }
298    if value.len() < 2 || value.len() > 64 {
299        return Err(ManifestError::InvalidName(value.to_string()));
300    }
301    if !chars.all(|ch| ch.is_ascii_lowercase() || ch.is_ascii_digit() || ch == '-') {
302        return Err(ManifestError::InvalidName(value.to_string()));
303    }
304    Ok(())
305}
306
307/// Returns `Ok` if `value` is a valid `MAJOR.MINOR.PATCH` semver (pre-release/build suffixes accepted).
308fn validate_semver(value: &str) -> Result<(), ManifestError> {
309    let core = value.split_once(['-', '+']).map_or(value, |(core, _)| core);
310    let mut parts = core.split('.');
311    let valid = matches!(
312        (parts.next(), parts.next(), parts.next(), parts.next()),
313        (Some(major), Some(minor), Some(patch), None)
314            if numeric_part(major) && numeric_part(minor) && numeric_part(patch)
315    );
316    if valid {
317        Ok(())
318    } else {
319        Err(ManifestError::InvalidVersion(value.to_string()))
320    }
321}
322
323/// Returns `true` if `value` is a non-empty ASCII-digit string.
324fn numeric_part(value: &str) -> bool {
325    !value.is_empty() && value.chars().all(|ch| ch.is_ascii_digit())
326}