todomd 0.3.1

A simple markdown-based todo list CLI and TUI - Added Kanban
Documentation
use crate::core::model::{Category, ColorTag, State, SubTask, Todo};
use anyhow::Result;
use chrono::{DateTime, Local, NaiveDateTime, TimeZone};

/// Parses the todo.md content into State
pub fn parse(content: &str) -> Result<State> {
    parse_state_machine(content)
}

// Helper for parsing indentation
fn get_indent_level(line: &str) -> usize {
    let trimmed = line.trim_start();
    let indent = line.len() - trimmed.len();
    // Assuming 2 spaces per level
    indent / 2
}

fn parse_state_machine(content: &str) -> Result<State> {
    let mut state = State::new();
    let mut current_cat: Option<Category> = None;
    let mut max_id = 0;

    // But we write directly to state. 
    // Let's stick to "Current Parent" tracking.
    // If strict 3 levels: 
    // Level 0: Category Vector
    // Level 1: Todo in Category
    // Level 2: Sub in Todo
    // Level 3: Sub in Sub

    // Since we only need to Append, maybe we can keep indices?
    // current_cat
    // idx_lvl1 (index in cat.vec)
    // idx_lvl2 (index in cat.vec[lvl1].subtasks)
    // idx_lvl3 (index in cat.vec[lvl1].subtasks[lvl2].subtasks)
    
    let mut idx_lvl1: Option<usize> = None;
    let mut idx_lvl2: Option<usize> = None;
    // We only support appending generally.

    for line_raw in content.lines() {
        if line_raw.trim().is_empty() { continue; }

        if line_raw.trim().starts_with("##") {
            let cat_str = line_raw.trim().trim_start_matches('#').trim();
            if let Some(cat) = Category::from_str(cat_str) {
                current_cat = Some(cat);
                idx_lvl1 = None;
                idx_lvl2 = None;
            }
            continue;
        }

        if line_raw.trim().starts_with("- [") {
            let indent = get_indent_level(line_raw);
             // 0 indent -> Level 1 (Todo)
             // 1 indent (2 spaces) -> Level 2 (Sub of L1)
             // 2 indent (4 spaces) -> Level 3 (Sub of L2)
             
            let trimmed = line_raw.trim();
            let done = trimmed.starts_with("- [x]") || trimmed.starts_with("- [X]");
            let after_bracket = &trimmed[5..].trim();

            let mut id_num = 0;
            let mut title_val;
            let mut color = ColorTag::None;
            let mut created = 0;
            let mut updated = 0;

             if let Some((id_part, rest)) = extract_wrapped(after_bracket, '(', ')') {
                // Parse ID
                // id: for Todo, sid: for Sub. But now they are just IDs. 
                // We keep robust parsing.
                if let Some(n_str) = id_part.strip_prefix("id:") {
                    id_num = n_str.parse::<u64>().unwrap_or(0);
                } else if let Some(n_str) = id_part.strip_prefix("sid:") {
                    id_num = n_str.parse::<u64>().unwrap_or(0);
                }

                if id_num > max_id { max_id = id_num; }
                
                title_val = rest.to_string();
                if let Some(start_meta) = title_val.rfind('{') {
                    if let Some(end_meta) = title_val.rfind('}') {
                        if end_meta > start_meta {
                            let meta_str = &title_val[start_meta+1..end_meta];
                            parse_metadata(meta_str, &mut color, &mut created, &mut updated);
                            title_val = title_val[..start_meta].trim().to_string();
                        }
                    }
                }
             } else {
                 // Fallback if formatting broken? Skip or take whole line?
                 title_val = after_bracket.to_string();
             }

            let now_ts = Local::now().timestamp();
            if created == 0 { created = now_ts; }
            if updated == 0 { updated = now_ts; }

            if current_cat.is_none() { continue; }
            let cat = current_cat.unwrap();
            let vec = state.get_category_mut(cat);

            // Logic to insert based on indent
            // Root (indent 0)
            if indent == 0 {
                let todo = Todo {
                    id: id_num,
                    title: title_val,
                    notes: None,
                    created_at: created,
                    updated_at: updated,
                    color,
                    subtasks: Vec::new(),
                };
                vec.push(todo);
                idx_lvl1 = Some(vec.len() - 1);
                idx_lvl2 = None;
            } else if indent == 1 {
                // Child of lvl1
                if let Some(i1) = idx_lvl1 {
                    if let Some(parent) = vec.get_mut(i1) {
                         let sub = SubTask {
                            id: id_num,
                            title: title_val,
                            done,
                            created_at: created,
                            updated_at: updated,
                            color,
                            notes: None,
                            subtasks: Vec::new(),
                        };
                        parent.subtasks.push(sub);
                        idx_lvl2 = Some(parent.subtasks.len() - 1);
                    }
                }
            } else if indent >= 2 {
                // Child of lvl2 (SubSub)
                if let Some(i1) = idx_lvl1 {
                    if let Some(i2) = idx_lvl2 {
                        if let Some(grandparent) = vec.get_mut(i1) {
                            if let Some(parent) = grandparent.subtasks.get_mut(i2) {
                                let sub = SubTask {
                                    id: id_num,
                                    title: title_val,
                                    done,
                                    created_at: created,
                                    updated_at: updated,
                                    color,
                                    notes: None,
                                    subtasks: Vec::new(),
                                };
                                parent.subtasks.push(sub);
                                // idx_lvl3 not needed unless we go deeper
                            }
                        }
                    }
                }
            }
        } else {
             // Treat as note line
             let indent = get_indent_level(line_raw);
             let note_content = line_raw.trim().to_string();
             if note_content.is_empty() { continue; }
             
             if let Some(cat) = current_cat {
                let vec = state.get_category_mut(cat);
                if indent <= 1 {
                    // Note for lvl1
                    if let Some(i1) = idx_lvl1 {
                        if let Some(todo) = vec.get_mut(i1) {
                            let curr = todo.notes.get_or_insert_with(String::new);
                            if !curr.is_empty() { curr.push('\n'); }
                            curr.push_str(&note_content);
                        }
                    }
                } else if indent == 2 {
                    // Note for lvl2
                    if let Some(i1) = idx_lvl1 {
                        if let Some(i2) = idx_lvl2 {
                            if let Some(parent) = vec.get_mut(i1) {
                                if let Some(sub) = parent.subtasks.get_mut(i2) {
                                    let curr = sub.notes.get_or_insert_with(String::new);
                                    if !curr.is_empty() { curr.push('\n'); }
                                    curr.push_str(&note_content);
                                }
                            }
                        }
                    }
                } else {
                    // Note for lvl3+ (append to last added subtask in lvl2)
                    if let Some(i1) = idx_lvl1 {
                        if let Some(i2) = idx_lvl2 {
                             if let Some(grandparent) = vec.get_mut(i1) {
                                if let Some(parent) = grandparent.subtasks.get_mut(i2) {
                                    if let Some(sub) = parent.subtasks.last_mut() {
                                        let curr = sub.notes.get_or_insert_with(String::new);
                                        if !curr.is_empty() { curr.push('\n'); }
                                        curr.push_str(&note_content);
                                    }
                                }
                             }
                        }
                    }
                }
             }
        }
    }

    state.next_id = max_id + 1;
    Ok(state)
}

