1use chrono::{DateTime, Utc};
2use serde::{Deserialize, Serialize};
3use std::path::PathBuf;
4
5use crate::config::Config;
6use crate::error::{Result, SkillxError};
7
8#[derive(Debug, Clone, Serialize, Deserialize)]
10pub struct InstalledState {
11 pub version: u32,
12 pub skills: Vec<InstalledSkill>,
13}
14
15#[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 #[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#[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#[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 pub fn file_path() -> Result<PathBuf> {
59 Ok(Config::base_dir()?.join("installed.json"))
60 }
61
62 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 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 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 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 let _ = std::fs::remove_file(&tmp_path);
106 SkillxError::Install(format!("failed to save installed.json: {e}"))
107 })?;
108 Ok(())
109 }
110
111 pub fn find_skill(&self, name: &str) -> Option<&InstalledSkill> {
113 self.skills.iter().find(|s| s.name == name)
114 }
115
116 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 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 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 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 self.skills
148 .retain(|s| s.name != skill_name || !s.injections.is_empty());
149 }
150
151 pub fn is_installed(&self, name: &str) -> bool {
153 self.find_skill(name).is_some()
154 }
155}
156
157pub 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 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 state.remove_injection("pdf", "cursor");
282
283 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 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 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 state.add_or_update_skill(skill);
370
371 let json = serde_json::to_string_pretty(&state).unwrap();
372 assert!(!json.contains("resolved_ref"));
374
375 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}