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//!   [`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. [`schema_for_document`] is the same adapter for one document: a field
25//!   prov declares `under:` an index governs only the documents below it, so
26//!   which declaration a document is edited under is a fact about where it sits.
27//! - [`config_schema()`] — the same trick turned on the config document itself, so
28//!   the metadata editor a frontend already ships can edit a workspace's policy
29//!   instead of a hand-written settings form.
30//! - [`facets`] — what each frontmatter key *is* to prov: a relation, a pointer
31//!   at machinery, identity, policy, a declared field, or a value prov only
32//!   carries. Read off the workspace's own vocabulary rather than a list this
33//!   crate keeps.
34//! - [`links`] — the links a document's *frontmatter* declares, each with the
35//!   metadata **path** it sits at, so "is the row under the cursor a link?" is a
36//!   question with an answer. Lexical: no filesystem, no registry.
37//! - [`mod@body_links`] — the same question of the *prose*, answered with a byte
38//!   range into the body instead of a metadata path. Both kinds carry a
39//!   [`prov::Link`] and a [`TargetKind`], and [`AnyLink`] is what lets one
40//!   resolver answer for both — so following a link from the body caret and
41//!   following one from the metadata cursor are the same code.
42//! - [`findings`] — what prov's integrity check says about one document,
43//!   placed: a broken link becomes a metadata path or a byte range in the body,
44//!   which is a place an editor can draw. prov reports a relation's *name*; this
45//!   recovers the list index where the document's own links make that
46//!   unambiguous, and says so where they do not.
47//!   [`DocumentSession::apply_findings`] then hands each half to the editor that
48//!   owns it — leaf highlights under the prose, flower
49//!   [`Annotation`](flower_core::Annotation)s on the rows — so both widgets draw
50//!   them without the host drawing anything.
51//! - [`workspace`] — [`WorkspaceView`], which finds the workspace a document
52//!   belongs to and resolves a link to a document you can open. The one piece
53//!   that reads the filesystem, and read-only. It also runs that backwards:
54//!   [`WorkspaceView::reference_to`] and [`reference_here`] give the link text
55//!   to *write* to a document — or to a place inside one — in the workspace's
56//!   own reference style. Still a read; nothing is written and nothing is
57//!   registered.
58//!
59//! ## What this crate will not do for you
60//!
61//! It classifies, and it never arranges. Nothing here hides a row, sinks one,
62//! reorders them, or makes one read-only — even where it plainly knows enough
63//! to: [`Facets`] can tell you `id` is minted and `contents` is structure, and
64//! hands you the lists shaped to go straight into flower's `derived` and
65//! `demoted` sets, and then stops.
66//!
67//! That is deliberate. An application over prov usually does separate prov's
68//! structure from the values a person typed — diaryx does — but *how* is a
69//! product decision, and a mobile inspector, a terminal band and a settings
70//! sheet do not want the same one. The classification is general and lives here
71//! once; the arrangement is local and lives in the frontend. `provui-tui`'s
72//! `nav` module is a worked example of the whole policy, and it is two lines.
73//!
74//! Scope: the single-document metadata surface (prov's `edit` layer), plus
75//! read-only navigation across documents. Relation fields that *maintain inverse
76//! links* across documents belong to prov's `mutate` layer — a later,
77//! relationship-aware backend, not this one. Following a link reads; retargeting
78//! one would write two documents, and this crate's backend edits one.
79
80pub mod body_links;
81pub mod config_schema;
82pub mod facets;
83pub mod findings;
84pub mod links;
85pub mod rules;
86pub mod schema;
87mod session;
88pub mod workspace;
89
90pub use body_links::{BodyLink, body_link_at, body_links};
91pub use config_schema::{CONFIG_READONLY_KEYS, config_schema};
92pub use facets::{Facet, Facets};
93pub use findings::{Finding, Severity, Site};
94pub use links::{AnyLink, MetaLink, TargetKind, link_at, links_in, links_under};
95pub use schema::{Vocabularies, schema_for_document, schema_from_config};
96pub use session::{DocumentSession, Heading, Region, SessionError, annotations_of};
97pub use workspace::{Destination, WorkspaceView, reference_here, reference_without_workspace};
98
99use std::collections::HashMap;
100
101use fig::Value;
102use flower_core::schema::FieldRuleExt;
103use flower_core::tree::{self, to_fig};
104use flower_core::{Backend, BackendError, Choice, EditOp, Schema, Seg};
105use prov::edit::MetaEditor;
106use prov::{Document, MetaCarrier};
107
108fn be(e: impl std::fmt::Display) -> BackendError {
109    BackendError(e.to_string())
110}
111
112/// Run one expression against whichever fig editor sits behind a
113/// [`MetaEditor`]. The fenced and whole-file editors share every comment
114/// method by name and signature without sharing a trait, so a comment op is
115/// one body written once and matched into both arms.
116macro_rules! with_fig {
117    ($editor:expr, |$e:ident| $body:expr) => {
118        match $editor {
119            MetaEditor::Fenced($e) => $body,
120            MetaEditor::Whole($e) => $body,
121        }
122    };
123}
124
125/// A comment read is an answer, not a failure, on a format with no comment
126/// syntax: a page over JSON frontmatter has no comments on it, rather than a
127/// read error on every row. A write to such a format still refuses.
128fn comment_read(read: Result<Option<String>, fig::Error>) -> Result<Option<String>, BackendError> {
129    match read {
130        Err(fig::Error::UnsupportedFormat) => Ok(None),
131        other => other.map_err(be),
132    }
133}
134
135/// A backend over a single prov document, editing its embedded metadata.
136pub struct ProvBackend {
137    /// The document path — drives carrier/format detection (extension for a
138    /// whole-file config doc, content sniffing for a fenced block).
139    path: std::path::PathBuf,
140    /// The current full document text (frontmatter + body); the source of truth.
141    text: String,
142    /// The schema governing this document, when the embedder resolved one from the
143    /// workspace config (see [`schema_from_config`]). Returned via
144    /// [`Backend::schema`] so the flower model validates values and a frontend can
145    /// pick schema-driven widgets. `None` for a bare document with no workspace.
146    schema: Option<Schema>,
147    /// What a picker on a reference field should offer, per **relation** — the
148    /// answer to [`Backend::candidates`], injected because this backend cannot
149    /// work it out.
150    ///
151    /// Empty by default, which is the honest state of a backend over a document
152    /// with no workspace behind it: there is nothing to enumerate, and flower
153    /// opens a text line instead
154    /// ([`Model::begin_choose`](flower_core::Model::begin_choose) falls back).
155    /// See [`set_candidates`](Self::set_candidates).
156    candidates: HashMap<String, Vec<Choice>>,
157}
158
159impl ProvBackend {
160    /// Open a prov document from its full `text`, with no schema. Errors if prov
161    /// cannot parse it.
162    pub fn open(
163        path: impl Into<std::path::PathBuf>,
164        text: impl Into<String>,
165    ) -> Result<Self, BackendError> {
166        Self::open_with_schema_opt(path, text, None)
167    }
168
169    /// Open a prov document carrying the workspace `schema` — the prov-aware path,
170    /// so the flower model validates controlled fields and offers pickers.
171    pub fn open_with_schema(
172        path: impl Into<std::path::PathBuf>,
173        text: impl Into<String>,
174        schema: Schema,
175    ) -> Result<Self, BackendError> {
176        Self::open_with_schema_opt(path, text, Some(schema))
177    }
178
179    fn open_with_schema_opt(
180        path: impl Into<std::path::PathBuf>,
181        text: impl Into<String>,
182        schema: Option<Schema>,
183    ) -> Result<Self, BackendError> {
184        let path = path.into();
185        let text = text.into();
186        // Fail fast if the document doesn't parse.
187        Document::parse(&path, &text).map_err(be)?;
188        Ok(Self {
189            path,
190            text,
191            schema,
192            candidates: HashMap::new(),
193        })
194    }
195
196    fn document(&self) -> Result<Document, BackendError> {
197        Document::parse(&self.path, &self.text).map_err(be)
198    }
199
200    /// An editor over the metadata block as it stands, for a read — `None` when
201    /// the document has no block, which is a document with no comments on it.
202    /// (`apply` opens with `open_or_init` instead, since an edit to a block-less
203    /// document synthesizes one.)
204    fn editor(&self) -> Result<Option<MetaEditor>, BackendError> {
205        match self.document()?.carrier {
206            Some(carrier) => MetaEditor::open(&self.text, carrier).map(Some).map_err(be),
207            None => Ok(None),
208        }
209    }
210
211    /// The prose body outside the metadata block — the region a `leaf` editor
212    /// would own. Empty for a whole-file config document.
213    pub fn body(&self) -> Result<String, BackendError> {
214        Ok(self.document()?.body)
215    }
216
217    /// Whether the document has an editable prose body (a fenced carrier). A
218    /// whole-file config document has none — its body cannot be replaced.
219    pub fn has_body(&self) -> Result<bool, BackendError> {
220        Ok(matches!(
221            self.document()?.carrier,
222            Some(MetaCarrier::Fenced(_))
223        ))
224    }
225
226    /// Tell the backend what a picker on each **relation**'s field should
227    /// offer — the injection [`Backend::candidates`] exists for.
228    ///
229    /// A reference field's candidates are *other documents*, and this backend is
230    /// one document with a path: it can say which relation a path is, from the
231    /// schema it is already carrying, and nothing about what else exists. So the
232    /// list arrives from whoever has a workspace —
233    /// [`WorkspaceView::candidates_map`] builds one, and
234    /// [`WorkspaceView::open_document`] hands it over at open.
235    ///
236    /// ## What it costs, and when it is paid
237    ///
238    /// Enumerating a workspace's documents is a **walk**. It is paid once, when
239    /// the map is built, and never again: this is a lookup by relation name
240    /// against an owned map, so the picker opens in constant time however many
241    /// times it is opened. A census per open, never per keystroke. The
242    /// staleness that buys is the staleness a per-document check already has — a
243    /// document created in another window is not on the list until this one is
244    /// reopened — and it is the right trade for a key pressed on a keystroke.
245    ///
246    /// Keyed by relation rather than by path so that `contents`, `contents[4]`
247    /// and the append position `contents[len]` are one entry: the schema rule at
248    /// each of those names the same relation, and a list of link targets does
249    /// not change because the index did.
250    ///
251    /// Replaces the whole map; an empty one puts the backend back where it
252    /// started.
253    pub fn set_candidates(&mut self, candidates: HashMap<String, Vec<Choice>>) {
254        self.candidates = candidates;
255    }
256
257    /// The relation a metadata `path` is a reference for, according to the
258    /// schema this backend carries — `None` for a path that is not a reference
259    /// field, and for a backend with no schema.
260    ///
261    /// **A reified vocabulary answers `None` here, and that is the point.** A
262    /// key that is both a declared field with a vocabulary and a relation gets
263    /// two rules from [`schema_from_config`], the field's first; a schema
264    /// resolves first-match-wins, so the rule at that path is the
265    /// [`Enum`](flower_core::Constraint::Enum) and flower answers the picker
266    /// from the vocabulary's own terms without asking a backend at all. Reading
267    /// the same rule here is what keeps the two from disagreeing — there is no
268    /// second precedence rule written down anywhere.
269    pub fn relation_at(&self, path: &[Seg]) -> Option<&str> {
270        self.schema.as_ref()?.rule_for(path)?.reference()
271    }
272
273    /// Replace the prose body, leaving the metadata block untouched — the write
274    /// path for edits a `leaf` editor makes to [`body`](Self::body).
275    ///
276    /// Uses fig's `Embed::replace_body` (the same lossless primitive prov edits
277    /// through). A frontend that wants fixity/`updated` restamping routes this
278    /// through prov's write path instead; here it demonstrates that the metadata
279    /// and body regions edit independently over one document.
280    pub fn set_body(&mut self, body: &str) -> Result<(), BackendError> {
281        match self.document()?.carrier {
282            Some(MetaCarrier::Fenced(kind)) => {
283                let mut embed = fig::Embed::open(self.text.as_bytes(), kind).map_err(be)?;
284                embed.replace_body(body).map_err(be)?;
285                self.text = embed.render().map_err(be)?.to_string();
286                Ok(())
287            }
288            _ => Err(BackendError(
289                "document has no fenced body to replace".into(),
290            )),
291        }
292    }
293}
294
295impl Backend for ProvBackend {
296    fn apply(&mut self, op: EditOp) -> Result<(), BackendError> {
297        let carrier = self.document()?.carrier;
298        // `open_or_init` so an edit to a document with no block synthesizes one
299        // (frontmatter for a prose file) rather than failing.
300        let mut editor = MetaEditor::open_or_init(&self.text, carrier).map_err(be)?;
301
302        match op {
303            EditOp::ReplaceValue { path, value } => {
304                let segs = to_fig(&path);
305                // Mirror prov's `set_in_text`: an index-terminated path is a pure
306                // replacement (there is no "insert at absent index"); a
307                // key-terminated path upserts.
308                match path.last() {
309                    Some(Seg::Index(_)) => editor.replace_value(&segs, value).map_err(be)?,
310                    _ => editor.set_value(&segs, value).map_err(be)?,
311                }
312            }
313            EditOp::DeleteKey { path } => editor.delete(&to_fig(&path)).map_err(be)?,
314            EditOp::RemoveItem { seq_path, index } => {
315                editor.remove_item(&to_fig(&seq_path), index).map_err(be)?
316            }
317            // prov's MetaEditor has no distinct "insert": `set_value` at the new
318            // key path upserts, which is exactly an insert for an absent key.
319            EditOp::InsertKey {
320                map_path,
321                key,
322                value,
323            } => {
324                let mut path = map_path;
325                path.push(Seg::Key(key));
326                editor.set_value(&to_fig(&path), value).map_err(be)?
327            }
328            EditOp::AppendItem { seq_path, value } => {
329                editor.append_value(&to_fig(&seq_path), value).map_err(be)?
330            }
331            // No `move_item` on MetaEditor; express the move as a full index
332            // permutation through `reorder_items`, sized from the current sequence.
333            // The index arithmetic is flower's, so a move here means what a move
334            // means through any other backend.
335            EditOp::MoveItem { seq_path, from, to } => {
336                let len = tree::seq_len(&self.to_value()?, &seq_path)
337                    .ok_or_else(|| BackendError("target is not a sequence".into()))?;
338                if let Some(order) = flower_core::backend::move_permutation(len, from, to) {
339                    editor
340                        .reorder_items(&to_fig(&seq_path), &order)
341                        .map_err(be)?;
342                }
343            }
344            EditOp::ReorderKeys { map_path, keys } => {
345                editor.reorder_keys(&to_fig(&map_path), &keys).map_err(be)?
346            }
347            EditOp::RenameKey { path, new_key } => {
348                editor.replace_key(&to_fig(&path), &new_key).map_err(be)?
349            }
350            // `MetaEditor` stops at the value ops prov's own mutations need; the
351            // comment surface is fig's, reached through whichever editor is
352            // behind it. Two fig calls for a leading set, and still atomic: the
353            // text is only replaced once every call has succeeded, so a refused
354            // add after a delete leaves the document as it was.
355            EditOp::SetLeadingComment { path, text } => {
356                let path = to_fig(&path);
357                with_fig!(&mut editor, |e| {
358                    e.delete_leading_comments(&path).map_err(be)?;
359                    if let Some(text) = &text {
360                        e.add_leading_comment(&path, text).map_err(be)?;
361                    }
362                })
363            }
364            EditOp::SetTrailingComment { path, text } => {
365                let path = to_fig(&path);
366                with_fig!(&mut editor, |e| match &text {
367                    Some(text) => e.set_trailing_comment(&path, text).map_err(be)?,
368                    None => e.delete_trailing_comment(&path).map_err(be)?,
369                })
370            }
371        }
372
373        self.text = editor.render().map_err(be)?;
374        Ok(())
375    }
376
377    fn to_value(&self) -> Result<Value, BackendError> {
378        // prov's metadata tree → fig's value tree (the serde-free bridge).
379        Ok(Value::from(&self.document()?.meta))
380    }
381
382    fn source(&self) -> Result<String, BackendError> {
383        Ok(self.text.clone())
384    }
385
386    fn schema(&self) -> Option<Schema> {
387        self.schema.clone()
388    }
389
390    fn leading_comment(&self, path: &[Seg]) -> Result<Option<String>, BackendError> {
391        let Some(editor) = self.editor()? else {
392            return Ok(None);
393        };
394        let path = to_fig(path);
395        comment_read(with_fig!(&editor, |e| e.leading_comment(&path)))
396    }
397
398    fn trailing_comment(&self, path: &[Seg]) -> Result<Option<String>, BackendError> {
399        let Some(editor) = self.editor()? else {
400            return Ok(None);
401        };
402        let path = to_fig(path);
403        comment_read(with_fig!(&editor, |e| e.trailing_comment(&path)))
404    }
405
406    /// The documents a picker on this reference field should offer — whatever
407    /// [`set_candidates`](Self::set_candidates) was given for the relation the
408    /// schema says `path` is.
409    ///
410    /// `None` for anything that is not a reference field, for a relation the map
411    /// has no entry for, and always for a backend outside a workspace. A
412    /// controlled vocabulary never reaches here: flower asks its own schema
413    /// first — see [`relation_at`](Self::relation_at).
414    fn candidates(&self, path: &[Seg]) -> Result<Option<Vec<Choice>>, BackendError> {
415        Ok(self
416            .relation_at(path)
417            .and_then(|relation| self.candidates.get(relation))
418            .cloned())
419    }
420
421    /// What an item of a relation's list *is*, across a reorder: the document it
422    /// points at.
423    ///
424    /// A path addresses a sequence item by position, so a reorder re-points
425    /// every path after the item that moved — a page opened on `contents[1]`
426    /// goes on showing `contents[1]`, which is now a different document. A
427    /// link's **target** survives that, and survives a relabel with it:
428    /// `[The Vault](/README.md)` and `[Home](/README.md)` are one edge with a
429    /// different word on it, and the word is the part a reader edits. So a
430    /// reorder moves the page with the item, and retitling the item does not
431    /// move it at all.
432    ///
433    /// [`Link::addressed_target`](prov::Link::addressed_target), so the
434    /// `#locator` is stripped: `a.md#one` and `a.md#two` are one identity, and
435    /// the first of them wins — which is [`Backend::item_key`]'s documented
436    /// behaviour for a repeated key rather than a loss. Two items pointing into
437    /// the same document are two ways of saying where to look, and a page that
438    /// lands on the first has landed in the right document.
439    ///
440    /// `None` for a list that is not a relation's, for an item that is not a
441    /// scalar, and for an empty target. flower's own fallback — a mapping item's
442    /// title, a scalar's own text — is the better answer for those, and it is
443    /// what it uses when this declines.
444    fn item_key(&self, seq_path: &[Seg], index: usize) -> Result<Option<String>, BackendError> {
445        if self.relation_at(seq_path).is_none() {
446            return Ok(None);
447        }
448        let mut path = seq_path.to_vec();
449        path.push(Seg::Index(index));
450        let Some(Value::Str(text)) = tree::value_at(&self.to_value()?, &path).cloned() else {
451            return Ok(None);
452        };
453        let target = prov::Link::parse(&text).addressed_target().to_string();
454        Ok((!target.is_empty()).then_some(target))
455    }
456}
457
458#[cfg(test)]
459mod tests {
460    use super::*;
461    use flower_core::{Mode, Model};
462
463    const DOC: &str = "\
464---
465# the title
466title: Old Title
467draft: true
468tags:
469- a
470- b
471---
472# Heading
473
474Body prose that must survive metadata edits.
475";
476
477    fn model() -> Model<ProvBackend> {
478        let backend = ProvBackend::open("note.md", DOC).expect("open prov doc");
479        Model::new(backend).expect("build model")
480    }
481
482    fn select(model: &mut Model<ProvBackend>, path: &[Seg]) {
483        // `select_row`, not a write to `selected`: the field is flower's own now,
484        // and the setter is what asserts the tree projection this index belongs to.
485        let index = model
486            .rows
487            .iter()
488            .position(|r| r.path == path)
489            .unwrap_or_else(|| panic!("no row for {path:?}"));
490        model.select_row(index);
491    }
492
493    fn type_value(model: &mut Model<ProvBackend>, text: &str) {
494        // `..`: an edit now also carries the path it belongs to, which this
495        // helper has no use for — it types into whatever is already open.
496        if let Mode::Editing { buffer, .. } = &mut model.mode {
497            buffer.clear();
498        }
499        for c in text.chars() {
500            model.edit_push(c);
501        }
502        model.edit_commit();
503    }
504
505    /// The `EditOp` contract, checked against flower's own suite.
506    ///
507    /// `ProvBackend` is the second implementation of that trait, and a trait with
508    /// one implementation has only a behavior — this is where the two would
509    /// silently part ways. Running flower's suite rather than restating it means a
510    /// guarantee added upstream arrives here as a failing test, not as a difference
511    /// nobody looked for.
512    ///
513    /// The fixture is written as frontmatter because that is the carrier a prose
514    /// vault uses; the suite asserts on the value tree, so the format is ours to
515    /// pick.
516    #[test]
517    fn prov_backend_satisfies_the_edit_op_contract() {
518        const FIXTURE: &str = "\
519---
520title: note
521tags:
522- alpha
523- beta
524- gamma
525nested:
526  k: v
527  j: w
528---
529# Note
530
531Body prose.
532";
533        flower_core::backend::conformance::check(|| {
534            ProvBackend::open("note.md", FIXTURE).expect("open fixture")
535        })
536        .expect("prov backend honors the EditOp contract");
537    }
538
539    #[test]
540    fn renders_frontmatter_as_a_tree() {
541        let model = model();
542        let keys: Vec<&str> = model
543            .rows
544            .iter()
545            .filter(|r| r.depth == 0)
546            .map(|r| r.label.as_str())
547            .collect();
548        assert_eq!(
549            keys,
550            ["title", "draft", "tags"],
551            "top-level frontmatter keys"
552        );
553    }
554
555    #[test]
556    fn edits_metadata_leaving_the_body_untouched() {
557        let mut model = model();
558
559        select(&mut model, &[Seg::Key("title".into())]);
560        model.begin_edit();
561        type_value(&mut model, "New Title");
562
563        let out = model.source_snapshot();
564        assert!(out.contains("title: New Title"), "value changed:\n{out}");
565        assert!(out.contains("# the title"), "comment preserved:\n{out}");
566        assert!(out.starts_with("---\n"), "fences intact:\n{out}");
567        assert!(
568            out.contains("Body prose that must survive metadata edits."),
569            "body preserved:\n{out}"
570        );
571    }
572
573    #[test]
574    fn deletes_a_key() {
575        let mut model = model();
576
577        select(&mut model, &[Seg::Key("draft".into())]);
578        model.delete_selected();
579
580        let out = model.source_snapshot();
581        assert!(!out.contains("draft:"), "key removed:\n{out}");
582        assert!(out.contains("title: Old Title"), "siblings kept:\n{out}");
583        assert!(out.contains("Body prose"), "body kept:\n{out}");
584    }
585
586    #[test]
587    fn comments_are_read_per_node_and_edited_in_place_leaving_the_body_alone() {
588        let backend = ProvBackend::open("note.md", DOC).expect("open");
589        let mut model = Model::new(backend).expect("model");
590        let title = [Seg::Key("title".into())];
591        let draft = [Seg::Key("draft".into())];
592
593        // The block above `title` is read through the backend, into the page.
594        assert_eq!(
595            model.leading_comment_at(&title).as_deref(),
596            Some("the title")
597        );
598        assert_eq!(model.leading_comment_at(&draft), None);
599        assert_eq!(model.trailing_comment_at(&title), None);
600
601        model.set_leading_comment(&title, Some("what it is called"));
602        model.set_trailing_comment(&draft, Some("for now"));
603        let out = model.source_snapshot();
604        assert!(
605            out.contains("# what it is called\ntitle: Old Title"),
606            "{out}"
607        );
608        assert!(
609            !out.contains("# the title"),
610            "the block is replaced:\n{out}"
611        );
612        assert!(out.contains("draft: true # for now"), "{out}");
613        assert!(
614            out.contains("Body prose that must survive"),
615            "body kept:\n{out}"
616        );
617
618        model.set_leading_comment(&title, None);
619        let out = model.source_snapshot();
620        assert!(out.starts_with("---\ntitle: Old Title"), "{out}");
621    }
622
623    #[test]
624    fn a_comment_write_that_fig_refuses_leaves_the_document_as_it_was() {
625        let mut backend = ProvBackend::open("note.md", DOC).expect("open");
626        let before = backend.source().unwrap();
627        // A trailing comment is one line; a second line is refused whole, and
628        // the text is not replaced by a partial edit.
629        let result = backend.apply(EditOp::SetTrailingComment {
630            path: vec![Seg::Key("title".into())],
631            text: Some("two\nlines".into()),
632        });
633        assert!(result.is_err());
634        assert_eq!(backend.source().unwrap(), before);
635    }
636
637    #[test]
638    fn json_frontmatter_has_no_comments_to_read_and_refuses_to_write_one() {
639        // `;;;` is the JSON frontmatter fence; `---` around `{…}` would be
640        // YAML, which a `{…}` is a flow mapping of, and which has comments.
641        let doc = ";;;\n{\"title\": \"Note\"}\n;;;\n# Note\n";
642        let mut backend = ProvBackend::open("note.md", doc).expect("open");
643        let title = vec![Seg::Key("title".into())];
644        assert_eq!(backend.leading_comment(&title).unwrap(), None);
645        assert_eq!(backend.trailing_comment(&title).unwrap(), None);
646        let before = backend.source().unwrap();
647        assert!(
648            backend
649                .apply(EditOp::SetLeadingComment {
650                    path: title,
651                    text: Some("nope".into()),
652                })
653                .is_err()
654        );
655        assert_eq!(backend.source().unwrap(), before);
656    }
657
658    #[test]
659    fn a_document_with_no_metadata_block_has_no_comments() {
660        let backend = ProvBackend::open("note.md", "# Just prose\n").expect("open");
661        assert_eq!(
662            backend
663                .leading_comment(&[Seg::Key("title".into())])
664                .unwrap(),
665            None
666        );
667    }
668
669    /// A relation's list item is known by the document it points at, not by its
670    /// position and not by its label — so a reorder carries the page with the
671    /// item, and retitling the item does not move it at all.
672    ///
673    /// Driven through `Model`, which is the only caller that matters: it asks
674    /// the backend first and falls back to its own guess, and the fallback here
675    /// would be the item's whole text — which changes when the label does, and
676    /// is exactly the wrong answer.
677    #[test]
678    fn a_relations_item_is_identified_by_its_target_not_its_position_or_label() {
679        const INDEX: &str = "\
680---
681title: Index
682contents:
683- '[One](one.md)'
684- '[Two](two.md)'
685- '[Three](three.md)'
686tags:
687- alpha
688- beta
689---
690# Index
691";
692        let schema = schema_from_config(
693            &prov::config::WorkspaceConfig::default(),
694            &std::collections::BTreeMap::new(),
695        );
696        let backend = ProvBackend::open_with_schema("index.md", INDEX, schema).expect("open");
697        let mut model = Model::new(backend).expect("model");
698        let contents = [Seg::Key("contents".into())];
699
700        // The target, locator and label stripped — not the item's text.
701        assert_eq!(model.item_key(&contents, 0).as_deref(), Some("one.md"));
702        assert_eq!(model.item_key(&contents, 1).as_deref(), Some("two.md"));
703        assert_eq!(model.item_key(&contents, 2).as_deref(), Some("three.md"));
704
705        // A list that is not a relation's is flower's own business, and its
706        // answer is the scalar itself.
707        let tags = [Seg::Key("tags".into())];
708        assert_eq!(model.item_key(&tags, 0).as_deref(), Some("alpha"));
709
710        // Stand on the second item, in the page projection the widgets draw.
711        let second = [Seg::Key("contents".into()), Seg::Index(1)];
712        model.focus_on(&second);
713        assert_eq!(
714            model.page_item().map(|i| i.path.clone()),
715            Some(second.to_vec()),
716            "the cursor is on the item that was opened on"
717        );
718
719        // Move it up. The page cursor is now at index 0 — and it is the *same
720        // document*, which is the whole claim: without an identity the cursor
721        // would have stayed on index 1 and be looking at `one.md`.
722        model.move_selected_up();
723        let landed = model.page_item().expect("still on an item").path.clone();
724        assert_eq!(landed, [Seg::Key("contents".into()), Seg::Index(0)]);
725        assert_eq!(
726            model.item_key(&contents, 0).as_deref(),
727            Some("two.md"),
728            "the item moved, and the cursor moved with it"
729        );
730        assert_eq!(model.item_key(&contents, 1).as_deref(), Some("one.md"));
731
732        // Relabelling is not a move. The item's text changes entirely and its
733        // identity does not, so nothing re-points.
734        model.set_value_at(&landed, Value::Str("[The Second One](two.md)".into()));
735        assert_eq!(model.item_key(&contents, 0).as_deref(), Some("two.md"));
736        assert_eq!(
737            model.page_item().map(|i| i.path.clone()),
738            Some(landed),
739            "the cursor did not go looking for a document that never moved"
740        );
741
742        // A locator is not part of the identity: the item names a place inside
743        // a document, and the document is what the page is standing in.
744        model.set_value_at(
745            &[Seg::Key("contents".into()), Seg::Index(1)],
746            Value::Str("[One](one.md#a-heading)".into()),
747        );
748        assert_eq!(model.item_key(&contents, 1).as_deref(), Some("one.md"));
749    }
750
751    #[test]
752    fn schema_backed_backend_rejects_a_term_outside_a_closed_vocabulary() {
753        use flower_core::schema::{Constraint, FieldRule};
754        use flower_core::{FieldType, PathPat, Term};
755        // A prov document whose `audience` is a closed vocabulary.
756        let doc = "---\ntitle: Note\naudience:\n- public\n---\n# Note\n";
757        let schema = Schema::new(vec![
758            FieldRule::new(PathPat::each_item_of("audience"))
759                .ty(FieldType::Str)
760                .constraint(Constraint::Enum {
761                    values: vec![Term::value("public"), Term::value("private")],
762                    closed: true,
763                }),
764        ]);
765        let backend = ProvBackend::open_with_schema("note.md", doc, schema).expect("open");
766        let mut model = Model::new(backend).expect("model");
767
768        // The schema traveled through the backend into the model: an unknown
769        // term is rejected, the document untouched.
770        select(&mut model, &[Seg::Key("audience".into()), Seg::Index(0)]);
771        model.begin_edit();
772        type_value(&mut model, "familly");
773        assert!(
774            model.status.contains("rejected"),
775            "status: {}",
776            model.status
777        );
778        assert!(model.source_snapshot().contains("- public"), "unchanged");
779
780        // A known value commits.
781        model.begin_edit();
782        type_value(&mut model, "private");
783        assert!(model.source_snapshot().contains("- private"), "applied");
784    }
785}