Skip to main content

spec_driven_docs/plan/
decision.rs

1//! What the operator decides, and how they say it.
2//!
3//! A decision is workflow state the operator owns. The planner offers it,
4//! the operator answers it by re-planning with `--set`, and the answer
5//! joins the fingerprint, so an approval binds to the answers it was given
6//! with. Nothing here guesses a default: a decision the operator has not
7//! made is a decision the plan is waiting on.
8//!
9//! Decisions form an acyclic graph. Until an upstream decision is
10//! selected, the planner omits the findings, decisions, and operations
11//! that depend on it, rather than computing them against a value nobody
12//! chose.
13
14use std::collections::BTreeMap;
15
16use serde::{Deserialize, Serialize};
17use thiserror::Error;
18
19/// What shape an answer takes.
20#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
21#[serde(tag = "kind", rename_all = "kebab-case")]
22pub enum AnswerSchema {
23    /// One of a closed set of choice identifiers.
24    Choice {
25        /// Every identifier the operator may give.
26        choices: Vec<Choice>,
27    },
28    /// A choice identifier, or a prefixed value the operator supplies.
29    ChoiceOrValue {
30        /// Every plain identifier the operator may give.
31        choices: Vec<Choice>,
32        /// Every prefix that introduces an operator-supplied value.
33        prefixes: Vec<String>,
34    },
35}
36
37/// One answer the operator may give, and what it means.
38#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
39pub struct Choice {
40    /// The identifier, as `--set` spells it.
41    pub id: String,
42    /// What choosing it does.
43    pub consequence: String,
44}
45
46/// One question the plan is waiting on.
47#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
48pub struct Decision {
49    /// A stable identifier, as `--set` spells it.
50    pub id: String,
51    /// The question, in one sentence.
52    pub question: String,
53    /// What an answer may look like.
54    pub schema: AnswerSchema,
55    /// Decisions that must be answered before this one is offered.
56    #[serde(default, skip_serializing_if = "Vec::is_empty")]
57    pub depends_on: Vec<String>,
58    /// The answer the operator gave, where they gave one.
59    #[serde(default, skip_serializing_if = "Option::is_none")]
60    pub selected: Option<String>,
61}
62
63impl Decision {
64    /// Whether an answer is one this decision accepts.
65    #[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
81/// The identifiers the planner offers.
82pub mod id {
83    /// Which profile a first landing takes.
84    pub const PROFILE: &str = "profile";
85    /// Where material that is not a statement yet is staged.
86    pub const DOCS_SCRATCH: &str = "docs-scratch";
87    /// Which writing source the project selects.
88    pub const WRITING_STYLE: &str = "writing-style";
89    /// Whether a migration sweeps the whole corpus.
90    pub const MIGRATION_SCOPE: &str = "migration-scope";
91    /// Whether the inherited violations are recorded as debt.
92    pub const DEBT_BASELINE: &str = "debt-baseline";
93    /// Whether a yanked release is an acceptable destination.
94    pub const ACCEPT_YANKED: &str = "accept-yanked-release";
95}
96
97/// An answer the planner cannot take.
98#[derive(Debug, Clone, PartialEq, Eq, Error)]
99pub enum AnswerError {
100    /// The argument is not `<id>=<answer>`.
101    #[error("--set takes <decision-id>=<answer>; '{0}' has no '='")]
102    Malformed(String),
103
104    /// The same decision was answered twice.
105    #[error("--set {0} was given twice; one decision takes one answer")]
106    Duplicate(String),
107
108    /// No decision carries that identifier.
109    #[error("--set {0}: this plan offers no decision with that id")]
110    Unknown(String),
111
112    /// The decision does not accept that answer.
113    #[error("--set {id}={answer}: {id} does not offer that answer")]
114    Rejected {
115        /// The decision.
116        id: String,
117        /// What was given.
118        answer: String,
119    },
120}
121
122/// What the operator selected, by decision identifier.
123pub type Selections = BTreeMap<String, String>;
124
125/// Read repeatable `--set` arguments into selections.
126///
127/// Splitting on the first `=` is deliberate: a value may carry one, and a
128/// path frequently does.
129///
130/// # Errors
131///
132/// [`AnswerError`] for an argument that is not a pair and for a decision
133/// answered twice. Whether the decision exists and accepts the answer is
134/// [`validate`], because that needs the plan the answers are for.
135pub 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
152/// Check every selection against the decisions a plan offers.
153///
154/// # Errors
155///
156/// [`AnswerError::Unknown`] for an identifier the plan does not offer, and
157/// [`AnswerError::Rejected`] for an answer it does not accept. A stale
158/// answer is one of those two: a decision the plan stopped offering, or an
159/// answer it stopped accepting.
160pub 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
176/// Whether the decisions form an acyclic graph.
177///
178/// # Errors
179///
180/// The identifier of a decision that is part of a cycle or that names a
181/// prerequisite nothing offers.
182pub 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        // A prerequisite nothing offers is the same defect as a cycle: the
339        // decision can never be reached.
340        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}