Skip to main content

strop_core/buffer/
seed.rs

1//! Deterministic buffer seeds (R11 forensic replay): whole text, revision,
2//! undo history and the disk baseline — a replayed buffer keeps its exact
3//! live identity without reading the filesystem. `disk_stamp` is seed DATA
4//! for overwrite protection, never a request to stat anything.
5use serde::{Deserialize, Serialize};
6
7use super::Buffer;
8use crate::history::History;
9use crate::id::BufferRevision;
10
11/// A pure, serializable image of a buffer at the startup seed boundary.
12/// The buffer's diagnostic trace identity is deliberately absent: it names
13/// a process-local incarnation, not document semantics.
14#[derive(Debug, Clone, Serialize, Deserialize)]
15pub struct BufferSeed {
16    pub text: String,
17    pub revision: BufferRevision,
18    pub history: History,
19    #[serde(with = "crate::path_serde::option")]
20    pub path: Option<std::path::PathBuf>,
21    pub name: Option<String>,
22    pub dirty: bool,
23    pub readonly: bool,
24    /// Typed owner of the readonly policy (0056 AR14); absent in seeds
25    /// written before reasons existed.
26    #[serde(default)]
27    pub readonly_reason: Option<super::ReadonlyReason>,
28    pub disk_stamp: Option<std::time::SystemTime>,
29    #[serde(with = "crate::path_serde::option")]
30    pub file_identity: Option<std::path::PathBuf>,
31}
32
33impl Buffer {
34    pub fn seed(&self) -> BufferSeed {
35        BufferSeed {
36            text: self.rope.to_string(),
37            revision: self.revision(),
38            history: self.history.clone(),
39            path: self.path.clone(),
40            name: self.name.clone(),
41            dirty: self.dirty,
42            readonly: self.readonly,
43            readonly_reason: self.readonly_reason,
44            disk_stamp: self.disk_stamp,
45            file_identity: self.file_identity.clone(),
46        }
47    }
48}
49
50/// The per-action observation twin of [`BufferSeed`]: every field identical
51/// except the text is witnessed by digest and byte length, so an action's
52/// check proves equality without shipping a terminal snapshot's megabytes.
53/// Keep the field lists in lockstep — observation equality is the contract.
54#[derive(Serialize)]
55pub struct BufferWitness {
56    pub text_digest: String,
57    pub text_bytes: usize,
58    pub revision: BufferRevision,
59    pub history: History,
60    #[serde(with = "crate::path_serde::option")]
61    pub path: Option<std::path::PathBuf>,
62    pub name: Option<String>,
63    pub dirty: bool,
64    pub readonly: bool,
65    pub readonly_reason: Option<super::ReadonlyReason>,
66    pub disk_stamp: Option<std::time::SystemTime>,
67    #[serde(with = "crate::path_serde::option")]
68    pub file_identity: Option<std::path::PathBuf>,
69}
70
71impl Buffer {
72    /// The per-action observation: full field parity with [`Buffer::seed`],
73    /// text witnessed by digest.
74    pub fn witness(&self) -> BufferWitness {
75        BufferWitness {
76            text_digest: self.text_digest(),
77            text_bytes: self.len_bytes(),
78            revision: self.revision(),
79            history: self.history.clone(),
80            path: self.path.clone(),
81            name: self.name.clone(),
82            dirty: self.dirty,
83            readonly: self.readonly,
84            readonly_reason: self.readonly_reason,
85            disk_stamp: self.disk_stamp,
86            file_identity: self.file_identity.clone(),
87        }
88    }
89
90    /// SHA-256 over the buffer text, streamed chunk-wise without a copy.
91    pub fn text_digest(&self) -> String {
92        use sha2::{Digest, Sha256};
93        use std::fmt::Write as _;
94        let mut hasher = Sha256::new();
95        for chunk in self.rope.chunks() {
96            hasher.update(chunk.as_bytes());
97        }
98        let digest: [u8; 32] = hasher.finalize().into();
99        let mut text = String::with_capacity(64);
100        let _ = digest
101            .iter()
102            .try_for_each(|byte| write!(text, "{byte:02x}"));
103        text
104    }
105}
106
107impl BufferSeed {
108    /// Rebuild the buffer. Restored history is validated against the
109    /// seeded text; a seed that disagrees is rejected, not coerced.
110    pub fn into_buffer(self) -> Result<Buffer, crate::history::HistoryError> {
111        let mut buffer = Buffer::from_text(&self.text);
112        buffer.restore_history(self.history)?;
113        buffer.epoch = self.revision.get();
114        buffer.path = self.path;
115        buffer.name = self.name;
116        buffer.dirty = self.dirty;
117        buffer.readonly = self.readonly;
118        buffer.readonly_reason = self.readonly_reason;
119        buffer.disk_stamp = self.disk_stamp;
120        buffer.file_identity = self.file_identity;
121        Ok(buffer)
122    }
123}
124
125#[cfg(test)]
126mod tests {
127    use super::*;
128
129    #[test]
130    fn seed_round_trips_content_history_and_disk_baseline() {
131        let mut buffer = Buffer::from_text("line\nline two\n");
132        buffer.begin_undo_group();
133        buffer
134            .edit()
135            .replace(crate::Range::charwise(5, 8), "XY")
136            .unwrap();
137        buffer.commit_undo_group();
138        buffer.dirty = true;
139        buffer.disk_stamp = Some(std::time::UNIX_EPOCH);
140        let seed = buffer.seed();
141        let mut restored = seed.into_buffer().unwrap();
142        assert_eq!(restored.text().to_string(), "line\nXYe two\n");
143        // The undo that survived the seed still runs on the rebuilt buffer.
144        restored.undo().unwrap();
145        assert_eq!(restored.text().to_string(), "line\nline two\n");
146    }
147
148    #[test]
149    fn history_that_disagrees_with_text_is_rejected() {
150        let mut buffer = Buffer::from_text("abc\n");
151        buffer.begin_undo_group();
152        buffer
153            .edit()
154            .replace(crate::Range::charwise(0, 1), "z")
155            .unwrap();
156        buffer.commit_undo_group();
157        let mut seed = buffer.seed();
158        seed.text = "different bytes\n".into();
159        assert!(seed.into_buffer().is_err());
160    }
161
162    #[test]
163    fn witness_digest_tracks_text_exactly() {
164        let buffer = Buffer::from_text("abc\n");
165        let digest = buffer.text_digest();
166        // Same text, same digest; any edit changes it.
167        assert_eq!(Buffer::from_text("abc\n").text_digest(), digest);
168        let mut edited = Buffer::from_text("abc\n");
169        edited
170            .edit()
171            .replace(crate::Range::charwise(0, 1), "z")
172            .unwrap();
173        assert_ne!(edited.text_digest(), digest);
174        // The observation twin carries the true size and a full digest.
175        let witness = edited.witness();
176        assert_eq!(witness.text_bytes, 4);
177        assert_eq!(witness.text_digest.len(), 64);
178        assert_eq!(witness.revision, edited.revision());
179    }
180}