1#![allow(missing_docs)]
2use super::format::SkillFormat;
5use serde::{Deserialize, Serialize};
6use std::collections::HashMap;
7use std::path::PathBuf;
8
9#[derive(Debug, Clone, Default, Serialize, Deserialize)]
10pub struct Requirements {
11 #[serde(default)]
12 pub bins: Vec<String>,
13 #[serde(default, rename = "anyBins")]
14 pub any_bins: Vec<String>,
15 #[serde(default)]
16 pub env: Vec<String>,
17 #[serde(default)]
18 pub config: Vec<String>,
19}
20
21#[derive(Debug, Clone, Serialize, Deserialize)]
22pub struct SkillInstallSpec {
23 pub kind: InstallKind,
24 #[serde(default)]
25 pub formula: Option<String>,
26 #[serde(default)]
27 pub package: Option<String>,
28 #[serde(default)]
29 pub module: Option<String>,
30 #[serde(default)]
31 pub url: Option<String>,
32 #[serde(default)]
33 pub archive: Option<String>,
34 #[serde(default)]
35 pub extract: Option<bool>,
36 #[serde(default, rename = "stripComponents")]
37 pub strip_components: Option<u32>,
38 #[serde(default, rename = "targetDir")]
39 pub target_dir: Option<String>,
40 #[serde(default)]
41 pub os: Vec<String>,
42}
43
44#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
45#[serde(rename_all = "lowercase")]
46pub enum InstallKind {
47 Brew,
48 Node,
49 Go,
50 #[serde(rename = "uv")]
51 Uv,
52 Download,
53}
54
55impl std::fmt::Display for InstallKind {
56 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
57 match self {
58 InstallKind::Brew => write!(f, "brew"),
59 InstallKind::Node => write!(f, "node"),
60 InstallKind::Go => write!(f, "go"),
61 InstallKind::Uv => write!(f, "uv"),
62 InstallKind::Download => write!(f, "download"),
63 }
64 }
65}
66
67#[derive(Debug, Clone, Default, Serialize)]
68pub struct RequirementsCheck {
69 pub missing_bins: Vec<String>,
70 pub missing_any_bins: Vec<String>,
71 pub missing_env: Vec<String>,
72 pub missing_config: Vec<String>,
73 pub missing_os: Vec<String>,
74 pub eligible: bool,
75 pub config_checks: Vec<ConfigCheck>,
76}
77
78#[derive(Debug, Clone, Serialize)]
79pub struct ConfigCheck {
80 pub path: String,
81 pub satisfied: bool,
82}
83
84#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
85#[serde(rename_all = "snake_case")]
86pub enum SkillStatus {
87 Ready,
88 NeedsSetup,
89 Disabled,
90}
91
92impl std::fmt::Display for SkillStatus {
93 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
94 match self {
95 SkillStatus::Ready => write!(f, "ready"),
96 SkillStatus::NeedsSetup => write!(f, "needs_setup"),
97 SkillStatus::Disabled => write!(f, "disabled"),
98 }
99 }
100}
101
102#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
103#[serde(rename_all = "snake_case")]
104pub enum SkillSource {
105 Bundled,
106 Managed,
107 Workspace,
108}
109
110#[derive(Debug, Clone, Serialize, Deserialize)]
111pub struct SkillInvocationPolicy {
112 #[serde(default = "default_true")]
113 pub user_invocable: bool,
114 #[serde(default)]
115 pub disable_model_invocation: bool,
116}
117impl Default for SkillInvocationPolicy {
118 fn default() -> Self {
119 Self {
120 user_invocable: true,
121 disable_model_invocation: false,
122 }
123 }
124}
125
126#[derive(Debug, Clone, Default, Serialize, Deserialize)]
127pub struct SkillMetadata {
128 #[serde(default)]
129 pub author: Option<String>,
130 #[serde(default)]
131 pub version: Option<String>,
132 #[serde(default)]
133 pub emoji: Option<String>,
134 #[serde(default)]
135 pub homepage: Option<String>,
136 #[serde(default)]
137 pub requires: Requirements,
138 #[serde(default)]
139 pub os: Vec<String>,
140 #[serde(default)]
141 pub install: Vec<SkillInstallSpec>,
142 #[serde(default)]
143 pub always: bool,
144 #[serde(default, rename = "primaryEnv")]
145 pub primary_env: Option<String>,
146 #[serde(default, rename = "skillKey")]
147 pub skill_key: Option<String>,
148}
149
150#[derive(Debug, Clone, Default, Serialize, Deserialize)]
151pub struct SkillConfig {
152 #[serde(default = "default_true")]
153 pub enabled: bool,
154 #[serde(default)]
155 pub env: HashMap<String, String>,
156 #[serde(default)]
157 pub config: HashMap<String, String>,
158}
159
160#[derive(Debug, Clone, Serialize, Deserialize)]
161pub struct SkillState {
162 pub enabled: bool,
163 pub installed_at: String,
164 pub last_modified: String,
165}
166impl Default for SkillState {
167 fn default() -> Self {
168 let now = chrono::Utc::now().to_rfc3339();
169 Self {
170 enabled: true,
171 installed_at: now.clone(),
172 last_modified: now,
173 }
174 }
175}
176
177#[derive(Debug, Clone)]
178pub struct Skill {
179 pub name: String,
180 pub description: String,
181 pub content: String,
182 pub path: PathBuf,
183 pub base_dir: PathBuf,
184 pub file_path: PathBuf,
185}
186
187#[derive(Debug, Clone, Serialize, Deserialize)]
188pub struct SkillMeta {
189 pub name: String,
190 pub description: String,
191}
192impl From<&Skill> for SkillMeta {
193 fn from(s: &Skill) -> Self {
194 SkillMeta {
195 name: s.name.clone(),
196 description: s.description.clone(),
197 }
198 }
199}
200
201#[derive(Debug, Clone)]
202pub struct SkillEntry {
203 pub skill: Skill,
204 pub metadata: Option<SkillMetadata>,
205 pub eligibility: RequirementsCheck,
206 pub status: SkillStatus,
207 pub bundled: bool,
208 pub source: SkillSource,
209 pub invocation: SkillInvocationPolicy,
210 pub format: SkillFormat,
211 pub raw_yaml: serde_yaml::Value,
212}
213
214#[derive(Debug, Clone, Serialize, Deserialize)]
215pub struct SkillRef {
216 pub name: String,
217 pub description: String,
218 pub file_path: String,
219 pub primary_env: Option<String>,
220 pub required_env: Vec<String>,
221}
222
223#[derive(Debug, Clone, Serialize, Deserialize)]
224pub struct SkillSnapshot {
225 pub prompt: String,
226 pub skills: Vec<SkillRef>,
227 pub skill_filter: Option<Vec<String>>,
228}
229
230pub(crate) fn default_true() -> bool {
231 true
232}
233
234#[cfg(test)]
235mod tests {
236 use super::*;
237
238 #[test]
239 fn test_install_kind_display() {
240 assert_eq!(InstallKind::Brew.to_string(), "brew");
241 assert_eq!(InstallKind::Node.to_string(), "node");
242 assert_eq!(InstallKind::Go.to_string(), "go");
243 assert_eq!(InstallKind::Uv.to_string(), "uv");
244 assert_eq!(InstallKind::Download.to_string(), "download");
245 }
246
247 #[test]
248 fn test_install_kind_serialization() {
249 for (kind, expected) in [
250 (InstallKind::Brew, "\"brew\""),
251 (InstallKind::Node, "\"node\""),
252 (InstallKind::Go, "\"go\""),
253 (InstallKind::Uv, "\"uv\""),
254 (InstallKind::Download, "\"download\""),
255 ] {
256 let json = serde_json::to_string(&kind).unwrap();
257 assert_eq!(json, expected);
258 let restored: InstallKind = serde_json::from_str(&json).unwrap();
259 assert_eq!(kind, restored);
260 }
261 }
262
263 #[test]
264 fn test_skill_status_display() {
265 assert_eq!(SkillStatus::Ready.to_string(), "ready");
266 assert_eq!(SkillStatus::NeedsSetup.to_string(), "needs_setup");
267 assert_eq!(SkillStatus::Disabled.to_string(), "disabled");
268 }
269
270 #[test]
271 fn test_skill_status_serialization() {
272 for status in [
273 SkillStatus::Ready,
274 SkillStatus::NeedsSetup,
275 SkillStatus::Disabled,
276 ] {
277 let json = serde_json::to_string(&status).unwrap();
278 let restored: SkillStatus = serde_json::from_str(&json).unwrap();
279 assert_eq!(status, restored);
280 }
281 }
282
283 #[test]
284 fn test_requirements_default() {
285 let req = Requirements::default();
286 assert!(req.bins.is_empty());
287 assert!(req.any_bins.is_empty());
288 assert!(req.env.is_empty());
289 assert!(req.config.is_empty());
290 }
291
292 #[test]
293 fn test_requirements_serialization() {
294 let req = Requirements {
295 bins: vec!["cargo".to_string(), "node".to_string()],
296 any_bins: vec!["python3".to_string()],
297 env: vec!["API_KEY".to_string()],
298 config: vec!["server.host".to_string()],
299 };
300 let json = serde_json::to_string(&req).unwrap();
301 let restored: Requirements = serde_json::from_str(&json).unwrap();
302 assert_eq!(restored.bins, req.bins);
303 assert_eq!(restored.any_bins, req.any_bins);
304 assert_eq!(restored.env, req.env);
305 assert_eq!(restored.config, req.config);
306 }
307
308 #[test]
309 fn test_skill_install_spec_minimal() {
310 let spec = SkillInstallSpec {
311 kind: InstallKind::Brew,
312 formula: Some("git".to_string()),
313 package: None,
314 module: None,
315 url: None,
316 archive: None,
317 extract: None,
318 strip_components: None,
319 target_dir: None,
320 os: vec![],
321 };
322 let json = serde_json::to_string(&spec).unwrap();
323 let restored: SkillInstallSpec = serde_json::from_str(&json).unwrap();
324 assert_eq!(restored.kind, InstallKind::Brew);
325 assert_eq!(restored.formula.as_deref(), Some("git"));
326 }
327
328 #[test]
329 fn test_requirements_check_default() {
330 let check = RequirementsCheck::default();
331 assert!(check.missing_bins.is_empty());
332 assert!(check.missing_any_bins.is_empty());
333 assert!(check.missing_env.is_empty());
334 assert!(check.missing_config.is_empty());
335 assert!(check.missing_os.is_empty());
336 assert!(!check.eligible);
338 assert!(check.config_checks.is_empty());
339 }
340
341 #[test]
342 fn test_requirements_check_ineligible() {
343 let check = RequirementsCheck {
344 missing_bins: vec!["nonexistent".to_string()],
345 missing_any_bins: vec![],
346 missing_env: vec!["SECRET_KEY".to_string()],
347 missing_config: vec![],
348 missing_os: vec![],
349 eligible: false,
350 config_checks: vec![],
351 };
352 assert!(!check.eligible);
353 assert_eq!(check.missing_bins.len(), 1);
354 assert_eq!(check.missing_env.len(), 1);
355 }
356
357 #[test]
358 fn test_skill_invocation_policy_default() {
359 let policy = SkillInvocationPolicy::default();
360 assert!(policy.user_invocable);
361 assert!(!policy.disable_model_invocation);
362 }
363
364 #[test]
365 fn test_skill_config_default() {
366 let config = SkillConfig::default();
367 assert!(!config.enabled);
369 assert!(config.env.is_empty());
370 assert!(config.config.is_empty());
371 }
372
373 #[test]
374 fn test_skill_config_deserialization_default_enabled() {
375 let json = "{}";
377 let config: SkillConfig = serde_json::from_str(json).unwrap();
378 assert!(config.enabled);
379 assert!(config.env.is_empty());
380 }
381
382 #[test]
383 fn test_skill_state_default() {
384 let state = SkillState::default();
385 assert!(state.enabled);
386 assert!(!state.installed_at.is_empty());
387 assert!(!state.last_modified.is_empty());
388 }
389
390 #[test]
391 fn test_skill_metadata_default() {
392 let meta = SkillMetadata::default();
393 assert!(meta.author.is_none());
394 assert!(meta.version.is_none());
395 assert!(meta.emoji.is_none());
396 assert!(meta.homepage.is_none());
397 assert!(meta.install.is_empty());
398 assert!(!meta.always);
399 assert!(meta.primary_env.is_none());
400 }
401
402 #[test]
403 fn test_skill_meta_from_skill() {
404 let skill = Skill {
405 name: "test".to_string(),
406 description: "desc".to_string(),
407 content: "body".to_string(),
408 path: PathBuf::from("/tmp"),
409 base_dir: PathBuf::from("/tmp"),
410 file_path: PathBuf::from("/tmp/SKILL.md"),
411 };
412 let meta = SkillMeta::from(&skill);
413 assert_eq!(meta.name, "test");
414 assert_eq!(meta.description, "desc");
415 }
416
417 #[test]
418 fn test_skill_snapshot_serialization() {
419 let snap = SkillSnapshot {
420 prompt: "You are helpful".to_string(),
421 skills: vec![SkillRef {
422 name: "bash".to_string(),
423 description: "shell".to_string(),
424 file_path: "/skills/bash.md".to_string(),
425 primary_env: None,
426 required_env: vec![],
427 }],
428 skill_filter: Some(vec!["bash".to_string()]),
429 };
430 let json = serde_json::to_string(&snap).unwrap();
431 let restored: SkillSnapshot = serde_json::from_str(&json).unwrap();
432 assert_eq!(restored.prompt, "You are helpful");
433 assert_eq!(restored.skills.len(), 1);
434 assert_eq!(restored.skill_filter.as_ref().unwrap().len(), 1);
435 }
436
437 #[test]
438 fn test_config_check() {
439 let check = ConfigCheck {
440 path: "server.port".to_string(),
441 satisfied: true,
442 };
443 assert_eq!(check.path, "server.port");
444 assert!(check.satisfied);
445 }
446}