1use std::collections::BTreeMap;
15
16use serde::{Deserialize, Serialize};
17use thiserror::Error;
18
19#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
21#[serde(tag = "kind", rename_all = "kebab-case")]
22pub enum AnswerSchema {
23 Choice {
25 choices: Vec<Choice>,
27 },
28 ChoiceOrValue {
30 choices: Vec<Choice>,
32 prefixes: Vec<String>,
34 },
35}
36
37#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
39pub struct Choice {
40 pub id: String,
42 pub consequence: String,
44}
45
46#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
48pub struct Decision {
49 pub id: String,
51 pub question: String,
53 pub schema: AnswerSchema,
55 #[serde(default, skip_serializing_if = "Vec::is_empty")]
57 pub depends_on: Vec<String>,
58 #[serde(default, skip_serializing_if = "Option::is_none")]
60 pub selected: Option<String>,
61}
62
63impl Decision {
64 #[must_use]
66 pub fn accepts(&self, answer: &str) -> bool {
67 match &self.schema {
68 AnswerSchema::Choice { choices } => choices.iter().any(|choice| choice.id == answer),
69 AnswerSchema::ChoiceOrValue { choices, prefixes } => {
70 choices.iter().any(|choice| choice.id == answer)
71 || prefixes.iter().any(|prefix| {
72 answer
73 .strip_prefix(prefix.as_str())
74 .is_some_and(|rest| !rest.is_empty())
75 })
76 }
77 }
78 }
79}
80
81pub mod id {
83 pub const PROFILE: &str = "profile";
85 pub const DOCS_SCRATCH: &str = "docs-scratch";
87 pub const WRITING_STYLE: &str = "writing-style";
89 pub const MIGRATION_SCOPE: &str = "migration-scope";
91 pub const DEBT_BASELINE: &str = "debt-baseline";
93 pub const ACCEPT_YANKED: &str = "accept-yanked-release";
95}
96
97#[derive(Debug, Clone, PartialEq, Eq, Error)]
99pub enum AnswerError {
100 #[error("--set takes <decision-id>=<answer>; '{0}' has no '='")]
102 Malformed(String),
103
104 #[error("--set {0} was given twice; one decision takes one answer")]
106 Duplicate(String),
107
108 #[error("--set {0}: this plan offers no decision with that id")]
110 Unknown(String),
111
112 #[error("--set {id}={answer}: {id} does not offer that answer")]
114 Rejected {
115 id: String,
117 answer: String,
119 },
120}
121
122pub type Selections = BTreeMap<String, String>;
124
125pub fn parse(arguments: &[String]) -> Result<Selections, AnswerError> {
136 let mut selections = Selections::new();
137 for argument in arguments {
138 let (id, answer) = argument
139 .split_once('=')
140 .ok_or_else(|| AnswerError::Malformed(argument.clone()))?;
141 if id.is_empty() {
142 return Err(AnswerError::Malformed(argument.clone()));
143 }
144 if selections.contains_key(id) {
145 return Err(AnswerError::Duplicate(id.to_string()));
146 }
147 selections.insert(id.to_string(), answer.trim().to_string());
148 }
149 Ok(selections)
150}
151
152pub fn validate(offered: &[Decision], selections: &Selections) -> Result<(), AnswerError> {
161 for (id, answer) in selections {
162 let decision = offered
163 .iter()
164 .find(|decision| &decision.id == id)
165 .ok_or_else(|| AnswerError::Unknown(id.clone()))?;
166 if !decision.accepts(answer) {
167 return Err(AnswerError::Rejected {
168 id: id.clone(),
169 answer: answer.clone(),
170 });
171 }
172 }
173 Ok(())
174}
175
176pub fn acyclic(decisions: &[Decision]) -> Result<(), String> {
183 for decision in decisions {
184 for needed in &decision.depends_on {
185 if !decisions.iter().any(|other| &other.id == needed) {
186 return Err(format!(
187 "{} depends on {needed}, which this plan does not offer",
188 decision.id
189 ));
190 }
191 }
192 }
193 let mut settled: Vec<&str> = Vec::new();
194 while settled.len() < decisions.len() {
195 let before = settled.len();
196 for decision in decisions {
197 if settled.contains(&decision.id.as_str()) {
198 continue;
199 }
200 if decision
201 .depends_on
202 .iter()
203 .all(|needed| settled.contains(&needed.as_str()))
204 {
205 settled.push(&decision.id);
206 }
207 }
208 if settled.len() == before {
209 let stuck: Vec<&str> = decisions
210 .iter()
211 .map(|decision| decision.id.as_str())
212 .filter(|id| !settled.contains(id))
213 .collect();
214 return Err(format!(
215 "these decisions form a cycle: {}",
216 stuck.join(", ")
217 ));
218 }
219 }
220 Ok(())
221}
222
223#[cfg(test)]
224mod tests {
225 #![allow(
226 clippy::unwrap_used,
227 reason = "a test panics as its failure signal, not as control flow"
228 )]
229
230 use super::*;
231
232 fn choice(id: &str) -> Choice {
233 Choice {
234 id: id.to_string(),
235 consequence: format!("it does {id}"),
236 }
237 }
238
239 fn scope() -> Decision {
240 Decision {
241 id: id::MIGRATION_SCOPE.to_string(),
242 question: "how much of the corpus moves?".to_string(),
243 schema: AnswerSchema::Choice {
244 choices: vec![choice("sweep"), choice("incremental")],
245 },
246 depends_on: Vec::new(),
247 selected: None,
248 }
249 }
250
251 fn scratch() -> Decision {
252 Decision {
253 id: id::DOCS_SCRATCH.to_string(),
254 question: "where does material that is not a statement yet stage?".to_string(),
255 schema: AnswerSchema::ChoiceOrValue {
256 choices: vec![choice("none")],
257 prefixes: vec!["project:".to_string(), "external:".to_string()],
258 },
259 depends_on: vec![id::PROFILE.to_string()],
260 selected: None,
261 }
262 }
263
264 fn profile() -> Decision {
265 Decision {
266 id: id::PROFILE.to_string(),
267 question: "which profile?".to_string(),
268 schema: AnswerSchema::Choice {
269 choices: vec![choice("codebase"), choice("knowledge-base")],
270 },
271 depends_on: Vec::new(),
272 selected: None,
273 }
274 }
275
276 #[test]
277 fn a_closed_choice_takes_only_its_own_identifiers() {
278 let held = scope();
279 assert!(held.accepts("sweep"));
280 assert!(!held.accepts("Sweep"));
281 assert!(!held.accepts("project:.docs-scratch"));
282 }
283
284 #[test]
285 fn a_parameterized_answer_takes_a_prefix_with_something_after_it() {
286 let held = scratch();
287 assert!(held.accepts("none"));
288 assert!(held.accepts("project:.docs-scratch"));
289 assert!(held.accepts("external:../scratch"));
290 assert!(!held.accepts("project:"));
291 assert!(!held.accepts("elsewhere"));
292 }
293
294 #[test]
295 fn a_selection_splits_on_the_first_equals_so_a_value_may_carry_one() {
296 let held = parse(&["docs-scratch=project:scratch=1".to_string()]).unwrap();
297 assert_eq!(held["docs-scratch"], "project:scratch=1");
298 }
299
300 #[test]
301 fn a_malformed_or_repeated_selection_is_refused() {
302 assert_eq!(
303 parse(&["nonsense".to_string()]).unwrap_err(),
304 AnswerError::Malformed("nonsense".to_string())
305 );
306 assert!(matches!(
307 parse(&["=x".to_string()]).unwrap_err(),
308 AnswerError::Malformed(_)
309 ));
310 assert_eq!(
311 parse(&["a=1".to_string(), "a=2".to_string()]).unwrap_err(),
312 AnswerError::Duplicate("a".to_string())
313 );
314 }
315
316 #[test]
317 fn an_unknown_or_stale_answer_is_refused_against_the_plan() {
318 let offered = vec![scope()];
319 let selections = parse(&["migration-scope=sweep".to_string()]).unwrap();
320 assert!(validate(&offered, &selections).is_ok());
321
322 let unknown = parse(&["no-such-decision=x".to_string()]).unwrap();
323 assert!(matches!(
324 validate(&offered, &unknown).unwrap_err(),
325 AnswerError::Unknown(_)
326 ));
327
328 let stale = parse(&["migration-scope=partial".to_string()]).unwrap();
329 assert!(matches!(
330 validate(&offered, &stale).unwrap_err(),
331 AnswerError::Rejected { .. }
332 ));
333 }
334
335 #[test]
336 fn the_decision_dependency_graph_is_acyclic() {
337 assert!(acyclic(&[profile(), scratch()]).is_ok());
338 assert!(acyclic(&[scratch()]).is_err());
341
342 let mut one = profile();
343 let mut two = scratch();
344 one.depends_on = vec![two.id.clone()];
345 two.depends_on = vec![one.id.clone()];
346 let error = acyclic(&[one, two]).unwrap_err();
347 assert!(error.contains("cycle"), "{error}");
348 }
349}