Skip to main content

mai_cli/core/
pack.rs

1use crate::core::version::Version;
2use serde::{Deserialize, Serialize};
3
4/// Installation scope for a pack
5#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
6#[serde(rename_all = "lowercase")]
7pub enum InstallScope {
8    /// Global installation (user-wide, in XDG directories)
9    Global,
10    /// Local installation (project-specific, in .mai/ directory)
11    #[default]
12    Local,
13}
14
15impl std::fmt::Display for InstallScope {
16    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
17        match self {
18            InstallScope::Global => write!(f, "global"),
19            InstallScope::Local => write!(f, "local"),
20        }
21    }
22}
23
24#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
25#[serde(rename_all = "lowercase")]
26pub enum PackType {
27    Skill,
28    Command,
29    Mcp,
30}
31
32impl std::fmt::Display for PackType {
33    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
34        match self {
35            PackType::Skill => write!(f, "skill"),
36            PackType::Command => write!(f, "command"),
37            PackType::Mcp => write!(f, "mcp"),
38        }
39    }
40}
41
42#[derive(Debug, Clone, Serialize, Deserialize)]
43pub struct PackMetadata {
44    #[serde(default)]
45    pub description: Option<String>,
46    #[serde(default)]
47    pub source_url: Option<String>,
48    #[serde(default)]
49    pub author: Option<String>,
50    #[serde(default)]
51    pub license: Option<String>,
52}
53
54impl PackMetadata {
55    pub fn new() -> Self {
56        Self {
57            description: None,
58            source_url: None,
59            author: None,
60            license: None,
61        }
62    }
63
64    pub fn with_description(mut self, desc: impl Into<String>) -> Self {
65        self.description = Some(desc.into());
66        self
67    }
68
69    pub fn with_source_url(mut self, url: impl Into<String>) -> Self {
70        self.source_url = Some(url.into());
71        self
72    }
73
74    #[allow(dead_code)]
75    pub fn with_author(mut self, author: impl Into<String>) -> Self {
76        self.author = Some(author.into());
77        self
78    }
79
80    #[allow(dead_code)]
81    pub fn with_license(mut self, license: impl Into<String>) -> Self {
82        self.license = Some(license.into());
83        self
84    }
85}
86
87impl Default for PackMetadata {
88    fn default() -> Self {
89        Self::new()
90    }
91}
92
93#[derive(Debug, Clone, Serialize, Deserialize)]
94pub struct Pack {
95    pub name: String,
96    #[serde(rename = "type")]
97    pub pack_type: PackType,
98    pub version: Version,
99    #[serde(default)]
100    pub tool: Option<String>,
101    #[serde(default)]
102    pub metadata: PackMetadata,
103    #[serde(default)]
104    pub scope: InstallScope,
105}
106
107impl Pack {
108    pub fn new(name: impl Into<String>, pack_type: PackType, version: Version) -> Self {
109        Self {
110            name: name.into(),
111            pack_type,
112            version,
113            tool: None,
114            metadata: PackMetadata::new(),
115            scope: InstallScope::default(),
116        }
117    }
118
119    pub fn with_tool(mut self, tool: impl Into<String>) -> Self {
120        self.tool = Some(tool.into());
121        self
122    }
123
124    pub fn with_metadata(mut self, metadata: PackMetadata) -> Self {
125        self.metadata = metadata;
126        self
127    }
128
129    pub fn with_scope(mut self, scope: InstallScope) -> Self {
130        self.scope = scope;
131        self
132    }
133
134    pub fn id(&self) -> String {
135        format!(
136            "{}/{}/{}@{}",
137            self.scope,
138            self.tool.as_deref().unwrap_or("*"),
139            self.name,
140            self.version
141        )
142    }
143}
144
145#[cfg(test)]
146mod tests {
147    use super::*;
148
149    #[test]
150    fn test_pack_id() {
151        let pack = Pack::new("aif", PackType::Skill, Version::new(1, 0, 0));
152        assert_eq!(pack.id(), "local/*/aif@1.0.0");
153
154        let pack_with_tool = pack.clone().with_tool("qwen");
155        assert_eq!(pack_with_tool.id(), "local/qwen/aif@1.0.0");
156
157        let pack_global = Pack::new("aif", PackType::Skill, Version::new(1, 0, 0))
158            .with_tool("qwen")
159            .with_scope(InstallScope::Global);
160        assert_eq!(pack_global.id(), "global/qwen/aif@1.0.0");
161    }
162
163    #[test]
164    fn test_pack_metadata() {
165        let metadata = PackMetadata::new()
166            .with_description("A test pack")
167            .with_source_url("https://github.com/example/pack")
168            .with_author("Test Author")
169            .with_license("MIT");
170
171        assert_eq!(metadata.description, Some("A test pack".to_string()));
172        assert_eq!(
173            metadata.source_url,
174            Some("https://github.com/example/pack".to_string())
175        );
176        assert_eq!(metadata.author, Some("Test Author".to_string()));
177        assert_eq!(metadata.license, Some("MIT".to_string()));
178    }
179
180    #[test]
181    fn test_install_scope_default() {
182        let scope = InstallScope::default();
183        assert_eq!(scope, InstallScope::Local);
184    }
185
186    #[test]
187    fn test_install_scope_display() {
188        assert_eq!(InstallScope::Local.to_string(), "local");
189        assert_eq!(InstallScope::Global.to_string(), "global");
190    }
191}