use std::collections::BTreeMap;
use serde::{Deserialize, Serialize};
use thiserror::Error;
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(tag = "kind", rename_all = "kebab-case")]
pub enum AnswerSchema {
Choice {
choices: Vec<Choice>,
},
ChoiceOrValue {
choices: Vec<Choice>,
prefixes: Vec<String>,
},
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct Choice {
pub id: String,
pub consequence: String,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct Decision {
pub id: String,
pub question: String,
pub schema: AnswerSchema,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub depends_on: Vec<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub selected: Option<String>,
}
impl Decision {
#[must_use]
pub fn accepts(&self, answer: &str) -> bool {
match &self.schema {
AnswerSchema::Choice { choices } => choices.iter().any(|choice| choice.id == answer),
AnswerSchema::ChoiceOrValue { choices, prefixes } => {
choices.iter().any(|choice| choice.id == answer)
|| prefixes.iter().any(|prefix| {
answer
.strip_prefix(prefix.as_str())
.is_some_and(|rest| !rest.is_empty())
})
}
}
}
}
pub mod id {
pub const PROFILE: &str = "profile";
pub const PLAN_ZONE: &str = "plan-zone";
pub const DOCS_SCRATCH: &str = "docs-scratch";
pub const WRITING_STYLE: &str = "writing-style";
pub const MIGRATION_SCOPE: &str = "migration-scope";
pub const DEBT_BASELINE: &str = "debt-baseline";
pub const ACCEPT_YANKED: &str = "accept-yanked-release";
}
#[derive(Debug, Clone, PartialEq, Eq, Error)]
pub enum AnswerError {
#[error("--set takes <decision-id>=<answer>; '{0}' has no '='")]
Malformed(String),
#[error("--set {0} was given twice; one decision takes one answer")]
Duplicate(String),
#[error("--set {0}: this plan offers no decision with that id")]
Unknown(String),
#[error("--set {id}={answer}: {id} does not offer that answer")]
Rejected {
id: String,
answer: String,
},
}
pub type Selections = BTreeMap<String, String>;
pub fn parse(arguments: &[String]) -> Result<Selections, AnswerError> {
let mut selections = Selections::new();
for argument in arguments {
let (id, answer) = argument
.split_once('=')
.ok_or_else(|| AnswerError::Malformed(argument.clone()))?;
if id.is_empty() {
return Err(AnswerError::Malformed(argument.clone()));
}
if selections.contains_key(id) {
return Err(AnswerError::Duplicate(id.to_string()));
}
selections.insert(id.to_string(), answer.trim().to_string());
}
Ok(selections)
}
pub fn validate(offered: &[Decision], selections: &Selections) -> Result<(), AnswerError> {
for (id, answer) in selections {
let decision = offered
.iter()
.find(|decision| &decision.id == id)
.ok_or_else(|| AnswerError::Unknown(id.clone()))?;
if !decision.accepts(answer) {
return Err(AnswerError::Rejected {
id: id.clone(),
answer: answer.clone(),
});
}
}
Ok(())
}
pub fn acyclic(decisions: &[Decision]) -> Result<(), String> {
for decision in decisions {
for needed in &decision.depends_on {
if !decisions.iter().any(|other| &other.id == needed) {
return Err(format!(
"{} depends on {needed}, which this plan does not offer",
decision.id
));
}
}
}
let mut settled: Vec<&str> = Vec::new();
while settled.len() < decisions.len() {
let before = settled.len();
for decision in decisions {
if settled.contains(&decision.id.as_str()) {
continue;
}
if decision
.depends_on
.iter()
.all(|needed| settled.contains(&needed.as_str()))
{
settled.push(&decision.id);
}
}
if settled.len() == before {
let stuck: Vec<&str> = decisions
.iter()
.map(|decision| decision.id.as_str())
.filter(|id| !settled.contains(id))
.collect();
return Err(format!(
"these decisions form a cycle: {}",
stuck.join(", ")
));
}
}
Ok(())
}
#[cfg(test)]
mod tests {
#![allow(
clippy::unwrap_used,
reason = "a test panics as its failure signal, not as control flow"
)]
use super::*;
fn choice(id: &str) -> Choice {
Choice {
id: id.to_string(),
consequence: format!("it does {id}"),
}
}
fn scope() -> Decision {
Decision {
id: id::MIGRATION_SCOPE.to_string(),
question: "how much of the corpus moves?".to_string(),
schema: AnswerSchema::Choice {
choices: vec![choice("sweep"), choice("incremental")],
},
depends_on: Vec::new(),
selected: None,
}
}
fn zone() -> Decision {
Decision {
id: id::PLAN_ZONE.to_string(),
question: "where does the planning tool write?".to_string(),
schema: AnswerSchema::ChoiceOrValue {
choices: vec![choice("env"), choice("none")],
prefixes: vec!["project:".to_string(), "untracked:".to_string()],
},
depends_on: vec![id::PROFILE.to_string()],
selected: None,
}
}
fn profile() -> Decision {
Decision {
id: id::PROFILE.to_string(),
question: "which profile?".to_string(),
schema: AnswerSchema::Choice {
choices: vec![choice("codebase"), choice("knowledge-base")],
},
depends_on: Vec::new(),
selected: None,
}
}
#[test]
fn a_closed_choice_takes_only_its_own_identifiers() {
let held = scope();
assert!(held.accepts("sweep"));
assert!(!held.accepts("Sweep"));
assert!(!held.accepts("project:docs/plan"));
}
#[test]
fn a_parameterized_answer_takes_a_prefix_with_something_after_it() {
let held = zone();
assert!(held.accepts("env"));
assert!(held.accepts("project:docs/plan"));
assert!(held.accepts("untracked:.plans"));
assert!(!held.accepts("project:"));
assert!(!held.accepts("elsewhere"));
}
#[test]
fn a_selection_splits_on_the_first_equals_so_a_value_may_carry_one() {
let held = parse(&["plan-zone=project:docs/plan=1".to_string()]).unwrap();
assert_eq!(held["plan-zone"], "project:docs/plan=1");
}
#[test]
fn a_malformed_or_repeated_selection_is_refused() {
assert_eq!(
parse(&["nonsense".to_string()]).unwrap_err(),
AnswerError::Malformed("nonsense".to_string())
);
assert!(matches!(
parse(&["=x".to_string()]).unwrap_err(),
AnswerError::Malformed(_)
));
assert_eq!(
parse(&["a=1".to_string(), "a=2".to_string()]).unwrap_err(),
AnswerError::Duplicate("a".to_string())
);
}
#[test]
fn an_unknown_or_stale_answer_is_refused_against_the_plan() {
let offered = vec![scope()];
let selections = parse(&["migration-scope=sweep".to_string()]).unwrap();
assert!(validate(&offered, &selections).is_ok());
let unknown = parse(&["no-such-decision=x".to_string()]).unwrap();
assert!(matches!(
validate(&offered, &unknown).unwrap_err(),
AnswerError::Unknown(_)
));
let stale = parse(&["migration-scope=partial".to_string()]).unwrap();
assert!(matches!(
validate(&offered, &stale).unwrap_err(),
AnswerError::Rejected { .. }
));
}
#[test]
fn the_decision_dependency_graph_is_acyclic() {
assert!(acyclic(&[profile(), zone()]).is_ok());
assert!(acyclic(&[zone()]).is_err());
let mut one = profile();
let mut two = zone();
one.depends_on = vec![two.id.clone()];
two.depends_on = vec![one.id.clone()];
let error = acyclic(&[one, two]).unwrap_err();
assert!(error.contains("cycle"), "{error}");
}
}