Skip to main content

spec_driven_docs/landing/
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, which is how a declared location changes. What it may
92            // not do is land seeds beside a convention that is already
93            // there, or over a record nobody can read. Moving the profile
94            // of an installed instance is refused separately, by the
95            // landing itself.
96            | (
97                Self::Init,
98                Classification::Setup
99                | Classification::Upgrade
100                | Classification::Drift
101                | Classification::Current,
102            )
103            | (
104                Self::Upgrade,
105                Classification::Upgrade | Classification::Drift | Classification::Current,
106            ) => Ok(()),
107            (Self::Init, _) => Err(IntentRefused {
108                verb: "sdd init",
109                found,
110                next: "sdd stage",
111            }),
112            (Self::Upgrade, _) => Err(IntentRefused {
113                verb: "sdd upgrade",
114                found,
115                next: "sdd stage",
116            }),
117        }
118    }
119}
120
121/// What the observation has to say before a classification can be read.
122///
123/// Five independent answers rather than one enumeration, because the
124/// classification is what combines them and a caller that had to pick a
125/// combined value would be classifying before the classifier does.
126#[expect(
127    clippy::struct_excessive_bools,
128    reason = "each field is one independent observation, and folding them would move the classification into the caller"
129)]
130#[derive(Debug, Clone, Copy, PartialEq, Eq)]
131pub struct Signals {
132    /// Metadata exists and cannot be trusted.
133    pub invalid: bool,
134    /// An instance is recorded.
135    pub installed: bool,
136    /// The recorded release is the destination.
137    pub at_destination: bool,
138    /// A recorded file has moved.
139    pub drifted: bool,
140    /// The target documents itself already.
141    pub settled: bool,
142}
143
144/// Read the classification from what was observed.
145#[must_use]
146pub const fn classify(signals: Signals) -> Classification {
147    if signals.invalid {
148        return Classification::Invalid;
149    }
150    if !signals.installed {
151        if signals.settled {
152            return Classification::Migration;
153        }
154        return Classification::Setup;
155    }
156    if !signals.at_destination {
157        return Classification::Upgrade;
158    }
159    if signals.drifted {
160        return Classification::Drift;
161    }
162    Classification::Current
163}
164
165#[cfg(test)]
166mod tests {
167    use super::*;
168
169    const fn signals() -> Signals {
170        Signals {
171            invalid: false,
172            installed: false,
173            at_destination: false,
174            drifted: false,
175            settled: false,
176        }
177    }
178
179    #[test]
180    fn each_of_the_seven_target_states_classifies_correctly() {
181        // Empty.
182        assert_eq!(classify(signals()), Classification::Setup);
183        // Brownfield, whether or not it carries structural findings: the
184        // findings are a separate axis and do not move the classification.
185        let settled = Signals {
186            settled: true,
187            ..signals()
188        };
189        assert_eq!(classify(settled), Classification::Migration);
190        // Landed and older than the destination.
191        let old = Signals {
192            installed: true,
193            ..signals()
194        };
195        assert_eq!(classify(old), Classification::Upgrade);
196        // Landed, at the destination, and moved.
197        let drifted = Signals {
198            installed: true,
199            at_destination: true,
200            drifted: true,
201            ..signals()
202        };
203        assert_eq!(classify(drifted), Classification::Drift);
204        // Landed, at the destination, and untouched.
205        let current = Signals {
206            installed: true,
207            at_destination: true,
208            ..signals()
209        };
210        assert_eq!(classify(current), Classification::Current);
211        // Metadata that exists and cannot be trusted.
212        let invalid = Signals {
213            invalid: true,
214            installed: true,
215            ..signals()
216        };
217        assert_eq!(classify(invalid), Classification::Invalid);
218    }
219
220    #[test]
221    fn malformed_metadata_is_invalid_and_never_absence() {
222        // Absence and breakage are different findings, and reporting a
223        // broken instance as absent would invite a destructive landing.
224        let invalid = Signals {
225            invalid: true,
226            ..signals()
227        };
228        assert_eq!(classify(invalid), Classification::Invalid);
229        assert_ne!(classify(invalid), Classification::Setup);
230    }
231
232    #[test]
233    fn init_intent_cannot_force_a_settled_target_to_setup() {
234        let error = Intent::Init.accepts(Classification::Migration).unwrap_err();
235        assert_eq!(error.verb, "sdd init");
236        assert!(error.to_string().contains("sdd stage"));
237        assert!(Intent::Init.accepts(Classification::Setup).is_ok());
238        // A reinstall over an instance the verb already owns still works,
239        // which is how a declared location changes. Moving the profile is
240        // what the landing refuses, not the classification.
241        assert!(Intent::Init.accepts(Classification::Current).is_ok());
242        assert!(Intent::Init.accepts(Classification::Upgrade).is_ok());
243        assert!(Intent::Init.accepts(Classification::Invalid).is_err());
244    }
245
246    #[test]
247    fn upgrade_intent_cannot_turn_an_absent_instance_into_an_upgrade() {
248        assert!(Intent::Upgrade.accepts(Classification::Setup).is_err());
249        assert!(Intent::Upgrade.accepts(Classification::Migration).is_err());
250        assert!(Intent::Upgrade.accepts(Classification::Upgrade).is_ok());
251        assert!(Intent::Upgrade.accepts(Classification::Current).is_ok());
252    }
253
254    #[test]
255    fn reconcile_and_assess_serve_whatever_the_target_is() {
256        for found in [
257            Classification::Setup,
258            Classification::Migration,
259            Classification::Upgrade,
260            Classification::Drift,
261            Classification::Current,
262            Classification::Invalid,
263        ] {
264            assert!(Intent::Reconcile.accepts(found).is_ok());
265            assert!(Intent::Assess.accepts(found).is_ok());
266        }
267    }
268}