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 the planning tool writes its entry documents.
86    pub const PLAN_ZONE: &str = "plan-zone";
87    /// Where material that is not a statement yet is staged.
88    pub const DOCS_SCRATCH: &str = "docs-scratch";
89    /// Which writing source the project selects.
90    pub const WRITING_STYLE: &str = "writing-style";
91    /// Whether a migration sweeps the whole corpus.
92    pub const MIGRATION_SCOPE: &str = "migration-scope";
93    /// Whether the inherited violations are recorded as debt.
94    pub const DEBT_BASELINE: &str = "debt-baseline";
95    /// Whether a yanked release is an acceptable destination.
96    pub const ACCEPT_YANKED: &str = "accept-yanked-release";
97}
98
99/// An answer the planner cannot take.
100#[derive(Debug, Clone, PartialEq, Eq, Error)]
101pub enum AnswerError {
102    /// The argument is not `<id>=<answer>`.
103    #[error("--set takes <decision-id>=<answer>; '{0}' has no '='")]
104    Malformed(String),
105
106    /// The same decision was answered twice.
107    #[error("--set {0} was given twice; one decision takes one answer")]
108    Duplicate(String),
109
110    /// No decision carries that identifier.
111    #[error("--set {0}: this plan offers no decision with that id")]
112    Unknown(String),
113
114    /// The decision does not accept that answer.
115    #[error("--set {id}={answer}: {id} does not offer that answer")]
116    Rejected {
117        /// The decision.
118        id: String,
119        /// What was given.
120        answer: String,
121    },
122}
123
124/// What the operator selected, by decision identifier.
125pub type Selections = BTreeMap<String, String>;
126
127/// Read repeatable `--set` arguments into selections.
128///
129/// Splitting on the first `=` is deliberate: a value may carry one, and a
130/// path frequently does.
131///
132/// # Errors
133///
134/// [`AnswerError`] for an argument that is not a pair and for a decision
135/// answered twice. Whether the decision exists and accepts the answer is
136/// [`validate`], because that needs the plan the answers are for.
137pub 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
154/// Check every selection against the decisions a plan offers.
155///
156/// # Errors
157///
158/// [`AnswerError::Unknown`] for an identifier the plan does not offer, and
159/// [`AnswerError::Rejected`] for an answer it does not accept. A stale
160/// answer is one of those two: a decision the plan stopped offering, or an
161/// answer it stopped accepting.
162pub 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
178/// Whether the decisions form an acyclic graph.
179///
180/// # Errors
181///
182/// The identifier of a decision that is part of a cycle or that names a
183/// prerequisite nothing offers.
184pub 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        // A prerequisite nothing offers is the same defect as a cycle: the
341        // decision can never be reached.
342        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}