use crate::model::inbox::{Inbox, InboxItem};
use crate::parse::has_continuation_at_indent;
use crate::parse::task_parser::parse_title_and_tags;
pub fn parse_inbox(source: &str) -> (Inbox, Vec<String>) {
let lines: Vec<String> = source.lines().map(|l| l.to_string()).collect();
let mut header_lines = Vec::new();
let mut idx = 0;
while idx < lines.len() {
let trimmed = lines[idx].trim();
if trimmed.starts_with("- ") {
break;
}
header_lines.push(lines[idx].clone());
idx += 1;
}
let mut items = Vec::new();
let mut dropped_lines = Vec::new();
while idx < lines.len() {
let line = &lines[idx];
let trimmed = line.trim();
if let Some(title_content) = trimmed.strip_prefix("- ") {
let item_start = idx;
let (title, mut tags) = parse_title_and_tags(title_content);
idx += 1;
while idx < lines.len() {
let cont_line = &lines[idx];
let cont_trimmed = cont_line.trim();
if cont_trimmed.is_empty()
|| (!cont_line.starts_with(' ') && cont_trimmed.starts_with("- "))
{
break;
}
if is_tag_only_line(cont_trimmed) {
for word in cont_trimmed.split_whitespace() {
if let Some(tag) = word.strip_prefix('#')
&& !tag.is_empty()
{
tags.push(tag.to_string());
}
}
idx += 1;
} else {
break;
}
}
let mut body_lines = Vec::new();
let mut in_code_fence = false;
while idx < lines.len() {
let body_line = &lines[idx];
let body_trimmed = body_line.trim();
if body_trimmed.starts_with("```") {
in_code_fence = !in_code_fence;
}
if !in_code_fence {
if body_trimmed.is_empty() {
if has_continuation_at_indent(&lines, idx + 1, 1) {
body_lines.push(String::new());
idx += 1;
continue;
}
break;
}
if body_trimmed.starts_with("- ") && !body_line.starts_with(' ') {
break;
}
}
body_lines.push(strip_body_indent(body_line));
idx += 1;
}
while idx < lines.len() && lines[idx].trim().is_empty() {
idx += 1;
}
let body = if body_lines.is_empty() {
None
} else {
Some(body_lines.join("\n"))
};
let source_text = Some(lines[item_start..idx].to_vec());
items.push(InboxItem {
title,
tags,
body,
source_text,
dirty: false,
});
} else if trimmed.is_empty() {
idx += 1;
} else {
dropped_lines.push(lines[idx].clone());
idx += 1;
}
}
(
Inbox {
header_lines,
items,
source_lines: lines,
},
dropped_lines,
)
}
fn is_tag_only_line(trimmed: &str) -> bool {
if trimmed.is_empty() {
return false;
}
trimmed
.split_whitespace()
.all(|word| word.starts_with('#') && word.len() > 1)
}
fn strip_body_indent(line: &str) -> String {
if let Some(stripped) = line.strip_prefix(" ") {
stripped.to_string()
} else {
line.to_string()
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_parse_inbox_basic() {
let source = "\
# Inbox
- Parser crashes on empty effect block #bug
Saw this when testing with empty `handle {}` blocks.
Stack trace points to parser/effect.rs line 142.
- Think about whether `perform` should be an expression or statement
#design
- Read the Koka paper on named handlers #research
";
let (inbox, _) = parse_inbox(source);
assert_eq!(inbox.header_lines.len(), 2); assert_eq!(inbox.items.len(), 3);
assert_eq!(inbox.items[0].title, "Parser crashes on empty effect block");
assert_eq!(inbox.items[0].tags, vec!["bug"]);
assert!(
inbox.items[0]
.body
.as_ref()
.unwrap()
.contains("Saw this when testing")
);
assert_eq!(
inbox.items[1].title,
"Think about whether `perform` should be an expression or statement"
);
assert_eq!(inbox.items[1].tags, vec!["design"]);
assert_eq!(
inbox.items[2].title,
"Read the Koka paper on named handlers"
);
assert_eq!(inbox.items[2].tags, vec!["research"]);
assert!(inbox.items[2].body.is_none());
}
#[test]
fn test_parse_inbox_with_code_block_in_body() {
let source = "\
# Inbox
- Think about whether `perform` should be an expression or statement
#design
If it's an expression, we get composability:
```lace
let x = perform Ask() + 1
```
But it makes the effect type more complex.
- Simple item #bug
";
let (inbox, _) = parse_inbox(source);
assert_eq!(inbox.items.len(), 2);
let body = inbox.items[0].body.as_ref().unwrap();
assert!(body.contains("```lace"));
assert!(body.contains("perform Ask()"));
}
#[test]
fn test_parse_inbox_code_block_with_blank_line() {
let source = "\
# Inbox
- Item with code block containing blank line #bug
Here's the code:
```
fn main() {
println!(\"hello\");
}
```
Text after code block.
- Next item
";
let (inbox, _) = parse_inbox(source);
assert_eq!(inbox.items.len(), 2);
let body = inbox.items[0].body.as_ref().unwrap();
assert!(body.contains("fn main()"));
assert!(body.contains("println!"));
assert!(body.contains("Text after code block."));
assert!(body.contains("\n\n"));
assert_eq!(inbox.items[1].title, "Next item");
}
#[test]
fn test_parse_inbox_empty() {
let source = "# Inbox\n";
let (inbox, _) = parse_inbox(source);
assert!(inbox.items.is_empty());
}
#[test]
fn test_parse_inbox_body_with_blank_lines() {
let source = "\
# Inbox
- Multi-paragraph item #design
First paragraph of body.
Second paragraph of body.
- Next item #bug";
let (inbox, _) = parse_inbox(source);
assert_eq!(inbox.items.len(), 2);
let body = inbox.items[0].body.as_ref().unwrap();
assert!(body.contains("First paragraph"));
assert!(body.contains("Second paragraph"));
assert!(
body.contains("\n\n"),
"blank line within body should be preserved"
);
assert_eq!(inbox.items[1].title, "Next item");
assert_eq!(inbox.items[1].tags, vec!["bug"]);
}
#[test]
fn test_parse_inbox_body_multiple_blank_lines() {
let source = "\
# Inbox
- Item with double blank #tag
Para one.
Para two.
- Next";
let (inbox, _) = parse_inbox(source);
assert_eq!(inbox.items.len(), 2);
let body = inbox.items[0].body.as_ref().unwrap();
assert!(body.contains("Para one."));
assert!(body.contains("Para two."));
assert!(body.contains("\n\n\n"));
}
#[test]
fn test_parse_inbox_body_blank_line_before_code_block() {
let source = "\
# Inbox
- Item with code #dev
Some text.
```
fn main() {}
```
- Next";
let (inbox, _) = parse_inbox(source);
assert_eq!(inbox.items.len(), 2);
let body = inbox.items[0].body.as_ref().unwrap();
assert!(body.contains("Some text."));
assert!(body.contains("fn main()"));
}
#[test]
fn test_parse_inbox_dropped_lines() {
let source = "\
# Inbox
- First item #bug
Stray line between items
Another stray line
- Second item
";
let (inbox, dropped) = parse_inbox(source);
assert_eq!(inbox.items.len(), 2);
assert_eq!(inbox.items[0].title, "First item");
assert_eq!(inbox.items[1].title, "Second item");
assert_eq!(dropped.len(), 2);
assert_eq!(dropped[0], "Stray line between items");
assert_eq!(dropped[1], "Another stray line");
}
#[test]
fn test_parse_inbox_no_dropped_lines() {
let source = "\
# Inbox
- First item #bug
- Second item #design
";
let (inbox, dropped) = parse_inbox(source);
assert_eq!(inbox.items.len(), 2);
assert!(dropped.is_empty());
}
#[test]
fn test_parse_inbox_trailing_blank_not_in_body() {
let source = "\
# Inbox
- First item
Body text.
- Second item";
let (inbox, _) = parse_inbox(source);
assert_eq!(inbox.items.len(), 2);
let body = inbox.items[0].body.as_ref().unwrap();
assert_eq!(body, "Body text.");
assert!(!body.contains('\n'), "no trailing blank should be in body");
}
}