Skip to main content

provui_core/
session.rs

1//! [`DocumentSession`] — one open prov document, edited through two coordinated
2//! editors.
3//!
4//! A prov document is a metadata region plus a prose body. The session owns:
5//!
6//! - a [`flower_core::Model<ProvBackend>`] for the **metadata** (structural,
7//!   lossless, through prov's `MetaEditor`), and
8//! - a [`leaf_core::Doc`] for the **body** (a rich-text editor over its own
9//!   buffer).
10//!
11//! The two regions are independent — there are no shared byte offsets — so they
12//! edit freely and reconcile only at [`save`](DocumentSession::save): the body's
13//! current text is spliced back into the document (leaving the metadata edits in
14//! place), and the reassembled bytes are written to disk.
15//!
16//! A workspace [`Schema`](flower_core::Schema) can be supplied at open
17//! (`*_with_schema`) so the metadata model validates controlled fields and offers
18//! pickers; without one the session behaves exactly as a schema-free editor.
19//!
20//! File I/O lives here (not in flower-core/leaf-core, which stay fs-agnostic). A
21//! frontend that wants fixity and the `updated` stamp maintained routes the write
22//! through prov's `Storage`/`mutate` layer instead; this foundation writes the
23//! bytes directly, which is the unopinionated floor a frontend builds on.
24//!
25//! This is the surface a UniFFI facade will wrap, and the surface a TUI drives
26//! directly.
27
28use std::path::{Path, PathBuf};
29
30use fig::Value;
31use flower_core::{Model, Schema, Seg, ViewMode};
32use leaf_core::{Doc, Format as BodyFormat};
33use prov::{Document, MetaCarrier};
34
35use crate::ProvBackend;
36
37/// The answer for a document whose metadata block does not resolve — a `&Value`
38/// to hand back without an allocation or an `Option` every caller would unwrap
39/// the same way.
40static EMPTY_META: Value = Value::Null;
41
42/// A session error, carrying a human-readable message. UniFFI-friendly to widen
43/// into a typed enum later.
44#[derive(Debug)]
45pub struct SessionError(pub String);
46
47impl std::fmt::Display for SessionError {
48    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
49        f.write_str(&self.0)
50    }
51}
52
53impl std::error::Error for SessionError {}
54
55fn se(e: impl std::fmt::Display) -> SessionError {
56    SessionError(e.to_string())
57}
58
59/// One open prov document: a metadata editor and a body editor over the same
60/// file, reconciled on save.
61pub struct DocumentSession {
62    path: PathBuf,
63    metadata: Model<ProvBackend>,
64    body: Doc,
65    /// Whether the document has an editable prose body (a fenced carrier). A
66    /// whole-file config document has none — its body editor stays empty.
67    has_body: bool,
68    /// The body text as of the last open/save, for dirty tracking.
69    saved_body: String,
70}
71
72/// The grammar a document's body is written in, from its path.
73///
74/// leaf is grammar-agnostic and prov already knows which extensions mean what,
75/// so the only thing missing was asking. A path prov does not recognize as
76/// content falls back to Markdown — including a whole-file config document,
77/// which has no body for the format to apply to.
78fn body_format_of(path: &Path) -> BodyFormat {
79    match prov::ContentFormat::from_extension(path) {
80        Some(prov::ContentFormat::Djot) => BodyFormat::Djot,
81        Some(prov::ContentFormat::Html) => BodyFormat::Html,
82        Some(prov::ContentFormat::Markdown) | None => BodyFormat::Markdown,
83    }
84}
85
86impl DocumentSession {
87    /// Open a prov document from disk, parsing the body in the grammar its
88    /// extension declares, with no schema.
89    pub fn open(path: impl Into<PathBuf>) -> Result<Self, SessionError> {
90        let path = path.into();
91        let format = body_format_of(&path);
92        Self::open_with(path, format, None)
93    }
94
95    /// Open a prov document from disk carrying the workspace `schema`.
96    pub fn open_with_schema(
97        path: impl Into<PathBuf>,
98        schema: Schema,
99    ) -> Result<Self, SessionError> {
100        let path = path.into();
101        let format = body_format_of(&path);
102        Self::open_with(path, format, Some(schema))
103    }
104
105    /// Open a prov document declaring the keys the **workspace** maintains, so
106    /// the metadata model draws their rows and declines every edit to them.
107    ///
108    /// `derived` is
109    /// [`Facets::managed_key_names`](crate::Facets::managed_key_names) — `id`,
110    /// `content_hash`, and whatever the workspace named as its `updated` stamp.
111    /// It is a separate entry point rather than something
112    /// [`open_with_schema`](Self::open_with_schema) does for you because
113    /// declining an edit is a *policy*, and a repair tool that means to rewrite a
114    /// stale `id` is as legitimate a frontend as an editor that must not. This
115    /// crate hands over the list and lets the frontend decide (see
116    /// [`crate::facets`]); most editors want it, and this is the one line that
117    /// says so.
118    ///
119    /// The set has to arrive here rather than being applied afterwards: flower
120    /// takes it before it builds its first row list.
121    pub fn open_managed(
122        path: impl Into<PathBuf>,
123        schema: Option<Schema>,
124        derived: Vec<String>,
125    ) -> Result<Self, SessionError> {
126        let path = path.into();
127        let format = body_format_of(&path);
128        let text = std::fs::read_to_string(&path)
129            .map_err(|e| SessionError(format!("reading {}: {e}", path.display())))?;
130        Self::build(path, &text, format, schema, derived)
131    }
132
133    /// Open a prov document from disk, parsing the body as `body_format`, with an
134    /// optional workspace schema.
135    pub fn open_with(
136        path: impl Into<PathBuf>,
137        body_format: BodyFormat,
138        schema: Option<Schema>,
139    ) -> Result<Self, SessionError> {
140        let path = path.into();
141        let text = std::fs::read_to_string(&path)
142            .map_err(|e| SessionError(format!("reading {}: {e}", path.display())))?;
143        Self::from_text(path, &text, body_format, schema)
144    }
145
146    /// Build a session from in-memory `text`. The `path` still drives prov's
147    /// carrier/format detection (extension for a config doc, content sniffing for a
148    /// fenced block). `schema` governs the metadata model when present.
149    pub fn from_text(
150        path: impl Into<PathBuf>,
151        text: &str,
152        body_format: BodyFormat,
153        schema: Option<Schema>,
154    ) -> Result<Self, SessionError> {
155        Self::build(path, text, body_format, schema, Vec::new())
156    }
157
158    /// The one constructor the rest are written in terms of.
159    fn build(
160        path: impl Into<PathBuf>,
161        text: &str,
162        body_format: BodyFormat,
163        schema: Option<Schema>,
164        derived: Vec<String>,
165    ) -> Result<Self, SessionError> {
166        let path = path.into();
167        let parsed = Document::parse(&path, text).map_err(se)?;
168        let has_body = matches!(parsed.carrier, Some(MetaCarrier::Fenced(_)));
169
170        let backend = match schema {
171            Some(schema) => ProvBackend::open_with_schema(&path, text, schema),
172            None => ProvBackend::open(&path, text),
173        }
174        .map_err(se)?;
175        // `with_managed`, not `new`: a derived key keeps its row and declines
176        // every edit, which is a different thing from hiding it. Empty `hidden`
177        // — nothing about a prov document is a key this host should make
178        // invisible, and a frontend that wants one says so itself.
179        let metadata = Model::with_managed(backend, Vec::new(), derived).map_err(se)?;
180        let body = Doc::from_source(parsed.body, body_format).map_err(se)?;
181        let saved_body = body.source.clone();
182
183        Ok(Self {
184            path,
185            metadata,
186            body,
187            has_body,
188            saved_body,
189        })
190    }
191
192    pub fn path(&self) -> &Path {
193        &self.path
194    }
195
196    /// Whether the document has an editable prose body.
197    pub fn has_body(&self) -> bool {
198        self.has_body
199    }
200
201    /// The metadata editor (its `rows` are what a metadata pane renders).
202    pub fn metadata(&self) -> &Model<ProvBackend> {
203        &self.metadata
204    }
205
206    pub fn metadata_mut(&mut self) -> &mut Model<ProvBackend> {
207        &mut self.metadata
208    }
209
210    /// The metadata value tree — what [`crate::links`] and [`crate::facets`] ask
211    /// their questions of.
212    ///
213    /// The model's own copy, not a reparse: it is rebuilt on every edit, so this
214    /// is current and free.
215    pub fn meta(&self) -> &Value {
216        self.metadata.value_at(&[]).unwrap_or(&EMPTY_META)
217    }
218
219    /// The metadata path the cursor is on, whichever projection the model is
220    /// showing.
221    ///
222    /// flower has two — a flat row list and a page stack — and asks the question
223    /// a different way in each. A frontend that wants "the row under the cursor"
224    /// should not have to know which one it set, least of all a frontend that
225    /// switches between them; getting it wrong reads as a link that follows the
226    /// wrong document rather than as an error.
227    pub fn cursor_path(&self) -> Option<Vec<Seg>> {
228        match self.metadata.view() {
229            ViewMode::Pages => self.metadata.page_item().map(|item| item.path.clone()),
230            _ => self.metadata.selected_path(),
231        }
232    }
233
234    /// The body editor.
235    pub fn body(&self) -> &Doc {
236        &self.body
237    }
238
239    pub fn body_mut(&mut self) -> &mut Doc {
240        &mut self.body
241    }
242
243    /// Programmatically set the metadata value at `path` — the flat, by-path edit
244    /// a UI/FFI issues (vs. driving the selection).
245    pub fn set_metadata(&mut self, path: &[Seg], value: Value) {
246        self.metadata.set_value_at(path, value);
247    }
248
249    /// `true` if the metadata or the body has unsaved edits.
250    pub fn dirty(&self) -> bool {
251        self.metadata.dirty || (self.has_body && self.body.source != self.saved_body)
252    }
253
254    /// Reconcile the body edits into the document and return the full reassembled
255    /// text — exactly the bytes [`save`](Self::save) writes. Does not touch disk.
256    pub fn reassemble(&mut self) -> Result<String, SessionError> {
257        if self.has_body {
258            let body = self.body.source.clone();
259            self.metadata.backend_mut().set_body(&body).map_err(se)?;
260        }
261        Ok(self.metadata.source_snapshot())
262    }
263
264    /// Write the reassembled document (metadata edits + body edits) to disk.
265    pub fn save(&mut self) -> Result<(), SessionError> {
266        let full = self.reassemble()?;
267        std::fs::write(&self.path, full.as_bytes())
268            .map_err(|e| SessionError(format!("writing {}: {e}", self.path.display())))?;
269        self.saved_body = self.body.source.clone();
270        self.metadata.mark_saved();
271        Ok(())
272    }
273}
274
275#[cfg(test)]
276mod tests {
277    use super::*;
278
279    const DOC: &str = "\
280---
281# the title
282title: Old Title
283draft: true
284---
285# Heading
286
287Original body.
288";
289
290    fn preview_of(session: &DocumentSession, key: &str) -> Option<String> {
291        session
292            .metadata()
293            .rows
294            .iter()
295            .find(|r| r.path == [Seg::Key(key.into())])
296            .map(|r| r.preview.clone())
297    }
298
299    #[test]
300    fn reassembles_both_edits_without_disk() {
301        let mut session =
302            DocumentSession::from_text("note.md", DOC, BodyFormat::Markdown, None).unwrap();
303        assert!(session.has_body());
304        assert!(!session.dirty());
305
306        // Metadata edit (by path) + body edit (via leaf).
307        session.set_metadata(&[Seg::Key("title".into())], Value::Str("New Title".into()));
308        session.body_mut().insert("Edited: ");
309        assert!(session.dirty());
310
311        let out = session.reassemble().unwrap();
312        assert!(out.contains("title: New Title"), "metadata edit:\n{out}");
313        assert!(out.contains("# the title"), "frontmatter comment:\n{out}");
314        assert!(out.contains("draft: true"), "sibling key:\n{out}");
315        assert!(out.contains("Edited: "), "body edit:\n{out}");
316        assert!(out.contains("Original body."), "rest of body:\n{out}");
317        assert!(out.starts_with("---\n"), "fences:\n{out}");
318    }
319
320    /// A key the workspace maintains keeps its row and refuses the edit — the
321    /// difference between "not shown" and "not yours to type".
322    #[test]
323    fn a_managed_key_is_drawn_and_declines_every_edit() {
324        const WITH_ID: &str = "---\ntitle: A Note\nid: ajp7eq\nmood: rainy\n---\n# Note\n";
325        let path = std::env::temp_dir().join("provui_core_session_managed.md");
326        let _ = std::fs::remove_file(&path);
327        std::fs::write(&path, WITH_ID).unwrap();
328
329        let facets = crate::Facets::default();
330        let mut session =
331            DocumentSession::open_managed(&path, None, facets.managed_key_names()).unwrap();
332
333        // Drawn: the row is there, with its value.
334        assert_eq!(preview_of(&session, "id").as_deref(), Some("ajp7eq"));
335        assert!(session.metadata().is_derived(&[Seg::Key("id".into())]));
336
337        // And declined: the document is untouched and still clean.
338        session.set_metadata(&[Seg::Key("id".into())], Value::Str("typed".into()));
339        assert!(!session.dirty(), "a derived key takes no edit");
340        assert_eq!(preview_of(&session, "id").as_deref(), Some("ajp7eq"));
341
342        // An ordinary key beside it is unaffected.
343        session.set_metadata(&[Seg::Key("mood".into())], Value::Str("clear".into()));
344        assert!(session.dirty());
345
346        let _ = std::fs::remove_file(&path);
347    }
348
349    /// The whole composition, end to end on disk: open → edit both regions →
350    /// save → reopen, with comments, fences, untouched keys and untouched body
351    /// all still there. The in-memory test above proves the splice; this one
352    /// proves the bytes survive a round trip through the filesystem, which is
353    /// the only place a lossless claim can actually be falsified.
354    #[test]
355    fn open_edit_save_reopen_round_trip_on_disk() {
356        let path = std::env::temp_dir().join("provui_core_document_session_round_trip.md");
357        let _ = std::fs::remove_file(&path);
358        std::fs::write(&path, DOC).unwrap();
359
360        // Open, edit both regions, save.
361        let mut session = DocumentSession::open(&path).unwrap();
362        assert_eq!(preview_of(&session, "title").as_deref(), Some("Old Title"));
363        session.set_metadata(&[Seg::Key("title".into())], Value::Str("New Title".into()));
364        session.body_mut().insert("Edited: ");
365        session.save().unwrap();
366        assert!(!session.dirty(), "clean after save");
367
368        // The bytes on disk carry both edits, everything else preserved.
369        let saved = std::fs::read_to_string(&path).unwrap();
370        assert!(
371            saved.contains("title: New Title"),
372            "saved metadata:\n{saved}"
373        );
374        assert!(saved.contains("# the title"), "saved comment:\n{saved}");
375        assert!(saved.contains("Edited: "), "saved body:\n{saved}");
376        assert!(
377            saved.contains("Original body."),
378            "saved body rest:\n{saved}"
379        );
380
381        // Reopening parses cleanly and reflects both edits.
382        let reopened = DocumentSession::open(&path).unwrap();
383        assert_eq!(preview_of(&reopened, "title").as_deref(), Some("New Title"));
384        assert!(reopened.body().source.contains("Edited: "));
385
386        let _ = std::fs::remove_file(&path);
387    }
388}