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