tasks-cli-rs 0.6.1

Markdown-based TODO task management CLI: each task is a Markdown file with YAML front matter
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 })
}

/// A task file has three regions: the metadata block (front matter, handled by
/// storage), the TODO block, and the content block. The TODO block is the run
/// of checkbox lines at the top of the body (blank lines may be interleaved);
/// it ends at the first non-blank line that is not a checkbox.
///
/// Returns the index one past the last TODO-block checkbox, or 0 when the body
/// starts with content.
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()
}

/// Steps come from the TODO block only. Checkboxes in the content block are
/// plain text and are not tracked.
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()
}

/// Line index of the Nth (0-based) TODO-block checkbox.
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))
}

/// Appends a step to the end of the TODO block, creating the block at the top
/// of the body when it does not exist yet.
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 {
        // no TODO block yet: create one above the content
        lines.insert(0, String::new());
        lines.insert(0, checkbox);
    } else {
        lines.insert(end, checkbox);
    }
    join(lines)
}

/// Builds a TODO block at the top of the body (used when migrating legacy
/// YAML steps).
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");
    }
}