Skip to main content

soothfast_docs/
lockfile.rs

1//! `soothfast.lock`: committed fingerprints for every bound item. A bind is
2//! stale when the item's current fingerprint differs from the locked one;
3//! `docs accept` re-locks after the prose has been re-verified by a human.
4
5use std::collections::BTreeMap;
6use std::path::Path;
7
8use serde_json::{Value, json};
9
10/// Lockfile name, resolved relative to the workspace root.
11pub const LOCKFILE: &str = "soothfast.lock";
12
13/// item path → fingerprint (hex).
14pub type Binds = BTreeMap<String, String>;
15
16/// Read accepted binds from `root`'s lockfile; a missing file is an empty
17/// map, not an error (a fresh repo has accepted nothing yet).
18pub fn read(root: &Path) -> Result<Binds, String> {
19    let path = root.join(LOCKFILE);
20    if !path.exists() {
21        return Ok(Binds::new());
22    }
23    let text = std::fs::read_to_string(&path).map_err(|e| e.to_string())?;
24    let doc: Value = serde_json::from_str(&text).map_err(|e| e.to_string())?;
25    let mut binds = Binds::new();
26    if let Some(map) = doc["binds"].as_object() {
27        for (k, v) in map {
28            if let Some(fp) = v.as_str() {
29                binds.insert(k.clone(), fp.to_string());
30            }
31        }
32    }
33    Ok(binds)
34}
35
36/// Persist `binds` to `root`'s lockfile as stable, pretty-printed JSON.
37pub fn write(root: &Path, binds: &Binds) -> Result<(), String> {
38    let doc = json!({ "version": 1, "binds": binds });
39    let text = serde_json::to_string_pretty(&doc).map_err(|e| e.to_string())?;
40    std::fs::write(root.join(LOCKFILE), text + "\n").map_err(|e| e.to_string())
41}
42
43/// Merge freshly accepted binds over `existing`. A full-scope accept replaces
44/// the map outright, dropping binds that no longer exist anywhere; a partial
45/// (explicit-paths) accept must preserve out-of-scope entries.
46pub fn merge(existing: &Binds, fresh: &Binds, full_scope: bool) -> Binds {
47    if full_scope {
48        fresh.clone()
49    } else {
50        let mut merged = existing.clone();
51        merged.extend(fresh.iter().map(|(k, v)| (k.clone(), v.clone())));
52        merged
53    }
54}
55
56#[cfg(test)]
57mod tests {
58    use super::*;
59
60    fn binds(pairs: &[(&str, &str)]) -> Binds {
61        pairs
62            .iter()
63            .map(|(k, v)| (k.to_string(), v.to_string()))
64            .collect()
65    }
66
67    #[test]
68    fn partial_scope_merge_preserves_out_of_scope_entries() {
69        let existing = binds(&[("a::x", "1111"), ("b::y", "2222")]);
70        let fresh = binds(&[("a::x", "9999")]);
71        let merged = merge(&existing, &fresh, false);
72        assert_eq!(merged.get("a::x").map(String::as_str), Some("9999"));
73        assert_eq!(merged.get("b::y").map(String::as_str), Some("2222"));
74        assert_eq!(merged.len(), 2);
75    }
76
77    #[test]
78    fn full_scope_merge_drops_binds_that_no_longer_exist() {
79        let existing = binds(&[("a::x", "1111"), ("gone::z", "2222")]);
80        let fresh = binds(&[("a::x", "9999")]);
81        assert_eq!(merge(&existing, &fresh, true), fresh);
82    }
83}