Skip to main content

devboy_skills/
manifest.rs

1//! Per-location manifest (`.manifest.json`) describing installed skills
2//! plus the embedded history of every previously-shipped SHA256.
3//!
4//! See ADR-014 in `docs/architecture/adr/ADR-014-skills-lifecycle.md`
5//! at the repository root for the three-state install / upgrade logic
6//! that these types drive.
7
8use 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
19/// Name of the manifest file inside an install target.
20pub const MANIFEST_FILE: &str = ".manifest.json";
21
22/// Current manifest schema version. Bumped whenever the shape changes
23/// in a way readers need to know about.
24pub const MANIFEST_VERSION: u32 = 1;
25
26// ---------------------------------------------------------------------------
27// Embedded historical hashes (shipped alongside baseline skills)
28// ---------------------------------------------------------------------------
29
30/// Embedded history file. Ships in the binary; updated by the release
31/// tooling whenever a baseline skill's SKILL.md content changes.
32#[derive(RustEmbed)]
33#[folder = "skills/"]
34#[include = "history.json"]
35struct HistoryAsset;
36
37/// Entry for one historical shipped revision of a skill.
38#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
39pub struct HistoricalVersion {
40    /// Integer version the skill had at this point.
41    pub version: u32,
42    /// SHA256 of the full `SKILL.md` file contents (frontmatter + body)
43    /// at this version. Matches how [`classify`] and [`classify_path`]
44    /// compute their hashes.
45    pub sha256: String,
46}
47
48/// Per-skill history record.
49#[derive(Debug, Clone, Serialize, Deserialize)]
50pub struct SkillHistory {
51    /// The version shipped in this binary.
52    pub current: HistoricalVersion,
53    /// Previous shipped versions (chronological). Empty for brand-new
54    /// skills.
55    #[serde(default)]
56    pub history: Vec<HistoricalVersion>,
57}
58
59impl SkillHistory {
60    /// Return true if the given hash matches the current shipped version.
61    pub fn is_current(&self, sha256: &str) -> bool {
62        self.current.sha256.eq_ignore_ascii_case(sha256)
63    }
64
65    /// Return true if the hash matches any previously-shipped version.
66    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/// Registry of historical hashes for every baseline skill. Loaded from
74/// `skills/history.json` at compile time.
75#[derive(Debug, Clone, Default, Serialize, Deserialize)]
76#[serde(transparent)]
77pub struct HistoricalHashes {
78    /// Skill name → history record.
79    pub by_skill: BTreeMap<String, SkillHistory>,
80}
81
82impl HistoricalHashes {
83    /// Load the embedded history shipped with this binary. When the
84    /// `history.json` asset is missing (early development before the
85    /// first release) an empty registry is returned — callers treat
86    /// every on-disk skill as "unknown hash" in that case.
87    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    /// Look up the history for a skill name.
104    pub fn get(&self, name: &str) -> Option<&SkillHistory> {
105        self.by_skill.get(name)
106    }
107}
108
109// ---------------------------------------------------------------------------
110// Per-location install manifest
111// ---------------------------------------------------------------------------
112
113/// Recorded file within an installed skill.
114#[derive(Debug, Clone, Serialize, Deserialize)]
115pub struct InstalledFile {
116    /// SHA256 of the file at install time.
117    pub sha256: String,
118    pub size: u64,
119}
120
121/// One installed skill.
122#[derive(Debug, Clone, Serialize, Deserialize)]
123pub struct InstalledSkill {
124    /// Frontmatter version at install time.
125    pub version: u32,
126    /// When this skill was installed / last upgraded.
127    pub installed_at: DateTime<Utc>,
128    /// Name of the source the skill came from.
129    pub source: String,
130    /// Per-file record (keyed by filename within the skill directory).
131    pub files: BTreeMap<String, InstalledFile>,
132}
133
134/// Per-location manifest.
135#[derive(Debug, Clone, Serialize, Deserialize)]
136pub struct Manifest {
137    /// Schema version.
138    pub version: u32,
139    /// Human-readable origin tag (`"devboy-tools 0.18.0"`), for
140    /// diagnostics.
141    #[serde(default)]
142    pub installed_from: Option<String>,
143    /// Skill name → record.
144    #[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    /// Load a manifest from disk. Missing files produce an empty
160    /// manifest (cold install); corrupt manifests produce an error so
161    /// the caller can decide whether to reconstruct.
162    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    /// Persist the manifest atomically (temp file + rename).
182    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        // `std::fs::rename` does not overwrite an existing destination
204        // on Windows (it does on POSIX). Remove the destination first so
205        // the behaviour is consistent across platforms. A missing
206        // destination is fine — the rename creates it.
207        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    /// Mark a skill as installed (or update its record on upgrade).
221    pub fn record(&mut self, name: &str, entry: InstalledSkill) {
222        self.skills.insert(name.to_string(), entry);
223    }
224
225    /// Remove a skill from the manifest.
226    pub fn forget(&mut self, name: &str) -> Option<InstalledSkill> {
227        self.skills.remove(name)
228    }
229
230    /// Look up the stored record for a skill.
231    pub fn get(&self, name: &str) -> Option<&InstalledSkill> {
232        self.skills.get(name)
233    }
234}
235
236// ---------------------------------------------------------------------------
237// Three-state hash comparator
238// ---------------------------------------------------------------------------
239
240/// Outcome of comparing a file on disk against the embedded history.
241#[derive(Debug, Clone, Copy, PartialEq, Eq)]
242pub enum InstallState {
243    /// Hash matches the current shipped version — nothing to do.
244    Unchanged,
245    /// Hash matches a previously-shipped version — safe to overwrite.
246    HistoricalSafe,
247    /// Hash is unknown — assumed to be a user modification. Install
248    /// should refuse without `--force`.
249    UserModified,
250    /// The skill is not tracked in the embedded history — we cannot
251    /// classify it, treat it as user-modified by default.
252    Unknown,
253}
254
255/// Classify a SKILL.md body against the embedded history registry.
256pub 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
270/// Read a file and classify it. Missing files produce `None` (nothing
271/// to compare against) — the caller interprets absence as a fresh
272/// install.
273pub 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
295/// Hex-encoded SHA256 of the given byte slice.
296pub 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        // eq_ignore_ascii_case on both branches.
419        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        // The test must not crash regardless of whether `history.json`
428        // has been embedded yet — early in development it's empty, once
429        // releases land it fills up. Both shapes are valid.
430        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        // Regression: on Windows `fs::rename` does not overwrite. The
460        // manifest's atomic-save path removes the destination first;
461        // exercise the overwrite branch so that code path stays covered.
462        let dir = tempdir().unwrap();
463        let path = dir.path().join(MANIFEST_FILE);
464
465        // First write.
466        let m1 = Manifest {
467            installed_from: Some("v1".into()),
468            ..Default::default()
469        };
470        m1.save(&path).unwrap();
471
472        // Second write must overwrite, not error.
473        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        // Missing file → None.
524        assert!(classify_path(&history, "s", &path).unwrap().is_none());
525
526        // Present file with matching body → Unchanged.
527        std::fs::write(&path, body).unwrap();
528        assert_eq!(
529            classify_path(&history, "s", &path).unwrap(),
530            Some(InstallState::Unchanged)
531        );
532
533        // Present file with unknown body → UserModified.
534        std::fs::write(&path, b"drifted").unwrap();
535        assert_eq!(
536            classify_path(&history, "s", &path).unwrap(),
537            Some(InstallState::UserModified)
538        );
539    }
540}