pub mod common;
use std::collections::{BTreeMap, HashMap};
use ciclostile::Docx;
use common::{DOCUMENT, Fixture};
fn fill<T>(edit: impl FnOnce(&mut Docx) -> T) -> (Vec<u8>, Vec<u8>, T) {
let template = Fixture::template().zip();
let mut docx = Docx::from_bytes(template.clone()).unwrap();
let result = edit(&mut docx);
(
common::untouched(&template),
docx.to_bytes().to_vec(),
result,
)
}
fn assert_paragraph(zip: &[u8], name: &str, text: &str) {
let paragraphs = common::paragraphs(&common::read_text(zip, name));
assert!(
paragraphs.iter().any(|paragraph| paragraph == text),
"no paragraph {text:?} in {name}: {paragraphs:#?}"
);
}
#[test]
fn placeholder_split_across_runs_is_replaced() {
let (baseline, output, count) = fill(|docx| docx.replace_text([("{{number}}", "2026-0042")]));
assert_eq!(count, 1);
assert_paragraph(&output, "word/document.xml", "Invoice 2026-0042");
let added = common::assert_kept_except(&baseline, &output, &["word/document.xml"]);
assert_eq!(added, [] as [String; 0]);
}
#[test]
fn placeholders_are_replaced_in_every_story() {
let (baseline, output, count) = fill(|docx| {
docx.replace_text([
("{{number}}", "42"),
("{{customer}}", "Ada Lovelace"),
("{{site}}", "example.com"),
("{{phone}}", "555-0100"),
("{{total}}", "£ 17,000"),
("{{boxed}}", "Paid"),
("{{date}}", "3 November 2026"),
("{{company}}", "Babbage & Co."),
("{{currency}}", "pounds"),
])
});
assert_eq!(count, 8 + 3 + 1);
let document = "word/document.xml";
assert_paragraph(&output, document, "Invoice 42");
assert_paragraph(&output, document, "Dear Ada Lovelace,");
assert_paragraph(&output, document, "Visit example.com or call 555-0100.");
assert_paragraph(&output, document, "£ 17,000");
assert_paragraph(&output, document, "3 November 2026");
let paragraphs = common::paragraphs(&common::read_text(&output, document));
let paid = paragraphs.iter().filter(|text| *text == "Paid").count();
assert_eq!(paid, 2, "{paragraphs:#?}");
assert_paragraph(&output, "word/header1.xml", "Babbage & Co. - {{logo}}");
assert_paragraph(&output, "word/footer1.xml", "Page 1 - Babbage & Co.");
assert_paragraph(&output, "word/footnotes.xml", " Prices in pounds.");
assert_paragraph(
&output,
"word/endnotes.xml",
" Issued by Babbage & Co. in London.",
);
let added = common::assert_kept_except(
&baseline,
&output,
&[
"word/document.xml",
"word/footnotes.xml",
"word/endnotes.xml",
"word/header1.xml",
"word/footer1.xml",
],
);
assert_eq!(added, [] as [String; 0]);
}
#[test]
fn only_changed_parts_are_rewritten() {
let (baseline, output, count) = fill(|docx| docx.replace_text([("{{currency}}", "euros")]));
assert_eq!(count, 1);
let added = common::assert_kept_except(&baseline, &output, &["word/footnotes.xml"]);
assert_eq!(added, [] as [String; 0]);
}
#[test]
fn edited_part_keeps_the_bytes_around_the_edit() {
let (_, output, _) = fill(|docx| docx.replace_text([("{{customer}}", "Ada")]));
let after = common::read_text(&output, "word/document.xml");
let start = DOCUMENT.find(r#"<w:p w14:paraId="2B3C4D5E""#).unwrap();
let end = start + DOCUMENT[start..].find("</w:p>").unwrap() + "</w:p>".len();
let tail = &DOCUMENT[end..];
assert_eq!(after[..start], DOCUMENT[..start]);
assert!(after.ends_with(tail));
assert_paragraph(&output, "word/document.xml", "Dear Ada,");
let paragraph = &after[start..after.len() - tail.len()];
assert!(
paragraph.contains("<w:delText xml:space=\"preserve\">{{customer}} </w:delText>"),
"{paragraph}"
);
}
#[test]
fn unmatched_placeholders_leave_the_document_untouched() {
let (baseline, output, count) =
fill(|docx| docx.replace_text([("{{nowhere}}", "x"), ("{{title}}", "y")]));
assert_eq!(count, 0);
assert_eq!(output, baseline);
}
#[test]
fn empty_keys_are_ignored() {
let (baseline, output, count) = fill(|docx| docx.replace_text([("", "x")]));
assert_eq!(count, 0);
assert_eq!(output, baseline);
}
#[test]
fn replacements_are_made_in_a_single_pass() {
let (_, output, count) = fill(|docx| {
docx.replace_text([
("{{customer}}", "{{phone}} and {{customer}}"),
("{{phone}}", "555-0100"),
])
});
assert_eq!(count, 2);
assert_paragraph(
&output,
"word/document.xml",
"Dear {{phone}} and {{customer}},",
);
assert_paragraph(
&output,
"word/document.xml",
"Visit {{site}} or call 555-0100.",
);
}
#[test]
fn longer_key_wins_at_the_same_position() {
let (_, output, count) =
fill(|docx| docx.replace_text([("{{cust", "short"), ("{{customer}}", "long")]));
assert_eq!(count, 1);
assert_paragraph(&output, "word/document.xml", "Dear long,");
}
#[test]
fn first_value_wins_for_a_repeated_key() {
let (_, output, count) =
fill(|docx| docx.replace_text([("{{date}}", "first"), ("{{date}}", "second")]));
assert_eq!(count, 1);
assert_paragraph(&output, "word/document.xml", "first");
}
#[test]
fn line_breaks_and_tabs_become_breaks_and_tabs() {
let (_, output, _) =
fill(|docx| docx.replace_text([("{{date}}", "one\ntwo\r\nthree\rfour\tfive")]));
assert_paragraph(&output, "word/document.xml", "one\ntwo\nthree\nfour\tfive");
let document = common::read_text(&output, "word/document.xml");
assert_eq!(common::count(&document, "<w:br/>"), 3, "{document}");
assert_eq!(common::count(&document, "<w:tab/>"), 1, "{document}");
}
#[test]
fn values_are_escaped() {
let (_, output, _) =
fill(|docx| docx.replace_text([("{{date}}", "<b>\"Fish\" & 'chips'</b>\u{1}\u{fffe}")]));
assert_paragraph(&output, "word/document.xml", "<b>\"Fish\" & 'chips'</b>");
Docx::from_bytes(output).unwrap();
}
#[test]
fn values_can_come_from_maps() {
let template = Fixture::template().zip();
let mut docx = Docx::from_bytes(template.clone()).unwrap();
let values = HashMap::from([
("{{date}}".to_owned(), "today".to_owned()),
("{{total}}".to_owned(), "0".to_owned()),
]);
assert_eq!(docx.replace_text(&values), 2);
let mut docx = Docx::from_bytes(template).unwrap();
let values = BTreeMap::from([("{{date}}", String::from("today"))]);
assert_eq!(docx.replace_text(values), 1);
}
#[test]
fn filled_document_can_be_filled_again() {
let (_, output, _) = fill(|docx| docx.replace_text([("{{customer}}", "{{phone}}")]));
let mut docx = Docx::from_bytes(output).unwrap();
assert_eq!(docx.replace_text([("{{phone}}", "555-0100")]), 2);
let output = docx.to_bytes();
assert_paragraph(&output, "word/document.xml", "Dear 555-0100,");
assert_paragraph(
&output,
"word/document.xml",
"Visit {{site}} or call 555-0100.",
);
}