tasks-cli-rs 0.4.0

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

/// Find the line index of the Nth checkbox (0-based), skipping code blocks.
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();
    // Replace "[ ]" with "[x]"
    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}");
    // Find last checkbox line index
    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 {
        // No existing checkboxes — append at end with blank line separator
        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");
    }
}