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)]
147 pub autonomous: bool,
148 #[serde(default, rename = "primaryEnv")]
149 pub primary_env: Option<String>,
150 #[serde(default, rename = "skillKey")]
151 pub skill_key: Option<String>,
152}
153
154#[derive(Debug, Clone, Default, Serialize, Deserialize)]
155pub struct SkillConfig {
156 #[serde(default = "default_true")]
157 pub enabled: bool,
158 #[serde(default)]
159 pub env: HashMap<String, String>,
160 #[serde(default)]
161 pub config: HashMap<String, String>,
162}
163
164#[derive(Debug, Clone, Serialize, Deserialize)]
165pub struct SkillState {
166 pub enabled: bool,
167 pub installed_at: String,
168 pub last_modified: String,
169}
170impl Default for SkillState {
171 fn default() -> Self {
172 let now = chrono::Utc::now().to_rfc3339();
173 Self {
174 enabled: true,
175 installed_at: now.clone(),
176 last_modified: now,
177 }
178 }
179}
180
181#[derive(Debug, Clone)]
182pub struct Skill {
183 pub name: String,
184 pub description: String,
185 pub content: String,
186 pub path: PathBuf,
187 pub base_dir: PathBuf,
188 pub file_path: PathBuf,
189}
190
191#[derive(Debug, Clone, Serialize, Deserialize)]
192pub struct SkillMeta {
193 pub name: String,
194 pub description: String,
195}
196impl From<&Skill> for SkillMeta {
197 fn from(s: &Skill) -> Self {
198 SkillMeta {
199 name: s.name.clone(),
200 description: s.description.clone(),
201 }
202 }
203}
204
205#[derive(Debug, Clone)]
206pub struct SkillEntry {
207 pub skill: Skill,
208 pub metadata: Option<SkillMetadata>,
209 pub eligibility: RequirementsCheck,
210 pub status: SkillStatus,
211 pub bundled: bool,
212 pub source: SkillSource,
213 pub invocation: SkillInvocationPolicy,
214 pub format: SkillFormat,
215 pub raw_yaml: serde_yaml::Value,
216}
217
218#[derive(Debug, Clone, Serialize, Deserialize)]
219pub struct SkillRef {
220 pub name: String,
221 pub description: String,
222 pub file_path: String,
223 pub primary_env: Option<String>,
224 pub required_env: Vec<String>,
225}
226
227#[derive(Debug, Clone, Serialize, Deserialize)]
228pub struct SkillSnapshot {
229 pub prompt: String,
230 pub skills: Vec<SkillRef>,
231 pub skill_filter: Option<Vec<String>>,
232}
233
234pub(crate) fn default_true() -> bool {
235 true
236}
237
238#[cfg(test)]
239mod tests {
240 use super::*;
241
242 #[test]
243 fn test_install_kind_display() {
244 assert_eq!(InstallKind::Brew.to_string(), "brew");
245 assert_eq!(InstallKind::Node.to_string(), "node");
246 assert_eq!(InstallKind::Go.to_string(), "go");
247 assert_eq!(InstallKind::Uv.to_string(), "uv");
248 assert_eq!(InstallKind::Download.to_string(), "download");
249 }
250
251 #[test]
252 fn test_install_kind_serialization() {
253 for (kind, expected) in [
254 (InstallKind::Brew, "\"brew\""),
255 (InstallKind::Node, "\"node\""),
256 (InstallKind::Go, "\"go\""),
257 (InstallKind::Uv, "\"uv\""),
258 (InstallKind::Download, "\"download\""),
259 ] {
260 let json = serde_json::to_string(&kind).unwrap();
261 assert_eq!(json, expected);
262 let restored: InstallKind = serde_json::from_str(&json).unwrap();
263 assert_eq!(kind, restored);
264 }
265 }
266
267 #[test]
268 fn test_skill_status_display() {
269 assert_eq!(SkillStatus::Ready.to_string(), "ready");
270 assert_eq!(SkillStatus::NeedsSetup.to_string(), "needs_setup");
271 assert_eq!(SkillStatus::Disabled.to_string(), "disabled");
272 }
273
274 #[test]
275 fn test_skill_status_serialization() {
276 for status in [
277 SkillStatus::Ready,
278 SkillStatus::NeedsSetup,
279 SkillStatus::Disabled,
280 ] {
281 let json = serde_json::to_string(&status).unwrap();
282 let restored: SkillStatus = serde_json::from_str(&json).unwrap();
283 assert_eq!(status, restored);
284 }
285 }
286
287 #[test]
288 fn test_requirements_default() {
289 let req = Requirements::default();
290 assert!(req.bins.is_empty());
291 assert!(req.any_bins.is_empty());
292 assert!(req.env.is_empty());
293 assert!(req.config.is_empty());
294 }
295
296 #[test]
297 fn test_requirements_serialization() {
298 let req = Requirements {
299 bins: vec!["cargo".to_string(), "node".to_string()],
300 any_bins: vec!["python3".to_string()],
301 env: vec!["API_KEY".to_string()],
302 config: vec!["server.host".to_string()],
303 };
304 let json = serde_json::to_string(&req).unwrap();
305 let restored: Requirements = serde_json::from_str(&json).unwrap();
306 assert_eq!(restored.bins, req.bins);
307 assert_eq!(restored.any_bins, req.any_bins);
308 assert_eq!(restored.env, req.env);
309 assert_eq!(restored.config, req.config);
310 }
311
312 #[test]
313 fn test_skill_install_spec_minimal() {
314 let spec = SkillInstallSpec {
315 kind: InstallKind::Brew,
316 formula: Some("git".to_string()),
317 package: None,
318 module: None,
319 url: None,
320 archive: None,
321 extract: None,
322 strip_components: None,
323 target_dir: None,
324 os: vec![],
325 };
326 let json = serde_json::to_string(&spec).unwrap();
327 let restored: SkillInstallSpec = serde_json::from_str(&json).unwrap();
328 assert_eq!(restored.kind, InstallKind::Brew);
329 assert_eq!(restored.formula.as_deref(), Some("git"));
330 }
331
332 #[test]
333 fn test_requirements_check_default() {
334 let check = RequirementsCheck::default();
335 assert!(check.missing_bins.is_empty());
336 assert!(check.missing_any_bins.is_empty());
337 assert!(check.missing_env.is_empty());
338 assert!(check.missing_config.is_empty());
339 assert!(check.missing_os.is_empty());
340 assert!(!check.eligible);
342 assert!(check.config_checks.is_empty());
343 }
344
345 #[test]
346 fn test_requirements_check_ineligible() {
347 let check = RequirementsCheck {
348 missing_bins: vec!["nonexistent".to_string()],
349 missing_any_bins: vec![],
350 missing_env: vec!["SECRET_KEY".to_string()],
351 missing_config: vec![],
352 missing_os: vec![],
353 eligible: false,
354 config_checks: vec![],
355 };
356 assert!(!check.eligible);
357 assert_eq!(check.missing_bins.len(), 1);
358 assert_eq!(check.missing_env.len(), 1);
359 }
360
361 #[test]
362 fn test_skill_invocation_policy_default() {
363 let policy = SkillInvocationPolicy::default();
364 assert!(policy.user_invocable);
365 assert!(!policy.disable_model_invocation);
366 }
367
368 #[test]
369 fn test_skill_config_default() {
370 let config = SkillConfig::default();
371 assert!(!config.enabled);
373 assert!(config.env.is_empty());
374 assert!(config.config.is_empty());
375 }
376
377 #[test]
378 fn test_skill_config_deserialization_default_enabled() {
379 let json = "{}";
381 let config: SkillConfig = serde_json::from_str(json).unwrap();
382 assert!(config.enabled);
383 assert!(config.env.is_empty());
384 }
385
386 #[test]
387 fn test_skill_state_default() {
388 let state = SkillState::default();
389 assert!(state.enabled);
390 assert!(!state.installed_at.is_empty());
391 assert!(!state.last_modified.is_empty());
392 }
393
394 #[test]
395 fn test_skill_metadata_default() {
396 let meta = SkillMetadata::default();
397 assert!(meta.author.is_none());
398 assert!(meta.version.is_none());
399 assert!(meta.emoji.is_none());
400 assert!(meta.homepage.is_none());
401 assert!(meta.install.is_empty());
402 assert!(!meta.always);
403 assert!(meta.primary_env.is_none());
404 }
405
406 #[test]
407 fn test_skill_meta_from_skill() {
408 let skill = Skill {
409 name: "test".to_string(),
410 description: "desc".to_string(),
411 content: "body".to_string(),
412 path: PathBuf::from("/tmp"),
413 base_dir: PathBuf::from("/tmp"),
414 file_path: PathBuf::from("/tmp/SKILL.md"),
415 };
416 let meta = SkillMeta::from(&skill);
417 assert_eq!(meta.name, "test");
418 assert_eq!(meta.description, "desc");
419 }
420
421 #[test]
422 fn test_skill_snapshot_serialization() {
423 let snap = SkillSnapshot {
424 prompt: "You are helpful".to_string(),
425 skills: vec![SkillRef {
426 name: "bash".to_string(),
427 description: "shell".to_string(),
428 file_path: "/skills/bash.md".to_string(),
429 primary_env: None,
430 required_env: vec![],
431 }],
432 skill_filter: Some(vec!["bash".to_string()]),
433 };
434 let json = serde_json::to_string(&snap).unwrap();
435 let restored: SkillSnapshot = serde_json::from_str(&json).unwrap();
436 assert_eq!(restored.prompt, "You are helpful");
437 assert_eq!(restored.skills.len(), 1);
438 assert_eq!(restored.skill_filter.as_ref().unwrap().len(), 1);
439 }
440
441 #[test]
442 fn test_config_check() {
443 let check = ConfigCheck {
444 path: "server.port".to_string(),
445 satisfied: true,
446 };
447 assert_eq!(check.path, "server.port");
448 assert!(check.satisfied);
449 }
450}