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}