use std::collections::BTreeSet;
use serde::{Deserialize, Serialize};
use super::{ChartSpec, validate_text};
use crate::error::{ChartError, ChartWarning};
const MAX_RELEASES: usize = 5;
const MAX_ACTIVITIES: usize = 8;
const MAX_TASKS: usize = 16;
const MAX_STORIES: usize = 12;
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
pub struct StorymapSpec {
pub releases: Vec<String>,
pub activities: Vec<StorymapActivitySpec>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
pub struct StorymapActivitySpec {
pub label: String,
pub tasks: Vec<StorymapTaskSpec>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
pub struct StorymapTaskSpec {
pub label: String,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub stories: Vec<StorymapStorySpec>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
pub struct StorymapStorySpec {
pub title: String,
pub release: String,
}
impl StorymapSpec {
pub(crate) fn task_count(&self) -> usize {
self.activities
.iter()
.map(|activity| activity.tasks.len())
.sum()
}
pub(crate) fn story_count(&self) -> usize {
self.activities
.iter()
.flat_map(|activity| &activity.tasks)
.map(|task| task.stories.len())
.sum()
}
}
impl ChartSpec {
pub(super) fn validate_storymap(&self) -> Result<Vec<ChartWarning>, ChartError> {
self.reject_chart_fields("a story map is drawn from its releases and activities")?;
let Some(map) = &self.storymap else {
return Err(ChartError::new(
"missing_storymap",
"/storymap",
"a story map requires a storymap block",
));
};
for (path, empty) in [
("/storymap/releases", map.releases.is_empty()),
("/storymap/activities", map.activities.is_empty()),
] {
if empty {
return Err(ChartError::new(
"empty_data",
path,
"provide at least one entry",
));
}
}
for (path, length, limit) in [
("/storymap/releases", map.releases.len(), MAX_RELEASES),
("/storymap/activities", map.activities.len(), MAX_ACTIVITIES),
("/storymap/activities", map.task_count(), MAX_TASKS),
] {
if length > limit {
return Err(ChartError::new(
"too_many_nodes",
path,
format!("at most {limit} are supported here; merge or split the map"),
));
}
}
let mut releases = BTreeSet::new();
for (index, release) in map.releases.iter().enumerate() {
validate_text(release, &format!("/storymap/releases/{index}"), 60)?;
if !releases.insert(release.as_str()) {
return Err(ChartError::new(
"duplicate_label",
format!("/storymap/releases/{index}"),
"release names must be unique",
));
}
}
let mut activities = BTreeSet::new();
for (index, activity) in map.activities.iter().enumerate() {
let path = format!("/storymap/activities/{index}");
validate_text(&activity.label, &format!("{path}/label"), 100)?;
if !activities.insert(activity.label.as_str()) {
return Err(ChartError::new(
"duplicate_label",
format!("{path}/label"),
"activity labels must be unique",
));
}
if activity.tasks.is_empty() {
return Err(ChartError::new(
"empty_data",
format!("{path}/tasks"),
"an activity needs at least one task",
));
}
for (task_index, task) in activity.tasks.iter().enumerate() {
let task_path = format!("{path}/tasks/{task_index}");
validate_text(&task.label, &format!("{task_path}/label"), 100)?;
if task.stories.len() > MAX_STORIES {
return Err(ChartError::new(
"too_many_nodes",
format!("{task_path}/stories"),
format!("at most {MAX_STORIES} stories per task are supported"),
));
}
for (story_index, story) in task.stories.iter().enumerate() {
let story_path = format!("{task_path}/stories/{story_index}");
validate_text(&story.title, &format!("{story_path}/title"), 100)?;
if !releases.contains(story.release.as_str()) {
return Err(ChartError::new(
"unknown_release",
format!("{story_path}/release"),
"a story names a release from /storymap/releases",
));
}
}
}
}
Ok(Vec::new())
}
}