use fig::{Format, Value};
use crate::tree::{self, Seg};
#[derive(Debug, Clone)]
pub enum EditOp {
ReplaceValue { path: Vec<Seg>, value: Value },
DeleteKey { path: Vec<Seg> },
RemoveItem { seq_path: Vec<Seg>, index: usize },
InsertKey {
map_path: Vec<Seg>,
key: String,
value: Value,
},
AppendItem { seq_path: Vec<Seg>, value: Value },
MoveItem {
seq_path: Vec<Seg>,
from: usize,
to: usize,
},
ReorderKeys {
map_path: Vec<Seg>,
keys: Vec<String>,
},
RenameKey { path: Vec<Seg>, new_key: String },
}
pub fn move_permutation(len: usize, from: usize, to: usize) -> Option<Vec<usize>> {
if from >= len || to >= len {
return None;
}
let mut order: Vec<usize> = (0..len).collect();
let moved = order.remove(from);
order.insert(to, moved);
Some(order)
}
#[derive(Debug)]
pub struct BackendError(pub String);
impl std::fmt::Display for BackendError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(&self.0)
}
}
impl std::error::Error for BackendError {}
fn err(e: impl std::fmt::Display) -> BackendError {
BackendError(e.to_string())
}
pub trait Backend {
fn apply(&mut self, op: EditOp) -> Result<(), BackendError>;
fn to_value(&self) -> Result<Value, BackendError>;
fn source(&self) -> Result<String, BackendError>;
fn schema(&self) -> Option<crate::schema::Schema> {
None
}
}
pub struct FigBackend {
editor: fig::Editor,
format: Format,
}
impl FigBackend {
pub fn open(source: &[u8], format: Format) -> Result<Self, BackendError> {
let editor = fig::Editor::open(source, format).map_err(err)?;
Ok(Self { editor, format })
}
}
impl Backend for FigBackend {
fn apply(&mut self, op: EditOp) -> Result<(), BackendError> {
match op {
EditOp::ReplaceValue { path, value } => self
.editor
.replace_value(&tree::to_fig(&path), value)
.map_err(err),
EditOp::DeleteKey { path } => self.editor.delete(&tree::to_fig(&path)).map_err(err),
EditOp::RemoveItem { seq_path, index } => self
.editor
.remove_item(&tree::to_fig(&seq_path), index)
.map_err(err),
EditOp::InsertKey {
map_path,
key,
value,
} => self
.editor
.insert_value(&tree::to_fig(&map_path), &key, value)
.map_err(err),
EditOp::AppendItem { seq_path, value } => self
.editor
.append_value(&tree::to_fig(&seq_path), value)
.map_err(err),
EditOp::MoveItem { seq_path, from, to } => self
.editor
.move_item(&tree::to_fig(&seq_path), from, to)
.map_err(err),
EditOp::ReorderKeys { map_path, keys } => self
.editor
.reorder_keys(&tree::to_fig(&map_path), &keys)
.map_err(err),
EditOp::RenameKey { path, new_key } => self
.editor
.replace_key(&tree::to_fig(&path), &new_key)
.map_err(err),
}
}
fn to_value(&self) -> Result<Value, BackendError> {
let src = self.editor.source().map_err(err)?;
let doc = fig::Document::parse(src.as_bytes(), self.format).map_err(err)?;
doc.to_value().map_err(err)
}
fn source(&self) -> Result<String, BackendError> {
self.editor.source().map(|s| s.to_string()).map_err(err)
}
}
pub mod conformance {
use super::{Backend, EditOp};
use crate::tree::{self, Seg};
use fig::Value;
pub const FIXTURE_TOML: &str = "\
title = \"note\"
tags = [\"alpha\", \"beta\", \"gamma\"]
[nested]
k = \"v\"
j = \"w\"
";
pub fn fixture() -> Value {
fn s(v: &str) -> Value {
Value::Str(v.to_string())
}
Value::Map(vec![
(s("title"), s("note")),
(
s("tags"),
Value::Seq(vec![s("alpha"), s("beta"), s("gamma")]),
),
(
s("nested"),
Value::Map(vec![(s("k"), s("v")), (s("j"), s("w"))]),
),
])
}
pub struct Report(pub Vec<String>);
impl std::fmt::Display for Report {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
writeln!(f, "{} backend contract violation(s):", self.0.len())?;
for failure in &self.0 {
writeln!(f, " - {failure}")?;
}
Ok(())
}
}
impl std::fmt::Debug for Report {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "\n{self}")
}
}
fn key(k: &str) -> Seg {
Seg::Key(k.to_string())
}
pub fn check<B: Backend>(open: impl Fn() -> B) -> Result<(), Report> {
let mut failures = Vec::new();
match open().to_value() {
Ok(v) if v == fixture() => {}
Ok(v) => {
return Err(Report(vec![format!(
"`open` does not yield the fixture document.\n expected: {:?}\n got: {v:?}",
fixture()
)]));
}
Err(e) => return Err(Report(vec![format!("`open().to_value()` failed: {e}")])),
}
let mut case = |name: &str, op: EditOp, assert: &dyn Fn(&Value) -> Option<String>| {
let mut backend = open();
match backend.apply(op) {
Err(e) => failures.push(format!("{name}: apply failed: {e}")),
Ok(()) => match backend.to_value() {
Err(e) => failures.push(format!("{name}: to_value failed: {e}")),
Ok(tree) => {
if let Some(why) = assert(&tree) {
failures.push(format!("{name}: {why}"));
}
}
},
}
};
let at = |tree: &Value, path: &[Seg], want: Value| -> Option<String> {
match tree::value_at(tree, path) {
Some(got) if *got == want => None,
Some(got) => Some(format!("at {path:?}: expected {want:?}, got {got:?}")),
None => Some(format!("at {path:?}: path did not resolve")),
}
};
let str_v = |v: &str| Value::Str(v.to_string());
let strs = |vs: &[&str]| Value::Seq(vs.iter().map(|v| Value::Str(v.to_string())).collect());
case(
"ReplaceValue on a scalar key",
EditOp::ReplaceValue {
path: vec![key("title")],
value: str_v("REPLACED"),
},
&|t| {
at(t, &[key("title")], str_v("REPLACED"))
.or_else(|| at(t, &[key("tags")], strs(&["alpha", "beta", "gamma"])))
},
);
case(
"ReplaceValue on a sequence item",
EditOp::ReplaceValue {
path: vec![key("tags"), Seg::Index(1)],
value: str_v("REPLACED"),
},
&|t| at(t, &[key("tags")], strs(&["alpha", "REPLACED", "gamma"])),
);
case(
"ReplaceValue on a container",
EditOp::ReplaceValue {
path: vec![key("nested")],
value: str_v("REPLACED"),
},
&|t| at(t, &[key("nested")], str_v("REPLACED")),
);
case(
"DeleteKey",
EditOp::DeleteKey {
path: vec![key("nested"), key("k")],
},
&|t| {
at(
t,
&[key("nested")],
Value::Map(vec![(str_v("j"), str_v("w"))]),
)
.or_else(|| at(t, &[key("title")], str_v("note")))
},
);
case(
"RemoveItem shifts later items down",
EditOp::RemoveItem {
seq_path: vec![key("tags")],
index: 0,
},
&|t| at(t, &[key("tags")], strs(&["beta", "gamma"])),
);
case(
"InsertKey into a nested mapping",
EditOp::InsertKey {
map_path: vec![key("nested")],
key: "added".to_string(),
value: str_v("x"),
},
&|t| {
at(
t,
&[key("nested")],
Value::Map(vec![
(str_v("k"), str_v("v")),
(str_v("j"), str_v("w")),
(str_v("added"), str_v("x")),
]),
)
},
);
case(
"InsertKey at the root (empty path)",
EditOp::InsertKey {
map_path: Vec::new(),
key: "added".to_string(),
value: str_v("x"),
},
&|t| {
at(t, &[key("added")], str_v("x")).or_else(|| at(t, &[key("title")], str_v("note")))
},
);
case(
"AppendItem lands at the end",
EditOp::AppendItem {
seq_path: vec![key("tags")],
value: str_v("delta"),
},
&|t| {
at(
t,
&[key("tags")],
strs(&["alpha", "beta", "gamma", "delta"]),
)
},
);
case(
"MoveItem backwards",
EditOp::MoveItem {
seq_path: vec![key("tags")],
from: 2,
to: 0,
},
&|t| at(t, &[key("tags")], strs(&["gamma", "alpha", "beta"])),
);
case(
"MoveItem forwards",
EditOp::MoveItem {
seq_path: vec![key("tags")],
from: 0,
to: 2,
},
&|t| at(t, &[key("tags")], strs(&["beta", "gamma", "alpha"])),
);
case(
"ReorderKeys",
EditOp::ReorderKeys {
map_path: vec![key("nested")],
keys: vec!["j".to_string(), "k".to_string()],
},
&|t| {
at(
t,
&[key("nested")],
Value::Map(vec![(str_v("j"), str_v("w")), (str_v("k"), str_v("v"))]),
)
},
);
case(
"RenameKey keeps the value and the position",
EditOp::RenameKey {
path: vec![key("nested"), key("k")],
new_key: "renamed".to_string(),
},
&|t| {
at(
t,
&[key("nested")],
Value::Map(vec![
(str_v("renamed"), str_v("v")),
(str_v("j"), str_v("w")),
]),
)
},
);
for (name, op) in [
(
"RemoveItem past the end",
EditOp::RemoveItem {
seq_path: vec![key("tags")],
index: 99,
},
),
(
"MoveItem past the end",
EditOp::MoveItem {
seq_path: vec![key("tags")],
from: 0,
to: 99,
},
),
(
"DeleteKey on an absent key",
EditOp::DeleteKey {
path: vec![key("nope")],
},
),
(
"AppendItem onto a non-sequence",
EditOp::AppendItem {
seq_path: vec![key("title")],
value: str_v("x"),
},
),
] {
let mut backend = open();
let _ = backend.apply(op);
match backend.to_value() {
Ok(tree) if tree == fixture() => {}
Ok(tree) => failures.push(format!(
"{name}: document changed by a rejected op.\n expected: {:?}\n got: {tree:?}",
fixture()
)),
Err(e) => {
failures.push(format!("{name}: document unreadable after a rejected op: {e}"))
}
}
}
{
let mut backend = open();
let op = EditOp::ReplaceValue {
path: vec![key("title")],
value: str_v("REPLACED"),
};
match backend.apply(op).and_then(|()| backend.source()) {
Ok(src) if src.contains("REPLACED") => {
if !src.contains("gamma") {
failures.push(
"source() after an edit dropped an untouched sibling value".to_string(),
);
}
}
Ok(src) => failures.push(format!(
"source() does not carry the committed edit:\n{src}"
)),
Err(e) => failures.push(format!("source() after an edit failed: {e}")),
}
}
if failures.is_empty() {
Ok(())
} else {
Err(Report(failures))
}
}
#[cfg(test)]
mod permutation_tests {
use crate::backend::move_permutation;
#[test]
fn move_permutation_matches_remove_then_reinsert() {
assert_eq!(move_permutation(3, 2, 0), Some(vec![2, 0, 1]));
assert_eq!(move_permutation(3, 0, 2), Some(vec![1, 2, 0]));
assert_eq!(move_permutation(3, 1, 1), Some(vec![0, 1, 2]));
assert_eq!(move_permutation(3, 0, 3), None);
assert_eq!(move_permutation(0, 0, 0), None);
}
}
}
#[cfg(test)]
mod tests {
use super::*;
fn open_fixture() -> FigBackend {
FigBackend::open(conformance::FIXTURE_TOML.as_bytes(), Format::Toml).expect("open fixture")
}
#[test]
fn fig_backend_satisfies_the_edit_op_contract_but_for_a_known_fig_defect() {
let report = conformance::check(open_fixture)
.expect_err("if this now passes, delete the allowance below");
assert_eq!(
report.0.len(),
1,
"only the known deviation is allowed:{report}"
);
assert!(
report.0[0].starts_with("ReplaceValue on a container"),
"unexpected deviation:{report}"
);
}
#[test]
fn replacing_a_toml_table_header_is_refused_and_changes_nothing() {
let mut backend = open_fixture();
let before = backend.source().expect("source");
let result = backend.apply(EditOp::ReplaceValue {
path: vec![Seg::Key("nested".into())],
value: Value::Str("REPLACED".into()),
});
assert!(result.is_err(), "fig refuses the shape it cannot rewrite");
assert_eq!(
backend.source().expect("source"),
before,
"a refused op leaves the document byte-identical"
);
}
}