Skip to main content

oxios_kernel/skill/
types.rs

1#![allow(missing_docs)]
2//! Domain types for the skill system.
3
4use 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        // Default eligible is false (no derive default_true for bool)
337        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        // Default derived: enabled is false (serde default_true only applies on deserialization)
368        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        // When deserializing from empty JSON, default_true should kick in
376        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}