1use std::collections::BTreeMap;
9use std::io::Read;
10use std::path::{Path, PathBuf};
11
12use chrono::{DateTime, Utc};
13use rust_embed::RustEmbed;
14use serde::{Deserialize, Serialize};
15use sha2::{Digest, Sha256};
16
17use crate::error::{Result, SkillError};
18
19pub const MANIFEST_FILE: &str = ".manifest.json";
21
22pub const MANIFEST_VERSION: u32 = 1;
25
26#[derive(RustEmbed)]
33#[folder = "skills/"]
34#[include = "history.json"]
35struct HistoryAsset;
36
37#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
39pub struct HistoricalVersion {
40 pub version: u32,
42 pub sha256: String,
46}
47
48#[derive(Debug, Clone, Serialize, Deserialize)]
50pub struct SkillHistory {
51 pub current: HistoricalVersion,
53 #[serde(default)]
56 pub history: Vec<HistoricalVersion>,
57}
58
59impl SkillHistory {
60 pub fn is_current(&self, sha256: &str) -> bool {
62 self.current.sha256.eq_ignore_ascii_case(sha256)
63 }
64
65 pub fn is_historical(&self, sha256: &str) -> bool {
67 self.history
68 .iter()
69 .any(|h| h.sha256.eq_ignore_ascii_case(sha256))
70 }
71}
72
73#[derive(Debug, Clone, Default, Serialize, Deserialize)]
76#[serde(transparent)]
77pub struct HistoricalHashes {
78 pub by_skill: BTreeMap<String, SkillHistory>,
80}
81
82impl HistoricalHashes {
83 pub fn load_embedded() -> Result<Self> {
88 match HistoryAsset::get("history.json") {
89 Some(asset) => {
90 let parsed: HistoricalHashes =
91 serde_json::from_slice(&asset.data).map_err(|source| {
92 SkillError::InvalidManifest {
93 path: PathBuf::from("<embedded>/history.json"),
94 source,
95 }
96 })?;
97 Ok(parsed)
98 }
99 None => Ok(Self::default()),
100 }
101 }
102
103 pub fn get(&self, name: &str) -> Option<&SkillHistory> {
105 self.by_skill.get(name)
106 }
107}
108
109#[derive(Debug, Clone, Serialize, Deserialize)]
115pub struct InstalledFile {
116 pub sha256: String,
118 pub size: u64,
119}
120
121#[derive(Debug, Clone, Serialize, Deserialize)]
123pub struct InstalledSkill {
124 pub version: u32,
126 pub installed_at: DateTime<Utc>,
128 pub source: String,
130 pub files: BTreeMap<String, InstalledFile>,
132}
133
134#[derive(Debug, Clone, Serialize, Deserialize)]
136pub struct Manifest {
137 pub version: u32,
139 #[serde(default)]
142 pub installed_from: Option<String>,
143 #[serde(default)]
145 pub skills: BTreeMap<String, InstalledSkill>,
146}
147
148impl Default for Manifest {
149 fn default() -> Self {
150 Self {
151 version: MANIFEST_VERSION,
152 installed_from: None,
153 skills: BTreeMap::new(),
154 }
155 }
156}
157
158impl Manifest {
159 pub fn load(path: &Path) -> Result<Self> {
163 match std::fs::read(path) {
164 Ok(bytes) => {
165 let parsed: Manifest = serde_json::from_slice(&bytes).map_err(|source| {
166 SkillError::InvalidManifest {
167 path: path.to_path_buf(),
168 source,
169 }
170 })?;
171 Ok(parsed)
172 }
173 Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(Self::default()),
174 Err(source) => Err(SkillError::Io {
175 path: path.to_path_buf(),
176 source,
177 }),
178 }
179 }
180
181 pub fn save(&self, path: &Path) -> Result<()> {
183 if let Some(parent) = path.parent()
184 && !parent.as_os_str().is_empty()
185 {
186 std::fs::create_dir_all(parent).map_err(|source| SkillError::Io {
187 path: parent.to_path_buf(),
188 source,
189 })?;
190 }
191
192 let pretty =
193 serde_json::to_vec_pretty(self).map_err(|source| SkillError::InvalidManifest {
194 path: path.to_path_buf(),
195 source,
196 })?;
197
198 let tmp_path = path.with_extension("tmp");
199 std::fs::write(&tmp_path, &pretty).map_err(|source| SkillError::Io {
200 path: tmp_path.clone(),
201 source,
202 })?;
203 if path.exists() {
208 std::fs::remove_file(path).map_err(|source| SkillError::Io {
209 path: path.to_path_buf(),
210 source,
211 })?;
212 }
213 std::fs::rename(&tmp_path, path).map_err(|source| SkillError::Io {
214 path: path.to_path_buf(),
215 source,
216 })?;
217 Ok(())
218 }
219
220 pub fn record(&mut self, name: &str, entry: InstalledSkill) {
222 self.skills.insert(name.to_string(), entry);
223 }
224
225 pub fn forget(&mut self, name: &str) -> Option<InstalledSkill> {
227 self.skills.remove(name)
228 }
229
230 pub fn get(&self, name: &str) -> Option<&InstalledSkill> {
232 self.skills.get(name)
233 }
234}
235
236#[derive(Debug, Clone, Copy, PartialEq, Eq)]
242pub enum InstallState {
243 Unchanged,
245 HistoricalSafe,
247 UserModified,
250 Unknown,
253}
254
255pub fn classify(history: &HistoricalHashes, name: &str, body: &[u8]) -> InstallState {
257 let sha = sha256_hex(body);
258 let Some(entry) = history.get(name) else {
259 return InstallState::Unknown;
260 };
261 if entry.is_current(&sha) {
262 InstallState::Unchanged
263 } else if entry.is_historical(&sha) {
264 InstallState::HistoricalSafe
265 } else {
266 InstallState::UserModified
267 }
268}
269
270pub fn classify_path(
274 history: &HistoricalHashes,
275 name: &str,
276 path: &Path,
277) -> Result<Option<InstallState>> {
278 match std::fs::File::open(path) {
279 Ok(mut f) => {
280 let mut buf = Vec::new();
281 f.read_to_end(&mut buf).map_err(|source| SkillError::Io {
282 path: path.to_path_buf(),
283 source,
284 })?;
285 Ok(Some(classify(history, name, &buf)))
286 }
287 Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None),
288 Err(source) => Err(SkillError::Io {
289 path: path.to_path_buf(),
290 source,
291 }),
292 }
293}
294
295pub fn sha256_hex(bytes: &[u8]) -> String {
297 let mut hasher = Sha256::new();
298 hasher.update(bytes);
299 let digest = hasher.finalize();
300 hex_encode(&digest)
301}
302
303fn hex_encode(bytes: &[u8]) -> String {
304 const HEX: &[u8; 16] = b"0123456789abcdef";
305 let mut out = String::with_capacity(bytes.len() * 2);
306 for &b in bytes {
307 out.push(HEX[(b >> 4) as usize] as char);
308 out.push(HEX[(b & 0x0F) as usize] as char);
309 }
310 out
311}
312
313#[cfg(test)]
314mod tests {
315 use super::*;
316 use tempfile::tempdir;
317
318 #[test]
319 fn sha256_is_deterministic() {
320 let a = sha256_hex(b"hello");
321 let b = sha256_hex(b"hello");
322 assert_eq!(a, b);
323 assert_eq!(a.len(), 64);
324 }
325
326 #[test]
327 fn manifest_round_trip() {
328 let dir = tempdir().unwrap();
329 let path = dir.path().join(MANIFEST_FILE);
330
331 let mut m = Manifest {
332 installed_from: Some("devboy-tools 0.18.0".into()),
333 ..Default::default()
334 };
335 let mut files = BTreeMap::new();
336 files.insert(
337 "SKILL.md".to_string(),
338 InstalledFile {
339 sha256: "aa".repeat(32),
340 size: 42,
341 },
342 );
343 m.record(
344 "setup",
345 InstalledSkill {
346 version: 1,
347 installed_at: Utc::now(),
348 source: "embedded".into(),
349 files,
350 },
351 );
352 m.save(&path).unwrap();
353
354 let loaded = Manifest::load(&path).unwrap();
355 assert_eq!(loaded.version, MANIFEST_VERSION);
356 assert_eq!(loaded.skills["setup"].version, 1);
357 }
358
359 #[test]
360 fn manifest_load_missing_is_empty() {
361 let dir = tempdir().unwrap();
362 let path = dir.path().join("does-not-exist.json");
363 let m = Manifest::load(&path).unwrap();
364 assert!(m.skills.is_empty());
365 }
366
367 #[test]
368 fn classify_unchanged_historical_usermod() {
369 let current_body = b"current";
370 let older_body = b"older";
371 let user_body = b"user-edited";
372
373 let mut history = HistoricalHashes::default();
374 history.by_skill.insert(
375 "setup".into(),
376 SkillHistory {
377 current: HistoricalVersion {
378 version: 2,
379 sha256: sha256_hex(current_body),
380 },
381 history: vec![HistoricalVersion {
382 version: 1,
383 sha256: sha256_hex(older_body),
384 }],
385 },
386 );
387
388 assert_eq!(
389 classify(&history, "setup", current_body),
390 InstallState::Unchanged
391 );
392 assert_eq!(
393 classify(&history, "setup", older_body),
394 InstallState::HistoricalSafe
395 );
396 assert_eq!(
397 classify(&history, "setup", user_body),
398 InstallState::UserModified
399 );
400 assert_eq!(
401 classify(&history, "devboy-unknown", user_body),
402 InstallState::Unknown
403 );
404 }
405
406 #[test]
407 fn skill_history_is_current_and_is_historical_are_case_insensitive() {
408 let hist = SkillHistory {
409 current: HistoricalVersion {
410 version: 2,
411 sha256: "AbCdEf1234567890".repeat(4),
412 },
413 history: vec![HistoricalVersion {
414 version: 1,
415 sha256: "11223344".repeat(8),
416 }],
417 };
418 assert!(hist.is_current(&"abcdef1234567890".repeat(4)));
420 assert!(hist.is_historical(&"11223344".repeat(8).to_uppercase()));
421 assert!(!hist.is_current("00".repeat(32).as_str()));
422 assert!(!hist.is_historical("00".repeat(32).as_str()));
423 }
424
425 #[test]
426 fn historical_hashes_load_embedded_returns_parsed_or_empty() {
427 let hashes = HistoricalHashes::load_embedded().expect("parses or empty");
431 for (name, entry) in &hashes.by_skill {
432 assert!(!name.is_empty(), "history keys must be non-empty");
433 assert!(!entry.current.sha256.is_empty());
434 }
435 }
436
437 #[test]
438 fn manifest_forget_and_get_round_trip() {
439 let mut m = Manifest::default();
440 assert!(m.get("ghost").is_none());
441
442 let entry = InstalledSkill {
443 version: 3,
444 installed_at: Utc::now(),
445 source: "embedded".into(),
446 files: BTreeMap::new(),
447 };
448 m.record("setup", entry.clone());
449 assert_eq!(m.get("setup").unwrap().version, 3);
450
451 let removed = m.forget("setup").expect("entry removed");
452 assert_eq!(removed.version, entry.version);
453 assert!(m.forget("setup").is_none());
454 assert!(m.get("setup").is_none());
455 }
456
457 #[test]
458 fn manifest_save_overwrites_existing_destination() {
459 let dir = tempdir().unwrap();
463 let path = dir.path().join(MANIFEST_FILE);
464
465 let m1 = Manifest {
467 installed_from: Some("v1".into()),
468 ..Default::default()
469 };
470 m1.save(&path).unwrap();
471
472 let mut m2 = Manifest {
474 installed_from: Some("v2".into()),
475 ..Default::default()
476 };
477 m2.record(
478 "setup",
479 InstalledSkill {
480 version: 7,
481 installed_at: Utc::now(),
482 source: "embedded".into(),
483 files: BTreeMap::new(),
484 },
485 );
486 m2.save(&path).unwrap();
487
488 let loaded = Manifest::load(&path).unwrap();
489 assert_eq!(loaded.installed_from.as_deref(), Some("v2"));
490 assert_eq!(loaded.skills["setup"].version, 7);
491 }
492
493 #[test]
494 fn manifest_load_rejects_corrupt_json() {
495 let dir = tempdir().unwrap();
496 let path = dir.path().join(MANIFEST_FILE);
497 std::fs::write(&path, "{ not json").unwrap();
498 let err = Manifest::load(&path).unwrap_err();
499 assert!(
500 matches!(err, SkillError::InvalidManifest { .. }),
501 "expected InvalidManifest, got {err:?}"
502 );
503 }
504
505 #[test]
506 fn classify_path_handles_missing_and_present_files() {
507 let dir = tempdir().unwrap();
508 let path = dir.path().join("SKILL.md");
509
510 let mut history = HistoricalHashes::default();
511 let body = b"ship";
512 history.by_skill.insert(
513 "s".into(),
514 SkillHistory {
515 current: HistoricalVersion {
516 version: 1,
517 sha256: sha256_hex(body),
518 },
519 history: vec![],
520 },
521 );
522
523 assert!(classify_path(&history, "s", &path).unwrap().is_none());
525
526 std::fs::write(&path, body).unwrap();
528 assert_eq!(
529 classify_path(&history, "s", &path).unwrap(),
530 Some(InstallState::Unchanged)
531 );
532
533 std::fs::write(&path, b"drifted").unwrap();
535 assert_eq!(
536 classify_path(&history, "s", &path).unwrap(),
537 Some(InstallState::UserModified)
538 );
539 }
540}