use omgbase_mutate::effects::{EffectExpect, Repair, Target};
use omgbase_mutate::{At, Effect};
use serde_json::{Map, Value, json};
use crate::docs_ops::canonical;
#[derive(Clone, Debug, PartialEq)]
pub struct Sugar {
pub effects: Vec<Effect>,
pub reason: String,
}
#[must_use]
pub fn document(doc_id: &str) -> Target {
Target::Document {
id: Some(doc_id.to_owned()),
path: None,
}
}
#[must_use]
pub fn block(id: &str) -> Target {
Target::Block { id: id.to_owned() }
}
#[must_use]
pub fn create_document(
path: &str,
markdown: &str,
frontmatter: Option<&Map<String, Value>>,
) -> Sugar {
let rel = canonical(path);
Sugar {
effects: vec![Effect::Create {
path: rel.clone(),
markdown: markdown.to_owned(),
frontmatter: frontmatter.cloned(),
}],
reason: format!("create {rel}"),
}
}
#[must_use]
pub fn move_document(
doc_id: &str,
from_path: &str,
to_path: &str,
retarget_inbound: bool,
) -> Sugar {
let to_rel = canonical(to_path);
Sugar {
effects: vec![Effect::MoveDocument {
target: document(doc_id),
to: to_rel.clone(),
retarget_inbound,
}],
reason: format!("move {from_path} -> {to_rel}"),
}
}
#[must_use]
pub fn delete_document(doc_id: &str, path: &str) -> Sugar {
Sugar {
effects: vec![Effect::RemoveDocument {
target: document(doc_id),
}],
reason: format!("delete {path}"),
}
}
#[must_use]
pub fn set_meta(
doc_id: &str,
path: &str,
set: Option<&Map<String, Value>>,
unset: &[String],
) -> Sugar {
let target = document(doc_id);
let mut effects: Vec<Effect> = Vec::new();
if let Some(set) = set {
for (k, v) in set {
if !unset.contains(k) {
effects.push(Effect::Set {
target: target.clone(),
field: format!("frontmatter.{k}"),
value: v.clone(),
expect: None,
});
}
}
}
for k in unset {
effects.push(Effect::Unset {
target: target.clone(),
field: format!("frontmatter.{k}"),
expect: None,
});
}
Sugar {
effects,
reason: format!("set_meta {path}"),
}
}
#[must_use]
pub fn update_content(doc_id: &str, content: &str) -> Vec<Effect> {
vec![Effect::Set {
target: document(doc_id),
field: "content".to_owned(),
value: json!(content),
expect: None,
}]
}
#[must_use]
pub fn insert(into: Target, at: At, markdown: &str, expect: Option<EffectExpect>) -> Vec<Effect> {
vec![Effect::Insert {
into,
at,
markdown: markdown.to_owned(),
expect,
}]
}
#[must_use]
pub fn update_block(
id: &str,
markdown: Option<&str>,
checked: Option<bool>,
expect: Option<EffectExpect>,
) -> Vec<Effect> {
let mut effects = Vec::new();
if let Some(md) = markdown {
effects.push(set_block(id, "markdown", json!(md), expect.clone()));
}
if let Some(c) = checked {
let expect = if effects.is_empty() { expect } else { None };
effects.push(set_block(id, "attrs.checked", json!(c), expect));
}
effects
}
#[must_use]
pub fn check_blocks(ids: &[String], checked: bool) -> Vec<Effect> {
ids.iter()
.map(|b| set_block(b, "attrs.checked", json!(checked), None))
.collect()
}
#[must_use]
pub fn move_blocks(
targets: Vec<String>,
into: Target,
at: At,
expect: Option<EffectExpect>,
) -> Vec<Effect> {
vec![Effect::MoveBlocks {
targets,
into,
at,
expect,
}]
}
#[must_use]
pub fn remove_blocks(targets: Vec<String>) -> Vec<Effect> {
vec![Effect::RemoveBlocks {
targets,
expect: None,
}]
}
#[must_use]
pub fn split_block(id: &str, at: Vec<usize>) -> Vec<Effect> {
vec![Effect::Split {
target: id.to_owned(),
at,
expect: None,
}]
}
#[must_use]
pub fn merge_blocks(targets: Vec<String>, separator: Option<&str>) -> Vec<Effect> {
vec![Effect::Merge {
targets,
separator: separator.map(str::to_owned),
expect: None,
}]
}
#[must_use]
pub fn set_node(node_id: &str, prop: &str, value: &str) -> Vec<Effect> {
vec![Effect::Set {
target: Target::Node {
id: node_id.to_owned(),
},
field: prop.to_owned(),
value: json!(value),
expect: None,
}]
}
#[must_use]
pub fn retarget(repairs: &[(String, String)], path_glob: Option<&str>) -> Vec<Effect> {
vec![Effect::Retarget {
repairs: repairs
.iter()
.map(|(from, to)| Repair {
from: from.clone(),
to: to.clone(),
})
.collect(),
path_glob: path_glob.filter(|g| !g.is_empty()).map(str::to_owned),
}]
}
#[must_use]
pub fn set_block(id: &str, field: &str, value: Value, expect: Option<EffectExpect>) -> Effect {
Effect::Set {
target: block(id),
field: field.to_owned(),
value,
expect,
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn document_operations_build_their_effects_and_reasons() {
let c = create_document("/notes/n.md", "# N\n", None);
assert_eq!(c.reason, "create notes/n.md");
assert_eq!(
c.effects[0].to_json(),
json!({ "op": "create", "path": "notes/n.md", "markdown": "# N\n" })
);
let m = move_document("d_0", "a.md", "/b/c.md", false);
assert_eq!(m.reason, "move a.md -> b/c.md");
assert_eq!(
m.effects[0].to_json(),
json!({ "op": "move", "target": { "kind": "document", "id": "d_0" }, "to": "b/c.md", "retarget_inbound": false })
);
let d = delete_document("d_0", "a.md");
assert_eq!(d.reason, "delete a.md");
assert_eq!(
d.effects[0].to_json(),
json!({ "op": "remove", "target": { "kind": "document", "id": "d_0" } })
);
let mut set = Map::new();
set.insert("status".to_owned(), json!("x"));
set.insert("other".to_owned(), json!(1));
let s = set_meta("d_0", "a.md", Some(&set), &["status".to_owned()]);
assert_eq!(s.reason, "set_meta a.md");
assert_eq!(
s.effects.iter().map(Effect::to_json).collect::<Vec<_>>(),
vec![
json!({ "op": "set", "target": { "kind": "document", "id": "d_0" }, "field": "frontmatter.other", "value": 1 }),
json!({ "op": "unset", "target": { "kind": "document", "id": "d_0" }, "field": "frontmatter.status" }),
],
"a key in both lists is only unset"
);
assert!(set_meta("d_0", "a.md", None, &[]).effects.is_empty());
assert_eq!(
update_content("d_0", "# A\n")[0].to_json(),
json!({ "op": "set", "target": { "kind": "document", "id": "d_0" }, "field": "content", "value": "# A\n" })
);
}
#[test]
fn block_tools_build_their_effects() {
let expect = EffectExpect {
content_hash: Some("ab".into()),
parent_children_hash: None,
revision: None,
};
let both = update_block("b_1", Some("x"), Some(true), Some(expect.clone()));
assert_eq!(both.len(), 2);
assert_eq!(both[0].to_json()["expect"], json!({ "content_hash": "ab" }));
assert!(
both[1].to_json().get("expect").is_none(),
"the guard rides the first"
);
let checked_only = update_block("b_1", None, Some(false), Some(expect));
assert_eq!(checked_only[0].to_json()["field"], "attrs.checked");
assert_eq!(
checked_only[0].to_json()["expect"],
json!({ "content_hash": "ab" })
);
assert!(update_block("b_1", None, None, None).is_empty());
assert_eq!(check_blocks(&["b_1".into(), "b_2".into()], true).len(), 2);
assert_eq!(
retarget(&[("a".into(), "b".into())], Some(""))[0].to_json(),
json!({ "op": "retarget", "repairs": [{ "from": "a", "to": "b" }] }),
"an empty glob is none"
);
assert_eq!(
set_node("n_1", "value", "/x.md")[0].to_json()["target"],
json!({ "kind": "node", "id": "n_1" })
);
assert_eq!(
merge_blocks(vec!["b_1".into(), "b_2".into()], None)[0].to_json()["op"],
"merge"
);
assert_eq!(split_block("b_1", vec![3])[0].to_json()["at"], json!([3]));
assert_eq!(
remove_blocks(vec!["b_1".into()])[0].to_json()["op"],
"remove"
);
assert_eq!(
move_blocks(vec!["b_1".into()], document("d_0"), At::Start, None)[0].to_json()["at"],
"start"
);
assert_eq!(
insert(block("b_1"), At::End, "x", None)[0].to_json()["into"],
json!({ "kind": "block", "id": "b_1" })
);
}
}