use crate::Error;
use crate::journey::{JourneyDiagram, Section, Task};
use crate::parser::common::strip_inline_comment;
pub fn parse(src: &str) -> Result<JourneyDiagram, Error> {
let mut diag = JourneyDiagram::default();
let mut header_seen = false;
let mut current_section: Option<usize> = None;
for raw in src.lines() {
let line = strip_inline_comment(raw).trim();
if line.is_empty() {
continue;
}
if !header_seen {
if !line.eq_ignore_ascii_case("journey") {
return Err(Error::ParseError(format!(
"expected `journey` header, got {line:?}"
)));
}
header_seen = true;
continue;
}
if let Some(rest) = strip_keyword_ci(line, "title") {
diag.title = Some(rest.to_string());
continue;
}
if let Some(rest) = strip_keyword_ci(line, "section") {
diag.sections.push(Section {
name: Some(rest.to_string()),
tasks: Vec::new(),
});
current_section = Some(diag.sections.len() - 1);
continue;
}
let task = parse_task_line(line)?;
let idx = match current_section {
Some(i) => i,
None => {
diag.sections.push(Section {
name: None,
tasks: Vec::new(),
});
let i = diag.sections.len() - 1;
current_section = Some(i);
i
}
};
diag.sections[idx].tasks.push(task);
}
if !header_seen {
return Err(Error::ParseError(
"missing `journey` header line".to_string(),
));
}
Ok(diag)
}
fn parse_task_line(line: &str) -> Result<Task, Error> {
let mut parts = line.splitn(3, ':');
let title = parts
.next()
.map(str::trim)
.filter(|s| !s.is_empty())
.ok_or_else(|| Error::ParseError(format!("task line missing title: {line:?}")))?;
let score_str = parts
.next()
.map(str::trim)
.ok_or_else(|| Error::ParseError(format!("task line missing score: {line:?}")))?;
let score_raw: i32 = score_str.parse().map_err(|_| {
Error::ParseError(format!(
"task score is not an integer: {score_str:?} in {line:?}"
))
})?;
if !(1..=5).contains(&score_raw) {
return Err(Error::ParseError(format!(
"task score must be between 1 and 5, got {score_raw} in {line:?}"
)));
}
let actors_str = parts
.next()
.map(str::trim)
.ok_or_else(|| Error::ParseError(format!("task line missing actors: {line:?}")))?;
let actors: Vec<String> = actors_str
.split(',')
.map(|a| a.trim().to_string())
.filter(|a| !a.is_empty())
.collect();
if actors.is_empty() {
return Err(Error::ParseError(format!(
"task line has no actors: {line:?}"
)));
}
Ok(Task {
title: title.to_string(),
score: score_raw as u8,
actors,
})
}
fn strip_keyword_ci<'a>(line: &'a str, keyword: &str) -> Option<&'a str> {
let klen = keyword.len();
if line.len() > klen
&& line[..klen].eq_ignore_ascii_case(keyword)
&& line.as_bytes()[klen].is_ascii_whitespace()
{
Some(line[klen..].trim())
} else {
None
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parses_minimal_journey() {
let diag = parse("journey\nStep one: 3: Me").unwrap();
assert_eq!(diag.title, None);
assert_eq!(diag.sections.len(), 1);
assert_eq!(diag.sections[0].name, None); assert_eq!(diag.sections[0].tasks.len(), 1);
let task = &diag.sections[0].tasks[0];
assert_eq!(task.title, "Step one");
assert_eq!(task.score, 3);
assert_eq!(task.actors, vec!["Me"]);
}
#[test]
fn parses_title_and_section() {
let diag = parse(
"journey\n\
title My Day\n\
section Morning\n\
Make tea: 5: Alice",
)
.unwrap();
assert_eq!(diag.title.as_deref(), Some("My Day"));
assert_eq!(diag.sections.len(), 1);
assert_eq!(diag.sections[0].name.as_deref(), Some("Morning"));
assert_eq!(diag.sections[0].tasks[0].title, "Make tea");
assert_eq!(diag.sections[0].tasks[0].score, 5);
}
#[test]
fn parses_multiple_actors_per_task() {
let diag = parse("journey\nTask: 5: Alice, Bob").unwrap();
let actors = &diag.sections[0].tasks[0].actors;
assert_eq!(actors, &["Alice", "Bob"]);
}
#[test]
fn tasks_before_section_go_to_unnamed_section() {
let diag = parse(
"journey\n\
Implicit: 4: Me\n\
section Named\n\
Explicit: 2: Me",
)
.unwrap();
assert_eq!(diag.sections.len(), 2);
assert_eq!(diag.sections[0].name, None);
assert_eq!(diag.sections[0].tasks[0].title, "Implicit");
assert_eq!(diag.sections[1].name.as_deref(), Some("Named"));
assert_eq!(diag.sections[1].tasks[0].title, "Explicit");
}
#[test]
fn comment_and_blank_lines_are_ignored() {
let diag = parse(
"%% leading comment\n\
journey\n\
\n\
%% mid comment\n\
Step: 2: Me",
)
.unwrap();
assert_eq!(diag.sections[0].tasks.len(), 1);
}
#[test]
fn score_outside_1_to_5_is_an_error() {
let err = parse("journey\nTask: 6: Me").unwrap_err();
assert!(
err.to_string().contains("1 and 5"),
"unexpected error: {err}"
);
let err2 = parse("journey\nTask: 0: Me").unwrap_err();
assert!(err2.to_string().contains("1 and 5"));
}
#[test]
fn missing_journey_header_returns_error() {
let err = parse("section Work\nMake tea: 5: Me").unwrap_err();
assert!(err.to_string().contains("journey"));
}
#[test]
fn non_integer_score_is_an_error() {
let err = parse("journey\nTask: abc: Me").unwrap_err();
assert!(err.to_string().contains("not an integer"));
}
#[test]
fn multiple_sections_tasks_grouped_correctly() {
let diag = parse(
"journey\n\
section A\n\
T1: 1: Me\n\
T2: 2: Me\n\
section B\n\
T3: 3: Me",
)
.unwrap();
assert_eq!(diag.sections.len(), 2);
assert_eq!(diag.sections[0].tasks.len(), 2);
assert_eq!(diag.sections[1].tasks.len(), 1);
assert_eq!(diag.total_tasks(), 3);
}
}