Skip to main content

skillx/
installed.rs

1use chrono::{DateTime, Utc};
2use serde::{Deserialize, Serialize};
3use std::path::PathBuf;
4
5use crate::config::Config;
6use crate::error::{Result, SkillxError};
7
8/// Persistent state for installed skills, stored at `~/.skillx/installed.json`.
9#[derive(Debug, Clone, Serialize, Deserialize)]
10pub struct InstalledState {
11    pub version: u32,
12    pub skills: Vec<InstalledSkill>,
13}
14
15/// A single installed skill with its injections.
16#[derive(Debug, Clone, Serialize, Deserialize)]
17pub struct InstalledSkill {
18    pub name: String,
19    pub source: String,
20    #[serde(skip_serializing_if = "Option::is_none")]
21    pub resolved_ref: Option<String>,
22    /// Exact commit SHA for the installed version (populated in v0.4+).
23    #[serde(skip_serializing_if = "Option::is_none")]
24    pub resolved_commit: Option<String>,
25    pub installed_at: DateTime<Utc>,
26    pub updated_at: DateTime<Utc>,
27    pub scan_level: String,
28    pub injections: Vec<Injection>,
29}
30
31/// An injection of a skill into a specific agent.
32#[derive(Debug, Clone, Serialize, Deserialize)]
33pub struct Injection {
34    pub agent: String,
35    pub scope: String,
36    pub path: String,
37    pub files: Vec<InjectedFileRecord>,
38}
39
40/// A record of an injected file with its SHA256 hash.
41#[derive(Debug, Clone, Serialize, Deserialize)]
42pub struct InjectedFileRecord {
43    pub relative: String,
44    pub sha256: String,
45}
46
47impl Default for InstalledState {
48    fn default() -> Self {
49        InstalledState {
50            version: 1,
51            skills: Vec::new(),
52        }
53    }
54}
55
56impl InstalledState {
57    /// Path to `~/.skillx/installed.json`.
58    pub fn file_path() -> Result<PathBuf> {
59        Ok(Config::base_dir()?.join("installed.json"))
60    }
61
62    /// Load from `~/.skillx/installed.json`. Returns empty state if file doesn't exist.
63    pub fn load() -> Result<Self> {
64        let path = Self::file_path()?;
65        if !path.exists() {
66            return Ok(Self::default());
67        }
68        let content = std::fs::read_to_string(&path)
69            .map_err(|e| SkillxError::Install(format!("failed to read installed.json: {e}")))?;
70        let state: InstalledState = serde_json::from_str(&content)
71            .map_err(|e| SkillxError::Install(format!("failed to parse installed.json: {e}")))?;
72        // Version check for future format migrations
73        if state.version > 1 {
74            return Err(SkillxError::Install(format!(
75                "installed.json version {} is newer than supported (1). Please upgrade skillx.",
76                state.version
77            )));
78        }
79        Ok(state)
80    }
81
82    /// Save to `~/.skillx/installed.json` atomically. Creates parent directory if needed.
83    ///
84    /// Uses write-to-temp + rename to avoid corruption from concurrent access or crashes.
85    pub fn save(&self) -> Result<()> {
86        let path = Self::file_path()?;
87        if let Some(parent) = path.parent() {
88            std::fs::create_dir_all(parent).map_err(|e| {
89                SkillxError::Install(format!(
90                    "failed to create directory {}: {e}",
91                    parent.display()
92                ))
93            })?;
94        }
95        let json = serde_json::to_string_pretty(self).map_err(|e| {
96            SkillxError::Install(format!("failed to serialize installed.json: {e}"))
97        })?;
98        // Atomic write: write to temp file then rename
99        let tmp_path = path.with_extension("json.tmp");
100        std::fs::write(&tmp_path, &json).map_err(|e| {
101            SkillxError::Install(format!("failed to write installed.json.tmp: {e}"))
102        })?;
103        std::fs::rename(&tmp_path, &path).map_err(|e| {
104            // Fallback: if rename fails (cross-device), try direct write
105            let _ = std::fs::remove_file(&tmp_path);
106            SkillxError::Install(format!("failed to save installed.json: {e}"))
107        })?;
108        Ok(())
109    }
110
111    /// Find an installed skill by name.
112    pub fn find_skill(&self, name: &str) -> Option<&InstalledSkill> {
113        self.skills.iter().find(|s| s.name == name)
114    }
115
116    /// Find an installed skill by name (mutable).
117    pub fn find_skill_mut(&mut self, name: &str) -> Option<&mut InstalledSkill> {
118        self.skills.iter_mut().find(|s| s.name == name)
119    }
120
121    /// Add or replace a skill (matched by name).
122    ///
123    /// **Warning**: If the skill already exists, it is completely replaced,
124    /// including all existing injections. Use `find_skill_mut()` to selectively
125    /// update individual fields or injections without losing others.
126    pub fn add_or_update_skill(&mut self, skill: InstalledSkill) {
127        if let Some(existing) = self.skills.iter_mut().find(|s| s.name == skill.name) {
128            *existing = skill;
129        } else {
130            self.skills.push(skill);
131        }
132    }
133
134    /// Remove a skill entirely. Returns the removed skill if found.
135    pub fn remove_skill(&mut self, name: &str) -> Option<InstalledSkill> {
136        let pos = self.skills.iter().position(|s| s.name == name)?;
137        Some(self.skills.remove(pos))
138    }
139
140    /// Remove a specific agent injection from a skill.
141    /// If the skill has no remaining injections, remove the entire skill entry.
142    pub fn remove_injection(&mut self, skill_name: &str, agent_name: &str) {
143        if let Some(skill) = self.skills.iter_mut().find(|s| s.name == skill_name) {
144            skill.injections.retain(|inj| inj.agent != agent_name);
145        }
146        // Remove skill entirely if no injections remain
147        self.skills
148            .retain(|s| s.name != skill_name || !s.injections.is_empty());
149    }
150
151    /// Check if a skill is installed.
152    pub fn is_installed(&self, name: &str) -> bool {
153        self.find_skill(name).is_some()
154    }
155}
156
157/// Recursively collect (relative_path, sha256) pairs for all files in a directory.
158/// Used for comparing installed vs fetched skill content.
159pub fn collect_file_hashes(
160    dir: &std::path::Path,
161) -> std::result::Result<std::collections::BTreeSet<(String, String)>, std::io::Error> {
162    let mut result = std::collections::BTreeSet::new();
163    collect_file_hashes_inner(dir, dir, &mut result)?;
164    Ok(result)
165}
166
167fn collect_file_hashes_inner(
168    current: &std::path::Path,
169    root: &std::path::Path,
170    result: &mut std::collections::BTreeSet<(String, String)>,
171) -> std::result::Result<(), std::io::Error> {
172    use sha2::{Digest, Sha256};
173
174    for entry in std::fs::read_dir(current)? {
175        let entry = entry?;
176        let path = entry.path();
177        if path.is_dir() {
178            collect_file_hashes_inner(&path, root, result)?;
179        } else {
180            let relative = path
181                .strip_prefix(root)
182                .unwrap_or(&path)
183                .to_string_lossy()
184                .to_string();
185            let content = std::fs::read(&path)?;
186            let mut hasher = Sha256::new();
187            hasher.update(&content);
188            let sha256 = format!("{:x}", hasher.finalize());
189            result.insert((relative, sha256));
190        }
191    }
192    Ok(())
193}
194
195#[cfg(test)]
196mod tests {
197    use super::*;
198
199    fn make_skill(name: &str, agent: &str) -> InstalledSkill {
200        InstalledSkill {
201            name: name.to_string(),
202            source: format!("github:org/{name}"),
203            resolved_ref: None,
204            resolved_commit: None,
205            installed_at: Utc::now(),
206            updated_at: Utc::now(),
207            scan_level: "pass".to_string(),
208            injections: vec![Injection {
209                agent: agent.to_string(),
210                scope: "global".to_string(),
211                path: format!("/path/to/{name}"),
212                files: vec![InjectedFileRecord {
213                    relative: "SKILL.md".to_string(),
214                    sha256: "abc123".to_string(),
215                }],
216            }],
217        }
218    }
219
220    #[test]
221    fn test_default_state() {
222        let state = InstalledState::default();
223        assert_eq!(state.version, 1);
224        assert!(state.skills.is_empty());
225    }
226
227    #[test]
228    fn test_add_and_find() {
229        let mut state = InstalledState::default();
230        state.add_or_update_skill(make_skill("pdf", "claude-code"));
231
232        assert!(state.find_skill("pdf").is_some());
233        assert!(state.find_skill("other").is_none());
234        assert!(state.is_installed("pdf"));
235        assert!(!state.is_installed("other"));
236    }
237
238    #[test]
239    fn test_add_or_update_replaces() {
240        let mut state = InstalledState::default();
241        state.add_or_update_skill(make_skill("pdf", "claude-code"));
242        assert_eq!(state.skills.len(), 1);
243
244        // Update with different agent
245        state.add_or_update_skill(make_skill("pdf", "cursor"));
246        assert_eq!(state.skills.len(), 1);
247        assert_eq!(state.skills[0].injections[0].agent, "cursor");
248    }
249
250    #[test]
251    fn test_remove_skill() {
252        let mut state = InstalledState::default();
253        state.add_or_update_skill(make_skill("pdf", "claude-code"));
254        state.add_or_update_skill(make_skill("review", "cursor"));
255
256        let removed = state.remove_skill("pdf");
257        assert!(removed.is_some());
258        assert_eq!(removed.unwrap().name, "pdf");
259        assert_eq!(state.skills.len(), 1);
260        assert_eq!(state.skills[0].name, "review");
261
262        assert!(state.remove_skill("nonexistent").is_none());
263    }
264
265    #[test]
266    fn test_remove_injection_partial() {
267        let mut state = InstalledState::default();
268        let mut skill = make_skill("pdf", "claude-code");
269        skill.injections.push(Injection {
270            agent: "cursor".to_string(),
271            scope: "global".to_string(),
272            path: "/path/to/pdf-cursor".to_string(),
273            files: vec![InjectedFileRecord {
274                relative: "SKILL.md".to_string(),
275                sha256: "abc123".to_string(),
276            }],
277        });
278        state.add_or_update_skill(skill);
279
280        // Remove only cursor injection
281        state.remove_injection("pdf", "cursor");
282
283        // Skill still exists with claude-code injection
284        let skill = state.find_skill("pdf").unwrap();
285        assert_eq!(skill.injections.len(), 1);
286        assert_eq!(skill.injections[0].agent, "claude-code");
287    }
288
289    #[test]
290    fn test_remove_injection_complete() {
291        let mut state = InstalledState::default();
292        state.add_or_update_skill(make_skill("pdf", "claude-code"));
293
294        // Remove the only injection -> skill should be removed entirely
295        state.remove_injection("pdf", "claude-code");
296        assert!(!state.is_installed("pdf"));
297        assert!(state.skills.is_empty());
298    }
299
300    #[test]
301    fn test_remove_injection_nonexistent() {
302        let mut state = InstalledState::default();
303        state.add_or_update_skill(make_skill("pdf", "claude-code"));
304
305        // Removing nonexistent agent doesn't affect anything
306        state.remove_injection("pdf", "nonexistent");
307        assert!(state.is_installed("pdf"));
308        assert_eq!(state.find_skill("pdf").unwrap().injections.len(), 1);
309    }
310
311    #[test]
312    fn test_json_roundtrip() {
313        let mut state = InstalledState::default();
314        state.add_or_update_skill(make_skill("pdf", "claude-code"));
315        state.add_or_update_skill(make_skill("review", "cursor"));
316
317        let json = serde_json::to_string_pretty(&state).unwrap();
318        let loaded: InstalledState = serde_json::from_str(&json).unwrap();
319
320        assert_eq!(loaded.version, 1);
321        assert_eq!(loaded.skills.len(), 2);
322        assert_eq!(loaded.skills[0].name, "pdf");
323        assert_eq!(loaded.skills[1].name, "review");
324        assert_eq!(loaded.skills[0].injections[0].agent, "claude-code");
325        assert_eq!(loaded.skills[0].injections[0].files[0].relative, "SKILL.md");
326        assert_eq!(loaded.skills[0].injections[0].files[0].sha256, "abc123");
327    }
328
329    #[test]
330    fn test_multi_agent_single_skill() {
331        let mut state = InstalledState::default();
332        let mut skill = make_skill("pdf", "claude-code");
333        skill.injections.push(Injection {
334            agent: "cursor".to_string(),
335            scope: "project".to_string(),
336            path: "/cursor/path".to_string(),
337            files: vec![InjectedFileRecord {
338                relative: "SKILL.md".to_string(),
339                sha256: "def456".to_string(),
340            }],
341        });
342        state.add_or_update_skill(skill);
343
344        let skill = state.find_skill("pdf").unwrap();
345        assert_eq!(skill.injections.len(), 2);
346        assert_eq!(skill.injections[0].agent, "claude-code");
347        assert_eq!(skill.injections[1].agent, "cursor");
348    }
349
350    #[test]
351    fn test_resolved_ref_json_roundtrip() {
352        let mut state = InstalledState::default();
353        let mut skill = make_skill("pdf", "claude-code");
354        skill.resolved_ref = Some("v1.3".to_string());
355        state.add_or_update_skill(skill);
356
357        let json = serde_json::to_string_pretty(&state).unwrap();
358        assert!(json.contains("\"resolved_ref\": \"v1.3\""));
359
360        let loaded: InstalledState = serde_json::from_str(&json).unwrap();
361        assert_eq!(loaded.skills[0].resolved_ref.as_deref(), Some("v1.3"));
362    }
363
364    #[test]
365    fn test_resolved_ref_none_skipped_in_json() {
366        let mut state = InstalledState::default();
367        let skill = make_skill("pdf", "claude-code");
368        // resolved_ref is None by default in make_skill
369        state.add_or_update_skill(skill);
370
371        let json = serde_json::to_string_pretty(&state).unwrap();
372        // Should not appear in JSON when None (skip_serializing_if)
373        assert!(!json.contains("resolved_ref"));
374
375        // Should still deserialize correctly (backward compat)
376        let loaded: InstalledState = serde_json::from_str(&json).unwrap();
377        assert!(loaded.skills[0].resolved_ref.is_none());
378    }
379
380    #[test]
381    fn test_find_skill_mut() {
382        let mut state = InstalledState::default();
383        state.add_or_update_skill(make_skill("pdf", "claude-code"));
384
385        let skill = state.find_skill_mut("pdf").unwrap();
386        skill.source = "github:new/source".to_string();
387
388        assert_eq!(state.find_skill("pdf").unwrap().source, "github:new/source");
389    }
390}