use crate::models::plan::{Phase, PhaseStatus, PlanFile, PlanMetadata, Task};
use anyhow::{Context, Result};
use regex::Regex;
use std::fs;
use std::path::Path;
type TaskMetadata = (
Option<u32>,
Option<String>,
Option<String>,
Option<String>,
Option<String>,
);
pub struct PlanParser;
impl PlanParser {
pub fn parse_file(path: &Path) -> Result<Option<PlanFile>> {
if !path.exists() {
return Ok(None);
}
let content =
fs::read_to_string(path).context(format!("Failed to read PLAN.md at {:?}", path))?;
Self::parse(&content)
}
pub fn parse(content: &str) -> Result<Option<PlanFile>> {
let (frontmatter, body) = Self::split_frontmatter(content)?;
if frontmatter.is_none() {
return Ok(None);
}
let metadata = Self::parse_metadata(frontmatter.unwrap())
.context("Failed to parse PLAN.md frontmatter")?;
let phases = Self::parse_phases(&body).context("Failed to parse phases")?;
Ok(Some(PlanFile { metadata, phases }))
}
fn split_frontmatter(content: &str) -> Result<(Option<&str>, String)> {
if !content.trim_start().starts_with("---") {
return Ok((None, content.to_string()));
}
let parts: Vec<&str> = content.splitn(3, "---").collect();
if parts.len() < 3 {
return Ok((None, content.to_string()));
}
Ok((Some(parts[1].trim()), parts[2].to_string()))
}
fn parse_metadata(yaml: &str) -> Result<PlanMetadata> {
serde_yaml::from_str(yaml).context("Failed to parse YAML frontmatter")
}
fn parse_phases(body: &str) -> Result<Vec<Phase>> {
let mut phases = Vec::new();
let phase_re = Regex::new(r"(?m)^##\s+(?:[✅🚧⏸️❌]\s+)?Phase\s+([A-Za-z0-9\.]+):\s+(.+)$")
.context("Failed to compile phase regex")?;
let priority_re = Regex::new(r"\(Priority:.+?\s+([A-Z]+)\)")
.context("Failed to compile priority regex")?;
let matches: Vec<_> = phase_re.captures_iter(body).collect();
for (i, cap) in matches.iter().enumerate() {
let phase_id = cap[1].trim().to_string();
let rest_of_line = cap[2].trim();
let priority = priority_re
.captures(rest_of_line)
.and_then(|c| c.get(1))
.map(|m| m.as_str().to_string());
let mut phase_title = rest_of_line.to_string();
if let Some(pos) = phase_title.find(" (Priority:") {
phase_title = phase_title[..pos].trim().to_string();
}
let start = cap.get(0).unwrap().end();
let end = if i + 1 < matches.len() {
matches[i + 1].get(0).unwrap().start()
} else {
body.len()
};
let section = &body[start..end];
let header = cap.get(0).unwrap().as_str();
let (status, estimated_duration, version_target) =
Self::parse_phase_metadata(section, header);
let tasks = Self::parse_tasks(&phase_id, section)?;
phases.push(Phase {
id: phase_id,
title: phase_title,
status,
tasks,
estimated_duration,
priority,
version_target,
});
}
Ok(phases)
}
fn parse_phase_metadata(
section: &str,
header: &str,
) -> (PhaseStatus, Option<String>, Option<String>) {
let mut status = if header.contains("✅") {
PhaseStatus::Complete
} else if header.contains("🚧") {
PhaseStatus::InProgress
} else {
PhaseStatus::Future
};
let mut estimated_duration = None;
let mut version_target = None;
for line in section.lines().take(20) {
let line = line.trim();
if line.starts_with("**Durée estimée**") {
if let Some(duration) = Self::extract_metadata_value(line) {
estimated_duration = Some(duration);
}
}
if line.starts_with("**Version cible**") {
if let Some(version) = Self::extract_metadata_value(line) {
version_target = Some(version);
}
}
if line.contains("in-progress") || line.contains("IN PROGRESS") {
status = PhaseStatus::InProgress;
}
if line.contains("complete") || line.contains("COMPLETE") {
status = PhaseStatus::Complete;
}
}
(status, estimated_duration, version_target)
}
fn extract_metadata_value(line: &str) -> Option<String> {
if let Some(pos) = line.find(':') {
let value = line[pos + 1..].trim();
if !value.is_empty() {
return Some(value.to_string());
}
}
None
}
fn parse_tasks(_phase_id: &str, section: &str) -> Result<Vec<Task>> {
let mut tasks = Vec::new();
let task_re =
Regex::new(r"(?m)^####\s+Task\s+([A-Za-z0-9\.]+):\s+([^\(✅🚧⏸️❌]+?)(?:\s+[✅🚧⏸️❌])?(?:\s+\(([^\)]+)\))?\s*$")
.context("Failed to compile task regex")?;
let matches: Vec<_> = task_re.captures_iter(section).collect();
for (i, cap) in matches.iter().enumerate() {
let task_id = cap[1].trim().to_string();
let task_title = cap[2].trim().to_string();
let priority = cap.get(3).map(|m| m.as_str().to_string());
let start = cap.get(0).unwrap().end();
let end = if i + 1 < matches.len() {
matches[i + 1].get(0).unwrap().start()
} else {
section[start..]
.find("\n###")
.map(|pos| start + pos)
.unwrap_or(section.len())
};
let task_content = §ion[start..end];
let (issue, duration, difficulty, crate_name, description) =
Self::parse_task_metadata(task_content);
tasks.push(Task {
id: task_id,
title: task_title,
description,
priority,
duration,
difficulty,
crate_name,
issue,
});
}
Ok(tasks)
}
fn parse_task_metadata(content: &str) -> TaskMetadata {
let mut issue = None;
let mut duration = None;
let mut difficulty = None;
let mut crate_name = None;
let mut description = None;
for line in content.lines().take(30) {
let line = line.trim();
if line.starts_with("**Issue**") {
if let Some(val) = Self::extract_metadata_value(line) {
if let Some(num_str) = val.strip_prefix('#') {
issue = num_str.parse::<u32>().ok();
}
}
}
if line.starts_with("**Durée**") {
duration = Self::extract_metadata_value(line);
}
if line.starts_with("**Difficulté**") {
difficulty = Self::extract_metadata_value(line);
}
if line.starts_with("**Crate**") {
crate_name = Self::extract_metadata_value(line);
}
if description.is_none()
&& !line.is_empty()
&& !line.starts_with('*')
&& !line.starts_with('#')
&& !line.starts_with('-')
{
description = Some(line.to_string());
}
}
(issue, duration, difficulty, crate_name, description)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_split_frontmatter() {
let content = r#"---
date: 2026-02-12
title: Test Plan
---
# Body content
"#;
let (fm, body) = PlanParser::split_frontmatter(content).unwrap();
assert!(fm.is_some());
assert!(fm.unwrap().contains("date: 2026-02-12"));
assert!(body.contains("Body content"));
}
#[test]
fn test_split_frontmatter_missing() {
let content = "# No frontmatter\n\nJust body";
let (fm, body) = PlanParser::split_frontmatter(content).unwrap();
assert!(fm.is_none());
assert_eq!(body, content);
}
#[test]
fn test_parse_metadata() {
let yaml = r#"
date: 2026-02-12
title: Test Plan
status: in-progress
version: 0.7.0
"#;
let meta = PlanParser::parse_metadata(yaml).unwrap();
assert_eq!(meta.title, "Test Plan");
assert_eq!(meta.status, Some("in-progress".to_string()));
assert_eq!(meta.version, Some("0.7.0".to_string()));
}
#[test]
fn test_extract_metadata_value() {
assert_eq!(
PlanParser::extract_metadata_value("**Durée**: 3-4h"),
Some("3-4h".to_string())
);
assert_eq!(
PlanParser::extract_metadata_value("**Issue**: #42"),
Some("#42".to_string())
);
}
}