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 PLAN_ZONE: &str = "plan-zone";
87 pub const DOCS_SCRATCH: &str = "docs-scratch";
89 pub const WRITING_STYLE: &str = "writing-style";
91 pub const MIGRATION_SCOPE: &str = "migration-scope";
93 pub const DEBT_BASELINE: &str = "debt-baseline";
95 pub const ACCEPT_YANKED: &str = "accept-yanked-release";
97}
98
99#[derive(Debug, Clone, PartialEq, Eq, Error)]
101pub enum AnswerError {
102 #[error("--set takes <decision-id>=<answer>; '{0}' has no '='")]
104 Malformed(String),
105
106 #[error("--set {0} was given twice; one decision takes one answer")]
108 Duplicate(String),
109
110 #[error("--set {0}: this plan offers no decision with that id")]
112 Unknown(String),
113
114 #[error("--set {id}={answer}: {id} does not offer that answer")]
116 Rejected {
117 id: String,
119 answer: String,
121 },
122}
123
124pub type Selections = BTreeMap<String, String>;
126
127pub fn parse(arguments: &[String]) -> Result<Selections, AnswerError> {
138 let mut selections = Selections::new();
139 for argument in arguments {
140 let (id, answer) = argument
141 .split_once('=')
142 .ok_or_else(|| AnswerError::Malformed(argument.clone()))?;
143 if id.is_empty() {
144 return Err(AnswerError::Malformed(argument.clone()));
145 }
146 if selections.contains_key(id) {
147 return Err(AnswerError::Duplicate(id.to_string()));
148 }
149 selections.insert(id.to_string(), answer.trim().to_string());
150 }
151 Ok(selections)
152}
153
154pub fn validate(offered: &[Decision], selections: &Selections) -> Result<(), AnswerError> {
163 for (id, answer) in selections {
164 let decision = offered
165 .iter()
166 .find(|decision| &decision.id == id)
167 .ok_or_else(|| AnswerError::Unknown(id.clone()))?;
168 if !decision.accepts(answer) {
169 return Err(AnswerError::Rejected {
170 id: id.clone(),
171 answer: answer.clone(),
172 });
173 }
174 }
175 Ok(())
176}
177
178pub fn acyclic(decisions: &[Decision]) -> Result<(), String> {
185 for decision in decisions {
186 for needed in &decision.depends_on {
187 if !decisions.iter().any(|other| &other.id == needed) {
188 return Err(format!(
189 "{} depends on {needed}, which this plan does not offer",
190 decision.id
191 ));
192 }
193 }
194 }
195 let mut settled: Vec<&str> = Vec::new();
196 while settled.len() < decisions.len() {
197 let before = settled.len();
198 for decision in decisions {
199 if settled.contains(&decision.id.as_str()) {
200 continue;
201 }
202 if decision
203 .depends_on
204 .iter()
205 .all(|needed| settled.contains(&needed.as_str()))
206 {
207 settled.push(&decision.id);
208 }
209 }
210 if settled.len() == before {
211 let stuck: Vec<&str> = decisions
212 .iter()
213 .map(|decision| decision.id.as_str())
214 .filter(|id| !settled.contains(id))
215 .collect();
216 return Err(format!(
217 "these decisions form a cycle: {}",
218 stuck.join(", ")
219 ));
220 }
221 }
222 Ok(())
223}
224
225#[cfg(test)]
226mod tests {
227 #![allow(
228 clippy::unwrap_used,
229 reason = "a test panics as its failure signal, not as control flow"
230 )]
231
232 use super::*;
233
234 fn choice(id: &str) -> Choice {
235 Choice {
236 id: id.to_string(),
237 consequence: format!("it does {id}"),
238 }
239 }
240
241 fn scope() -> Decision {
242 Decision {
243 id: id::MIGRATION_SCOPE.to_string(),
244 question: "how much of the corpus moves?".to_string(),
245 schema: AnswerSchema::Choice {
246 choices: vec![choice("sweep"), choice("incremental")],
247 },
248 depends_on: Vec::new(),
249 selected: None,
250 }
251 }
252
253 fn zone() -> Decision {
254 Decision {
255 id: id::PLAN_ZONE.to_string(),
256 question: "where does the planning tool write?".to_string(),
257 schema: AnswerSchema::ChoiceOrValue {
258 choices: vec![choice("env"), choice("none")],
259 prefixes: vec!["project:".to_string(), "untracked:".to_string()],
260 },
261 depends_on: vec![id::PROFILE.to_string()],
262 selected: None,
263 }
264 }
265
266 fn profile() -> Decision {
267 Decision {
268 id: id::PROFILE.to_string(),
269 question: "which profile?".to_string(),
270 schema: AnswerSchema::Choice {
271 choices: vec![choice("codebase"), choice("knowledge-base")],
272 },
273 depends_on: Vec::new(),
274 selected: None,
275 }
276 }
277
278 #[test]
279 fn a_closed_choice_takes_only_its_own_identifiers() {
280 let held = scope();
281 assert!(held.accepts("sweep"));
282 assert!(!held.accepts("Sweep"));
283 assert!(!held.accepts("project:docs/plan"));
284 }
285
286 #[test]
287 fn a_parameterized_answer_takes_a_prefix_with_something_after_it() {
288 let held = zone();
289 assert!(held.accepts("env"));
290 assert!(held.accepts("project:docs/plan"));
291 assert!(held.accepts("untracked:.plans"));
292 assert!(!held.accepts("project:"));
293 assert!(!held.accepts("elsewhere"));
294 }
295
296 #[test]
297 fn a_selection_splits_on_the_first_equals_so_a_value_may_carry_one() {
298 let held = parse(&["plan-zone=project:docs/plan=1".to_string()]).unwrap();
299 assert_eq!(held["plan-zone"], "project:docs/plan=1");
300 }
301
302 #[test]
303 fn a_malformed_or_repeated_selection_is_refused() {
304 assert_eq!(
305 parse(&["nonsense".to_string()]).unwrap_err(),
306 AnswerError::Malformed("nonsense".to_string())
307 );
308 assert!(matches!(
309 parse(&["=x".to_string()]).unwrap_err(),
310 AnswerError::Malformed(_)
311 ));
312 assert_eq!(
313 parse(&["a=1".to_string(), "a=2".to_string()]).unwrap_err(),
314 AnswerError::Duplicate("a".to_string())
315 );
316 }
317
318 #[test]
319 fn an_unknown_or_stale_answer_is_refused_against_the_plan() {
320 let offered = vec![scope()];
321 let selections = parse(&["migration-scope=sweep".to_string()]).unwrap();
322 assert!(validate(&offered, &selections).is_ok());
323
324 let unknown = parse(&["no-such-decision=x".to_string()]).unwrap();
325 assert!(matches!(
326 validate(&offered, &unknown).unwrap_err(),
327 AnswerError::Unknown(_)
328 ));
329
330 let stale = parse(&["migration-scope=partial".to_string()]).unwrap();
331 assert!(matches!(
332 validate(&offered, &stale).unwrap_err(),
333 AnswerError::Rejected { .. }
334 ));
335 }
336
337 #[test]
338 fn the_decision_dependency_graph_is_acyclic() {
339 assert!(acyclic(&[profile(), zone()]).is_ok());
340 assert!(acyclic(&[zone()]).is_err());
343
344 let mut one = profile();
345 let mut two = zone();
346 one.depends_on = vec![two.id.clone()];
347 two.depends_on = vec![one.id.clone()];
348 let error = acyclic(&[one, two]).unwrap_err();
349 assert!(error.contains("cycle"), "{error}");
350 }
351}