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}