fn extract_wrapped(s: &str, start: char, end: char) -> Option<(&str, &str)> {
    if s.starts_with(start) {
        if let Some(idx) = s.find(end) {
            return Some((&s[1..idx], s[idx+1..].trim()));
        }
    }
    None
}

fn parse_metadata(meta: &str, color: &mut ColorTag, created: &mut i64, updated: &mut i64) {
    for part in meta.split(',') {
        let part = part.trim();
        if let Some(val) = part.strip_prefix("c:") {
            *color = ColorTag::from_str(val);
        } else if let Some(val) = part.strip_prefix("created:") {
            if let Ok(dt) = DateTime::parse_from_rfc3339(val) {
                *created = dt.timestamp();
            } else if let Ok(ndt) = NaiveDateTime::parse_from_str(val, "%Y/%m/%d %H:%M:%S") {
                if let Some(dt) = Local.from_local_datetime(&ndt).single() {
                    *created = dt.timestamp();
                }
            }
        } else if let Some(val) = part.strip_prefix("updated:") {
            if let Ok(dt) = DateTime::parse_from_rfc3339(val) {
                *updated = dt.timestamp();
            } else if let Ok(ndt) = NaiveDateTime::parse_from_str(val, "%Y/%m/%d %H:%M:%S") {
                if let Some(dt) = Local.from_local_datetime(&ndt).single() {
                    *updated = dt.timestamp();
                }
            }
        }
    }
}

