airs_memspec/parser/markdown.rs
1//! Markdown parsing for Multi-Project Memory Bank files
2//!
3//! This module provides functionality for parsing markdown content from memory bank files,
4//! extracting structured sections, handling YAML frontmatter, and parsing task lists with
5//! status information.
6
7use std::collections::HashMap;
8use std::path::Path;
9
10use pulldown_cmark::{Event, Parser, Tag, TagEnd};
11use serde_yml::Value as YamlValue;
12
13use crate::utils::fs::FsResult;
14
15/// Represents the parsed content of a markdown file
16///
17/// This structure captures all the important information that can be extracted
18/// from a memory bank markdown file, including frontmatter, content sections,
19/// and structured data like task lists.
20#[derive(Debug, Clone)]
21pub struct MarkdownContent {
22 /// YAML frontmatter data, if present
23 pub frontmatter: Option<HashMap<String, YamlValue>>,
24
25 /// Raw markdown content without frontmatter
26 pub content: String,
27
28 /// Structured sections extracted from the content
29 /// Maps section headings to their content
30 pub sections: HashMap<String, String>,
31
32 /// Extracted task information, if any
33 pub tasks: Vec<TaskItem>,
34
35 /// File metadata extracted from content
36 pub metadata: FileMetadata,
37}
38
39/// Represents a task item found in markdown content
40///
41/// Task items can appear in various formats within memory bank files,
42/// such as task lists, progress tracking tables, or status sections.
43#[derive(Debug, Clone)]
44pub struct TaskItem {
45 /// Task identifier (e.g., "task_001", "TASK-123")
46 pub id: Option<String>,
47
48 /// Task title or description
49 pub title: String,
50
51 /// Current status of the task
52 pub status: TaskStatus,
53
54 /// Additional details or notes
55 pub details: Option<String>,
56
57 /// Last updated date, if specified
58 pub updated: Option<String>,
59}
60
61/// Task status enumeration
62///
63/// Represents the various states a task can be in, based on common
64/// patterns found in memory bank task tracking.
65#[derive(Debug, Clone, PartialEq, Eq, Hash)]
66pub enum TaskStatus {
67 /// Task has not been started yet
68 NotStarted,
69 /// Task is currently being worked on
70 InProgress,
71 /// Task has been completed successfully
72 Completed,
73 /// Task is blocked by dependencies or issues
74 Blocked,
75 /// Task has been abandoned or cancelled
76 Abandoned,
77 /// Status could not be determined or is in unknown format
78 Unknown(String),
79}
80
81/// File metadata extracted from markdown content
82///
83/// Contains information about the file itself, such as title, description,
84/// and other metadata that can be extracted from the content structure.
85#[derive(Debug, Clone, Default)]
86pub struct FileMetadata {
87 /// Main title of the document (usually from first heading)
88 pub title: Option<String>,
89
90 /// Document description or summary
91 pub description: Option<String>,
92
93 /// Status information, if present
94 pub status: Option<String>,
95
96 /// Last updated date mentioned in content
97 pub updated: Option<String>,
98
99 /// Any additional metadata found in the content
100 pub extra: HashMap<String, String>,
101}
102
103/// Markdown parser for memory bank files
104///
105/// This parser is specifically designed to handle the structured markdown format
106/// used in Multi-Project Memory Bank files, extracting relevant information
107/// while being resilient to variations in format and missing sections.
108pub struct MarkdownParser;
109
110impl MarkdownParser {
111 /// Parse a markdown file from a given path
112 ///
113 /// Reads the file content and parses it using the comprehensive markdown
114 /// parsing pipeline to extract all relevant structured information.
115 ///
116 /// # Arguments
117 /// * `file_path` - Path to the markdown file to parse
118 ///
119 /// # Returns
120 /// * `Ok(MarkdownContent)` - Parsed content with all extracted information
121 /// * `Err(FsError)` - File reading or parsing errors
122 ///
123 /// # Example
124 /// ```rust,no_run
125 /// use airs_memspec::parser::markdown::MarkdownParser;
126 /// use std::path::PathBuf;
127 ///
128 /// # fn main() -> Result<(), Box<dyn std::error::Error>> {
129 /// let content = MarkdownParser::parse_file(&PathBuf::from("project_brief.md"))?;
130 /// println!("Found {} sections", content.sections.len());
131 /// # Ok(())
132 /// # }
133 /// ```
134 pub fn parse_file(file_path: &Path) -> FsResult<MarkdownContent> {
135 let content = std::fs::read_to_string(file_path).map_err(crate::utils::fs::FsError::Io)?;
136
137 Self::parse_content(&content)
138 }
139
140 /// Parse markdown content from a string
141 ///
142 /// The core parsing function that handles the complete markdown parsing
143 /// pipeline including frontmatter extraction, section parsing, and
144 /// task item detection.
145 ///
146 /// # Arguments
147 /// * `content` - Raw markdown content as a string
148 ///
149 /// # Returns
150 /// * `Ok(MarkdownContent)` - Parsed content with all extracted information
151 /// * `Err(FsError)` - Parsing errors
152 ///
153 /// # Parsing Process
154 /// 1. Extract YAML frontmatter if present
155 /// 2. Parse markdown structure using pulldown-cmark
156 /// 3. Extract sections based on heading hierarchy
157 /// 4. Identify and parse task items
158 /// 5. Extract file metadata from content patterns
159 pub fn parse_content(content: &str) -> FsResult<MarkdownContent> {
160 // Step 1: Extract frontmatter
161 let (frontmatter, markdown_content) = Self::extract_frontmatter(content)?;
162
163 // Step 2: Parse markdown structure
164 let sections = Self::extract_sections(&markdown_content);
165
166 // Step 3: Extract task items
167 let tasks = Self::extract_tasks(&markdown_content, §ions);
168
169 // Step 4: Extract file metadata
170 let metadata = Self::extract_metadata(&markdown_content, §ions, &frontmatter);
171
172 Ok(MarkdownContent {
173 frontmatter,
174 content: markdown_content,
175 sections,
176 tasks,
177 metadata,
178 })
179 }
180
181 /// Extract YAML frontmatter from markdown content
182 ///
183 /// Detects and parses YAML frontmatter at the beginning of markdown files,
184 /// which is commonly used for metadata in memory bank files.
185 ///
186 /// # Arguments
187 /// * `content` - Raw markdown content
188 ///
189 /// # Returns
190 /// * `(Option<HashMap<String, YamlValue>>, String)` - Frontmatter and remaining content
191 /// * `Err(FsError)` - YAML parsing errors
192 ///
193 /// # Frontmatter Format
194 /// Expects frontmatter in the format:
195 /// ```yaml
196 /// ---
197 /// key: value
198 /// another_key: another_value
199 /// ---
200 /// ```
201 fn extract_frontmatter(
202 content: &str,
203 ) -> FsResult<(Option<HashMap<String, YamlValue>>, String)> {
204 let content = content.trim_start();
205
206 // Check if content starts with frontmatter delimiter
207 if !content.starts_with("---") {
208 return Ok((None, content.to_string()));
209 }
210
211 // Find the closing delimiter
212 let lines: Vec<&str> = content.lines().collect();
213 if lines.len() < 3 {
214 return Ok((None, content.to_string()));
215 }
216
217 // Look for the closing --- delimiter
218 let mut frontmatter_end = None;
219 for (i, line) in lines.iter().enumerate().skip(1) {
220 if line.trim() == "---" {
221 frontmatter_end = Some(i);
222 break;
223 }
224 }
225
226 let Some(end_line) = frontmatter_end else {
227 // No closing delimiter found, treat as regular content
228 return Ok((None, content.to_string()));
229 };
230
231 // Extract frontmatter YAML
232 let frontmatter_lines = &lines[1..end_line];
233 let frontmatter_yaml = frontmatter_lines.join("\n");
234
235 // Parse YAML frontmatter
236 let frontmatter: HashMap<String, YamlValue> = serde_yml::from_str(&frontmatter_yaml)
237 .map_err(|e| {
238 crate::utils::fs::FsError::ParseError {
239 message: format!("YAML frontmatter parsing failed: {e}"),
240 suggestion: "Check the YAML syntax in the frontmatter. Common issues: incorrect indentation, missing quotes, or invalid YAML structure.".to_string(),
241 }
242 })?;
243
244 // Return remaining content
245 let remaining_content = lines[(end_line + 1)..].join("\n");
246
247 Ok((Some(frontmatter), remaining_content))
248 }
249
250 /// Extract sections from markdown content based on heading hierarchy
251 ///
252 /// Parses the markdown structure to identify sections defined by headings
253 /// and captures the content under each section for structured access.
254 ///
255 /// # Arguments
256 /// * `content` - Markdown content without frontmatter
257 ///
258 /// # Returns
259 /// * `HashMap<String, String>` - Map of section titles to their content
260 ///
261 /// # Section Extraction Rules
262 /// - Sections are defined by markdown headings (# ## ### etc.)
263 /// - The first heading is treated as the document title, not a section
264 /// - Section content includes everything until the next heading of equal or higher level
265 /// - Nested headings are included in parent section content
266 fn extract_sections(content: &str) -> HashMap<String, String> {
267 let mut sections = HashMap::new();
268 let lines: Vec<&str> = content.lines().collect();
269
270 let mut i = 0;
271 let mut first_heading_found = false;
272
273 while i < lines.len() {
274 let line = lines[i].trim();
275
276 // Check if this line is a heading
277 if line.starts_with('#') {
278 let heading_level = line.chars().take_while(|&c| c == '#').count();
279 let heading_text = line.trim_start_matches('#').trim().to_string();
280
281 // Skip the first heading (document title)
282 if !first_heading_found {
283 first_heading_found = true;
284 i += 1;
285 continue;
286 }
287
288 // Find content for this section (everything until next heading of same or higher level)
289 let mut section_content = Vec::new();
290 i += 1; // Move past heading line
291
292 while i < lines.len() {
293 let next_line = lines[i].trim();
294
295 // Check if we've hit another heading
296 if next_line.starts_with('#') {
297 let next_heading_level =
298 next_line.chars().take_while(|&c| c == '#').count();
299 if next_heading_level <= heading_level {
300 // This heading is at same or higher level, so our section ends here
301 break;
302 }
303 }
304
305 section_content.push(lines[i]);
306 i += 1;
307 }
308
309 // Store the section
310 if !heading_text.is_empty() {
311 let content_text = section_content.join("\n").trim().to_string();
312 sections.insert(heading_text, content_text);
313 }
314 } else {
315 i += 1;
316 }
317 }
318
319 sections
320 }
321
322 /// Extract task items from markdown content and sections
323 ///
324 /// Identifies and parses task-related information from various formats
325 /// commonly found in memory bank files, including task lists, tables,
326 /// and status sections.
327 ///
328 /// # Arguments
329 /// * `content` - Full markdown content
330 /// * `sections` - Pre-parsed sections for targeted extraction
331 ///
332 /// # Returns
333 /// * `Vec<TaskItem>` - List of all identified task items
334 ///
335 /// # Task Recognition Patterns
336 /// - Markdown task lists with checkboxes: `- [ ] Task description`
337 /// - Task tables with status columns
338 /// - Task index entries with IDs and status
339 /// - Progress tracking sections with task references
340 fn extract_tasks(content: &str, sections: &HashMap<String, String>) -> Vec<TaskItem> {
341 let mut tasks = Vec::new();
342
343 // Extract from task-specific sections
344 for (section_name, section_content) in sections {
345 if section_name.to_lowercase().contains("task")
346 || section_name.to_lowercase().contains("progress")
347 || section_name.to_lowercase().contains("subtask")
348 {
349 tasks.extend(Self::parse_task_content(section_content));
350 }
351 }
352
353 // Also check the full content for task patterns
354 tasks.extend(Self::parse_task_content(content));
355
356 // Deduplicate tasks based on ID or title
357 Self::deduplicate_tasks(tasks)
358 }
359
360 /// Parse task items from a content string
361 ///
362 /// Internal method that handles the actual parsing logic for identifying
363 /// task items in various formats within a given content string.
364 ///
365 /// # Arguments
366 /// * `content` - Content string to parse for task items
367 ///
368 /// # Returns
369 /// * `Vec<TaskItem>` - List of task items found in the content
370 fn parse_task_content(content: &str) -> Vec<TaskItem> {
371 let mut tasks = Vec::new();
372
373 for line in content.lines() {
374 let line = line.trim();
375
376 // Pattern 1: Task index entries (check first to avoid conflict with task list)
377 // - [task_001] Task Name - Status description
378 if let Some(task) = Self::parse_task_index_item(line) {
379 tasks.push(task);
380 continue;
381 }
382
383 // Pattern 2: Markdown task list items
384 // - [ ] Task description
385 // - [x] Completed task
386 if let Some(task) = Self::parse_task_list_item(line) {
387 tasks.push(task);
388 continue;
389 }
390
391 // Pattern 3: Table rows with task information
392 // | task_001 | Task Description | completed | 2025-08-03 |
393 if let Some(task) = Self::parse_task_table_row(line) {
394 tasks.push(task);
395 continue;
396 }
397 }
398
399 tasks
400 }
401
402 /// Parse a markdown task list item
403 ///
404 /// Handles checkbox-style task items commonly found in markdown files.
405 ///
406 /// # Arguments
407 /// * `line` - Line of text to parse
408 ///
409 /// # Returns
410 /// * `Option<TaskItem>` - Parsed task item if line matches pattern
411 fn parse_task_list_item(line: &str) -> Option<TaskItem> {
412 // Match patterns like:
413 // - [ ] Task description
414 // - [x] Completed task
415 // - [X] Completed task
416 // Note: Must have exactly one character between brackets for checkbox
417
418 if line.starts_with("- [") && line.len() > 5 && line.chars().nth(4) == Some(']') {
419 let checkbox = line.chars().nth(3)?;
420 let status = match checkbox {
421 ' ' => TaskStatus::NotStarted,
422 'x' | 'X' => TaskStatus::Completed,
423 _ => return None, // Not a valid checkbox, might be something else
424 };
425
426 let title = line[5..].trim().to_string();
427 if !title.is_empty() {
428 return Some(TaskItem {
429 id: None,
430 title,
431 status,
432 details: None,
433 updated: None,
434 });
435 }
436 }
437
438 None
439 }
440
441 /// Parse a task index item
442 ///
443 /// Handles task entries from task index files or sections.
444 ///
445 /// # Arguments
446 /// * `line` - Line of text to parse
447 ///
448 /// # Returns
449 /// * `Option<TaskItem>` - Parsed task item if line matches pattern
450 fn parse_task_index_item(line: &str) -> Option<TaskItem> {
451 // Match patterns like:
452 // - [task_001] Task Name - Status description
453 // - [TASK-123] Another task name - In Progress
454 // But NOT patterns like:
455 // - [ ] Checkbox item
456 // - [x] Completed checkbox
457
458 if line.starts_with("- [") {
459 if let Some(close_bracket) = line.find(']') {
460 // Skip if this looks like a checkbox (single character between brackets)
461 if close_bracket == 4 {
462 // This is likely a checkbox like "- [ ]" or "- [x]"
463 return None;
464 }
465
466 let id = line[3..close_bracket].trim().to_string();
467 let remaining = line[(close_bracket + 1)..].trim();
468
469 // Look for status information after dash
470 let (title, status) = if let Some(dash_pos) = remaining.rfind(" - ") {
471 let title = remaining[..dash_pos].trim().to_string();
472 let status_text = remaining[(dash_pos + 3)..].trim();
473 let status = Self::parse_status_text(status_text);
474 (title, status)
475 } else {
476 (
477 remaining.to_string(),
478 TaskStatus::Unknown("no status".to_string()),
479 )
480 };
481
482 if !title.is_empty() {
483 return Some(TaskItem {
484 id: Some(id),
485 title,
486 status,
487 details: None,
488 updated: None,
489 });
490 }
491 }
492 }
493
494 None
495 }
496
497 /// Parse a task table row
498 ///
499 /// Handles task information presented in markdown table format.
500 ///
501 /// # Arguments
502 /// * `line` - Line of text to parse
503 ///
504 /// # Returns
505 /// * `Option<TaskItem>` - Parsed task item if line matches pattern
506 fn parse_task_table_row(line: &str) -> Option<TaskItem> {
507 // Match table rows like:
508 // | task_001 | Task Description | completed | 2025-08-03 | Notes |
509
510 if line.starts_with('|') && line.ends_with('|') && line.matches('|').count() >= 4 {
511 let parts: Vec<&str> = line[1..line.len() - 1]
512 .split('|')
513 .map(|s| s.trim())
514 .collect();
515
516 if parts.len() >= 3 {
517 let id = parts[0].to_string();
518 let title = parts[1].to_string();
519 let status_text = parts[2];
520 let status = Self::parse_status_text(status_text);
521
522 let updated = if parts.len() > 3 && !parts[3].is_empty() {
523 Some(parts[3].to_string())
524 } else {
525 None
526 };
527
528 let details = if parts.len() > 4 && !parts[4].is_empty() {
529 Some(parts[4].to_string())
530 } else {
531 None
532 };
533
534 // Only return if we have meaningful content
535 // Exclude header rows and separator rows
536 if !id.is_empty()
537 && !title.is_empty()
538 && id != "ID"
539 && title != "Description"
540 && !id.chars().all(|c| c == '-' || c == ' ')
541 && !title.chars().all(|c| c == '-' || c == ' ')
542 {
543 return Some(TaskItem {
544 id: Some(id),
545 title,
546 status,
547 details,
548 updated,
549 });
550 }
551 }
552 }
553
554 None
555 }
556
557 /// Parse status text into TaskStatus enum
558 ///
559 /// Converts various textual status representations into the standardized
560 /// TaskStatus enumeration, handling common variations and formats.
561 ///
562 /// # Arguments
563 /// * `status_text` - Status text to parse
564 ///
565 /// # Returns
566 /// * `TaskStatus` - Parsed status or Unknown with original text
567 fn parse_status_text(status_text: &str) -> TaskStatus {
568 let normalized = status_text.to_lowercase().trim().replace(['-', '_'], " ");
569
570 match normalized.as_str() {
571 "not started" | "pending" | "todo" | "not start" => TaskStatus::NotStarted,
572 "in progress" | "active" | "working" | "ongoing" | "in work" => TaskStatus::InProgress,
573 "completed" | "done" | "finished" | "complete" | "success" => TaskStatus::Completed,
574 "blocked" | "stuck" | "waiting" | "on hold" => TaskStatus::Blocked,
575 "abandoned" | "cancelled" | "canceled" | "dropped" | "abort" => TaskStatus::Abandoned,
576 _ => TaskStatus::Unknown(status_text.to_string()),
577 }
578 }
579
580 /// Extract file metadata from content and sections
581 ///
582 /// Analyzes the parsed content to extract metadata information such as
583 /// title, description, status, and other relevant file properties.
584 ///
585 /// # Arguments
586 /// * `content` - Full markdown content
587 /// * `sections` - Pre-parsed sections
588 /// * `frontmatter` - YAML frontmatter data
589 ///
590 /// # Returns
591 /// * `FileMetadata` - Extracted metadata information
592 fn extract_metadata(
593 content: &str,
594 _sections: &HashMap<String, String>,
595 frontmatter: &Option<HashMap<String, YamlValue>>,
596 ) -> FileMetadata {
597 let mut metadata = FileMetadata::default();
598
599 // Extract title from first heading or frontmatter
600 if let Some(frontmatter) = frontmatter {
601 if let Some(YamlValue::String(title)) = frontmatter.get("title") {
602 metadata.title = Some(title.clone());
603 }
604 if let Some(YamlValue::String(desc)) = frontmatter.get("description") {
605 metadata.description = Some(desc.clone());
606 }
607 if let Some(YamlValue::String(status)) = frontmatter.get("status") {
608 metadata.status = Some(status.clone());
609 }
610 }
611
612 // If no title from frontmatter, extract from first heading
613 if metadata.title.is_none() {
614 if let Some(first_heading) = Self::extract_first_heading(content) {
615 metadata.title = Some(first_heading);
616 }
617 }
618
619 // Look for status information in content
620 if metadata.status.is_none() {
621 for line in content.lines() {
622 if line.to_lowercase().contains("status:") {
623 if let Some(status) = line.split(':').nth(1) {
624 metadata.status = Some(status.trim().to_string());
625 break;
626 }
627 }
628 }
629 }
630
631 // Look for update information
632 for line in content.lines() {
633 if line.to_lowercase().contains("updated:") {
634 if let Some(updated) = line.split(':').nth(1) {
635 metadata.updated = Some(updated.trim().to_string());
636 break;
637 }
638 }
639 }
640
641 metadata
642 }
643
644 /// Extract the first heading from markdown content
645 ///
646 /// Helper method to find and extract the first heading in the content,
647 /// which is commonly used as the document title.
648 ///
649 /// # Arguments
650 /// * `content` - Markdown content to search
651 ///
652 /// # Returns
653 /// * `Option<String>` - First heading text if found
654 fn extract_first_heading(content: &str) -> Option<String> {
655 let parser = Parser::new(content);
656 let mut in_heading = false;
657 let mut heading_text = String::new();
658
659 for event in parser {
660 match event {
661 Event::Start(Tag::Heading { .. }) => {
662 in_heading = true;
663 heading_text.clear();
664 }
665 Event::End(TagEnd::Heading(_)) => {
666 if in_heading && !heading_text.is_empty() {
667 return Some(heading_text.trim().to_string());
668 }
669 in_heading = false;
670 }
671 Event::Text(text) => {
672 if in_heading {
673 heading_text.push_str(&text);
674 }
675 }
676 _ => {}
677 }
678 }
679
680 None
681 }
682
683 /// Deduplicate tasks based on ID or title
684 ///
685 /// Removes duplicate task entries that might have been found in multiple
686 /// sections or formats, preferring entries with more complete information.
687 ///
688 /// # Arguments
689 /// * `tasks` - Vector of task items to deduplicate
690 ///
691 /// # Returns
692 /// * `Vec<TaskItem>` - Deduplicated list of task items
693 fn deduplicate_tasks(tasks: Vec<TaskItem>) -> Vec<TaskItem> {
694 let mut seen_ids = std::collections::HashSet::new();
695 let mut seen_titles = std::collections::HashSet::new();
696 let mut result = Vec::new();
697
698 for task in tasks {
699 let mut is_duplicate = false;
700
701 // Check for ID-based duplicates
702 if let Some(ref id) = task.id {
703 if seen_ids.contains(id) {
704 is_duplicate = true;
705 } else {
706 seen_ids.insert(id.clone());
707 }
708 }
709
710 // Check for title-based duplicates (only if no ID match)
711 if !is_duplicate {
712 if seen_titles.contains(&task.title) {
713 is_duplicate = true;
714 } else {
715 seen_titles.insert(task.title.clone());
716 }
717 }
718
719 if !is_duplicate {
720 result.push(task);
721 }
722 }
723
724 result
725 }
726}
727
728#[cfg(test)]
729mod tests {
730 use super::*;
731
732 #[test]
733 fn test_parse_simple_markdown() {
734 let content = r#"# Test Document
735
736This is a test document.
737
738## Section 1
739
740Some content here.
741
742## Section 2
743
744More content here.
745"#;
746
747 let parsed = MarkdownParser::parse_content(content).unwrap();
748
749 assert_eq!(parsed.metadata.title, Some("Test Document".to_string()));
750 assert_eq!(parsed.sections.len(), 2);
751 assert!(parsed.sections.contains_key("Section 1"));
752 assert!(parsed.sections.contains_key("Section 2"));
753 }
754
755 #[test]
756 fn test_parse_frontmatter() {
757 let content = r#"---
758title: "Test Document"
759status: "completed"
760---
761
762# Content
763
764This is the content.
765"#;
766
767 let parsed = MarkdownParser::parse_content(content).unwrap();
768
769 assert!(parsed.frontmatter.is_some());
770 let frontmatter = parsed.frontmatter.unwrap();
771 assert!(frontmatter.contains_key("title"));
772 assert!(frontmatter.contains_key("status"));
773 }
774
775 #[test]
776 fn test_parse_task_list() {
777 let content = r#"# Tasks
778
779- [ ] Not started task
780- [x] Completed task
781- [X] Another completed task
782"#;
783
784 let parsed = MarkdownParser::parse_content(content).unwrap();
785
786 assert_eq!(parsed.tasks.len(), 3);
787 assert_eq!(parsed.tasks[0].status, TaskStatus::NotStarted);
788 assert_eq!(parsed.tasks[1].status, TaskStatus::Completed);
789 assert_eq!(parsed.tasks[2].status, TaskStatus::Completed);
790 }
791
792 #[test]
793 fn test_parse_task_index() {
794 let content = r#"# Task Index
795
796- [task_001] First task - completed
797- [task_002] Second task - in progress
798- [task_003] Third task - blocked
799"#;
800
801 let parsed = MarkdownParser::parse_content(content).unwrap();
802
803 assert_eq!(parsed.tasks.len(), 3);
804 assert_eq!(parsed.tasks[0].id, Some("task_001".to_string()));
805 assert_eq!(parsed.tasks[0].status, TaskStatus::Completed);
806 assert_eq!(parsed.tasks[1].status, TaskStatus::InProgress);
807 assert_eq!(parsed.tasks[2].status, TaskStatus::Blocked);
808 }
809
810 #[test]
811 fn test_parse_task_table() {
812 let content = r#"# Progress Tracking
813
814| ID | Description | Status | Updated | Notes |
815|----|-------------|--------|---------|-------|
816| 1.1 | First subtask | complete | 2025-08-03 | Working well |
817| 1.2 | Second subtask | in progress | 2025-08-03 | Almost done |
818"#;
819
820 let parsed = MarkdownParser::parse_content(content).unwrap();
821
822 assert_eq!(parsed.tasks.len(), 2);
823 assert_eq!(parsed.tasks[0].id, Some("1.1".to_string()));
824 assert_eq!(parsed.tasks[0].title, "First subtask");
825 assert_eq!(parsed.tasks[0].status, TaskStatus::Completed);
826 assert_eq!(parsed.tasks[0].updated, Some("2025-08-03".to_string()));
827 assert_eq!(parsed.tasks[0].details, Some("Working well".to_string()));
828 }
829
830 #[test]
831 fn test_status_parsing() {
832 assert_eq!(
833 MarkdownParser::parse_status_text("completed"),
834 TaskStatus::Completed
835 );
836 assert_eq!(
837 MarkdownParser::parse_status_text("in-progress"),
838 TaskStatus::InProgress
839 );
840 assert_eq!(
841 MarkdownParser::parse_status_text("not_started"),
842 TaskStatus::NotStarted
843 );
844 assert_eq!(
845 MarkdownParser::parse_status_text("blocked"),
846 TaskStatus::Blocked
847 );
848 assert_eq!(
849 MarkdownParser::parse_status_text("abandoned"),
850 TaskStatus::Abandoned
851 );
852
853 match MarkdownParser::parse_status_text("custom-status") {
854 TaskStatus::Unknown(s) => assert_eq!(s, "custom-status"),
855 _ => panic!("Expected Unknown status"),
856 }
857 }
858}