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, &sections);
168
169        // Step 4: Extract file metadata
170        let metadata = Self::extract_metadata(&markdown_content, &sections, &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}