use crate::core::model::{Category, ColorTag, State, SubTask, Todo};
use anyhow::Result;
use chrono::{DateTime, Local, NaiveDateTime, TimeZone};
pub fn parse(content: &str) -> Result<State> {
parse_state_machine(content)
}
fn get_indent_level(line: &str) -> usize {
let trimmed = line.trim_start();
let indent = line.len() - trimmed.len();
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;
let mut idx_lvl1: Option<usize> = None;
let mut idx_lvl2: Option<usize> = None;
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);
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, '(', ')') {
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 {
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);
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 {
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 {
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);
}
}
}
}
}
} else {
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 {
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(¬e_content);
}
}
} else if indent == 2 {
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(¬e_content);
}
}
}
}
} else {
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(¬e_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); let s_check = if sub.done { "x" } else { " " };
let s_meta = format_metadata(sub.color, sub.created_at, sub.updated_at);
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));
}
}
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);
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);
}
}