use crate::model::Step;
fn parse_checkbox_line(line: &str) -> Option<Step> {
let trimmed = line.trim_start();
let rest = trimmed.strip_prefix("- [")?;
let (done, title) = if let Some(r) = rest.strip_prefix("x] ") {
(true, r)
} else if let Some(r) = rest.strip_prefix("X] ") {
(true, r)
} else {
let r = rest.strip_prefix(" ] ")?;
(false, r)
};
let title = title.trim();
if title.is_empty() {
return None;
}
Some(Step { title: title.to_string(), done })
}
fn todo_region_end(lines: &[&str]) -> usize {
let mut last = None;
for (i, line) in lines.iter().enumerate() {
if line.trim().is_empty() {
continue;
}
if parse_checkbox_line(line).is_some() {
last = Some(i);
} else {
break;
}
}
last.map(|i| i + 1).unwrap_or(0)
}
fn as_refs(lines: &[String]) -> Vec<&str> {
lines.iter().map(String::as_str).collect()
}
pub fn parse_checkboxes(body: &str) -> Vec<Step> {
let lines: Vec<&str> = body.lines().collect();
let end = todo_region_end(&lines);
lines[..end].iter().filter_map(|l| parse_checkbox_line(l)).collect()
}
fn nth_step_line(lines: &[&str], index: usize) -> Option<usize> {
let end = todo_region_end(lines);
lines[..end]
.iter()
.enumerate()
.filter(|(_, l)| parse_checkbox_line(l).is_some())
.map(|(i, _)| i)
.nth(index)
}
fn join(lines: Vec<String>) -> String {
let mut out = lines.join("\n");
if !out.is_empty() && !out.ends_with('\n') {
out.push('\n');
}
out
}
pub fn set_done(body: &str, index: usize) -> Result<String, String> {
let mut lines: Vec<String> = body.lines().map(String::from).collect();
let target = nth_step_line(&as_refs(&lines), index)
.ok_or_else(|| "step index out of bounds".to_string())?;
let line = &lines[target];
let indent_len = line.len() - line.trim_start().len();
let (indent, trimmed) = line.split_at(indent_len);
let rest = trimmed
.strip_prefix("- [ ] ")
.ok_or_else(|| "step is already done".to_string())?;
lines[target] = format!("{indent}- [x] {rest}");
Ok(join(lines))
}
pub fn remove_line(body: &str, index: usize) -> Result<String, String> {
let mut lines: Vec<String> = body.lines().map(String::from).collect();
let target = nth_step_line(&as_refs(&lines), index)
.ok_or_else(|| "step index out of bounds".to_string())?;
lines.remove(target);
Ok(join(lines))
}
pub fn append(body: &str, title: &str) -> String {
let checkbox = format!("- [ ] {title}");
if body.trim().is_empty() {
return format!("{checkbox}\n");
}
let mut lines: Vec<String> = body.lines().map(String::from).collect();
let end = todo_region_end(&as_refs(&lines));
if end == 0 {
lines.insert(0, String::new());
lines.insert(0, checkbox);
} else {
lines.insert(end, checkbox);
}
join(lines)
}
pub fn prepend_steps(body: &str, steps: &[(String, bool)]) -> String {
let mut block: Vec<String> = steps
.iter()
.map(|(title, done)| format!("- [{}] {title}", if *done { "x" } else { " " }))
.collect();
if body.trim().is_empty() {
return join(block);
}
block.push(String::new());
block.extend(body.lines().map(String::from));
join(block)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parse_todo_block() {
let body = "- [ ] first\n- [x] second\n- [X] third\n";
let steps = parse_checkboxes(body);
assert_eq!(steps.len(), 3);
assert!(!steps[0].done);
assert!(steps[1].done);
assert!(steps[2].done);
assert_eq!(steps[0].title, "first");
}
#[test]
fn parse_allows_blank_lines_inside_block() {
let body = "\n- [ ] a\n\n- [x] b\n\n## Content\n";
assert_eq!(parse_checkboxes(body).len(), 2);
}
#[test]
fn content_block_checkboxes_are_not_steps() {
let body = "- [ ] real step\n\n## Notes\n\n- [ ] just text\n- [x] also text\n";
let steps = parse_checkboxes(body);
assert_eq!(steps.len(), 1);
assert_eq!(steps[0].title, "real step");
}
#[test]
fn body_starting_with_content_has_no_steps() {
let body = "## Notes\n\n- [ ] not a step\n";
assert!(parse_checkboxes(body).is_empty());
}
#[test]
fn parse_indented() {
let steps = parse_checkboxes(" - [ ] indented\n");
assert_eq!(steps.len(), 1);
assert_eq!(steps[0].title, "indented");
}
#[test]
fn parse_ignores_non_checkboxes() {
let body = "- [y] nope\n- [] nope\n- not a checkbox\ntext\n";
assert!(parse_checkboxes(body).is_empty());
}
#[test]
fn parse_empty_body() {
assert!(parse_checkboxes("").is_empty());
}
#[test]
fn set_done_works() {
assert_eq!(set_done("- [ ] a\n- [ ] b\n", 1).unwrap(), "- [ ] a\n- [x] b\n");
}
#[test]
fn set_done_preserves_indent() {
assert_eq!(set_done(" - [ ] a\n", 0).unwrap(), " - [x] a\n");
}
#[test]
fn set_done_ignores_content_checkboxes() {
let body = "- [ ] step\n\n## Notes\n- [ ] text\n";
assert!(set_done(body, 1).is_err(), "content checkbox is not step s2");
}
#[test]
fn set_done_already_done() {
assert!(set_done("- [x] a\n", 0).is_err());
}
#[test]
fn set_done_out_of_bounds() {
assert!(set_done("- [ ] a\n", 5).is_err());
}
#[test]
fn remove_line_works() {
assert_eq!(remove_line("- [ ] a\n- [x] b\n\ntext\n", 0).unwrap(), "- [x] b\n\ntext\n");
}
#[test]
fn remove_line_out_of_bounds() {
assert!(remove_line("- [ ] a\n", 3).is_err());
}
#[test]
fn append_to_empty() {
assert_eq!(append("", "new"), "- [ ] new\n");
}
#[test]
fn append_to_end_of_todo_block() {
let body = "- [ ] a\n- [x] b\n\n## Notes\ntext\n";
assert_eq!(append(body, "c"), "- [ ] a\n- [x] b\n- [ ] c\n\n## Notes\ntext\n");
}
#[test]
fn append_creates_block_above_content() {
assert_eq!(append("## Notes\ntext\n", "new"), "- [ ] new\n\n## Notes\ntext\n");
}
#[test]
fn prepend_steps_above_content() {
let steps = vec![("Design".to_string(), true), ("Build".to_string(), false)];
assert_eq!(
prepend_steps("## Notes\ntext\n", &steps),
"- [x] Design\n- [ ] Build\n\n## Notes\ntext\n"
);
}
#[test]
fn prepend_steps_to_empty_body() {
let steps = vec![("Only".to_string(), false)];
assert_eq!(prepend_steps("", &steps), "- [ ] Only\n");
}
}