use toml_edit::{DocumentMut, Item, Table};
use crate::builder::canonical::canonicalize;
#[derive(Debug)]
pub(crate) enum TomlError {
Parse(toml_edit::TomlError),
}
impl std::fmt::Display for TomlError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
TomlError::Parse(e) => write!(f, "TOML parse error: {e}"),
}
}
}
impl std::error::Error for TomlError {}
pub(crate) fn parse(input: &str) -> Result<DocumentMut, TomlError> {
input.parse::<DocumentMut>().map_err(TomlError::Parse)
}
pub(crate) fn emit_canonical(doc: &DocumentMut) -> String {
let mut copy = doc.clone();
sort_recursively(copy.as_table_mut());
let raw = copy.to_string();
canonicalize(&raw)
}
fn sort_recursively(table: &mut Table) {
table.sort_values();
for (_, item) in table.iter_mut() {
match item {
Item::Table(t) => sort_recursively(t),
Item::ArrayOfTables(aot) => {
for entry in aot.iter_mut() {
sort_recursively(entry);
}
}
Item::Value(_) | Item::None => {}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use toml_edit::value;
fn fixture() -> DocumentMut {
let mut doc = DocumentMut::new();
doc["zeta"] = value("z");
doc["alpha"] = value(1_i64);
let mut project = Table::new();
project["name"] = value("demo");
project["created_at"] = value("2026-05-15T10:30:00Z");
doc["project"] = Item::Table(project);
let mut aot = toml_edit::ArrayOfTables::new();
{
let mut m = Table::new();
m["name"] = value("Patient");
m["table"] = value("patients");
aot.push(m);
}
{
let mut m = Table::new();
m["name"] = value("Doctor");
m["table"] = value("doctors");
aot.push(m);
}
doc["models"] = Item::ArrayOfTables(aot);
doc
}
#[test]
fn canonical_round_trip() {
let first = emit_canonical(&fixture());
let parsed = parse(&first).expect("emitter output must parse");
let second = emit_canonical(&parsed);
assert_eq!(first, second, "canonical form is not byte-stable");
}
#[test]
fn keys_sorted_within_table() {
let out = emit_canonical(&fixture());
let alpha_pos = out.find("alpha = ").expect("alpha emitted");
let zeta_pos = out.find("zeta = ").expect("zeta emitted");
assert!(
alpha_pos < zeta_pos,
"alpha must precede zeta in canonical output:\n{out}"
);
let created_at_pos = out.find("created_at = ").expect("created_at emitted");
let name_pos = out.find("name = \"demo\"").expect("project.name emitted");
assert!(
created_at_pos < name_pos,
"created_at must precede name in [project]:\n{out}"
);
}
#[test]
fn array_of_tables_preserves_insertion_order() {
let out = emit_canonical(&fixture());
let patient_pos = out.find("name = \"Patient\"").expect("Patient emitted");
let doctor_pos = out.find("name = \"Doctor\"").expect("Doctor emitted");
assert!(
patient_pos < doctor_pos,
"AOT element order must be preserved:\n{out}"
);
}
#[test]
fn output_ends_with_single_lf() {
let out = emit_canonical(&fixture());
assert!(out.ends_with('\n'), "output must end with LF");
assert!(
!out.ends_with("\n\n"),
"output must not have trailing blank lines"
);
}
#[test]
fn output_uses_lf_line_endings_only() {
let out = emit_canonical(&fixture());
assert!(!out.contains('\r'), "canonical output must not contain CR");
}
#[test]
fn empty_document_round_trips() {
let doc = DocumentMut::new();
let out = emit_canonical(&doc);
let parsed = parse(&out).expect("empty doc parses");
let again = emit_canonical(&parsed);
assert_eq!(out, again);
}
}