Skip to main content

plugin_packager/
platform.rs

1// Copyright 2024 Vincents AI
2// SPDX-License-Identifier: MIT OR Apache-2.0
3
4//! Cross-platform plugin artifact support
5//!
6//! This module provides platform detection and artifact type validation for
7//! cross-platform plugin packaging as specified in RFC-0003.
8//!
9//! ## Supported Platforms
10//! - Linux: `.so` (shared object)
11//! - Windows: `.dll` (dynamic link library)
12//! - macOS: `.dylib` (dynamic library)
13//!
14//! ## Naming Convention
15//! Artifacts follow the pattern: `<plugin-name>-v<version>-<target-triple>.tar.gz`
16//!
17//! Examples:
18//! - `myplugin-v1.0.0-x86_64-unknown-linux-gnu.tar.gz`
19//! - `myplugin-v1.0.0-x86_64-pc-windows-gnu.tar.gz`
20//! - `myplugin-v1.0.0-x86_64-apple-darwin.tar.gz`
21//! - `myplugin-v1.0.0-aarch64-apple-darwin.tar.gz`
22
23use anyhow::{bail, Result};
24use serde::{Deserialize, Serialize};
25use std::fmt;
26use std::path::Path;
27
28/// Supported platform types for plugin artifacts
29#[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    /// Get the expected artifact extension for this platform
39    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    /// Get the expected artifact filename for this platform
48    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    /// Detect platform from target triple
57    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    /// Get current host platform
70    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 // Default fallback
86        }
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
100/// All supported artifact extensions
101pub const SUPPORTED_ARTIFACT_EXTENSIONS: &[&str] = &["so", "dll", "dylib"];
102
103/// All supported artifact filenames
104pub const SUPPORTED_ARTIFACT_FILENAMES: &[&str] = &["plugin.so", "plugin.dll", "plugin.dylib"];
105
106/// Information extracted from an artifact filename
107#[derive(Debug, Clone, PartialEq, Eq)]
108pub struct ArtifactMetadata {
109    /// Plugin name (lowercase with hyphens/underscores)
110    pub name: String,
111    /// Plugin version (semver with 'v' prefix)
112    pub version: String,
113    /// Target triple (e.g., "x86_64-unknown-linux-gnu")
114    pub target_triple: String,
115    /// Detected platform
116    pub platform: Platform,
117}
118
119impl ArtifactMetadata {
120    /// Parse artifact filename to extract metadata
121    ///
122    /// Expected format: `<plugin-name>-v<version>-<target-triple>.tar.gz`
123    ///
124    /// # Examples
125    /// ```
126    /// use plugin_packager::platform::{ArtifactMetadata, Platform};
127    ///
128    /// let meta = ArtifactMetadata::parse("myplugin-v1.0.0-x86_64-unknown-linux-gnu.tar.gz").unwrap();
129    /// assert_eq!(meta.name, "myplugin");
130    /// assert_eq!(meta.version, "1.0.0");
131    /// assert_eq!(meta.target_triple, "x86_64-unknown-linux-gnu");
132    /// assert_eq!(meta.platform, Platform::Linux);
133    /// ```
134    pub fn parse(filename: &str) -> Result<Self> {
135        // Must end with .tar.gz
136        if !filename.ends_with(".tar.gz") {
137            bail!("Artifact filename must end with .tar.gz: {}", filename);
138        }
139
140        // Strip .tar.gz suffix
141        let base = &filename[..filename.len() - 7];
142
143        // Split into parts by '-'
144        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        // Find the version part (starts with 'v' followed by digit)
156        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        // Name is everything before the version (joined by '-')
176        let name = parts[..version_idx].join("-");
177
178        // Validate name format
179        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        // Version is the version part without 'v' prefix
198        let version = parts[version_idx][1..].to_string();
199
200        // Validate version format (basic semver check)
201        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        // Target triple is everything after version (joined by '-')
216        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        // Detect platform from target triple
226        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    /// Generate artifact filename from metadata
243    pub fn to_filename(&self) -> String {
244        format!(
245            "{}-v{}-{}.tar.gz",
246            self.name, self.version, self.target_triple
247        )
248    }
249}
250
251/// Validate that a path contains a valid artifact for the given platform
252pub 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    // Check for any supported artifact filename
265    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    // For the expected platform, the file should match
275    if filename != expected_filename {
276        // This is a warning-level issue - the artifact exists but is for a different platform
277        // We still allow it for cross-compilation scenarios
278    }
279
280    Ok(())
281}
282
283/// Get all valid artifact filenames for verification
284pub fn get_valid_artifact_filenames() -> &'static [&'static str] {
285    SUPPORTED_ARTIFACT_FILENAMES
286}
287
288/// Check if a filename is a valid plugin artifact
289pub fn is_valid_artifact_filename(filename: &str) -> bool {
290    SUPPORTED_ARTIFACT_FILENAMES.contains(&filename)
291}
292
293/// Check if an extension is a valid artifact extension
294pub 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}