//! Markdown parsing and generation for todo files. //! //! This module handles reading and writing Markdown-formatted todo and archive files. //! It supports single-level nesting (tasks with subtasks) and multiple named lists. use std::collections::HashMap; /// Represents a single task with optional subtasks #[derive(Debug, Clone, PartialEq)] pub struct Task { pub text: String, pub subtasks: Vec, } impl Task { pub fn new(text: String) -> Self { Task { text, subtasks: Vec::new(), } } #[allow(dead_code)] pub fn with_subtasks(text: String, subtasks: Vec) -> Self { Task { text, subtasks } } pub fn add_subtask(&mut self, subtask: String) { self.subtasks.push(subtask); } #[allow(dead_code)] pub fn has_subtasks(&self) -> bool { !self.subtasks.is_empty() } #[allow(dead_code)] pub fn subtask_count(&self) -> usize { self.subtasks.len() } } /// Represents the entire todo file with a single task list #[derive(Debug, Clone, PartialEq)] pub struct TodoFile { pub title: String, pub tasks: Vec, } impl TodoFile { pub fn new(title: String) -> Self { TodoFile { title, tasks: Vec::new(), } } pub fn add_task(&mut self, task: Task) { self.tasks.push(task); } pub fn prepend_task(&mut self, task: Task) { self.tasks.insert(0, task); } pub fn is_empty(&self) -> bool { self.tasks.is_empty() } pub fn task_count(&self) -> usize { self.tasks.len() } } /// Parse task reference in format "1", "2a", "10b", etc. #[derive(Debug, Clone, PartialEq)] pub struct TaskRef { pub task_index: usize, pub subtask_index: Option, } impl TaskRef { pub fn parse(input: &str) -> Result { if input.is_empty() { return Err("Empty task reference".to_string()); } let chars: Vec = input.chars().collect(); let mut task_part = String::new(); let mut subtask_char = None; for (i, &ch) in chars.iter().enumerate() { if ch.is_ascii_digit() { task_part.push(ch); } else if ch.is_ascii_lowercase() { if i == 0 { return Err(format!( "Task reference must start with a number: {}", input )); } if subtask_char.is_some() { return Err(format!("Multiple subtask letters not allowed: {}", input)); } subtask_char = Some(ch); } else { return Err(format!("Invalid character in task reference: {}", ch)); } } if task_part.is_empty() { return Err(format!("No task number found: {}", input)); } let task_num = task_part .parse::() .map_err(|_| format!("Invalid task number: {}", task_part))?; // Validate task number range if task_num == 0 { return Err("Task number must be at least 1".to_string()); } const MAX_TASK_NUM: usize = 10000; if task_num > MAX_TASK_NUM { return Err(format!( "Task number too large: {}. Maximum is {}", task_num, MAX_TASK_NUM )); } let task_index = task_num - 1; // Convert to 0-based let subtask_index = subtask_char.map(|ch| (ch as usize) - ('a' as usize)); Ok(TaskRef { task_index, subtask_index, }) } #[allow(dead_code)] pub fn is_subtask(&self) -> bool { self.subtask_index.is_some() } } /// Parse a markdown todo file with resilient handling of user edits pub fn parse_todo_file(content: &str) -> Result { let lines: Vec<&str> = content.lines().collect(); if lines.is_empty() { return Ok(TodoFile::new("TODOs".to_string())); } let mut todo_file = TodoFile::new("TODOs".to_string()); let mut current_task: Option = None; let mut warned_about_deep_nesting = false; for (line_num, line) in lines.iter().enumerate() { let trimmed = line.trim(); if trimmed.is_empty() { continue; } // Handle title - be flexible with spacing if let Some(title) = trimmed.strip_prefix("# ") { todo_file.title = title.trim().to_string(); } else if trimmed.starts_with("#") && !trimmed.starts_with("##") { // Handle cases where user might not have space after # (but not ##) let title = &trimmed[1..]; todo_file.title = title.trim().to_string(); } // Skip any list headers - we ignore them now since we don't support multiple lists else if trimmed.starts_with("##") { continue; } // Check for deep nesting first - warn and convert to level 1 subtask else if (line.starts_with(" ") || line.starts_with("\t\t")) && (line.trim_start().starts_with("- ") || line.trim_start().starts_with("* ") || line.trim_start().starts_with("+ ")) { // This is a deeply nested item - warn once and treat as level 1 subtask if !warned_about_deep_nesting { eprintln!("Warning: Deep nesting detected (line {}). Converting to level 1 subtask. Only single-level subtasks are supported.", line_num + 1); warned_about_deep_nesting = true; } let item_text = line .trim_start() .strip_prefix("- ") .or_else(|| line.trim_start().strip_prefix("* ")) .or_else(|| line.trim_start().strip_prefix("+ ")) .unwrap_or(line.trim_start()); if let Some(ref mut task) = current_task { task.add_subtask(item_text.trim().to_string()); } else { // If no current task, treat as main task current_task = Some(Task::new(item_text.trim().to_string())); } } // Handle subtasks - be flexible with indentation (2, 3, or 4 spaces, or single tab) else if let Some(subtask_text) = line.strip_prefix(" - ") { if let Some(ref mut task) = current_task { task.add_subtask(subtask_text.trim().to_string()); } else { // If no current task, treat as main task (user error but be forgiving) current_task = Some(Task::new(subtask_text.trim().to_string())); } } else if let Some(subtask_text) = line.strip_prefix(" - ") { // Handle 3-space indentation if let Some(ref mut task) = current_task { task.add_subtask(subtask_text.trim().to_string()); } else { current_task = Some(Task::new(subtask_text.trim().to_string())); } } else if let Some(subtask_text) = line.strip_prefix(" - ") { // Handle 4-space indentation if let Some(ref mut task) = current_task { task.add_subtask(subtask_text.trim().to_string()); } else { current_task = Some(Task::new(subtask_text.trim().to_string())); } } else if let Some(subtask_text) = line.strip_prefix("\t- ") { // Handle single tab indentation if let Some(ref mut task) = current_task { task.add_subtask(subtask_text.trim().to_string()); } else { current_task = Some(Task::new(subtask_text.trim().to_string())); } } // Handle main tasks - flexible with spacing and different bullet styles else if let Some(task_text) = trimmed.strip_prefix("- ") { // Save previous task if exists if let Some(task) = current_task.take() { todo_file.add_task(task); } current_task = Some(Task::new(task_text.trim().to_string())); } else if let Some(task_text) = trimmed.strip_prefix("* ") { // Handle asterisk bullet points if let Some(task) = current_task.take() { todo_file.add_task(task); } current_task = Some(Task::new(task_text.trim().to_string())); } else if let Some(task_text) = trimmed.strip_prefix("+ ") { // Handle plus bullet points if let Some(task) = current_task.take() { todo_file.add_task(task); } current_task = Some(Task::new(task_text.trim().to_string())); } // Handle non-markdown lines gracefully - ignore unknown formatting else if !trimmed.is_empty() { // If it looks like it might be a task without proper formatting, treat it as one if !trimmed.starts_with('#') && !trimmed.starts_with('<') && !trimmed.contains("```") { // Save previous task if exists if let Some(task) = current_task.take() { todo_file.add_task(task); } current_task = Some(Task::new(trimmed.to_string())); } // Otherwise just skip unknown lines (comments, HTML, code blocks, etc.) } } // Save final task if let Some(task) = current_task { todo_file.add_task(task); } Ok(todo_file) } /// Generate markdown content from a TodoFile pub fn generate_todo_file(todo_file: &TodoFile) -> String { let mut content = String::new(); content.push_str(&format!("# {}\n\n", todo_file.title)); for task in &todo_file.tasks { content.push_str(&format!("- {}\n", task.text)); for subtask in &task.subtasks { content.push_str(&format!(" - {}\n", subtask)); } } content } /// Parse an archive file with date-based sections #[derive(Debug, Clone, PartialEq)] pub struct ArchiveFile { pub title: String, pub entries: Vec, } #[derive(Debug, Clone, PartialEq)] pub struct ArchiveEntry { pub date: String, pub lists: HashMap>, } impl ArchiveFile { pub fn new() -> Self { ArchiveFile { title: "Archive".to_string(), entries: Vec::new(), } } pub fn add_items_for_today(&mut self, list_name: &str, tasks: Vec) { let today = chrono::Local::now().format("%Y-%m-%d").to_string(); // Find today's entry position let entry_pos = self.entries.iter().position(|e| e.date == today); if let Some(pos) = entry_pos { // Entry exists, add tasks to it self.entries[pos] .lists .entry(list_name.to_string()) .or_default() .extend(tasks); } else { // Create new entry for today let mut lists = HashMap::new(); lists.insert(list_name.to_string(), tasks); self.entries.insert(0, ArchiveEntry { date: today, lists }); } } } /// Parse an archive file pub fn parse_archive_file(content: &str) -> Result { let lines: Vec<&str> = content.lines().collect(); if lines.is_empty() { return Ok(ArchiveFile::new()); } let mut archive = ArchiveFile::new(); let mut current_entry: Option = None; let mut current_list_name = "Default".to_string(); let mut current_task: Option = None; for (line_num, line) in lines.iter().enumerate() { let trimmed = line.trim(); if trimmed.is_empty() { continue; } if let Some(title) = trimmed.strip_prefix("# ") { archive.title = title.to_string(); } else if let Some(date) = trimmed.strip_prefix("## ") { // Save previous task and entry if let (Some(mut entry), Some(task)) = (current_entry.take(), current_task.take()) { entry .lists .entry(current_list_name.clone()) .or_default() .push(task); archive.entries.push(entry); } else if let Some(entry) = current_entry.take() { archive.entries.push(entry); } current_entry = Some(ArchiveEntry { date: date.to_string(), lists: HashMap::new(), }); current_list_name = "Default".to_string(); } else if let Some(list_name) = trimmed.strip_prefix("### ") { // Save previous task if let (Some(ref mut entry), Some(task)) = (current_entry.as_mut(), current_task.take()) { entry .lists .entry(current_list_name.clone()) .or_default() .push(task); } current_list_name = list_name.to_string(); } else if let Some(task_text) = trimmed.strip_prefix("- ") { // Save previous task if let (Some(ref mut entry), Some(task)) = (current_entry.as_mut(), current_task.take()) { entry .lists .entry(current_list_name.clone()) .or_default() .push(task); } current_task = Some(Task::new(task_text.to_string())); } else if let Some(subtask_text) = trimmed.strip_prefix(" - ") { if let Some(ref mut task) = current_task { task.add_subtask(subtask_text.to_string()); } else { return Err(format!( "Subtask found without parent task at line {}: {}", line_num + 1, trimmed )); } } else if !trimmed.is_empty() { return Err(format!( "Invalid archive format at line {}: {}", line_num + 1, trimmed )); } } // Save final task and entry if let Some(mut entry) = current_entry { if let Some(task) = current_task { entry.lists.entry(current_list_name).or_default().push(task); } archive.entries.push(entry); } Ok(archive) } /// Generate archive file content pub fn generate_archive_file(archive: &ArchiveFile) -> String { let mut content = String::new(); content.push_str(&format!("# {}\n\n", archive.title)); for entry in &archive.entries { content.push_str(&format!("## {}\n", entry.date)); // Add Default list items first (without header) if let Some(default_tasks) = entry.lists.get("Default") { for task in default_tasks { content.push_str(&format!("- {}\n", task.text)); for subtask in &task.subtasks { content.push_str(&format!(" - {}\n", subtask)); } } } // Add other lists with headers for (list_name, tasks) in &entry.lists { if list_name != "Default" && !tasks.is_empty() { content.push_str(&format!("\n### {}\n", list_name)); for task in tasks { content.push_str(&format!("- {}\n", task.text)); for subtask in &task.subtasks { content.push_str(&format!(" - {}\n", subtask)); } } } } content.push('\n'); } content.trim_end().to_string() + "\n" } #[cfg(test)] mod tests { use super::*; #[test] fn test_task_ref_parsing() { assert_eq!( TaskRef::parse("1").unwrap(), TaskRef { task_index: 0, subtask_index: None } ); assert_eq!( TaskRef::parse("5a").unwrap(), TaskRef { task_index: 4, subtask_index: Some(0) } ); assert_eq!( TaskRef::parse("10z").unwrap(), TaskRef { task_index: 9, subtask_index: Some(25) } ); assert!(TaskRef::parse("").is_err()); assert!(TaskRef::parse("a").is_err()); assert!(TaskRef::parse("1A").is_err()); assert!(TaskRef::parse("1ab").is_err()); assert!(TaskRef::parse("1-2").is_err()); // Test validation edge cases assert!(TaskRef::parse("0").is_err()); // Zero task number assert!(TaskRef::parse("10001").is_err()); // Too large task number assert!(TaskRef::parse("999999999999999999999").is_err()); // Integer overflow } #[test] fn test_parse_simple_todo_file() { let content = r#"# TODOs - First task - Second task - Subtask A - Subtask B - Third task "#; let todo_file = parse_todo_file(content).unwrap(); assert_eq!(todo_file.title, "TODOs"); assert_eq!(todo_file.tasks.len(), 3); assert_eq!(todo_file.tasks[1].subtasks.len(), 2); assert_eq!(todo_file.tasks[1].subtasks[0], "Subtask A"); assert_eq!(todo_file.tasks[1].subtasks[1], "Subtask B"); } #[test] fn test_generate_todo_file() { let mut todo_file = TodoFile::new("TODOs".to_string()); let mut task = Task::new("Task with subtasks".to_string()); task.add_subtask("Subtask 1".to_string()); task.add_subtask("Subtask 2".to_string()); todo_file.add_task(task); let generated = generate_todo_file(&todo_file); let expected = r#"# TODOs - Task with subtasks - Subtask 1 - Subtask 2 "#; assert_eq!(generated, expected); } #[test] fn test_handle_deep_nesting_gracefully() { let content = r#"# TODOs - Task - Subtask - Deep subtask "#; // Resilient parser should ignore deep nesting rather than error let todo_file = parse_todo_file(content).unwrap(); // The resilient parser treats 4 spaces as valid subtask indentation // So "Deep subtask" becomes a subtask rather than being ignored assert_eq!(todo_file.tasks.len(), 1); assert_eq!(todo_file.tasks[0].text, "Task"); assert_eq!(todo_file.tasks[0].subtasks.len(), 2); assert_eq!(todo_file.tasks[0].subtasks[0], "Subtask"); assert_eq!(todo_file.tasks[0].subtasks[1], "Deep subtask"); } #[test] fn test_resilient_parsing_various_formats() { let content = r#"# TODOs - Task with dash * Task with asterisk + Task with plus - Subtask with 2 spaces - Subtask with 3 spaces - Subtask with 4 spaces - Subtask with tab Plain text task without bullet - Normal task ``` This is a code block ``` "#; let todo_file = parse_todo_file(content).unwrap(); assert_eq!(todo_file.title, "TODOs"); // Now all tasks are in the single task list assert_eq!(todo_file.tasks.len(), 6); // All tasks are top-level now (including code block content) // First task (dash) has no subtasks assert_eq!(todo_file.tasks[0].text, "Task with dash"); assert_eq!(todo_file.tasks[0].subtasks.len(), 0); // Second task (asterisk) has no subtasks assert_eq!(todo_file.tasks[1].text, "Task with asterisk"); assert_eq!(todo_file.tasks[1].subtasks.len(), 0); // Third task (plus) has all the subtasks (different indentation styles) assert_eq!(todo_file.tasks[2].text, "Task with plus"); assert_eq!(todo_file.tasks[2].subtasks.len(), 4); // Plain text task and normal task assert_eq!(todo_file.tasks[3].text, "Plain text task without bullet"); assert_eq!(todo_file.tasks[4].text, "Normal task"); assert_eq!(todo_file.tasks[5].text, "This is a code block"); // Comments are ignored but code block content is parsed } }