use serde::{Deserialize, Serialize};
use super::{ChartSpec, validate_optional_text};
use crate::error::{ChartError, ChartWarning};
const MAX_STAGES: usize = 8;
const MAX_CELL: usize = 160;
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
pub struct JourneySpec {
pub stages: Vec<JourneyStageSpec>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub display: Option<JourneyDisplay>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub enum JourneyDisplay {
#[default]
Journey,
Blueprint,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
pub struct JourneyStageSpec {
pub label: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub action: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub touchpoint: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub thought: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub sentiment: Option<i8>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub frontstage: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub backstage: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub support: Option<String>,
}
impl JourneySpec {
pub(crate) fn is_blueprint(&self) -> bool {
self.display == Some(JourneyDisplay::Blueprint)
}
}
impl JourneyStageSpec {
pub(crate) fn text(&self, row: JourneyRow) -> Option<&str> {
match row {
JourneyRow::Action => &self.action,
JourneyRow::Touchpoint => &self.touchpoint,
JourneyRow::Thought => &self.thought,
JourneyRow::Frontstage => &self.frontstage,
JourneyRow::Backstage => &self.backstage,
JourneyRow::Support => &self.support,
}
.as_deref()
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum JourneyRow {
Action,
Touchpoint,
Thought,
Frontstage,
Backstage,
Support,
}
impl ChartSpec {
pub(super) fn validate_journey(&self) -> Result<Vec<ChartWarning>, ChartError> {
self.reject_chart_fields("a journey is drawn from its stages")?;
let Some(journey) = &self.journey else {
return Err(ChartError::new(
"missing_journey",
"/journey",
"a journey requires a journey block",
));
};
if journey.stages.len() < 2 {
return Err(ChartError::new(
"empty_data",
"/journey/stages",
"provide at least two stages",
));
}
if journey.stages.len() > MAX_STAGES {
return Err(ChartError::new(
"too_many_nodes",
"/journey/stages",
format!("at most {MAX_STAGES} stages are supported; merge stages"),
));
}
let mut seen: Vec<&str> = Vec::new();
for (index, stage) in journey.stages.iter().enumerate() {
let path = format!("/journey/stages/{index}");
super::validate_text(&stage.label, &format!("{path}/label"), 60)?;
if seen.contains(&stage.label.as_str()) {
return Err(ChartError::new(
"duplicate_label",
format!("{path}/label"),
"stage labels must be unique",
));
}
seen.push(&stage.label);
for (name, value) in [
("action", &stage.action),
("touchpoint", &stage.touchpoint),
("thought", &stage.thought),
("frontstage", &stage.frontstage),
("backstage", &stage.backstage),
("support", &stage.support),
] {
validate_optional_text(value.as_ref(), &format!("{path}/{name}"), MAX_CELL)?;
let service_row = matches!(name, "frontstage" | "backstage" | "support");
if service_row && value.is_some() && !journey.is_blueprint() {
return Err(ChartError::new(
"option_not_supported",
format!("{path}/{name}"),
"the rows of the service belong to a blueprint; set \"display\": \"blueprint\" or remove this field",
));
}
}
if let Some(sentiment) = stage.sentiment
&& !(-2..=2).contains(&sentiment)
{
return Err(ChartError::new(
"invalid_value",
format!("{path}/sentiment"),
"sentiment is a whole number from -2 to 2",
));
}
}
Ok(Vec::new())
}
}