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/// One seeded document: its buffer plus whether it came from a file.
18/// Surfaces (diff/log/output) are job-owned content, never startup state.
19#[derive(Serialize, Deserialize)]
20struct DocSeed {
21    buffer: BufferSeed,
22    file: bool,
23}
24
25#[derive(Serialize, Deserialize)]
26pub struct Seed {
27    documents: ArenaSeed<DocSeed>,
28    panes: Vec<Pane>,
29    mru: Vec<DocumentId>,
30    active: usize,
31    layout: LayoutDir,
32    #[serde(with = "strop_core::path_serde")]
33    cwd: PathBuf,
34    #[serde(with = "strop_core::path_serde::option")]
35    state_dir: Option<PathBuf>,
36    session_policy: crate::session::SessionPolicy,
37    config: crate::config::Config,
38    git: Option<strop_git::GitContext>,
39    git_view: WorkerId,
40    git_discovery: Load<crate::editor::git_memory::ContextKey>,
41    worker_ids: WorkerIds,
42    focus_epoch: u64,
43    generation: u64,
44    message: String,
45}
46
47impl Seed {
48    /// Capture the startup state. This is only legal at the seed boundary:
49    /// pickers, LSP servers, git surfaces and in-flight discovery mean
50    /// services already started and the recording is not a full replay.
51    pub fn capture(editor: &Editor) -> io::Result<Self> {
52        if editor.picker.is_some()
53            || !editor.lsp_servers.is_empty()
54            || matches!(editor.git_discovery, Load::Running(_))
55            || editor.docs.iter().any(|(_, document)| {
56                !matches!(
57                    document.source,
58                    DocumentSource::File | DocumentSource::Scratch
59                )
60            })
61        {
62            return Err(io::Error::other("seed must precede service startup"));
63        }
64        Ok(Self {
65            documents: editor.docs.seed_with(|document| DocSeed {
66                buffer: document.buf.seed(),
67                file: matches!(document.source, DocumentSource::File),
68            }),
69            panes: editor.panes.clone(),
70            mru: editor.mru.clone(),
71            active: editor.active_pane,
72            layout: editor.layout,
73            cwd: editor.cwd.clone(),
74            state_dir: editor.state_dir.clone(),
75            session_policy: editor.session_policy,
76            config: editor.config.clone(),
77            git: editor.git.clone(),
78            git_view: editor.git_view,
79            git_discovery: editor.git_discovery.clone(),
80            worker_ids: editor.worker_ids.clone(),
81            focus_epoch: editor.focus_epoch,
82            generation: editor.generation,
83            message: editor.message.clone(),
84        })
85    }
86
87    /// Input-only extraction begins with the active startup document, not
88    /// a lazily emitted diagnostic snapshot after the first key.
89    pub fn input_text(&self) -> io::Result<&str> {
90        let pane = self
91            .panes
92            .get(self.active)
93            .ok_or_else(|| io::Error::other("seed has no active pane"))?;
94        let document = self
95            .documents
96            .slots
97            .get(pane.doc.index())
98            .filter(|(generation, _)| *generation == pane.doc.generation())
99            .and_then(|(_, document)| document.as_ref())
100            .ok_or_else(|| io::Error::other("seed has no active document"))?;
101        Ok(&document.buffer.text)
102    }
103
104    /// Rebuild the editor: same identities, same revisions, same history,
105    /// pure `new_in` construction, the given tape installed.
106    pub fn into_editor(self, tape: std::rc::Rc<strop_trace::replay::Tape>) -> io::Result<Editor> {
107        let mut slots = Vec::with_capacity(self.documents.slots.len());
108        for (generation, value) in self.documents.slots {
109            let document = value
110                .map(|seed| {
111                    let buffer = seed.buffer.into_buffer().map_err(io::Error::other)?;
112                    Ok::<_, io::Error>(if seed.file {
113                        Document::new(buffer)
114                    } else {
115                        Document::scratch(buffer)
116                    })
117                })
118                .transpose()?;
119            slots.push((generation, document));
120        }
121        let docs = Arena::from_seed(ArenaSeed {
122            slots,
123            free: self.documents.free,
124        })
125        .map_err(io::Error::other)?;
126        if docs.is_empty()
127            || self.active >= self.panes.len()
128            || self.panes.iter().any(|pane| docs.get(pane.doc).is_none())
129            || self.mru.iter().any(|id| docs.get(*id).is_none())
130        {
131            return Err(io::Error::other("invalid initial document references"));
132        }
133        for pane in &self.panes {
134            let buffer = &docs
135                .get(pane.doc)
136                .ok_or_else(|| io::Error::other("invalid initial document reference"))?
137                .buf;
138            for selection in
139                std::iter::once(pane.sels.primary()).chain(pane.sels.extra_heads().iter().copied())
140            {
141                if selection.anchor > buffer.len_bytes()
142                    || selection.head > buffer.len_bytes()
143                    || !buffer.is_boundary(selection.anchor)
144                    || !buffer.is_boundary(selection.head)
145                {
146                    return Err(io::Error::other("invalid initial selection"));
147                }
148            }
149        }
150        let mut editor = Editor::new_in(Buffer::from_text(""), self.cwd);
151        editor.docs = docs;
152        editor.panes = self.panes;
153        editor.mru = self.mru;
154        editor.active_pane = self.active;
155        editor.layout = self.layout;
156        editor.state_dir = self.state_dir;
157        editor.session_policy = self.session_policy;
158        editor.config = self.config;
159        editor.git = self.git;
160        editor.git_view = self.git_view;
161        editor.git_discovery = self.git_discovery;
162        editor.worker_ids = self.worker_ids;
163        editor.focus_epoch = self.focus_epoch;
164        editor.generation = self.generation;
165        editor.message = self.message;
166        editor.tape = tape;
167        Ok(editor)
168    }
169}