Skip to main content

provui_core/
lib.rs

1//! provui-core — a frontend-neutral UI composition core over
2//! [`prov`](https://docs.rs/prov).
3//!
4//! prov describes a plaintext workspace; [`flower`](https://docs.rs/flower-core)
5//! edits structured metadata; [`leaf`](https://docs.rs/leaf-core) edits prose.
6//! This crate is the composition of the three, with no opinion about what draws
7//! it — the same core is meant to sit under a TUI, a SwiftUI app behind UniFFI,
8//! or a test harness:
9//!
10//! - [`ProvBackend`] — a [`flower_core::Backend`] that edits a prov document's
11//!   embedded metadata through prov's carrier-aware
12//!   [`MetaEditor`](prov::edit::MetaEditor). Lossless: comments, key order, the
13//!   carrier/format, and the prose body are all preserved. Unlike
14//!   [`flower_core::FigBackend`] (a standalone config file, schema-free), a
15//!   `ProvBackend` can carry the workspace **schema** — the controlled
16//!   vocabularies and relations resolved from the prov config — so a frontend
17//!   renders term pickers, spanning-link widgets, and type-directed edits.
18//! - [`DocumentSession`] — one open prov document edited through a flower metadata
19//!   model *and* a leaf body editor, reconciled on save.
20//! - [`schema_from_config`] — the adapter turning a resolved prov
21//!   [`WorkspaceConfig`](prov::config::WorkspaceConfig) (+ its vocabularies) into a
22//!   generic [`flower_core::Schema`] for the workspace's **content** documents.
23//!   This is where prov's controlled vocabularies and spanning relation reach the
24//!   UI.
25//! - [`config_schema`] — the same trick turned on the config document itself, so
26//!   the metadata editor a frontend already ships can edit a workspace's policy
27//!   instead of a hand-written settings form.
28//! - [`facets`] — what each frontmatter key *is* to prov: a relation, a pointer
29//!   at machinery, identity, policy, a declared field, or a value prov only
30//!   carries. Read off the workspace's own vocabulary rather than a list this
31//!   crate keeps.
32//! - [`links`] — the links a document declares, each with the metadata **path**
33//!   it sits at, so "is the row under the cursor a link?" is a question with an
34//!   answer. Lexical: no filesystem, no registry.
35//! - [`workspace`] — [`WorkspaceView`], which finds the workspace a document
36//!   belongs to and resolves a link to a document you can open. The one piece
37//!   that reads the filesystem, and read-only.
38//!
39//! ## What this crate will not do for you
40//!
41//! It classifies, and it never arranges. Nothing here hides a row, sinks one,
42//! reorders them, or makes one read-only — even where it plainly knows enough
43//! to: [`Facets`] can tell you `id` is minted and `contents` is structure, and
44//! hands you the lists shaped to go straight into flower's `derived` and
45//! `demoted` sets, and then stops.
46//!
47//! That is deliberate. An application over prov usually does separate prov's
48//! structure from the values a person typed — diaryx does — but *how* is a
49//! product decision, and a mobile inspector, a terminal band and a settings
50//! sheet do not want the same one. The classification is general and lives here
51//! once; the arrangement is local and lives in the frontend. `provui-tui`'s
52//! `nav` module is a worked example of the whole policy, and it is two lines.
53//!
54//! Scope: the single-document metadata surface (prov's `edit` layer), plus
55//! read-only navigation across documents. Relation fields that *maintain inverse
56//! links* across documents belong to prov's `mutate` layer — a later,
57//! relationship-aware backend, not this one. Following a link reads; retargeting
58//! one would write two documents, and this crate's backend edits one.
59
60pub mod config_schema;
61pub mod facets;
62pub mod links;
63pub mod rules;
64pub mod schema;
65mod session;
66pub mod workspace;
67
68pub use config_schema::{CONFIG_READONLY_KEYS, config_schema};
69pub use facets::{Facet, Facets};
70pub use links::{MetaLink, TargetKind, link_at, links_in, links_under};
71pub use schema::schema_from_config;
72pub use session::{DocumentSession, SessionError};
73pub use workspace::{Destination, WorkspaceView};
74
75use fig::Value;
76use flower_core::tree::{self, to_fig};
77use flower_core::{Backend, BackendError, EditOp, Schema, Seg};
78use prov::edit::MetaEditor;
79use prov::{Document, MetaCarrier};
80
81fn be(e: impl std::fmt::Display) -> BackendError {
82    BackendError(e.to_string())
83}
84
85/// A backend over a single prov document, editing its embedded metadata.
86pub struct ProvBackend {
87    /// The document path — drives carrier/format detection (extension for a
88    /// whole-file config doc, content sniffing for a fenced block).
89    path: std::path::PathBuf,
90    /// The current full document text (frontmatter + body); the source of truth.
91    text: String,
92    /// The schema governing this document, when the embedder resolved one from the
93    /// workspace config (see [`schema_from_config`]). Returned via
94    /// [`Backend::schema`] so the flower model validates values and a frontend can
95    /// pick schema-driven widgets. `None` for a bare document with no workspace.
96    schema: Option<Schema>,
97}
98
99impl ProvBackend {
100    /// Open a prov document from its full `text`, with no schema. Errors if prov
101    /// cannot parse it.
102    pub fn open(
103        path: impl Into<std::path::PathBuf>,
104        text: impl Into<String>,
105    ) -> Result<Self, BackendError> {
106        Self::open_with_schema_opt(path, text, None)
107    }
108
109    /// Open a prov document carrying the workspace `schema` — the prov-aware path,
110    /// so the flower model validates controlled fields and offers pickers.
111    pub fn open_with_schema(
112        path: impl Into<std::path::PathBuf>,
113        text: impl Into<String>,
114        schema: Schema,
115    ) -> Result<Self, BackendError> {
116        Self::open_with_schema_opt(path, text, Some(schema))
117    }
118
119    fn open_with_schema_opt(
120        path: impl Into<std::path::PathBuf>,
121        text: impl Into<String>,
122        schema: Option<Schema>,
123    ) -> Result<Self, BackendError> {
124        let path = path.into();
125        let text = text.into();
126        // Fail fast if the document doesn't parse.
127        Document::parse(&path, &text).map_err(be)?;
128        Ok(Self { path, text, schema })
129    }
130
131    fn document(&self) -> Result<Document, BackendError> {
132        Document::parse(&self.path, &self.text).map_err(be)
133    }
134
135    /// The prose body outside the metadata block — the region a `leaf` editor
136    /// would own. Empty for a whole-file config document.
137    pub fn body(&self) -> Result<String, BackendError> {
138        Ok(self.document()?.body)
139    }
140
141    /// Whether the document has an editable prose body (a fenced carrier). A
142    /// whole-file config document has none — its body cannot be replaced.
143    pub fn has_body(&self) -> Result<bool, BackendError> {
144        Ok(matches!(
145            self.document()?.carrier,
146            Some(MetaCarrier::Fenced(_))
147        ))
148    }
149
150    /// Replace the prose body, leaving the metadata block untouched — the write
151    /// path for edits a `leaf` editor makes to [`body`](Self::body).
152    ///
153    /// Uses fig's `Embed::replace_body` (the same lossless primitive prov edits
154    /// through). A frontend that wants fixity/`updated` restamping routes this
155    /// through prov's write path instead; here it demonstrates that the metadata
156    /// and body regions edit independently over one document.
157    pub fn set_body(&mut self, body: &str) -> Result<(), BackendError> {
158        match self.document()?.carrier {
159            Some(MetaCarrier::Fenced(kind)) => {
160                let mut embed = fig::Embed::open(self.text.as_bytes(), kind).map_err(be)?;
161                embed.replace_body(body).map_err(be)?;
162                self.text = embed.render().map_err(be)?.to_string();
163                Ok(())
164            }
165            _ => Err(BackendError(
166                "document has no fenced body to replace".into(),
167            )),
168        }
169    }
170}
171
172impl Backend for ProvBackend {
173    fn apply(&mut self, op: EditOp) -> Result<(), BackendError> {
174        let carrier = self.document()?.carrier;
175        // `open_or_init` so an edit to a document with no block synthesizes one
176        // (frontmatter for a prose file) rather than failing.
177        let mut editor = MetaEditor::open_or_init(&self.text, carrier).map_err(be)?;
178
179        match op {
180            EditOp::ReplaceValue { path, value } => {
181                let segs = to_fig(&path);
182                // Mirror prov's `set_in_text`: an index-terminated path is a pure
183                // replacement (there is no "insert at absent index"); a
184                // key-terminated path upserts.
185                match path.last() {
186                    Some(Seg::Index(_)) => editor.replace_value(&segs, value).map_err(be)?,
187                    _ => editor.set_value(&segs, value).map_err(be)?,
188                }
189            }
190            EditOp::DeleteKey { path } => editor.delete(&to_fig(&path)).map_err(be)?,
191            EditOp::RemoveItem { seq_path, index } => {
192                editor.remove_item(&to_fig(&seq_path), index).map_err(be)?
193            }
194            // prov's MetaEditor has no distinct "insert": `set_value` at the new
195            // key path upserts, which is exactly an insert for an absent key.
196            EditOp::InsertKey {
197                map_path,
198                key,
199                value,
200            } => {
201                let mut path = map_path;
202                path.push(Seg::Key(key));
203                editor.set_value(&to_fig(&path), value).map_err(be)?
204            }
205            EditOp::AppendItem { seq_path, value } => {
206                editor.append_value(&to_fig(&seq_path), value).map_err(be)?
207            }
208            // No `move_item` on MetaEditor; express the move as a full index
209            // permutation through `reorder_items`, sized from the current sequence.
210            // The index arithmetic is flower's, so a move here means what a move
211            // means through any other backend.
212            EditOp::MoveItem { seq_path, from, to } => {
213                let len = tree::seq_len(&self.to_value()?, &seq_path)
214                    .ok_or_else(|| BackendError("target is not a sequence".into()))?;
215                if let Some(order) = flower_core::backend::move_permutation(len, from, to) {
216                    editor
217                        .reorder_items(&to_fig(&seq_path), &order)
218                        .map_err(be)?;
219                }
220            }
221            EditOp::ReorderKeys { map_path, keys } => {
222                editor.reorder_keys(&to_fig(&map_path), &keys).map_err(be)?
223            }
224            EditOp::RenameKey { path, new_key } => {
225                editor.replace_key(&to_fig(&path), &new_key).map_err(be)?
226            }
227        }
228
229        self.text = editor.render().map_err(be)?;
230        Ok(())
231    }
232
233    fn to_value(&self) -> Result<Value, BackendError> {
234        // prov's metadata tree → fig's value tree (the serde-free bridge).
235        Ok(Value::from(&self.document()?.meta))
236    }
237
238    fn source(&self) -> Result<String, BackendError> {
239        Ok(self.text.clone())
240    }
241
242    fn schema(&self) -> Option<Schema> {
243        self.schema.clone()
244    }
245}
246
247#[cfg(test)]
248mod tests {
249    use super::*;
250    use flower_core::{Mode, Model};
251
252    const DOC: &str = "\
253---
254# the title
255title: Old Title
256draft: true
257tags:
258- a
259- b
260---
261# Heading
262
263Body prose that must survive metadata edits.
264";
265
266    fn model() -> Model<ProvBackend> {
267        let backend = ProvBackend::open("note.md", DOC).expect("open prov doc");
268        Model::new(backend).expect("build model")
269    }
270
271    fn select(model: &mut Model<ProvBackend>, path: &[Seg]) {
272        // `select_row`, not a write to `selected`: the field is flower's own now,
273        // and the setter is what asserts the tree projection this index belongs to.
274        let index = model
275            .rows
276            .iter()
277            .position(|r| r.path == path)
278            .unwrap_or_else(|| panic!("no row for {path:?}"));
279        model.select_row(index);
280    }
281
282    fn type_value(model: &mut Model<ProvBackend>, text: &str) {
283        // `..`: an edit now also carries the path it belongs to, which this
284        // helper has no use for — it types into whatever is already open.
285        if let Mode::Editing { buffer, .. } = &mut model.mode {
286            buffer.clear();
287        }
288        for c in text.chars() {
289            model.edit_push(c);
290        }
291        model.edit_commit();
292    }
293
294    /// The `EditOp` contract, checked against flower's own suite.
295    ///
296    /// `ProvBackend` is the second implementation of that trait, and a trait with
297    /// one implementation has only a behavior — this is where the two would
298    /// silently part ways. Running flower's suite rather than restating it means a
299    /// guarantee added upstream arrives here as a failing test, not as a difference
300    /// nobody looked for.
301    ///
302    /// The fixture is written as frontmatter because that is the carrier a prose
303    /// vault uses; the suite asserts on the value tree, so the format is ours to
304    /// pick.
305    #[test]
306    fn prov_backend_satisfies_the_edit_op_contract() {
307        const FIXTURE: &str = "\
308---
309title: note
310tags:
311- alpha
312- beta
313- gamma
314nested:
315  k: v
316  j: w
317---
318# Note
319
320Body prose.
321";
322        flower_core::backend::conformance::check(|| {
323            ProvBackend::open("note.md", FIXTURE).expect("open fixture")
324        })
325        .expect("prov backend honors the EditOp contract");
326    }
327
328    #[test]
329    fn renders_frontmatter_as_a_tree() {
330        let model = model();
331        let keys: Vec<&str> = model
332            .rows
333            .iter()
334            .filter(|r| r.depth == 0)
335            .map(|r| r.label.as_str())
336            .collect();
337        assert_eq!(
338            keys,
339            ["title", "draft", "tags"],
340            "top-level frontmatter keys"
341        );
342    }
343
344    #[test]
345    fn edits_metadata_leaving_the_body_untouched() {
346        let mut model = model();
347
348        select(&mut model, &[Seg::Key("title".into())]);
349        model.begin_edit();
350        type_value(&mut model, "New Title");
351
352        let out = model.source_snapshot();
353        assert!(out.contains("title: New Title"), "value changed:\n{out}");
354        assert!(out.contains("# the title"), "comment preserved:\n{out}");
355        assert!(out.starts_with("---\n"), "fences intact:\n{out}");
356        assert!(
357            out.contains("Body prose that must survive metadata edits."),
358            "body preserved:\n{out}"
359        );
360    }
361
362    #[test]
363    fn deletes_a_key() {
364        let mut model = model();
365
366        select(&mut model, &[Seg::Key("draft".into())]);
367        model.delete_selected();
368
369        let out = model.source_snapshot();
370        assert!(!out.contains("draft:"), "key removed:\n{out}");
371        assert!(out.contains("title: Old Title"), "siblings kept:\n{out}");
372        assert!(out.contains("Body prose"), "body kept:\n{out}");
373    }
374
375    #[test]
376    fn schema_backed_backend_rejects_a_term_outside_a_closed_vocabulary() {
377        use flower_core::schema::{Constraint, FieldRule};
378        use flower_core::{FieldType, PathPat, Term};
379        // A prov document whose `audience` is a closed vocabulary.
380        let doc = "---\ntitle: Note\naudience:\n- public\n---\n# Note\n";
381        let schema = Schema::new(vec![
382            FieldRule::new(PathPat::each_item_of("audience"))
383                .ty(FieldType::Str)
384                .constraint(Constraint::Enum {
385                    values: vec![Term::value("public"), Term::value("private")],
386                    closed: true,
387                }),
388        ]);
389        let backend = ProvBackend::open_with_schema("note.md", doc, schema).expect("open");
390        let mut model = Model::new(backend).expect("model");
391
392        // The schema traveled through the backend into the model: an unknown
393        // term is rejected, the document untouched.
394        select(&mut model, &[Seg::Key("audience".into()), Seg::Index(0)]);
395        model.begin_edit();
396        type_value(&mut model, "familly");
397        assert!(
398            model.status.contains("rejected"),
399            "status: {}",
400            model.status
401        );
402        assert!(model.source_snapshot().contains("- public"), "unchanged");
403
404        // A known value commits.
405        model.begin_edit();
406        type_value(&mut model, "private");
407        assert!(model.source_snapshot().contains("- private"), "applied");
408    }
409}