pub fn to_markdown(state: &State) -> String {
    let mut out = String::new();
    out.push_str("# TODO\n\n");

    for cat in State::all_categories() {
        out.push_str(&format!("## {}\n", cat.as_str()));
        let todos = state.get_category(cat);
        for todo in todos {
            let check = if cat == Category::Completed { "x" } else { " " };
            let meta = format_metadata(todo.color, todo.created_at, todo.updated_at);
            out.push_str(&format!("- [{}] (id:{}) {} {{{}}}\n", check, todo.id, todo.title, meta));
            if let Some(ref notes) = todo.notes {
                for line in notes.lines() {
                    out.push_str(&format!("  {}\n", line));
                }
            }
            
            write_subtasks(&mut out, &todo.subtasks, 1);
        }
        out.push('\n');
    }
    out
}

fn write_subtasks(out: &mut String, subs: &[SubTask], level: usize) {
    for sub in subs {
        let indent = "  ".repeat(level); // 2 spaces per level
        let s_check = if sub.done { "x" } else { " " };
        let s_meta = format_metadata(sub.color, sub.created_at, sub.updated_at);
        // Using "sid:" for consistency or just "id:"? Previous used "sid:".
        // Let's stick to "sid:" for subtasks to match old format, though parser handles both.
        // Actually unique global ID means "id" is fine, but "sid" hints visually.
        out.push_str(&format!("{}- [{}] (sid:{}) {} {{{}}}\n", indent, s_check, sub.id, sub.title, s_meta));
        if let Some(ref notes) = sub.notes {
            let note_indent = "  ".repeat(level + 1);
            for line in notes.lines() {
                out.push_str(&format!("{}{}\n", note_indent, line));
            }
        }
        
        // Recurse
        if !sub.subtasks.is_empty() {
            write_subtasks(out, &sub.subtasks, level + 1);
        }
    }
}

fn format_metadata(color: ColorTag, created: i64, updated: i64) -> String {
    use chrono::TimeZone;
    let created_dt: DateTime<Local> = Local.timestamp_opt(created, 0).unwrap();
    let updated_dt: DateTime<Local> = Local.timestamp_opt(updated, 0).unwrap();
    
    format!("c:{}, created:{}, updated:{}", 
        color.as_str(), 
        created_dt.format("%Y/%m/%d %H:%M:%S"), 
        updated_dt.format("%Y/%m/%d %H:%M:%S")
    )
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn test_parse_and_to_markdown() {
        let content = r#"## Short
- [ ] (id:1) Task 1 {c:none, created:2026/01/26 21:00:00, updated:2026/01/26 21:00:00}
  This is a note
  - [x] (sid:2) Subtask 1 {c:red, created:2026/01/26 21:05:00, updated:2026/01/26 21:05:00}

## Completed
- [x] (id:3) Done Task {c:green, created:2026/01/26 20:00:00, updated:2026/01/26 21:10:00}
"#;

        let state = parse(content).unwrap();
        
        assert_eq!(state.short.len(), 1);
        assert_eq!(state.short[0].title, "Task 1");
        assert_eq!(state.short[0].subtasks.len(), 1);
        assert_eq!(state.short[0].subtasks[0].title, "Subtask 1");
        assert_eq!(state.short[0].subtasks[0].done, true);
        assert_eq!(state.completed.len(), 1);
        assert_eq!(state.completed[0].id, 3);

        let output = to_markdown(&state);
        // The output might have slightly different formatting (indentation, newlines) 
        // but it should be parseable back to the same state.
        let state2 = parse(&output).unwrap();
        assert_eq!(state.short.len(), state2.short.len());
        assert_eq!(state.short[0].title, state2.short[0].title);
        assert_eq!(state.short[0].subtasks[0].id, state2.short[0].subtasks[0].id);
    }
}