use std::collections::{BTreeSet, HashMap};
use std::sync::LazyLock;
use omgbase_format::hash::hex;
use omgbase_mutate::{ErrorCode, Expect, MutationError, Op};
use omgbase_properties::{Value as YamlValue, parse_document};
use omgbase_reconcile::Config;
use regex::Regex;
use rusqlite::{OptionalExtension, params};
use serde_json::{Map, Value, json};
use crate::Store;
use crate::doc_store::DocStore;
use crate::effects_plan::{PlanContext, Receipt};
use crate::error::Result;
use crate::links::{InboundLink, doc_dir_of, inbound_links_to, retarget_links_in_raw};
use crate::mutate::{ApplyOrigin, find_doc_by_ref};
use crate::read::blob_text;
use crate::sugar;
use crate::yaml_emit::stringify;
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct DocOpContext {
pub repo_id: String,
pub actor: Option<String>,
pub ts: String,
}
impl DocOpContext {
fn plan_ctx(&self) -> PlanContext {
PlanContext {
repo_id: self.repo_id.clone(),
ts: self.ts.clone(),
config: Config::default(),
}
}
fn origin(&self, reason: &str) -> ApplyOrigin {
ApplyOrigin::new(self.actor.as_deref().unwrap_or(""), Some(reason))
}
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct DocOpResult {
pub doc_id: String,
pub path: String,
pub committed: bool,
pub receipt: Receipt,
}
impl DocOpResult {
#[must_use]
pub fn to_json(&self) -> Value {
json!({ "doc": self.doc_id, "path": self.path, "committed": self.committed })
}
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct Retargeted {
pub blocks: Vec<String>,
pub docs: Vec<String>,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct DocMoveResult {
pub doc_id: String,
pub path: String,
pub committed: bool,
pub receipt: Receipt,
pub dangling: Vec<InboundLink>,
pub retargeted: Option<Retargeted>,
}
impl DocMoveResult {
#[must_use]
pub fn to_json(&self) -> Value {
let mut m = Map::new();
m.insert("doc".to_owned(), json!(self.doc_id));
m.insert("path".to_owned(), json!(self.path));
m.insert("committed".to_owned(), json!(self.committed));
m.insert(
"dangling".to_owned(),
Value::Array(self.dangling.iter().map(InboundLink::to_json).collect()),
);
m.insert(
"retargeted".to_owned(),
retargeted_json(self.retargeted.as_ref()),
);
Value::Object(m)
}
}
#[must_use]
pub fn retargeted_json(r: Option<&Retargeted>) -> Value {
json!(r.map(|r| json!({ "blocks": r.blocks, "docs": r.docs })))
}
pub struct RetargetPlan {
pub ops: Vec<Op>,
pub blocks: Vec<String>,
pub docs: Vec<String>,
pub dangling: Vec<InboundLink>,
}
#[must_use]
pub fn canonical(path: &str) -> String {
path.trim_start_matches('/').replace('\\', "/")
}
#[must_use]
pub fn compose_file(markdown: &str, frontmatter: Option<&Map<String, Value>>) -> String {
let body = if markdown.ends_with('\n') || markdown.is_empty() {
markdown.to_owned()
} else {
format!("{markdown}\n")
};
let Some(fm) = frontmatter.filter(|m| !m.is_empty()) else {
return body;
};
let yaml = stringify(fm);
let yaml = yaml.strip_suffix('\n').unwrap_or(&yaml);
let sep = if body.starts_with('\n') { "" } else { "\n" };
format!("---\n{yaml}\n---\n{sep}{body}")
}
static FRONTMATTER: LazyLock<Regex> =
LazyLock::new(|| Regex::new(r"(?s)^---\r?\n(.*?)\r?\n---\r?\n?").expect("regex"));
fn yaml_to_json(v: &YamlValue) -> Value {
v.to_json()
}
#[must_use]
pub fn split_frontmatter(content: &str) -> (Map<String, Value>, String) {
let Some(caps) = FRONTMATTER.captures(content) else {
return (Map::new(), content.to_owned());
};
let whole = caps.get(0).expect("match");
let yaml = caps.get(1).map_or("", |m| m.as_str());
let fm = match parse_document(yaml) {
Some(YamlValue::Mapping(m)) => match yaml_to_json(&YamlValue::Mapping(m)) {
Value::Object(o) => o,
_ => Map::new(),
},
_ => Map::new(),
};
(fm, content[whole.end()..].to_owned())
}
#[must_use]
pub fn patch_frontmatter(
original: &str,
set: Option<&Map<String, Value>>,
unset: &[String],
) -> String {
let (mut merged, body) = split_frontmatter(original);
if let Some(set) = set {
for (k, v) in set {
merged.insert(k.clone(), v.clone());
}
}
if !unset.is_empty() {
merged = merged
.into_iter()
.filter(|(k, _)| !unset.contains(k))
.collect();
}
compose_file(&body, Some(&merged))
}
fn doc_missing(r: &str) -> crate::error::Error {
MutationError::new(ErrorCode::DocMissing, format!("no document {r}")).into()
}
fn path_taken(msg: String) -> crate::error::Error {
MutationError::new(ErrorCode::PathTaken, msg).into()
}
impl Store {
pub fn live_doc_at(&self, repo_id: &str, path: &str) -> Result<bool> {
Ok(self
.conn
.query_row(
"SELECT 1 FROM docs WHERE repo_id = ?1 AND path = ?2 AND deleted_commit IS NULL",
params![repo_id, path],
|_| Ok(()),
)
.optional()?
.is_some())
}
fn require_absent(
&self,
repo_id: &str,
doc_store: &dyn DocStore,
rel: &str,
doc_message: String,
) -> Result<()> {
if self.live_doc_at(repo_id, rel)? {
return Err(path_taken(doc_message));
}
if doc_store.exists(rel) {
return Err(path_taken(format!("file already exists on disk at {rel}")));
}
Ok(())
}
pub fn docs_create(
&mut self,
ctx: &DocOpContext,
doc_store: &mut dyn DocStore,
path: &str,
markdown: &str,
frontmatter: Option<&Map<String, Value>>,
) -> Result<DocOpResult> {
let sugar::Sugar { effects, reason } = sugar::create_document(path, markdown, frontmatter);
let rel = canonical(path);
self.require_absent(
&ctx.repo_id,
doc_store,
&rel,
format!("document already exists at {rel}"),
)?;
let (_, receipt) = self.apply_effects_detailed(
&ctx.plan_ctx(),
&effects,
&ctx.origin(&reason),
doc_store,
)?;
let doc_id = receipt
.revisions
.first()
.map(|r| r.doc.clone())
.ok_or_else(|| {
crate::error::Error::Other(format!("create: nothing committed for {rel}"))
})?;
Ok(DocOpResult {
doc_id,
path: rel,
committed: true,
receipt,
})
}
pub fn docs_move(
&mut self,
ctx: &DocOpContext,
doc_store: &mut dyn DocStore,
doc_ref: &str,
to_path: &str,
retarget_inbound: bool,
) -> Result<DocMoveResult> {
let info = find_doc_by_ref(&self.conn, &ctx.repo_id, doc_ref)?
.ok_or_else(|| doc_missing(doc_ref))?;
let to_rel = canonical(to_path);
if self.live_doc_at(&ctx.repo_id, &to_rel)? {
return Err(path_taken(format!("a document already exists at {to_rel}")));
}
let inbound = inbound_links_to(&self.conn, &ctx.repo_id, &info.doc_id, &info.path)?;
if doc_store.exists(&to_rel) {
return Err(path_taken(format!(
"file already exists on disk at {to_rel}"
)));
}
let (dangling, retargeted) = if retarget_inbound && !inbound.is_empty() {
let p = self.plan_inbound_retarget(&info.doc_id, &info.path, &to_rel, &inbound)?;
(
p.dangling,
Some(Retargeted {
blocks: p.blocks,
docs: p.docs,
}),
)
} else {
(inbound, None)
};
let sugar::Sugar { effects, reason } =
sugar::move_document(&info.doc_id, &info.path, &to_rel, retarget_inbound);
let (_, receipt) = self.apply_effects_detailed(
&ctx.plan_ctx(),
&effects,
&ctx.origin(&reason),
doc_store,
)?;
Ok(DocMoveResult {
doc_id: info.doc_id,
path: to_rel,
committed: true,
receipt,
dangling,
retargeted,
})
}
pub fn plan_inbound_retarget(
&self,
doc_id: &str,
from_path: &str,
to_rel: &str,
inbound: &[InboundLink],
) -> Result<RetargetPlan> {
let mut by_block_order: Vec<String> = Vec::new();
let mut by_block: HashMap<String, &InboundLink> = HashMap::new();
for l in inbound {
if let Some(b) = &l.block {
if !by_block.contains_key(b) {
by_block_order.push(b.clone());
by_block.insert(b.clone(), l);
}
}
}
let parent_of = |id: &str| -> Result<Option<String>> {
Ok(self
.conn
.query_row(
"SELECT parent_block FROM blocks WHERE block_id = ?1 AND deleted_commit IS NULL",
params![id],
|r| r.get::<_, Option<String>>(0),
)
.optional()?
.flatten())
};
let mut containers: BTreeSet<String> = BTreeSet::new();
for block_id in &by_block_order {
let mut cur = Some(block_id.clone());
while let Some(c) = cur {
cur = parent_of(&c)?;
if let Some(p) = &cur {
if by_block.contains_key(p) {
containers.insert(p.clone());
}
}
}
}
let mut ops: Vec<Op> = Vec::new();
let mut blocks: Vec<String> = Vec::new();
let mut docs: Vec<String> = Vec::new();
for block_id in &by_block_order {
if containers.contains(block_id) {
continue;
}
let src = by_block[block_id];
let row: Option<Vec<u8>> = self
.conn
.query_row(
"SELECT raw_hash FROM blocks WHERE block_id = ?1 AND deleted_commit IS NULL",
params![block_id],
|r| r.get(0),
)
.optional()?;
let Some(raw_hash) = row else {
continue;
};
let raw = blob_text(&self.conn, &raw_hash)?;
let write_dir = if src.doc == doc_id {
doc_dir_of(to_rel)
} else {
doc_dir_of(&src.path)
};
let Some(new_raw) =
retarget_links_in_raw(&raw, doc_dir_of(&src.path), write_dir, from_path, to_rel)
else {
continue;
};
ops.push(Op::Update {
block: block_id.clone(),
markdown: Some(new_raw),
attrs: None,
expect: Some(Expect::content(hex(&raw_hash))),
trivia: None,
child_ids: None,
});
blocks.push(block_id.clone());
if !docs.contains(&src.doc) {
docs.push(src.doc.clone());
}
}
let rewritten: BTreeSet<&str> = blocks
.iter()
.map(String::as_str)
.chain(containers.iter().map(String::as_str))
.collect();
let dangling = inbound
.iter()
.filter(|l| l.block.as_deref().is_none_or(|b| !rewritten.contains(b)))
.cloned()
.collect();
Ok(RetargetPlan {
ops,
blocks,
docs,
dangling,
})
}
pub fn docs_delete(
&mut self,
ctx: &DocOpContext,
doc_store: &mut dyn DocStore,
doc_ref: &str,
) -> Result<DocOpResult> {
let info = find_doc_by_ref(&self.conn, &ctx.repo_id, doc_ref)?
.ok_or_else(|| doc_missing(doc_ref))?;
let sugar::Sugar { effects, reason } = sugar::delete_document(&info.doc_id, &info.path);
let (_, receipt) = self.apply_effects_detailed(
&ctx.plan_ctx(),
&effects,
&ctx.origin(&reason),
doc_store,
)?;
Ok(DocOpResult {
doc_id: info.doc_id,
path: info.path,
committed: true,
receipt,
})
}
pub fn docs_set_meta(
&mut self,
ctx: &DocOpContext,
doc_store: &mut dyn DocStore,
doc_ref: &str,
set: Option<&Map<String, Value>>,
unset: &[String],
) -> Result<DocOpResult> {
let info = find_doc_by_ref(&self.conn, &ctx.repo_id, doc_ref)?
.ok_or_else(|| doc_missing(doc_ref))?;
let sugar::Sugar { effects, reason } =
sugar::set_meta(&info.doc_id, &info.path, set, unset);
if effects.is_empty() {
return Err(crate::error::Error::InvalidEffect(
"pass `set` and/or `unset`".to_owned(),
));
}
let (_, receipt) = self.apply_effects_detailed(
&ctx.plan_ctx(),
&effects,
&ctx.origin(&reason),
doc_store,
)?;
Ok(DocOpResult {
doc_id: info.doc_id,
path: info.path,
committed: true,
receipt,
})
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn composes_files_with_and_without_frontmatter() {
assert_eq!(compose_file("# New\n\nHello.", None), "# New\n\nHello.\n");
assert_eq!(compose_file("", None), "");
let empty = Map::new();
assert_eq!(compose_file("Body only.\n", Some(&empty)), "Body only.\n");
let fm: Map<String, Value> =
serde_json::from_str(r#"{"title":"Hi","count":2,"draft":true,"tags":["a","b"]}"#)
.unwrap();
assert_eq!(
compose_file("# New\n", Some(&fm)),
"---\ntitle: Hi\ncount: 2\ndraft: true\ntags:\n - a\n - b\n---\n\n# New\n"
);
assert!(compose_file("\n# B\n", Some(&fm)).ends_with("---\n\n# B\n"));
}
#[test]
fn splits_frontmatter_like_the_reference_regex() {
let (fm, body) =
split_frontmatter("---\ntitle: Hello\ntags:\n - x\n---\n\n# Body\n\nText.\n");
assert_eq!(fm["title"], json!("Hello"));
assert_eq!(fm["tags"], json!(["x"]));
assert_eq!(body, "\n# Body\n\nText.\n");
let (fm, body) = split_frontmatter("# No fm\n");
assert!(fm.is_empty());
assert_eq!(body, "# No fm\n");
let (fm, body) = split_frontmatter("---\n- not a map\n---\nbody");
assert!(fm.is_empty());
assert_eq!(body, "body");
let (_, body) = split_frontmatter("---\r\na: 1\r\n---\r\nbody");
assert_eq!(body, "body");
}
#[test]
fn canonical_paths() {
assert_eq!(canonical("/dir\\sub\\x.md"), "dir/sub/x.md");
assert_eq!(canonical("//a.md"), "a.md");
assert_eq!(canonical("a.md"), "a.md");
}
#[test]
fn set_meta_patch_merges_sets_and_drops_unsets() {
let set: Map<String, Value> = serde_json::from_str(r#"{"status":"done","n":2}"#).unwrap();
let out = patch_frontmatter(
"---\ntitle: T\nstatus: open\nold: 1\n---\n\n# Body\n",
Some(&set),
&["old".to_owned()],
);
assert_eq!(out, "---\ntitle: T\nstatus: done\nn: 2\n---\n\n# Body\n");
assert_eq!(patch_frontmatter("# Plain\n", None, &[]), "# Plain\n");
}
}