use crate::model::task::{Metadata, Task, TaskState};
use crate::parse::{count_indent, has_continuation_at_indent};
const MAX_DEPTH: usize = 3;
pub fn parse_tasks(
lines: &[String],
start_idx: usize,
indent: usize,
depth: usize,
) -> (Vec<Task>, usize) {
let mut tasks = Vec::new();
let mut idx = start_idx;
while idx < lines.len() {
let line = &lines[idx];
if let Some(task_indent) = task_indent(line) {
if task_indent == indent {
let (task, next_idx) = parse_single_task(lines, idx, indent, depth);
tasks.push(task);
idx = next_idx;
} else if task_indent < indent {
break;
} else {
idx += 1;
}
} else {
if (line.trim().is_empty() || count_indent(line) > indent)
&& has_more_tasks_at_indent(lines, idx + 1, indent)
{
idx += 1;
continue;
}
break;
}
}
(tasks, idx)
}
fn parse_single_task(
lines: &[String],
start_idx: usize,
indent: usize,
depth: usize,
) -> (Task, usize) {
let line = &lines[start_idx];
let (state, id, title, tags) = parse_task_line(line, indent);
let mut task = Task {
state,
id,
title,
tags,
metadata: Vec::new(),
subtasks: Vec::new(),
depth,
source_lines: None,
source_text: None,
dirty: false,
};
let mut idx = start_idx + 1;
let meta_indent = indent + 2;
while idx < lines.len() {
let line = &lines[idx];
if let Some(ti) = task_indent(line)
&& ti <= meta_indent
{
break;
}
if is_metadata_line(line, meta_indent) {
let (meta, next_idx) = parse_metadata(lines, idx, meta_indent);
task.metadata.push(meta);
idx = next_idx;
continue;
}
let line_indent = count_indent(line);
if line_indent > indent && !line.trim().is_empty() {
idx += 1;
continue;
}
if line.trim().is_empty() {
let mut peek = idx + 1;
while peek < lines.len() && lines[peek].trim().is_empty() {
peek += 1;
}
if peek < lines.len()
&& (is_metadata_line(&lines[peek], meta_indent)
|| task_indent(&lines[peek]).is_some_and(|ti| ti == meta_indent))
{
idx += 1;
continue;
}
}
break;
}
let own_end_idx = idx;
task.source_text = Some(lines[start_idx..own_end_idx].to_vec());
if idx < lines.len()
&& let Some(ti) = task_indent(&lines[idx])
&& ti == meta_indent
&& depth + 1 < MAX_DEPTH
{
let (subtasks, next_idx) = parse_tasks(lines, idx, meta_indent, depth + 1);
task.subtasks = subtasks;
idx = next_idx;
}
task.source_lines = Some(start_idx..idx);
(task, idx)
}
fn parse_task_line(line: &str, indent: usize) -> (TaskState, Option<String>, String, Vec<String>) {
let content = &line[indent..];
let state_char = content
.strip_prefix("- [")
.and_then(|rest| rest.chars().next())
.unwrap_or(' ');
let state = TaskState::from_checkbox_char(state_char).unwrap_or(TaskState::Todo);
let after_checkbox = &content[5..]; let after_checkbox = after_checkbox.strip_prefix(' ').unwrap_or(after_checkbox);
let (id, after_id) = if let Some(after_tick) = after_checkbox.strip_prefix('`') {
if let Some(end_tick) = after_tick.find('`') {
let id_text = &after_tick[..end_tick];
let rest = &after_tick[end_tick + 1..];
let rest = rest.strip_prefix(' ').unwrap_or(rest);
(Some(id_text.to_string()), rest)
} else {
(None, after_checkbox)
}
} else {
(None, after_checkbox)
};
let (title, tags) = parse_title_and_tags(after_id);
(state, id, title, tags)
}
pub fn parse_title_and_tags(s: &str) -> (String, Vec<String>) {
let s = s.trim_end();
if s.is_empty() {
return (String::new(), Vec::new());
}
let mut tags = Vec::new();
let mut remaining = s;
loop {
let trimmed = remaining.trim_end();
if trimmed.is_empty() {
break;
}
if let Some(last_space) = trimmed.rfind(' ') {
let last_word = &trimmed[last_space + 1..];
if let Some(tag) = last_word.strip_prefix('#')
&& !tag.is_empty()
&& !tag.contains('#')
{
tags.push(tag.to_string());
remaining = &trimmed[..last_space];
continue;
}
} else {
if let Some(tag) = trimmed.strip_prefix('#')
&& !tag.is_empty()
&& !tag.contains('#')
{
tags.push(tag.to_string());
remaining = "";
continue;
}
}
break;
}
tags.reverse();
(remaining.trim_end().to_string(), tags)
}
fn task_indent(line: &str) -> Option<usize> {
let indent = count_indent(line);
let content = &line[indent..];
if content.starts_with("- [") && content.len() >= 5 && content.as_bytes().get(4) == Some(&b']')
{
Some(indent)
} else {
None
}
}
fn has_more_tasks_at_indent(lines: &[String], start: usize, indent: usize) -> bool {
for line in lines.iter().skip(start) {
if line.trim().is_empty() {
continue;
}
if count_indent(line) > indent {
continue; }
return task_indent(line).is_some_and(|ti| ti == indent);
}
false
}
fn is_metadata_line(line: &str, indent: usize) -> bool {
let line_indent = count_indent(line);
if line_indent != indent {
return false;
}
let content = line[indent..].trim_start();
if !content.starts_with("- ") {
return false;
}
let after_dash = &content[2..];
matches!(
after_dash.split_once(':'),
Some((key, _)) if is_metadata_key(key)
)
}
fn is_metadata_key(key: &str) -> bool {
matches!(
key.trim(),
"dep" | "ref" | "spec" | "note" | "added" | "resolved"
)
}
fn parse_metadata(lines: &[String], idx: usize, indent: usize) -> (Metadata, usize) {
let line = &lines[idx];
let content = line[indent..].trim_start();
let after_dash = &content[2..];
let (key, value_part) = after_dash.split_once(':').unwrap();
let key = key.trim();
let value = value_part.trim();
match key {
"dep" => {
let deps: Vec<String> = value
.split(',')
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty())
.collect();
(Metadata::Dep(deps), idx + 1)
}
"ref" => {
let refs: Vec<String> = value
.split(',')
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty())
.collect();
(Metadata::Ref(refs), idx + 1)
}
"spec" => (Metadata::Spec(value.to_string()), idx + 1),
"added" => (Metadata::Added(value.to_string()), idx + 1),
"resolved" => (Metadata::Resolved(value.to_string()), idx + 1),
"note" => {
if !value.is_empty() {
(Metadata::Note(value.to_string()), idx + 1)
} else {
let block_indent = indent + 2;
let (note_text, next_idx) = parse_note_block(lines, idx + 1, block_indent);
(Metadata::Note(note_text), next_idx)
}
}
_ => {
(Metadata::Note(format!("{}: {}", key, value)), idx + 1)
}
}
}
fn parse_note_block(lines: &[String], start_idx: usize, block_indent: usize) -> (String, usize) {
let mut note_lines = Vec::new();
let mut idx = start_idx;
let mut in_code_fence = false;
while idx < lines.len() {
let line = &lines[idx];
let line_indent = count_indent(line);
if in_code_fence {
note_lines.push(strip_block_indent(line, block_indent));
if line.trim().starts_with("```") && idx != start_idx {
if line_indent >= block_indent
&& line[block_indent..].trim_start().starts_with("```")
{
in_code_fence = false;
}
}
idx += 1;
continue;
}
if line.trim().is_empty() {
if has_continuation_at_indent(lines, idx + 1, block_indent) {
note_lines.push(String::new());
idx += 1;
continue;
} else {
break;
}
}
if line_indent < block_indent {
break;
}
let stripped = strip_block_indent(line, block_indent);
if stripped.trim_start().starts_with("```") {
in_code_fence = true;
}
note_lines.push(stripped);
idx += 1;
}
while note_lines.last().is_some_and(|l| l.is_empty()) {
note_lines.pop();
}
(note_lines.join("\n"), idx)
}
fn strip_block_indent(line: &str, block_indent: usize) -> String {
if line.len() >= block_indent {
line[block_indent..].to_string()
} else if line.trim().is_empty() {
String::new()
} else {
line.trim_start().to_string()
}
}
#[cfg(test)]
mod tests {
use super::*;
fn lines(s: &str) -> Vec<String> {
s.lines().map(|l| l.to_string()).collect()
}
#[test]
fn test_parse_minimal_task() {
let input = lines("- [ ] Fix parser crash on empty blocks");
let (tasks, _) = parse_tasks(&input, 0, 0, 0);
assert_eq!(tasks.len(), 1);
assert_eq!(tasks[0].state, TaskState::Todo);
assert_eq!(tasks[0].id, None);
assert_eq!(tasks[0].title, "Fix parser crash on empty blocks");
assert!(tasks[0].tags.is_empty());
}
#[test]
fn test_parse_task_with_id_and_tags() {
let input = lines("- [ ] `EFF-003` Implement effect handler desugaring #core #cc");
let (tasks, _) = parse_tasks(&input, 0, 0, 0);
assert_eq!(tasks.len(), 1);
assert_eq!(tasks[0].id.as_deref(), Some("EFF-003"));
assert_eq!(tasks[0].title, "Implement effect handler desugaring");
assert_eq!(tasks[0].tags, vec!["core", "cc"]);
}
#[test]
fn test_parse_task_states() {
for (ch, expected) in [
(' ', TaskState::Todo),
('>', TaskState::Active),
('-', TaskState::Blocked),
('x', TaskState::Done),
('~', TaskState::Parked),
] {
let input = lines(&format!("- [{}] Test task", ch));
let (tasks, _) = parse_tasks(&input, 0, 0, 0);
assert_eq!(tasks[0].state, expected);
}
}
#[test]
fn test_parse_task_with_metadata() {
let input = lines(
"- [>] `EFF-014` Implement effect inference #core\n\
\x20\x20- added: 2025-05-10\n\
\x20\x20- dep: EFF-003\n\
\x20\x20- spec: doc/spec/effects.md#closure-effects\n\
\x20\x20- ref: doc/design/effect-handlers-v2.md",
);
let (tasks, _) = parse_tasks(&input, 0, 0, 0);
assert_eq!(tasks[0].metadata.len(), 4);
assert!(matches!(&tasks[0].metadata[0], Metadata::Added(d) if d == "2025-05-10"));
assert!(matches!(&tasks[0].metadata[1], Metadata::Dep(d) if d == &["EFF-003"]));
assert!(
matches!(&tasks[0].metadata[2], Metadata::Spec(s) if s == "doc/spec/effects.md#closure-effects")
);
assert!(
matches!(&tasks[0].metadata[3], Metadata::Ref(r) if r == &["doc/design/effect-handlers-v2.md"])
);
}
#[test]
fn test_parse_subtasks() {
let input = lines(
"- [>] `EFF-014` Implement effect inference #core\n\
\x20\x20- added: 2025-05-10\n\
\x20\x20- [ ] `EFF-014.1` Add effect variables\n\
\x20\x20- [>] `EFF-014.2` Unify effect rows #cc\n\
\x20\x20- [ ] `EFF-014.3` Test with nested closures",
);
let (tasks, _) = parse_tasks(&input, 0, 0, 0);
assert_eq!(tasks[0].subtasks.len(), 3);
assert_eq!(tasks[0].subtasks[0].id.as_deref(), Some("EFF-014.1"));
assert_eq!(tasks[0].subtasks[1].tags, vec!["cc"]);
assert_eq!(tasks[0].subtasks[2].state, TaskState::Todo);
}
#[test]
fn test_parse_note_block() {
let input = lines(
"- [ ] `EFF-014` Test task\n\
\x20\x20- note:\n\
\x20\x20\x20\x20Found while working on EFF-002.\n\
\x20\x20\x20\x20\n\
\x20\x20\x20\x20The desugaring needs to handle three cases:\n\
\x20\x20\x20\x20 1. Simple perform\n\
\x20\x20\x20\x20 2. Single-shot resumption",
);
let (tasks, _) = parse_tasks(&input, 0, 0, 0);
assert_eq!(tasks[0].metadata.len(), 1);
if let Metadata::Note(note) = &tasks[0].metadata[0] {
assert!(note.contains("Found while working"));
assert!(note.contains("three cases"));
} else {
panic!("Expected Note metadata");
}
}
#[test]
fn test_parse_note_with_code_fence() {
let input = lines(
"- [ ] `EFF-014` Test task\n\
\x20\x20- note:\n\
\x20\x20\x20\x20See the Koka paper:\n\
\x20\x20\x20\x20```lace\n\
\x20\x20\x20\x20handle(e) { ... } with {\n\
\x20\x20\x20\x20 op(x, resume) -> resume(x + 1)\n\
\x20\x20\x20\x20}\n\
\x20\x20\x20\x20```",
);
let (tasks, _) = parse_tasks(&input, 0, 0, 0);
if let Metadata::Note(note) = &tasks[0].metadata[0] {
assert!(note.contains("```lace"));
assert!(note.contains("handle(e)"));
assert!(note.contains("```"));
} else {
panic!("Expected Note metadata");
}
}
#[test]
fn test_parse_multiple_deps() {
let input = lines(
"- [-] `EFF-012` Effect-aware DCE #core\n\
\x20\x20- dep: EFF-014, INFRA-003",
);
let (tasks, _) = parse_tasks(&input, 0, 0, 0);
if let Metadata::Dep(deps) = &tasks[0].metadata[0] {
assert_eq!(deps, &["EFF-014", "INFRA-003"]);
} else {
panic!("Expected Dep metadata");
}
}
#[test]
fn test_three_level_nesting() {
let input = lines(
"- [>] `EFF-014` Top level\n\
\x20\x20- [>] `EFF-014.2` Second level #cc\n\
\x20\x20\x20\x20- [ ] `EFF-014.2.1` Third level\n\
\x20\x20\x20\x20- [ ] `EFF-014.2.2` Third level 2",
);
let (tasks, _) = parse_tasks(&input, 0, 0, 0);
assert_eq!(tasks[0].subtasks.len(), 1);
assert_eq!(tasks[0].subtasks[0].subtasks.len(), 2);
assert_eq!(
tasks[0].subtasks[0].subtasks[0].id.as_deref(),
Some("EFF-014.2.1")
);
}
#[test]
fn test_blank_lines_between_note_and_subtasks() {
let input = lines(
"- [ ] `T-001` Parent task\n\
\x20\x20- note:\n\
\x20\x20\x20\x20Some note content\n\
\n\
\n\
\x20\x20- [ ] `T-001.1` First subtask\n\
\x20\x20- [ ] `T-001.2` Second subtask",
);
let (tasks, _) = parse_tasks(&input, 0, 0, 0);
assert_eq!(tasks.len(), 1);
assert_eq!(tasks[0].subtasks.len(), 2);
assert_eq!(tasks[0].subtasks[0].id.as_deref(), Some("T-001.1"));
assert_eq!(tasks[0].subtasks[1].id.as_deref(), Some("T-001.2"));
if let Metadata::Note(note) = &tasks[0].metadata[0] {
assert!(note.contains("Some note content"));
} else {
panic!("Expected Note metadata");
}
}
#[test]
fn test_blank_line_between_empty_note_and_metadata() {
let input = lines(
"- [ ] `T-001` Task\n\
\x20\x20- note:\n\
\n\
\x20\x20- spec: some-file.md\n\
\x20\x20- dep: T-002",
);
let (tasks, _) = parse_tasks(&input, 0, 0, 0);
assert_eq!(tasks[0].metadata.len(), 3); assert!(matches!(&tasks[0].metadata[0], Metadata::Note(n) if n.is_empty()));
assert!(matches!(&tasks[0].metadata[1], Metadata::Spec(s) if s == "some-file.md"));
assert!(matches!(&tasks[0].metadata[2], Metadata::Dep(d) if d == &["T-002"]));
}
#[test]
fn test_blank_lines_between_sibling_tasks() {
let input = lines(
"- [ ] `T-001` First task\n\
\x20\x20- added: 2025-01-01\n\
\n\
- [ ] `T-002` Second task",
);
let (tasks, _) = parse_tasks(&input, 0, 0, 0);
assert_eq!(tasks.len(), 2);
assert_eq!(tasks[0].id.as_deref(), Some("T-001"));
assert_eq!(tasks[1].id.as_deref(), Some("T-002"));
}
#[test]
fn test_blank_lines_before_section_header_stops() {
let input = lines(
"- [ ] `T-001` First task\n\
\n\
## Done",
);
let (tasks, next_idx) = parse_tasks(&input, 0, 0, 0);
assert_eq!(tasks.len(), 1);
assert_eq!(next_idx, 1); }
#[test]
fn test_parse_title_and_tags_edge_cases() {
let (title, tags) = parse_title_and_tags("Fix parser crash");
assert_eq!(title, "Fix parser crash");
assert!(tags.is_empty());
let (title, tags) = parse_title_and_tags("#core #cc");
assert!(title.is_empty());
assert_eq!(tags, vec!["core", "cc"]);
let (title, tags) = parse_title_and_tags("Fix #3 parser crash #bug");
assert_eq!(title, "Fix #3 parser crash");
assert_eq!(tags, vec!["bug"]);
}
}