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    /// RFC-031 §6.3: this skill may fire unattended during a token-maxing
145    /// window. Defaults false — frequency is never the gate, only this flag.
146    #[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        // Default eligible is false (no derive default_true for bool)
341        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        // Default derived: enabled is false (serde default_true only applies on deserialization)
372        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        // When deserializing from empty JSON, default_true should kick in
380        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}