1use anyhow::{bail, Result};
24use serde::{Deserialize, Serialize};
25use std::fmt;
26use std::path::Path;
27
28#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
30#[serde(rename_all = "lowercase")]
31pub enum Platform {
32 Linux,
33 Windows,
34 Macos,
35}
36
37impl Platform {
38 pub fn artifact_extension(&self) -> &'static str {
40 match self {
41 Platform::Linux => "so",
42 Platform::Windows => "dll",
43 Platform::Macos => "dylib",
44 }
45 }
46
47 pub fn artifact_filename(&self) -> &'static str {
49 match self {
50 Platform::Linux => "plugin.so",
51 Platform::Windows => "plugin.dll",
52 Platform::Macos => "plugin.dylib",
53 }
54 }
55
56 pub fn from_target_triple(target: &str) -> Option<Self> {
58 if target.contains("linux") {
59 Some(Platform::Linux)
60 } else if target.contains("windows") {
61 Some(Platform::Windows)
62 } else if target.contains("apple") || target.contains("darwin") {
63 Some(Platform::Macos)
64 } else {
65 None
66 }
67 }
68
69 pub fn host() -> Self {
71 #[cfg(target_os = "linux")]
72 {
73 Platform::Linux
74 }
75 #[cfg(target_os = "windows")]
76 {
77 Platform::Windows
78 }
79 #[cfg(target_os = "macos")]
80 {
81 Platform::Macos
82 }
83 #[cfg(not(any(target_os = "linux", target_os = "windows", target_os = "macos")))]
84 {
85 Platform::Linux }
87 }
88}
89
90impl fmt::Display for Platform {
91 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
92 match self {
93 Platform::Linux => write!(f, "linux"),
94 Platform::Windows => write!(f, "windows"),
95 Platform::Macos => write!(f, "macos"),
96 }
97 }
98}
99
100pub const SUPPORTED_ARTIFACT_EXTENSIONS: &[&str] = &["so", "dll", "dylib"];
102
103pub const SUPPORTED_ARTIFACT_FILENAMES: &[&str] = &["plugin.so", "plugin.dll", "plugin.dylib"];
105
106#[derive(Debug, Clone, PartialEq, Eq)]
108pub struct ArtifactMetadata {
109 pub name: String,
111 pub version: String,
113 pub target_triple: String,
115 pub platform: Platform,
117}
118
119impl ArtifactMetadata {
120 pub fn parse(filename: &str) -> Result<Self> {
135 if !filename.ends_with(".tar.gz") {
137 bail!("Artifact filename must end with .tar.gz: {}", filename);
138 }
139
140 let base = &filename[..filename.len() - 7];
142
143 let parts: Vec<&str> = base.split('-').collect();
145
146 if parts.len() < 4 {
147 bail!(
148 "Artifact filename must follow pattern: <name>-v<version>-<target>.tar.gz\n\
149 Got: {}\n\
150 Example: myplugin-v1.0.0-x86_64-unknown-linux-gnu.tar.gz",
151 filename
152 );
153 }
154
155 let version_idx = parts
157 .iter()
158 .position(|p| {
159 p.starts_with('v')
160 && p.len() > 1
161 && p[1..]
162 .chars()
163 .next()
164 .map(|c| c.is_ascii_digit())
165 .unwrap_or(false)
166 })
167 .ok_or_else(|| {
168 anyhow::anyhow!(
169 "Artifact filename must contain version with 'v' prefix (e.g., -v1.0.0-)\n\
170 Got: {}",
171 filename
172 )
173 })?;
174
175 let name = parts[..version_idx].join("-");
177
178 if name.is_empty() {
180 bail!(
181 "Plugin name cannot be empty in artifact filename: {}",
182 filename
183 );
184 }
185 if !name
186 .chars()
187 .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-' || c == '_')
188 {
189 bail!(
190 "Plugin name must be lowercase with hyphens/underscores only: {}\n\
191 Got: {}",
192 filename,
193 name
194 );
195 }
196
197 let version = parts[version_idx][1..].to_string();
199
200 let version_parts: Vec<&str> = version.split('.').collect();
202 if version_parts.len() < 3 {
203 bail!(
204 "Version must follow semantic versioning (major.minor.patch)\n\
205 Got: {}",
206 version
207 );
208 }
209 for part in &version_parts[..3] {
210 if part.parse::<u32>().is_err() {
211 bail!("Version parts must be numeric: {}", version);
212 }
213 }
214
215 let target_triple = parts[version_idx + 1..].join("-");
217
218 if target_triple.is_empty() {
219 bail!(
220 "Target triple cannot be empty in artifact filename: {}",
221 filename
222 );
223 }
224
225 let platform = Platform::from_target_triple(&target_triple).ok_or_else(|| {
227 anyhow::anyhow!(
228 "Unknown platform in target triple: {}\n\
229 Supported: linux, windows, apple/darwin",
230 target_triple
231 )
232 })?;
233
234 Ok(ArtifactMetadata {
235 name,
236 version,
237 target_triple,
238 platform,
239 })
240 }
241
242 pub fn to_filename(&self) -> String {
244 format!(
245 "{}-v{}-{}.tar.gz",
246 self.name, self.version, self.target_triple
247 )
248 }
249}
250
251pub fn validate_platform_artifact(path: &Path, platform: Platform) -> Result<()> {
253 let expected_filename = platform.artifact_filename();
254
255 if !path.exists() {
256 bail!("Artifact file not found: {}", path.display());
257 }
258
259 let filename = path
260 .file_name()
261 .and_then(|n| n.to_str())
262 .ok_or_else(|| anyhow::anyhow!("Invalid filename: {}", path.display()))?;
263
264 if !SUPPORTED_ARTIFACT_FILENAMES.contains(&filename) {
266 bail!(
267 "Invalid artifact filename: {}\n\
268 Expected one of: {}",
269 filename,
270 SUPPORTED_ARTIFACT_FILENAMES.join(", ")
271 );
272 }
273
274 if filename != expected_filename {
276 }
279
280 Ok(())
281}
282
283pub fn get_valid_artifact_filenames() -> &'static [&'static str] {
285 SUPPORTED_ARTIFACT_FILENAMES
286}
287
288pub fn is_valid_artifact_filename(filename: &str) -> bool {
290 SUPPORTED_ARTIFACT_FILENAMES.contains(&filename)
291}
292
293pub fn is_valid_artifact_extension(ext: &str) -> bool {
295 SUPPORTED_ARTIFACT_EXTENSIONS.contains(&ext)
296}
297
298#[cfg(test)]
299mod tests {
300 use super::*;
301
302 #[test]
303 fn test_platform_artifact_extensions() {
304 assert_eq!(Platform::Linux.artifact_extension(), "so");
305 assert_eq!(Platform::Windows.artifact_extension(), "dll");
306 assert_eq!(Platform::Macos.artifact_extension(), "dylib");
307 }
308
309 #[test]
310 fn test_platform_artifact_filenames() {
311 assert_eq!(Platform::Linux.artifact_filename(), "plugin.so");
312 assert_eq!(Platform::Windows.artifact_filename(), "plugin.dll");
313 assert_eq!(Platform::Macos.artifact_filename(), "plugin.dylib");
314 }
315
316 #[test]
317 fn test_platform_from_target_triple() {
318 assert_eq!(
319 Platform::from_target_triple("x86_64-unknown-linux-gnu"),
320 Some(Platform::Linux)
321 );
322 assert_eq!(
323 Platform::from_target_triple("x86_64-pc-windows-gnu"),
324 Some(Platform::Windows)
325 );
326 assert_eq!(
327 Platform::from_target_triple("x86_64-apple-darwin"),
328 Some(Platform::Macos)
329 );
330 assert_eq!(
331 Platform::from_target_triple("aarch64-apple-darwin"),
332 Some(Platform::Macos)
333 );
334 assert_eq!(Platform::from_target_triple("unknown-unknown"), None);
335 }
336
337 #[test]
338 fn test_artifact_metadata_parse_linux() {
339 let meta =
340 ArtifactMetadata::parse("myplugin-v1.0.0-x86_64-unknown-linux-gnu.tar.gz").unwrap();
341 assert_eq!(meta.name, "myplugin");
342 assert_eq!(meta.version, "1.0.0");
343 assert_eq!(meta.target_triple, "x86_64-unknown-linux-gnu");
344 assert_eq!(meta.platform, Platform::Linux);
345 }
346
347 #[test]
348 fn test_artifact_metadata_parse_windows() {
349 let meta = ArtifactMetadata::parse("myplugin-v2.3.4-x86_64-pc-windows-gnu.tar.gz").unwrap();
350 assert_eq!(meta.name, "myplugin");
351 assert_eq!(meta.version, "2.3.4");
352 assert_eq!(meta.target_triple, "x86_64-pc-windows-gnu");
353 assert_eq!(meta.platform, Platform::Windows);
354 }
355
356 #[test]
357 fn test_artifact_metadata_parse_macos() {
358 let meta = ArtifactMetadata::parse("myplugin-v0.1.0-aarch64-apple-darwin.tar.gz").unwrap();
359 assert_eq!(meta.name, "myplugin");
360 assert_eq!(meta.version, "0.1.0");
361 assert_eq!(meta.target_triple, "aarch64-apple-darwin");
362 assert_eq!(meta.platform, Platform::Macos);
363 }
364
365 #[test]
366 fn test_artifact_metadata_parse_with_hyphenated_name() {
367 let meta =
368 ArtifactMetadata::parse("my-awesome-plugin-v1.0.0-x86_64-unknown-linux-gnu.tar.gz")
369 .unwrap();
370 assert_eq!(meta.name, "my-awesome-plugin");
371 assert_eq!(meta.version, "1.0.0");
372 }
373
374 #[test]
375 fn test_artifact_metadata_parse_invalid_no_extension() {
376 let result = ArtifactMetadata::parse("myplugin-v1.0.0-x86_64-unknown-linux-gnu");
377 assert!(result.is_err());
378 }
379
380 #[test]
381 fn test_artifact_metadata_parse_invalid_no_v_prefix() {
382 let result = ArtifactMetadata::parse("myplugin-1.0.0-x86_64-unknown-linux-gnu.tar.gz");
383 assert!(result.is_err());
384 }
385
386 #[test]
387 fn test_artifact_metadata_parse_invalid_uppercase_name() {
388 let result = ArtifactMetadata::parse("MyPlugin-v1.0.0-x86_64-unknown-linux-gnu.tar.gz");
389 assert!(result.is_err());
390 }
391
392 #[test]
393 fn test_artifact_metadata_parse_invalid_bad_version() {
394 let result = ArtifactMetadata::parse("myplugin-v1.0-x86_64-unknown-linux-gnu.tar.gz");
395 assert!(result.is_err());
396 }
397
398 #[test]
399 fn test_artifact_metadata_to_filename() {
400 let meta = ArtifactMetadata {
401 name: "myplugin".to_string(),
402 version: "1.0.0".to_string(),
403 target_triple: "x86_64-unknown-linux-gnu".to_string(),
404 platform: Platform::Linux,
405 };
406 assert_eq!(
407 meta.to_filename(),
408 "myplugin-v1.0.0-x86_64-unknown-linux-gnu.tar.gz"
409 );
410 }
411
412 #[test]
413 fn test_is_valid_artifact_filename() {
414 assert!(is_valid_artifact_filename("plugin.so"));
415 assert!(is_valid_artifact_filename("plugin.dll"));
416 assert!(is_valid_artifact_filename("plugin.dylib"));
417 assert!(!is_valid_artifact_filename("plugin.bin"));
418 assert!(!is_valid_artifact_filename("plugin"));
419 }
420
421 #[test]
422 fn test_is_valid_artifact_extension() {
423 assert!(is_valid_artifact_extension("so"));
424 assert!(is_valid_artifact_extension("dll"));
425 assert!(is_valid_artifact_extension("dylib"));
426 assert!(!is_valid_artifact_extension("bin"));
427 assert!(!is_valid_artifact_extension("exe"));
428 }
429
430 #[test]
431 fn test_platform_display() {
432 assert_eq!(format!("{}", Platform::Linux), "linux");
433 assert_eq!(format!("{}", Platform::Windows), "windows");
434 assert_eq!(format!("{}", Platform::Macos), "macos");
435 }
436
437 #[test]
438 fn test_supported_extensions_constant() {
439 assert_eq!(SUPPORTED_ARTIFACT_EXTENSIONS, &["so", "dll", "dylib"]);
440 }
441
442 #[test]
443 fn test_supported_filenames_constant() {
444 assert_eq!(
445 SUPPORTED_ARTIFACT_FILENAMES,
446 &["plugin.so", "plugin.dll", "plugin.dylib"]
447 );
448 }
449}