1use std::ops::Range;
4use std::sync::LazyLock;
5
6use regex::Regex;
7
8static TITLE_RE: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"(?m)^# (.+)$").unwrap());
9static CHECKED_RE: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"(?i)^- \[x\] ").unwrap());
10static UNCHECKED_RE: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"^- \[ \] ").unwrap());
11static SECTION_RE: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"(?m)^## +(.*?)\s*$").unwrap());
12
13pub fn title(body: &str) -> String {
15 TITLE_RE
16 .captures(body)
17 .map(|c| c[1].trim().to_string())
18 .unwrap_or_default()
19}
20
21pub fn section(body: &str, header: &str) -> String {
23 let re = Regex::new(&format!(r"(?m)^## {}\s*$", regex::escape(header))).unwrap();
24 let Some(m) = re.find(body) else {
25 return String::new();
26 };
27 let rest = &body[m.end()..];
28 let next = Regex::new(r"(?m)^## ").unwrap();
29 match next.find(rest) {
30 Some(n) => rest[..n.start()].to_string(),
31 None => rest.to_string(),
32 }
33}
34
35#[derive(Debug, Clone)]
36pub struct ChecklistItem {
37 pub checked: bool,
38 pub line: String,
39}
40
41pub fn checklist_items(body: &str, header: &str) -> Vec<ChecklistItem> {
44 let mut out = Vec::new();
45 for line in section(body, header).lines() {
46 if CHECKED_RE.is_match(line) {
47 out.push(ChecklistItem {
48 checked: true,
49 line: line.to_string(),
50 });
51 } else if UNCHECKED_RE.is_match(line) {
52 out.push(ChecklistItem {
53 checked: false,
54 line: line.to_string(),
55 });
56 }
57 }
58 out
59}
60
61pub fn has_section(body: &str, header: &str) -> bool {
63 Regex::new(&format!(r"(?m)^## {}\s*$", regex::escape(header)))
64 .unwrap()
65 .is_match(body)
66}
67
68pub fn sections(body: &str) -> Vec<(String, String)> {
73 section_spans(body)
74 .into_iter()
75 .map(|(h, r)| (h, body[r].trim().to_string()))
76 .collect()
77}
78
79pub fn section_spans(body: &str) -> Vec<(String, Range<usize>)> {
84 let mut raw: Vec<(String, Range<usize>)> = Vec::new();
85 let mut content_start = 0;
86 let mut heading = String::new();
87 for m in SECTION_RE.captures_iter(body) {
88 let whole = m.get(0).unwrap();
89 raw.push((std::mem::take(&mut heading), content_start..whole.start()));
90 heading = m[1].trim().to_string();
91 content_start = whole.end();
92 }
93 raw.push((heading, content_start..body.len()));
94 raw.into_iter()
95 .filter(|(h, r)| !(h.is_empty() && body[r.clone()].trim().is_empty()))
96 .collect()
97}
98
99pub fn apply_section_edits(
104 body: &str,
105 spans: &[(String, Range<usize>)],
106 edits: &[(usize, String)],
107) -> String {
108 let mut edits: Vec<&(usize, String)> = edits.iter().filter(|(i, _)| *i < spans.len()).collect();
109 edits.sort_by(|a, b| spans[b.0].1.start.cmp(&spans[a.0].1.start));
110 let mut body = body.to_string();
111 for (i, new_text) in edits {
112 let range = spans[*i].1.clone();
113 let content = &body[range.clone()];
114 let trimmed = content.trim();
115 if trimmed.is_empty() {
116 body.replace_range(range, &format!("\n{new_text}\n"));
117 } else {
118 let lead = content.find(trimmed).unwrap_or(0);
119 let ts = range.start + lead;
120 let te = ts + trimmed.len();
121 body.replace_range(ts..te, new_text);
122 }
123 }
124 body
125}
126
127#[cfg(test)]
128mod tests {
129
130 #[test]
131 fn section_edit_splices_byte_accurately() {
132 let body = "# T\n\nintro\n\n## A\nold a\n\n## B\nold b\n";
133 let spans = section_spans(body);
134 let a = spans.iter().position(|(h, _)| h == "A").unwrap();
135 let out = apply_section_edits(body, &spans, &[(a, "new a".to_string())]);
136 assert_eq!(out, "# T\n\nintro\n\n## A\nnew a\n\n## B\nold b\n");
138 }
139 use super::*;
140
141 const BODY: &str = "# Tab title\n\n## Test plan\n- [x] valid UTF-8 — `tab::osc_title`\n- [ ] invalid UTF-8 — uncovered\n\n## Manual verification\n- Legible at scaling — *manual: rendering*\n - Setup: external monitor at 150%\n - Steps:\n 1. Open a tab\n 2. printf escape\n - Expect: crisp glyphs\n";
142
143 #[test]
144 fn finds_title() {
145 assert_eq!(title(BODY), "Tab title");
146 }
147
148 #[test]
149 fn parses_test_plan() {
150 let items = checklist_items(BODY, "Test plan");
151 assert_eq!(items.len(), 2);
152 assert!(items[0].checked);
153 assert!(!items[1].checked);
154 assert!(items[0].line.contains("tab::osc_title"));
155 }
156}