fn marker_end(line: &str) -> Option<usize> {
let indent = line.len() - line.trim_start().len();
let rest = &line[indent..];
let first = rest.chars().next()?;
if matches!(first, '-' | '*' | '+') && rest[1..].starts_with(' ') {
return Some(indent + 2);
}
let digits = rest.chars().take_while(char::is_ascii_digit).count();
if digits == 0 {
return None;
}
let after = &rest[digits..];
if after.starts_with(". ") || after.starts_with(") ") {
return Some(indent + digits + 2);
}
None
}
pub fn toggle_checkbox(line: &str) -> String {
let Some(end) = marker_end(line) else {
let indent = line.len() - line.trim_start().len();
return format!("{}- [ ] {}", &line[..indent], &line[indent..]);
};
let (head, body) = line.split_at(end);
if let Some(rest) = body.strip_prefix("[ ] ") {
format!("{head}[x] {rest}")
} else if let Some(rest) = body
.strip_prefix("[x] ")
.or_else(|| body.strip_prefix("[X] "))
{
format!("{head}{rest}")
} else {
format!("{head}[ ] {body}")
}
}
pub fn cycle_heading(line: &str) -> String {
let hashes = line.chars().take_while(|c| *c == '#').count();
let text = if hashes > 0 && line[hashes..].starts_with(' ') {
line[hashes + 1..].to_string()
} else if hashes > 0 && line.len() == hashes {
String::new()
} else {
return format!("# {line}");
};
if hashes >= 3 {
text
} else {
format!("{} {text}", "#".repeat(hashes + 1))
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn a_list_item_walks_through_plain_unchecked_checked_and_back() {
assert_eq!(toggle_checkbox("- item"), "- [ ] item");
assert_eq!(toggle_checkbox("- [ ] item"), "- [x] item");
assert_eq!(toggle_checkbox("- [x] item"), "- item");
assert_eq!(toggle_checkbox("- [X] item"), "- item");
}
#[test]
fn numbered_items_and_indentation_are_kept() {
assert_eq!(toggle_checkbox("1. item"), "1. [ ] item");
assert_eq!(toggle_checkbox("12) [ ] item"), "12) [x] item");
assert_eq!(toggle_checkbox(" * [x] item"), " * item");
assert_eq!(toggle_checkbox("\t+ item"), "\t+ [ ] item");
}
#[test]
fn a_plain_line_becomes_an_unchecked_item() {
assert_eq!(toggle_checkbox("call mum"), "- [ ] call mum");
assert_eq!(toggle_checkbox(" call mum"), " - [ ] call mum");
assert_eq!(toggle_checkbox(""), "- [ ] ");
assert_eq!(toggle_checkbox("-"), "- [ ] -");
assert_eq!(toggle_checkbox("2024"), "- [ ] 2024");
}
#[test]
fn headings_cycle_through_three_levels_and_off() {
assert_eq!(cycle_heading("title"), "# title");
assert_eq!(cycle_heading("# title"), "## title");
assert_eq!(cycle_heading("## title"), "### title");
assert_eq!(cycle_heading("### title"), "title");
assert_eq!(cycle_heading("#### deep"), "deep");
}
#[test]
fn a_hash_run_with_no_space_is_text_not_a_heading() {
assert_eq!(cycle_heading("#hashtag"), "# #hashtag");
assert_eq!(cycle_heading(""), "# ");
assert_eq!(cycle_heading("#"), "## ");
}
}