use crate::model::Step;
fn is_code_fence(line: &str) -> bool {
line.trim_start().starts_with("```")
}
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 })
}
pub fn parse_checkboxes(body: &str) -> Vec<Step> {
let mut steps = Vec::new();
let mut in_code = false;
for line in body.lines() {
if is_code_fence(line) {
in_code = !in_code;
continue;
}
if in_code {
continue;
}
if let Some(step) = parse_checkbox_line(line) {
steps.push(step);
}
}
steps
}
fn nth_checkbox_line_idx(body: &str, index: usize) -> Option<usize> {
let mut count = 0;
let mut in_code = false;
for (i, line) in body.lines().enumerate() {
if is_code_fence(line) {
in_code = !in_code;
continue;
}
if in_code {
continue;
}
if parse_checkbox_line(line).is_some() {
if count == index {
return Some(i);
}
count += 1;
}
}
None
}
pub fn set_done(body: &str, index: usize) -> Result<String, String> {
let target = nth_checkbox_line_idx(body, index)
.ok_or_else(|| "step index out of bounds".to_string())?;
let had_trailing_nl = body.ends_with('\n');
let mut lines: Vec<String> = body.lines().map(String::from).collect();
let line = &lines[target];
let indent_len = line.len() - line.trim_start().len();
let indent = &line[..indent_len];
let trimmed = line.trim_start();
let new_trimmed = if let Some(rest) = trimmed.strip_prefix("- [ ] ") {
format!("- [x] {rest}")
} else {
return Err("step is already done".to_string());
};
lines[target] = format!("{indent}{new_trimmed}");
let mut out = lines.join("\n");
if had_trailing_nl {
out.push('\n');
}
Ok(out)
}
pub fn remove_line(body: &str, index: usize) -> Result<String, String> {
let target = nth_checkbox_line_idx(body, index)
.ok_or_else(|| "step index out of bounds".to_string())?;
let had_trailing_nl = body.ends_with('\n');
let mut lines: Vec<String> = body.lines().map(String::from).collect();
lines.remove(target);
let mut out = lines.join("\n");
if had_trailing_nl && !out.is_empty() {
out.push('\n');
}
Ok(out)
}
pub fn append(body: &str, title: &str) -> String {
let checkbox = format!("- [ ] {title}");
let last_cb = {
let mut last = None;
let mut in_code = false;
for (i, line) in body.lines().enumerate() {
if is_code_fence(line) {
in_code = !in_code;
continue;
}
if !in_code && parse_checkbox_line(line).is_some() {
last = Some(i);
}
}
last
};
if body.is_empty() {
return format!("{checkbox}\n");
}
let had_trailing_nl = body.ends_with('\n');
let mut lines: Vec<String> = body.lines().map(String::from).collect();
if let Some(idx) = last_cb {
lines.insert(idx + 1, checkbox);
} else {
if lines.last().is_some_and(|l| !l.is_empty()) {
lines.push(String::new());
}
lines.push(checkbox);
}
let mut out = lines.join("\n");
if had_trailing_nl || !out.ends_with('\n') {
out.push('\n');
}
out
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parse_basic() {
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_skips_code_blocks() {
let body = "- [ ] real\n```\n- [ ] fake\n```\n- [x] also real\n";
let steps = parse_checkboxes(body);
assert_eq!(steps.len(), 2);
assert_eq!(steps[0].title, "real");
assert_eq!(steps[1].title, "also real");
}
#[test]
fn parse_indented() {
let body = " - [ ] indented\n";
let steps = parse_checkboxes(body);
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() {
let body = "- [ ] a\n- [ ] b\n";
let out = set_done(body, 1).unwrap();
assert_eq!(out, "- [ ] a\n- [x] b\n");
}
#[test]
fn set_done_preserves_indent() {
let body = " - [ ] a\n";
let out = set_done(body, 0).unwrap();
assert_eq!(out, " - [x] a\n");
}
#[test]
fn set_done_already_done() {
let body = "- [x] a\n";
assert!(set_done(body, 0).is_err());
}
#[test]
fn set_done_out_of_bounds() {
let body = "- [ ] a\n";
assert!(set_done(body, 5).is_err());
}
#[test]
fn remove_line_works() {
let body = "text\n- [ ] a\n- [x] b\nmore\n";
let out = remove_line(body, 0).unwrap();
assert_eq!(out, "text\n- [x] b\nmore\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_after_last_checkbox() {
let body = "intro\n- [ ] a\ntext\n- [x] b\nend\n";
let out = append(body, "c");
assert_eq!(out, "intro\n- [ ] a\ntext\n- [x] b\n- [ ] c\nend\n");
}
#[test]
fn append_no_existing_checkboxes() {
let body = "just text\n";
let out = append(body, "new");
assert_eq!(out, "just text\n\n- [ ] new\n");
}
}