use crate::hash::normalise;
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Markers {
open: String,
close: String,
}
impl Markers {
pub fn new(tool: &str) -> Self {
Markers {
open: format!("<!-- {tool}:harness -->"),
close: format!("<!-- /{tool}:harness -->"),
}
}
pub fn open(&self) -> &str {
&self.open
}
pub fn close(&self) -> &str {
&self.close
}
fn locate(&self, lines: &[&str]) -> Option<(usize, usize)> {
lines
.iter()
.enumerate()
.filter(|(_, l)| l.trim_end() == self.close)
.find_map(|(close, _)| {
let open = lines[..close]
.iter()
.rposition(|l| l.trim_end() == self.open)?;
Some((open, close))
})
}
fn framed(&self, block: &str) -> String {
let mut body = normalise(block);
if !body.is_empty() && !body.ends_with('\n') {
body.push('\n');
}
let gap = if body.is_empty() { "" } else { "\n" };
format!("{}\n{gap}{body}{}\n", self.open, self.close)
}
pub fn find(&self, text: &str) -> Option<String> {
let text = normalise(text);
let lines: Vec<&str> = text.split('\n').collect();
let (open, close) = self.locate(&lines)?;
let mut inner = &lines[open + 1..close];
if inner.first().is_some_and(|l| l.trim().is_empty()) {
inner = &inner[1..];
}
if inner.is_empty() {
return Some(String::new());
}
Some(format!("{}\n", inner.join("\n")))
}
pub fn render(&self, existing: Option<&str>, block: &str) -> Option<String> {
let new_block = self.framed(block);
let Some(existing) = existing else {
return Some(new_block);
};
let crlf = existing.contains("\r\n");
let text = normalise(existing);
let lines: Vec<&str> = text.split('\n').collect();
let out = match self.locate(&lines) {
Some((open, close)) => {
let head = lines[..open].join("\n");
let tail = lines[close + 1..].join("\n");
let head = if open == 0 { head } else { format!("{head}\n") };
format!("{head}{new_block}{tail}")
}
None if text.is_empty() => new_block,
None => {
let mut head = text.clone();
if !head.ends_with('\n') {
head.push('\n');
}
format!("{head}\n{new_block}")
}
};
let out = if crlf { out.replace('\n', "\r\n") } else { out };
(out != existing).then_some(out)
}
pub(crate) fn remove(&self, existing: &str) -> Option<String> {
let crlf = existing.contains("\r\n");
let text = normalise(existing);
let lines: Vec<&str> = text.split('\n').collect();
let (open, close) = self.locate(&lines)?;
let from = if open > 0 && lines[open - 1].trim().is_empty() {
open - 1
} else {
open
};
let kept: Vec<&str> = lines[..from]
.iter()
.chain(&lines[close + 1..])
.copied()
.collect();
let out = kept.join("\n");
Some(if crlf { out.replace('\n', "\r\n") } else { out })
}
}
#[cfg(test)]
mod tests {
use proptest::prelude::*;
use super::*;
const BLOCK: &str = "Read the skill.\n";
fn m() -> Markers {
Markers::new("tool")
}
#[test]
fn remove_undoes_render() {
for before in [None, Some(""), Some("Mine.\n"), Some("Mine.\r\nMore.\r\n")] {
let rendered = m().render(before, BLOCK).unwrap();
assert_eq!(
m().remove(&rendered).as_deref(),
Some(before.unwrap_or("")),
"{before:?}"
);
}
let moved = "A\n\n<!-- tool:harness -->\n\nx\n<!-- /tool:harness -->\nB\n";
assert_eq!(m().remove(moved).as_deref(), Some("A\nB\n"));
assert_eq!(m().remove("no region\n"), None);
}
#[test]
fn markers_carry_the_tool_name() {
assert_eq!(m().open(), "<!-- tool:harness -->");
assert_eq!(m().close(), "<!-- /tool:harness -->");
}
#[test]
fn a_new_file_is_the_block_alone() {
let out = m().render(None, BLOCK).unwrap();
assert_eq!(
out,
"<!-- tool:harness -->\n\nRead the skill.\n<!-- /tool:harness -->\n"
);
assert_eq!(m().find(&out).as_deref(), Some(BLOCK));
assert_eq!(m().render(Some(&out), BLOCK), None);
assert_eq!(
m().render(None, "x").unwrap(),
"<!-- tool:harness -->\n\nx\n<!-- /tool:harness -->\n"
);
assert_eq!(
m().render(None, "").unwrap(),
"<!-- tool:harness -->\n<!-- /tool:harness -->\n"
);
assert_eq!(
m().find(&m().render(None, "").unwrap()).as_deref(),
Some("")
);
}
#[test]
fn markers_absent_appends_after_one_blank_line() {
let out = m().render(Some("# Title\n\nText."), BLOCK).unwrap();
assert_eq!(
out,
"# Title\n\nText.\n\n<!-- tool:harness -->\n\nRead the skill.\n<!-- /tool:harness -->\n"
);
assert_eq!(m().render(Some(""), BLOCK).unwrap(), m().framed(BLOCK));
assert_eq!(
m().render(Some("x\n"), BLOCK).unwrap(),
format!("x\n\n{}", m().framed(BLOCK))
);
}
#[test]
fn markers_present_rewrites_between_the_first_ones_only() {
let before =
"# Title\n\n<!-- tool:harness -->\nold\n<!-- /tool:harness -->\n\n## After\n\nkept\n";
let out = m().render(Some(before), BLOCK).unwrap();
assert_eq!(
out,
"# Title\n\n<!-- tool:harness -->\n\nRead the skill.\n<!-- /tool:harness -->\n\n## After\n\nkept\n"
);
assert_eq!(m().render(Some(&out), BLOCK), None);
let before = "<!-- tool:harness -->\n<!-- /tool:harness -->\ntail\n";
assert_eq!(m().find(before).as_deref(), Some(""));
assert_eq!(
m().render(Some(before), BLOCK).unwrap(),
"<!-- tool:harness -->\n\nRead the skill.\n<!-- /tool:harness -->\ntail\n"
);
let other = "<!-- other:harness -->\nx\n<!-- /other:harness -->\n";
assert_eq!(m().find(other), None);
assert!(m().render(Some(other), BLOCK).unwrap().starts_with(other));
}
#[test]
fn a_stray_opening_marker_is_left_alone() {
let stray = "<!-- tool:harness -->\nmine\n";
assert_eq!(m().find(stray), None);
let out = m().render(Some(stray), BLOCK).unwrap();
assert_eq!(out, format!("{stray}\n{}", m().framed(BLOCK)));
assert_eq!(m().find(&out).as_deref(), Some(BLOCK));
assert_eq!(m().render(Some(&out), BLOCK), None, "current");
let again = m().render(Some(&out), "New.\n").unwrap();
assert!(again.starts_with(stray), "{again}");
}
#[test]
fn keeps_crlf_line_endings() {
let before = "a\r\n\r\n<!-- tool:harness -->\r\nold\r\n<!-- /tool:harness -->\r\n";
let out = m().render(Some(before), BLOCK).unwrap();
assert_eq!(
out,
"a\r\n\r\n<!-- tool:harness -->\r\n\r\nRead the skill.\r\n<!-- /tool:harness -->\r\n"
);
assert_eq!(m().find(&out).as_deref(), Some(BLOCK));
assert_eq!(m().render(Some(&out), BLOCK), None);
let out = m().render(Some("a\r\n"), BLOCK).unwrap();
assert!(out.contains("\r\n<!-- tool:harness -->\r\n"), "{out:?}");
}
#[test]
fn a_closing_marker_before_the_opening_one_is_no_block() {
assert_eq!(
m().find("<!-- /tool:harness -->\n<!-- tool:harness -->\n"),
None
);
assert_eq!(m().find("no markers"), None);
}
fn arb_text() -> impl Strategy<Value = String> {
prop::collection::vec("[a-z #@-]{0,12}", 0..6).prop_map(|lines| lines.join("\n"))
}
fn arb_block() -> impl Strategy<Value = String> {
(
prop::collection::vec("[ \t]{0,2}", 0..3),
prop::collection::vec("[a-zA-Z.][a-zA-Z .]{0,19}", 1..4),
)
.prop_map(|(blank, lines)| {
let mut all = blank;
all.extend(lines);
format!("{}\n", all.join("\n"))
})
}
fn strip_block(t: &str) -> String {
let lines: Vec<&str> = t.split('\n').collect();
match (
lines.iter().position(|l| *l == "<!-- tool:harness -->"),
lines.iter().position(|l| *l == "<!-- /tool:harness -->"),
) {
(Some(o), Some(c)) if c > o => [&lines[..o], &lines[c + 1..]].concat().join("\n"),
_ => t.to_string(),
}
}
proptest! {
#![proptest_config(ProptestConfig::with_cases(256))]
#[test]
fn region_touches_only_its_block(text in arb_text(), block in arb_block(), crlf in any::<bool>()) {
let text = if crlf { text.replace('\n', "\r\n") } else { text };
let Some(after) = m().render(Some(&text), &block) else {
let found = m().find(&text);
prop_assert_eq!(found.as_deref(), Some(block.as_str()));
return Ok(());
};
let lf = after.replace("\r\n", "\n");
prop_assert_eq!(lf.matches("<!-- tool:harness -->").count(), 1);
prop_assert!(lf.contains("<!-- tool:harness -->\n\n"), "a blank line after the opening marker");
let found = m().find(&after);
prop_assert_eq!(found.as_deref(), Some(block.as_str()));
let kept = strip_block(&lf);
let original = strip_block(&text.replace("\r\n", "\n"));
prop_assert!(kept.starts_with(original.trim_end_matches('\n')), "{kept:?} vs {original:?}");
prop_assert_eq!(m().render(Some(&after), &block), None);
prop_assert_eq!(after.contains("\r\n"), text.contains("\r\n"));
}
}
}