1use serde::{Deserialize, Serialize};
8use std::collections::BTreeMap;
9
10pub const MANIFEST_VERSION: u32 = 1;
12
13#[derive(Debug, thiserror::Error)]
15pub enum ManifestError {
16 #[error("invalid binary name `{0}`")]
18 InvalidName(String),
19 #[error("invalid semver `{0}`")]
21 InvalidVersion(String),
22 #[error("json serialization failed: {0}")]
24 Json(#[from] serde_json::Error),
25}
26
27#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
29#[serde(rename_all = "kebab-case")]
30pub enum ExecutableKind {
31 Cli,
33 Lsp,
35 Mcp,
37 Sidecar,
39 Dap,
41 Tool,
43}
44
45#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
47#[serde(rename_all = "kebab-case")]
48pub enum Language {
49 Rust,
51 Dotnet,
53 Dart,
55 Typescript,
57 Kotlin,
59 Javascript,
61}
62
63#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
65#[serde(rename_all = "camelCase")]
66pub struct VersionOutput {
67 pub manifest_version: u32,
69 pub name: String,
71 pub version: String,
73 pub kind: ExecutableKind,
75 pub language: Language,
77 #[serde(skip_serializing_if = "Option::is_none")]
79 pub build_time: Option<String>,
80 #[serde(skip_serializing_if = "Option::is_none")]
82 pub git_sha: Option<String>,
83 #[serde(skip_serializing_if = "Option::is_none")]
85 pub git_dirty: Option<bool>,
86 #[serde(skip_serializing_if = "Option::is_none")]
88 pub target: Option<String>,
89 #[serde(skip_serializing_if = "Option::is_none")]
91 pub toolchain: Option<String>,
92 #[serde(default, skip_serializing_if = "Vec::is_empty")]
94 pub capabilities: Vec<String>,
95 #[serde(skip_serializing_if = "Option::is_none")]
97 pub product: Option<String>,
98}
99
100impl VersionOutput {
101 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 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 #[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#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
156#[serde(rename_all = "camelCase")]
157pub struct ProductManifest {
158 pub manifest_version: u32,
160 pub product: Product,
162 pub components: Vec<Component>,
164 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
166 pub hosts: BTreeMap<String, HostPolicy>,
167}
168
169#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
171#[serde(rename_all = "camelCase")]
172pub struct Product {
173 pub id: String,
175 #[serde(skip_serializing_if = "Option::is_none")]
177 pub display_name: Option<String>,
178 pub version: String,
180 #[serde(skip_serializing_if = "Option::is_none")]
182 pub repository: Option<String>,
183 #[serde(skip_serializing_if = "Option::is_none")]
185 pub homepage: Option<String>,
186}
187
188#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
190#[serde(rename_all = "kebab-case")]
191pub enum ComponentKind {
192 Cli,
194 Lsp,
196 Mcp,
198 Sidecar,
200 Dap,
202 Tool,
204 ExtensionVscode,
206 ExtensionJetbrains,
208 ExtensionZed,
210 Asset,
212}
213
214#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
216#[serde(rename_all = "camelCase")]
217pub struct Component {
218 pub id: String,
220 pub kind: ComponentKind,
222 #[serde(skip_serializing_if = "Option::is_none")]
224 pub language: Option<Language>,
225 #[serde(skip_serializing_if = "Option::is_none")]
227 pub binary_name: Option<String>,
228 #[serde(skip_serializing_if = "Option::is_none")]
230 pub expected_version: Option<String>,
231 #[serde(default, skip_serializing_if = "Vec::is_empty")]
233 pub platforms: Vec<String>,
234 #[serde(default, skip_serializing_if = "Vec::is_empty")]
236 pub sources: Vec<String>,
237 #[serde(default = "default_required")]
239 pub required: bool,
240}
241
242#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
244#[serde(rename_all = "camelCase")]
245pub struct HostPolicy {
246 #[serde(skip_serializing_if = "Option::is_none")]
248 pub artifact: Option<String>,
249 #[serde(default, skip_serializing_if = "Vec::is_empty")]
251 pub activation_verifies: Vec<String>,
252 #[serde(skip_serializing_if = "Option::is_none")]
254 pub on_mismatch: Option<String>,
255}
256
257pub 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
268pub 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
284fn default_required() -> bool {
286 true
287}
288
289fn 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
307fn 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
323fn numeric_part(value: &str) -> bool {
325 !value.is_empty() && value.chars().all(|ch| ch.is_ascii_digit())
326}