spec_driven_docs/landing/
classify.rs1use serde::{Deserialize, Serialize};
13use thiserror::Error;
14
15#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
17#[serde(rename_all = "kebab-case")]
18pub enum Classification {
19 Setup,
21 Migration,
23 Upgrade,
25 Drift,
27 Current,
29 Invalid,
31}
32
33impl Classification {
34 #[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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
56pub enum Intent {
57 Reconcile,
59 Init,
61 Upgrade,
63 Assess,
65}
66
67#[derive(Debug, Clone, PartialEq, Eq, Error)]
69#[error("{verb} does not serve a {found} target; run {next}")]
70pub struct IntentRefused {
71 pub verb: &'static str,
73 pub found: Classification,
75 pub next: &'static str,
77}
78
79impl Intent {
80 pub const fn accepts(self, found: Classification) -> Result<(), IntentRefused> {
88 match (self, found) {
89 (Self::Reconcile | Self::Assess, _)
90 | (
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#[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 pub invalid: bool,
134 pub installed: bool,
136 pub at_destination: bool,
138 pub drifted: bool,
140 pub settled: bool,
142}
143
144#[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 assert_eq!(classify(signals()), Classification::Setup);
183 let settled = Signals {
186 settled: true,
187 ..signals()
188 };
189 assert_eq!(classify(settled), Classification::Migration);
190 let old = Signals {
192 installed: true,
193 ..signals()
194 };
195 assert_eq!(classify(old), Classification::Upgrade);
196 let drifted = Signals {
198 installed: true,
199 at_destination: true,
200 drifted: true,
201 ..signals()
202 };
203 assert_eq!(classify(drifted), Classification::Drift);
204 let current = Signals {
206 installed: true,
207 at_destination: true,
208 ..signals()
209 };
210 assert_eq!(classify(current), Classification::Current);
211 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 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 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}