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/// Current lockfile format. Bumped whenever the fingerprint input changes, so
17/// a lock written by an older release reports as needing one re-accept rather
18/// than as prose that drifted.
19pub const VERSION: u64 = 2;
20
21/// Accepted binds, and the format version they were written under.
22#[derive(Debug, Default)]
23pub struct Lock {
24    /// Format on disk. A lockfile that does not exist yet reports [`VERSION`].
25    pub version: u64,
26    /// item path → fingerprint (hex).
27    pub binds: Binds,
28}
29
30/// Read `root`'s lockfile. A missing file is an empty map at the current
31/// version, not an error (a fresh repo has accepted nothing yet). A file
32/// without a `version` key predates the field and reads as v1.
33pub fn read(root: &Path) -> Result<Lock, String> {
34    let path = root.join(LOCKFILE);
35    if !path.exists() {
36        return Ok(Lock {
37            version: VERSION,
38            binds: Binds::new(),
39        });
40    }
41    let text = std::fs::read_to_string(&path).map_err(|e| e.to_string())?;
42    let doc: Value = serde_json::from_str(&text).map_err(|e| e.to_string())?;
43    let mut binds = Binds::new();
44    if let Some(map) = doc["binds"].as_object() {
45        for (k, v) in map {
46            if let Some(fp) = v.as_str() {
47                binds.insert(k.clone(), fp.to_string());
48            }
49        }
50    }
51    Ok(Lock {
52        version: doc["version"].as_u64().unwrap_or(1),
53        binds,
54    })
55}
56
57/// Persist `binds` to `root`'s lockfile as stable, pretty-printed JSON.
58pub fn write(root: &Path, binds: &Binds) -> Result<(), String> {
59    let doc = json!({ "version": VERSION, "binds": binds });
60    let text = serde_json::to_string_pretty(&doc).map_err(|e| e.to_string())?;
61    std::fs::write(root.join(LOCKFILE), text + "\n").map_err(|e| e.to_string())
62}
63
64/// Merge freshly accepted binds over `existing`. A full-scope accept replaces
65/// the map outright, dropping binds that no longer exist anywhere; a partial
66/// (explicit-paths) accept must preserve out-of-scope entries.
67pub fn merge(existing: &Binds, fresh: &Binds, full_scope: bool) -> Binds {
68    if full_scope {
69        fresh.clone()
70    } else {
71        let mut merged = existing.clone();
72        merged.extend(fresh.iter().map(|(k, v)| (k.clone(), v.clone())));
73        merged
74    }
75}
76
77/// The entries of `lock` a fresh accept may merge over.
78///
79/// Fingerprints from an older format are not comparable to what this build
80/// derives, so they are dropped rather than carried under a stamp claiming
81/// they are current; their pages read as unlocked until their own
82/// `docs accept` runs, which is what lets a repo migrate one accept at a
83/// time.
84///
85/// A lock from a *newer* format is equally incomparable but is not this
86/// build's to discard, so it is refused instead. The asymmetry is the point:
87/// an older entry is worthless, a newer one is someone else's valid work.
88/// Accepting is deliberately stricter than checking here: a check against a
89/// newer lock only misreports, while an accept would overwrite it.
90pub fn comparable(lock: Lock) -> Result<Binds, String> {
91    if lock.version > VERSION {
92        return Err(format!(
93            "{LOCKFILE} is format v{}, this build writes v{VERSION}: upgrade cargo-soothfast rather than overwriting a newer lock",
94            lock.version
95        ));
96    }
97    if lock.version == VERSION {
98        Ok(lock.binds)
99    } else {
100        Ok(Binds::new())
101    }
102}
103
104#[cfg(test)]
105mod tests {
106    use super::*;
107
108    fn binds(pairs: &[(&str, &str)]) -> Binds {
109        pairs
110            .iter()
111            .map(|(k, v)| (k.to_string(), v.to_string()))
112            .collect()
113    }
114
115    #[test]
116    fn partial_scope_merge_preserves_out_of_scope_entries() {
117        let existing = binds(&[("a::x", "1111"), ("b::y", "2222")]);
118        let fresh = binds(&[("a::x", "9999")]);
119        let merged = merge(&existing, &fresh, false);
120        assert_eq!(merged.get("a::x").map(String::as_str), Some("9999"));
121        assert_eq!(merged.get("b::y").map(String::as_str), Some("2222"));
122        assert_eq!(merged.len(), 2);
123    }
124
125    #[test]
126    fn entries_from_an_older_format_are_not_merged_over() {
127        let lock = Lock {
128            version: VERSION - 1,
129            binds: binds(&[("a::x", "1111")]),
130        };
131        assert!(comparable(lock).unwrap().is_empty());
132    }
133
134    #[test]
135    fn entries_from_this_format_survive_a_partial_accept() {
136        let lock = Lock {
137            version: VERSION,
138            binds: binds(&[("a::x", "1111")]),
139        };
140        assert_eq!(comparable(lock).unwrap().len(), 1);
141    }
142
143    #[test]
144    fn a_newer_lock_is_refused_rather_than_overwritten() {
145        let lock = Lock {
146            version: VERSION + 1,
147            binds: binds(&[("a::x", "1111")]),
148        };
149        assert!(comparable(lock).is_err());
150    }
151
152    #[test]
153    fn full_scope_merge_drops_binds_that_no_longer_exist() {
154        let existing = binds(&[("a::x", "1111"), ("gone::z", "2222")]);
155        let fresh = binds(&[("a::x", "9999")]);
156        assert_eq!(merge(&existing, &fresh, true), fresh);
157    }
158}