Skip to main content

strop_engine/editor/trace/
seed.rs

1//! The forensic startup seed (R11): a pure, serializable image of the
2//! editor captured AFTER authorized live startup (file open, config load,
3//! session restore) and BEFORE any service start. Replay reconstructs
4//! documents with identical arena identities, buffer revisions, undo
5//! history and disk baselines — no Client, process, filesystem or network.
6use std::io;
7use std::path::PathBuf;
8
9use serde::{Deserialize, Serialize};
10use strop_core::id::{Arena, ArenaSeed, DocumentId};
11use strop_core::worker::{Load, WorkerId, WorkerIds};
12use strop_core::{Buffer, BufferSeed};
13
14use crate::editor::document::DocumentSource;
15use crate::editor::{Document, Editor, LayoutDir, Pane};
16
17// 0.30 unifies search keyboard and owned-review semantics. Old input must
18// never be silently replayed under new semantics; metadata export is separate.
19const SEMANTIC_VERSION: u32 = 2;
20
21/// One seeded document: its buffer plus whether it came from a file.
22/// Surfaces (diff/log/output) are job-owned content, never startup state.
23#[derive(Serialize, Deserialize)]
24struct DocSeed {
25    buffer: BufferSeed,
26    file: bool,
27}
28
29#[derive(Serialize, Deserialize)]
30pub struct Seed {
31    #[serde(default)]
32    semantic_version: u32,
33    documents: ArenaSeed<DocSeed>,
34    panes: Vec<Pane>,
35    mru: Vec<DocumentId>,
36    active: usize,
37    layout: LayoutDir,
38    #[serde(with = "strop_core::path_serde")]
39    cwd: PathBuf,
40    #[serde(with = "strop_core::path_serde::option")]
41    state_dir: Option<PathBuf>,
42    session_policy: crate::session::SessionPolicy,
43    config: crate::config::Config,
44    git: Option<strop_git::GitContext>,
45    git_view: WorkerId,
46    git_discovery: Load<crate::editor::git_memory::ContextKey>,
47    worker_ids: WorkerIds,
48    focus_epoch: u64,
49    generation: u64,
50    message: String,
51}
52
53impl Seed {
54    fn validate_semantics(&self) -> io::Result<()> {
55        if self.semantic_version != SEMANTIC_VERSION {
56            return Err(io::Error::other(format!(
57                "unsupported editor semantics version {}; expected {SEMANTIC_VERSION}; re-record with this version",
58                self.semantic_version)));
59        }
60        Ok(())
61    }
62
63    /// Capture the startup state. This is only legal at the seed boundary:
64    /// pickers, LSP servers, git surfaces and in-flight discovery mean
65    /// services already started and the recording is not a full replay.
66    pub fn capture(editor: &Editor) -> io::Result<Self> {
67        if editor.picker.is_some()
68            || !editor.lsp_servers.is_empty()
69            || matches!(editor.git_discovery, Load::Running(_))
70            || editor.docs.iter().any(|(_, document)| {
71                !matches!(
72                    document.source,
73                    DocumentSource::File | DocumentSource::Scratch
74                )
75            })
76        {
77            return Err(io::Error::other("seed must precede service startup"));
78        }
79        Ok(Self {
80            semantic_version: SEMANTIC_VERSION,
81            documents: editor.docs.seed_with(|document| DocSeed {
82                buffer: document.buf.seed(),
83                file: matches!(document.source, DocumentSource::File),
84            }),
85            panes: editor.panes.clone(),
86            mru: editor.mru.clone(),
87            active: editor.active_pane,
88            layout: editor.layout,
89            cwd: editor.cwd.clone(),
90            state_dir: editor.state_dir.clone(),
91            session_policy: editor.session_policy,
92            config: editor.config.clone(),
93            git: editor.git.clone(),
94            git_view: editor.git_view,
95            git_discovery: editor.git_discovery.clone(),
96            worker_ids: editor.worker_ids.clone(),
97            focus_epoch: editor.focus_epoch,
98            generation: editor.generation,
99            message: editor.message.clone(),
100        })
101    }
102
103    /// Input-only extraction begins with the active startup document, not
104    /// a lazily emitted diagnostic snapshot after the first key.
105    pub fn input_text(&self) -> io::Result<&str> {
106        self.validate_semantics()?;
107        let pane = self
108            .panes
109            .get(self.active)
110            .ok_or_else(|| io::Error::other("seed has no active pane"))?;
111        let document = self
112            .documents
113            .slots
114            .get(pane.doc.index())
115            .filter(|(generation, _)| *generation == pane.doc.generation())
116            .and_then(|(_, document)| document.as_ref())
117            .ok_or_else(|| io::Error::other("seed has no active document"))?;
118        Ok(&document.buffer.text)
119    }
120
121    /// Rebuild the editor: same identities, same revisions, same history,
122    /// pure `new_in` construction, the given tape installed.
123    pub fn into_editor(self, tape: std::rc::Rc<strop_trace::replay::Tape>) -> io::Result<Editor> {
124        self.validate_semantics()?;
125        let mut slots = Vec::with_capacity(self.documents.slots.len());
126        for (generation, value) in self.documents.slots {
127            let document = value
128                .map(|seed| {
129                    let buffer = seed.buffer.into_buffer().map_err(io::Error::other)?;
130                    Ok::<_, io::Error>(if seed.file {
131                        Document::new(buffer)
132                    } else {
133                        Document::scratch(buffer)
134                    })
135                })
136                .transpose()?;
137            slots.push((generation, document));
138        }
139        let docs = Arena::from_seed(ArenaSeed {
140            slots,
141            free: self.documents.free,
142        })
143        .map_err(io::Error::other)?;
144        if docs.is_empty()
145            || self.active >= self.panes.len()
146            || self.panes.iter().any(|pane| docs.get(pane.doc).is_none())
147            || self.mru.iter().any(|id| docs.get(*id).is_none())
148        {
149            return Err(io::Error::other("invalid initial document references"));
150        }
151        for pane in &self.panes {
152            let buffer = &docs
153                .get(pane.doc)
154                .ok_or_else(|| io::Error::other("invalid initial document reference"))?
155                .buf;
156            for selection in
157                std::iter::once(pane.sels.primary()).chain(pane.sels.extra_heads().iter().copied())
158            {
159                if selection.anchor > buffer.len_bytes()
160                    || selection.head > buffer.len_bytes()
161                    || !buffer.is_boundary(selection.anchor)
162                    || !buffer.is_boundary(selection.head)
163                {
164                    return Err(io::Error::other("invalid initial selection"));
165                }
166            }
167        }
168        let mut editor = Editor::new_in(Buffer::from_text(""), self.cwd);
169        editor.docs = docs;
170        editor.panes = self.panes;
171        editor.mru = self.mru;
172        editor.active_pane = self.active;
173        editor.layout = self.layout;
174        editor.state_dir = self.state_dir;
175        editor.session_policy = self.session_policy;
176        editor.config = self.config;
177        editor.git = self.git;
178        editor.git_view = self.git_view;
179        editor.git_discovery = self.git_discovery;
180        editor.worker_ids = self.worker_ids;
181        editor.focus_epoch = self.focus_epoch;
182        editor.generation = self.generation;
183        editor.message = self.message;
184        editor.tape = tape;
185        Ok(editor)
186    }
187}
188
189#[cfg(test)]
190mod tests {
191    use super::*;
192
193    #[test]
194    fn legacy_input_capture_is_not_reinterpreted_as_the_new_query_language() {
195        let editor = Editor::new_in(Buffer::from_text("seed\n"), PathBuf::from("/virtual"));
196        let mut serialized = serde_json::to_value(Seed::capture(&editor).unwrap()).unwrap();
197        serialized
198            .as_object_mut()
199            .unwrap()
200            .remove("semantic_version");
201        let legacy: Seed = serde_json::from_value(serialized).unwrap();
202        assert!(legacy
203            .input_text()
204            .unwrap_err()
205            .to_string()
206            .contains("editor semantics"));
207    }
208}