use std::path::{Path, PathBuf};
use fig::Value;
use flower_core::{Model, Schema, Seg, ViewMode};
use leaf_core::{Doc, Format as BodyFormat};
use prov::{Document, MetaCarrier};
use crate::ProvBackend;
static EMPTY_META: Value = Value::Null;
#[derive(Debug)]
pub struct SessionError(pub String);
impl std::fmt::Display for SessionError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(&self.0)
}
}
impl std::error::Error for SessionError {}
fn se(e: impl std::fmt::Display) -> SessionError {
SessionError(e.to_string())
}
pub struct DocumentSession {
path: PathBuf,
metadata: Model<ProvBackend>,
body: Doc,
has_body: bool,
saved_body: String,
}
fn body_format_of(path: &Path) -> BodyFormat {
match prov::ContentFormat::from_extension(path) {
Some(prov::ContentFormat::Djot) => BodyFormat::Djot,
Some(prov::ContentFormat::Html) => BodyFormat::Html,
Some(prov::ContentFormat::Markdown) | None => BodyFormat::Markdown,
}
}
impl DocumentSession {
pub fn open(path: impl Into<PathBuf>) -> Result<Self, SessionError> {
let path = path.into();
let format = body_format_of(&path);
Self::open_with(path, format, None)
}
pub fn open_with_schema(
path: impl Into<PathBuf>,
schema: Schema,
) -> Result<Self, SessionError> {
let path = path.into();
let format = body_format_of(&path);
Self::open_with(path, format, Some(schema))
}
pub fn open_managed(
path: impl Into<PathBuf>,
schema: Option<Schema>,
derived: Vec<String>,
) -> Result<Self, SessionError> {
let path = path.into();
let format = body_format_of(&path);
let text = std::fs::read_to_string(&path)
.map_err(|e| SessionError(format!("reading {}: {e}", path.display())))?;
Self::build(path, &text, format, schema, derived)
}
pub fn open_with(
path: impl Into<PathBuf>,
body_format: BodyFormat,
schema: Option<Schema>,
) -> Result<Self, SessionError> {
let path = path.into();
let text = std::fs::read_to_string(&path)
.map_err(|e| SessionError(format!("reading {}: {e}", path.display())))?;
Self::from_text(path, &text, body_format, schema)
}
pub fn from_text(
path: impl Into<PathBuf>,
text: &str,
body_format: BodyFormat,
schema: Option<Schema>,
) -> Result<Self, SessionError> {
Self::build(path, text, body_format, schema, Vec::new())
}
fn build(
path: impl Into<PathBuf>,
text: &str,
body_format: BodyFormat,
schema: Option<Schema>,
derived: Vec<String>,
) -> Result<Self, SessionError> {
let path = path.into();
let parsed = Document::parse(&path, text).map_err(se)?;
let has_body = matches!(parsed.carrier, Some(MetaCarrier::Fenced(_)));
let backend = match schema {
Some(schema) => ProvBackend::open_with_schema(&path, text, schema),
None => ProvBackend::open(&path, text),
}
.map_err(se)?;
let metadata = Model::with_managed(backend, Vec::new(), derived).map_err(se)?;
let body = Doc::from_source(parsed.body, body_format).map_err(se)?;
let saved_body = body.source.clone();
Ok(Self {
path,
metadata,
body,
has_body,
saved_body,
})
}
pub fn path(&self) -> &Path {
&self.path
}
pub fn has_body(&self) -> bool {
self.has_body
}
pub fn metadata(&self) -> &Model<ProvBackend> {
&self.metadata
}
pub fn metadata_mut(&mut self) -> &mut Model<ProvBackend> {
&mut self.metadata
}
pub fn meta(&self) -> &Value {
self.metadata.value_at(&[]).unwrap_or(&EMPTY_META)
}
pub fn cursor_path(&self) -> Option<Vec<Seg>> {
match self.metadata.view() {
ViewMode::Pages => self.metadata.page_item().map(|item| item.path.clone()),
_ => self.metadata.selected_path(),
}
}
pub fn body(&self) -> &Doc {
&self.body
}
pub fn body_mut(&mut self) -> &mut Doc {
&mut self.body
}
pub fn set_metadata(&mut self, path: &[Seg], value: Value) {
self.metadata.set_value_at(path, value);
}
pub fn dirty(&self) -> bool {
self.metadata.dirty || (self.has_body && self.body.source != self.saved_body)
}
pub fn reassemble(&mut self) -> Result<String, SessionError> {
if self.has_body {
let body = self.body.source.clone();
self.metadata.backend_mut().set_body(&body).map_err(se)?;
}
Ok(self.metadata.source_snapshot())
}
pub fn save(&mut self) -> Result<(), SessionError> {
let full = self.reassemble()?;
std::fs::write(&self.path, full.as_bytes())
.map_err(|e| SessionError(format!("writing {}: {e}", self.path.display())))?;
self.saved_body = self.body.source.clone();
self.metadata.mark_saved();
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::*;
const DOC: &str = "\
---
# the title
title: Old Title
draft: true
---
# Heading
Original body.
";
fn preview_of(session: &DocumentSession, key: &str) -> Option<String> {
session
.metadata()
.rows
.iter()
.find(|r| r.path == [Seg::Key(key.into())])
.map(|r| r.preview.clone())
}
#[test]
fn reassembles_both_edits_without_disk() {
let mut session =
DocumentSession::from_text("note.md", DOC, BodyFormat::Markdown, None).unwrap();
assert!(session.has_body());
assert!(!session.dirty());
session.set_metadata(&[Seg::Key("title".into())], Value::Str("New Title".into()));
session.body_mut().insert("Edited: ");
assert!(session.dirty());
let out = session.reassemble().unwrap();
assert!(out.contains("title: New Title"), "metadata edit:\n{out}");
assert!(out.contains("# the title"), "frontmatter comment:\n{out}");
assert!(out.contains("draft: true"), "sibling key:\n{out}");
assert!(out.contains("Edited: "), "body edit:\n{out}");
assert!(out.contains("Original body."), "rest of body:\n{out}");
assert!(out.starts_with("---\n"), "fences:\n{out}");
}
#[test]
fn a_managed_key_is_drawn_and_declines_every_edit() {
const WITH_ID: &str = "---\ntitle: A Note\nid: ajp7eq\nmood: rainy\n---\n# Note\n";
let path = std::env::temp_dir().join("provui_core_session_managed.md");
let _ = std::fs::remove_file(&path);
std::fs::write(&path, WITH_ID).unwrap();
let facets = crate::Facets::default();
let mut session =
DocumentSession::open_managed(&path, None, facets.managed_key_names()).unwrap();
assert_eq!(preview_of(&session, "id").as_deref(), Some("ajp7eq"));
assert!(session.metadata().is_derived(&[Seg::Key("id".into())]));
session.set_metadata(&[Seg::Key("id".into())], Value::Str("typed".into()));
assert!(!session.dirty(), "a derived key takes no edit");
assert_eq!(preview_of(&session, "id").as_deref(), Some("ajp7eq"));
session.set_metadata(&[Seg::Key("mood".into())], Value::Str("clear".into()));
assert!(session.dirty());
let _ = std::fs::remove_file(&path);
}
#[test]
fn open_edit_save_reopen_round_trip_on_disk() {
let path = std::env::temp_dir().join("provui_core_document_session_round_trip.md");
let _ = std::fs::remove_file(&path);
std::fs::write(&path, DOC).unwrap();
let mut session = DocumentSession::open(&path).unwrap();
assert_eq!(preview_of(&session, "title").as_deref(), Some("Old Title"));
session.set_metadata(&[Seg::Key("title".into())], Value::Str("New Title".into()));
session.body_mut().insert("Edited: ");
session.save().unwrap();
assert!(!session.dirty(), "clean after save");
let saved = std::fs::read_to_string(&path).unwrap();
assert!(
saved.contains("title: New Title"),
"saved metadata:\n{saved}"
);
assert!(saved.contains("# the title"), "saved comment:\n{saved}");
assert!(saved.contains("Edited: "), "saved body:\n{saved}");
assert!(
saved.contains("Original body."),
"saved body rest:\n{saved}"
);
let reopened = DocumentSession::open(&path).unwrap();
assert_eq!(preview_of(&reopened, "title").as_deref(), Some("New Title"));
assert!(reopened.body().source.contains("Edited: "));
let _ = std::fs::remove_file(&path);
}
}