use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
#[serde(deny_unknown_fields)]
pub struct ExecutionGuidelines {
#[serde(default)]
pub items: Vec<Guideline>,
#[serde(default)]
pub dependency: Vec<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct Guideline {
pub name: String,
pub guideline: String,
#[serde(default)]
pub note: Option<String>,
}
#[derive(Debug, Clone, PartialEq)]
pub struct Phase {
pub name: String,
pub guideline: String,
pub depends_on: Vec<String>,
}
impl ExecutionGuidelines {
pub fn is_empty(&self) -> bool {
self.items.is_empty()
}
pub fn get(&self, name: &str) -> Option<&Guideline> {
self.items.iter().find(|g| g.name == name)
}
pub fn names(&self) -> Vec<&str> {
self.items.iter().map(|g| g.name.as_str()).collect()
}
pub fn edges(&self) -> Result<Vec<(String, String)>, String> {
let mut out = Vec::new();
for line in &self.dependency {
out.extend(parse_chain(line)?);
}
Ok(out)
}
pub fn phases(&self) -> Result<Vec<Phase>, String> {
let edges = self.edges()?;
Ok(self
.items
.iter()
.map(|g| Phase {
name: g.name.clone(),
guideline: g.guideline.clone(),
depends_on: edges
.iter()
.filter(|(_, to)| *to == g.name)
.map(|(from, _)| from.clone())
.collect(),
})
.collect())
}
}
pub fn parse_chain(line: &str) -> Result<Vec<(String, String)>, String> {
let parts: Vec<&str> = line.split("->").map(str::trim).collect();
if parts.len() < 2 {
return Err(format!(
"`{line}` is not an ordering; write it as `earlier -> later`"
));
}
if let Some(blank) = parts.iter().position(|p| p.is_empty()) {
return Err(format!(
"`{line}` has an empty name at position {}; every `->` needs a guideline on both sides",
blank + 1
));
}
Ok(parts
.windows(2)
.map(|w| (w[0].to_string(), w[1].to_string()))
.collect())
}
#[cfg(test)]
mod tests {
use super::*;
fn guidelines(items: &[&str], dependency: &[&str]) -> ExecutionGuidelines {
ExecutionGuidelines {
items: items
.iter()
.map(|n| Guideline {
name: n.to_string(),
guideline: format!("do the {n} work"),
note: None,
})
.collect(),
dependency: dependency.iter().map(|s| s.to_string()).collect(),
}
}
#[test]
fn a_two_name_arrow_is_one_edge() {
assert_eq!(
parse_chain("gather -> draft").unwrap(),
vec![("gather".into(), "draft".into())]
);
}
#[test]
fn a_chain_becomes_consecutive_edges() {
assert_eq!(
parse_chain("a -> b -> c").unwrap(),
vec![("a".into(), "b".into()), ("b".into(), "c".into())]
);
}
#[test]
fn spacing_around_the_arrow_does_not_matter() {
assert_eq!(parse_chain("a->b").unwrap(), parse_chain("a -> b").unwrap());
}
#[test]
fn a_line_without_an_arrow_is_refused() {
let err = parse_chain("gather").unwrap_err();
assert!(err.contains("earlier -> later"), "got: {err}");
}
#[test]
fn a_dangling_arrow_is_refused() {
for line in ["a ->", "-> b", "a -> -> c"] {
let err = parse_chain(line).unwrap_err();
assert!(err.contains("empty name"), "for `{line}`, got: {err}");
}
}
#[test]
fn phases_carry_the_predecessors_the_arrows_named() {
let g = guidelines(&["gather", "draft", "review"], &["gather -> draft -> review"]);
let phases = g.phases().unwrap();
assert_eq!(phases[0].depends_on, Vec::<String>::new());
assert_eq!(phases[1].depends_on, vec!["gather"]);
assert_eq!(phases[2].depends_on, vec!["draft"]);
}
#[test]
fn guidelines_with_no_arrow_between_them_are_independent() {
let g = guidelines(&["seo", "social"], &[]);
let phases = g.phases().unwrap();
assert!(phases.iter().all(|p| p.depends_on.is_empty()));
}
#[test]
fn one_phase_can_wait_on_several() {
let g = guidelines(
&["a", "b", "publish"],
&["a -> publish", "b -> publish"],
);
let publish = &g.phases().unwrap()[2];
assert_eq!(publish.depends_on, vec!["a", "b"]);
}
}