Skip to main content

spec_driven_docs/plan/
classify.rs

1//! What a target is, read from the target and never from the request.
2//!
3//! A front verb says what the operator meant to do. It does not get to say
4//! what the target is: `init` cannot turn a settled corpus into a
5//! greenfield landing by asking nicely, and `upgrade` cannot turn an
6//! absent instance into an upgrade. The classification comes from what was
7//! observed, and a front constrains which classifications it will serve.
8//!
9//! Findings stay orthogonal. A migration with no structural finding and a
10//! migration with five are the same classification and different plans.
11
12use serde::{Deserialize, Serialize};
13use thiserror::Error;
14
15/// What the planner found the target to be.
16#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
17#[serde(rename_all = "kebab-case")]
18pub enum Classification {
19    /// No instance and nothing durable written: land one.
20    Setup,
21    /// No instance and a settled corpus: land one and move the corpus.
22    Migration,
23    /// An instance older than the destination.
24    Upgrade,
25    /// An instance at the destination whose files have moved.
26    Drift,
27    /// An instance at the destination with nothing to do.
28    Current,
29    /// Metadata that exists and cannot be trusted.
30    Invalid,
31}
32
33impl Classification {
34    /// The kebab-case word, as the JSON spells it.
35    #[must_use]
36    pub const fn as_str(self) -> &'static str {
37        match self {
38            Self::Setup => "setup",
39            Self::Migration => "migration",
40            Self::Upgrade => "upgrade",
41            Self::Drift => "drift",
42            Self::Current => "current",
43            Self::Invalid => "invalid",
44        }
45    }
46}
47
48impl std::fmt::Display for Classification {
49    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
50        f.write_str(self.as_str())
51    }
52}
53
54/// What a front verb is willing to serve.
55#[derive(Debug, Clone, Copy, PartialEq, Eq)]
56pub enum Intent {
57    /// Whatever the target is.
58    Reconcile,
59    /// A first landing.
60    Init,
61    /// A move to a newer release.
62    Upgrade,
63    /// A classification alone, writing nothing.
64    Assess,
65}
66
67/// A front verb asked for a target it does not serve.
68#[derive(Debug, Clone, PartialEq, Eq, Error)]
69#[error("{verb} does not serve a {found} target; run {next}")]
70pub struct IntentRefused {
71    /// The verb the operator ran.
72    pub verb: &'static str,
73    /// What the target turned out to be.
74    pub found: Classification,
75    /// What to run instead.
76    pub next: &'static str,
77}
78
79impl Intent {
80    /// Whether this front serves what the target turned out to be.
81    ///
82    /// # Errors
83    ///
84    /// [`IntentRefused`] naming the verb, the classification, and the next
85    /// command. A front that widened its own scope here would be the
86    /// classification lying about the target.
87    pub const fn accepts(self, found: Classification) -> Result<(), IntentRefused> {
88        match (self, found) {
89            (Self::Reconcile | Self::Assess, _)
90            // A landing verb still reinstalls over an instance it already
91            // owns. What it may not do is land seeds beside a convention
92            // that is already there, or over a record nobody can read.
93            | (
94                Self::Init,
95                Classification::Setup
96                | Classification::Upgrade
97                | Classification::Drift
98                | Classification::Current,
99            )
100            | (
101                Self::Upgrade,
102                Classification::Upgrade | Classification::Drift | Classification::Current,
103            ) => Ok(()),
104            (Self::Init, _) => Err(IntentRefused {
105                verb: "sdd init",
106                found,
107                next: "sdd reconcile plan",
108            }),
109            (Self::Upgrade, _) => Err(IntentRefused {
110                verb: "sdd upgrade",
111                found,
112                next: "sdd reconcile plan",
113            }),
114        }
115    }
116}
117
118/// What the observation has to say before a classification can be read.
119///
120/// Five independent answers rather than one enumeration, because the
121/// classification is what combines them and a caller that had to pick a
122/// combined value would be classifying before the classifier does.
123#[expect(
124    clippy::struct_excessive_bools,
125    reason = "each field is one independent observation, and folding them would move the classification into the caller"
126)]
127#[derive(Debug, Clone, Copy, PartialEq, Eq)]
128pub struct Signals {
129    /// Metadata exists and cannot be trusted.
130    pub invalid: bool,
131    /// An instance is recorded.
132    pub installed: bool,
133    /// The recorded release is the destination.
134    pub at_destination: bool,
135    /// A recorded file has moved.
136    pub drifted: bool,
137    /// The target documents itself already.
138    pub settled: bool,
139}
140
141/// Read the classification from what was observed.
142#[must_use]
143pub const fn classify(signals: Signals) -> Classification {
144    if signals.invalid {
145        return Classification::Invalid;
146    }
147    if !signals.installed {
148        if signals.settled {
149            return Classification::Migration;
150        }
151        return Classification::Setup;
152    }
153    if !signals.at_destination {
154        return Classification::Upgrade;
155    }
156    if signals.drifted {
157        return Classification::Drift;
158    }
159    Classification::Current
160}
161
162#[cfg(test)]
163mod tests {
164    use super::*;
165
166    const fn signals() -> Signals {
167        Signals {
168            invalid: false,
169            installed: false,
170            at_destination: false,
171            drifted: false,
172            settled: false,
173        }
174    }
175
176    #[test]
177    fn each_of_the_seven_target_states_classifies_correctly() {
178        // Empty.
179        assert_eq!(classify(signals()), Classification::Setup);
180        // Brownfield, whether or not it carries structural findings: the
181        // findings are a separate axis and do not move the classification.
182        let settled = Signals {
183            settled: true,
184            ..signals()
185        };
186        assert_eq!(classify(settled), Classification::Migration);
187        // Landed and older than the destination.
188        let old = Signals {
189            installed: true,
190            ..signals()
191        };
192        assert_eq!(classify(old), Classification::Upgrade);
193        // Landed, at the destination, and moved.
194        let drifted = Signals {
195            installed: true,
196            at_destination: true,
197            drifted: true,
198            ..signals()
199        };
200        assert_eq!(classify(drifted), Classification::Drift);
201        // Landed, at the destination, and untouched.
202        let current = Signals {
203            installed: true,
204            at_destination: true,
205            ..signals()
206        };
207        assert_eq!(classify(current), Classification::Current);
208        // Metadata that exists and cannot be trusted.
209        let invalid = Signals {
210            invalid: true,
211            installed: true,
212            ..signals()
213        };
214        assert_eq!(classify(invalid), Classification::Invalid);
215    }
216
217    #[test]
218    fn malformed_metadata_is_invalid_and_never_absence() {
219        // Absence and breakage are different findings, and reporting a
220        // broken instance as absent would invite a destructive landing.
221        let invalid = Signals {
222            invalid: true,
223            ..signals()
224        };
225        assert_eq!(classify(invalid), Classification::Invalid);
226        assert_ne!(classify(invalid), Classification::Setup);
227    }
228
229    #[test]
230    fn init_intent_cannot_force_a_settled_target_to_setup() {
231        let error = Intent::Init.accepts(Classification::Migration).unwrap_err();
232        assert_eq!(error.verb, "sdd init");
233        assert!(error.to_string().contains("sdd reconcile plan"));
234        assert!(Intent::Init.accepts(Classification::Setup).is_ok());
235        // A reinstall over an instance the verb already owns still works.
236        assert!(Intent::Init.accepts(Classification::Current).is_ok());
237        assert!(Intent::Init.accepts(Classification::Upgrade).is_ok());
238        assert!(Intent::Init.accepts(Classification::Invalid).is_err());
239    }
240
241    #[test]
242    fn upgrade_intent_cannot_turn_an_absent_instance_into_an_upgrade() {
243        assert!(Intent::Upgrade.accepts(Classification::Setup).is_err());
244        assert!(Intent::Upgrade.accepts(Classification::Migration).is_err());
245        assert!(Intent::Upgrade.accepts(Classification::Upgrade).is_ok());
246        assert!(Intent::Upgrade.accepts(Classification::Current).is_ok());
247    }
248
249    #[test]
250    fn reconcile_and_assess_serve_whatever_the_target_is() {
251        for found in [
252            Classification::Setup,
253            Classification::Migration,
254            Classification::Upgrade,
255            Classification::Drift,
256            Classification::Current,
257            Classification::Invalid,
258        ] {
259            assert!(Intent::Reconcile.accepts(found).is_ok());
260            assert!(Intent::Assess.accepts(found).is_ok());
261        }
262    }
263}