pub mod config_schema;
pub mod facets;
pub mod links;
pub mod rules;
pub mod schema;
mod session;
pub mod workspace;
pub use config_schema::{CONFIG_READONLY_KEYS, config_schema};
pub use facets::{Facet, Facets};
pub use links::{MetaLink, TargetKind, link_at, links_in, links_under};
pub use schema::schema_from_config;
pub use session::{DocumentSession, SessionError};
pub use workspace::{Destination, WorkspaceView};
use fig::Value;
use flower_core::tree::{self, to_fig};
use flower_core::{Backend, BackendError, EditOp, Schema, Seg};
use prov::edit::MetaEditor;
use prov::{Document, MetaCarrier};
fn be(e: impl std::fmt::Display) -> BackendError {
BackendError(e.to_string())
}
pub struct ProvBackend {
path: std::path::PathBuf,
text: String,
schema: Option<Schema>,
}
impl ProvBackend {
pub fn open(
path: impl Into<std::path::PathBuf>,
text: impl Into<String>,
) -> Result<Self, BackendError> {
Self::open_with_schema_opt(path, text, None)
}
pub fn open_with_schema(
path: impl Into<std::path::PathBuf>,
text: impl Into<String>,
schema: Schema,
) -> Result<Self, BackendError> {
Self::open_with_schema_opt(path, text, Some(schema))
}
fn open_with_schema_opt(
path: impl Into<std::path::PathBuf>,
text: impl Into<String>,
schema: Option<Schema>,
) -> Result<Self, BackendError> {
let path = path.into();
let text = text.into();
Document::parse(&path, &text).map_err(be)?;
Ok(Self { path, text, schema })
}
fn document(&self) -> Result<Document, BackendError> {
Document::parse(&self.path, &self.text).map_err(be)
}
pub fn body(&self) -> Result<String, BackendError> {
Ok(self.document()?.body)
}
pub fn has_body(&self) -> Result<bool, BackendError> {
Ok(matches!(
self.document()?.carrier,
Some(MetaCarrier::Fenced(_))
))
}
pub fn set_body(&mut self, body: &str) -> Result<(), BackendError> {
match self.document()?.carrier {
Some(MetaCarrier::Fenced(kind)) => {
let mut embed = fig::Embed::open(self.text.as_bytes(), kind).map_err(be)?;
embed.replace_body(body).map_err(be)?;
self.text = embed.render().map_err(be)?.to_string();
Ok(())
}
_ => Err(BackendError(
"document has no fenced body to replace".into(),
)),
}
}
}
impl Backend for ProvBackend {
fn apply(&mut self, op: EditOp) -> Result<(), BackendError> {
let carrier = self.document()?.carrier;
let mut editor = MetaEditor::open_or_init(&self.text, carrier).map_err(be)?;
match op {
EditOp::ReplaceValue { path, value } => {
let segs = to_fig(&path);
match path.last() {
Some(Seg::Index(_)) => editor.replace_value(&segs, value).map_err(be)?,
_ => editor.set_value(&segs, value).map_err(be)?,
}
}
EditOp::DeleteKey { path } => editor.delete(&to_fig(&path)).map_err(be)?,
EditOp::RemoveItem { seq_path, index } => {
editor.remove_item(&to_fig(&seq_path), index).map_err(be)?
}
EditOp::InsertKey {
map_path,
key,
value,
} => {
let mut path = map_path;
path.push(Seg::Key(key));
editor.set_value(&to_fig(&path), value).map_err(be)?
}
EditOp::AppendItem { seq_path, value } => {
editor.append_value(&to_fig(&seq_path), value).map_err(be)?
}
EditOp::MoveItem { seq_path, from, to } => {
let len = tree::seq_len(&self.to_value()?, &seq_path)
.ok_or_else(|| BackendError("target is not a sequence".into()))?;
if let Some(order) = flower_core::backend::move_permutation(len, from, to) {
editor
.reorder_items(&to_fig(&seq_path), &order)
.map_err(be)?;
}
}
EditOp::ReorderKeys { map_path, keys } => {
editor.reorder_keys(&to_fig(&map_path), &keys).map_err(be)?
}
EditOp::RenameKey { path, new_key } => {
editor.replace_key(&to_fig(&path), &new_key).map_err(be)?
}
}
self.text = editor.render().map_err(be)?;
Ok(())
}
fn to_value(&self) -> Result<Value, BackendError> {
Ok(Value::from(&self.document()?.meta))
}
fn source(&self) -> Result<String, BackendError> {
Ok(self.text.clone())
}
fn schema(&self) -> Option<Schema> {
self.schema.clone()
}
}
#[cfg(test)]
mod tests {
use super::*;
use flower_core::{Mode, Model};
const DOC: &str = "\
---
# the title
title: Old Title
draft: true
tags:
- a
- b
---
# Heading
Body prose that must survive metadata edits.
";
fn model() -> Model<ProvBackend> {
let backend = ProvBackend::open("note.md", DOC).expect("open prov doc");
Model::new(backend).expect("build model")
}
fn select(model: &mut Model<ProvBackend>, path: &[Seg]) {
let index = model
.rows
.iter()
.position(|r| r.path == path)
.unwrap_or_else(|| panic!("no row for {path:?}"));
model.select_row(index);
}
fn type_value(model: &mut Model<ProvBackend>, text: &str) {
if let Mode::Editing { buffer, .. } = &mut model.mode {
buffer.clear();
}
for c in text.chars() {
model.edit_push(c);
}
model.edit_commit();
}
#[test]
fn prov_backend_satisfies_the_edit_op_contract() {
const FIXTURE: &str = "\
---
title: note
tags:
- alpha
- beta
- gamma
nested:
k: v
j: w
---
# Note
Body prose.
";
flower_core::backend::conformance::check(|| {
ProvBackend::open("note.md", FIXTURE).expect("open fixture")
})
.expect("prov backend honors the EditOp contract");
}
#[test]
fn renders_frontmatter_as_a_tree() {
let model = model();
let keys: Vec<&str> = model
.rows
.iter()
.filter(|r| r.depth == 0)
.map(|r| r.label.as_str())
.collect();
assert_eq!(
keys,
["title", "draft", "tags"],
"top-level frontmatter keys"
);
}
#[test]
fn edits_metadata_leaving_the_body_untouched() {
let mut model = model();
select(&mut model, &[Seg::Key("title".into())]);
model.begin_edit();
type_value(&mut model, "New Title");
let out = model.source_snapshot();
assert!(out.contains("title: New Title"), "value changed:\n{out}");
assert!(out.contains("# the title"), "comment preserved:\n{out}");
assert!(out.starts_with("---\n"), "fences intact:\n{out}");
assert!(
out.contains("Body prose that must survive metadata edits."),
"body preserved:\n{out}"
);
}
#[test]
fn deletes_a_key() {
let mut model = model();
select(&mut model, &[Seg::Key("draft".into())]);
model.delete_selected();
let out = model.source_snapshot();
assert!(!out.contains("draft:"), "key removed:\n{out}");
assert!(out.contains("title: Old Title"), "siblings kept:\n{out}");
assert!(out.contains("Body prose"), "body kept:\n{out}");
}
#[test]
fn schema_backed_backend_rejects_a_term_outside_a_closed_vocabulary() {
use flower_core::schema::{Constraint, FieldRule};
use flower_core::{FieldType, PathPat, Term};
let doc = "---\ntitle: Note\naudience:\n- public\n---\n# Note\n";
let schema = Schema::new(vec![
FieldRule::new(PathPat::each_item_of("audience"))
.ty(FieldType::Str)
.constraint(Constraint::Enum {
values: vec![Term::value("public"), Term::value("private")],
closed: true,
}),
]);
let backend = ProvBackend::open_with_schema("note.md", doc, schema).expect("open");
let mut model = Model::new(backend).expect("model");
select(&mut model, &[Seg::Key("audience".into()), Seg::Index(0)]);
model.begin_edit();
type_value(&mut model, "familly");
assert!(
model.status.contains("rejected"),
"status: {}",
model.status
);
assert!(model.source_snapshot().contains("- public"), "unchanged");
model.begin_edit();
type_value(&mut model, "private");
assert!(model.source_snapshot().contains("- private"), "applied");
}
}