1use 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 | (
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#[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 pub invalid: bool,
131 pub installed: bool,
133 pub at_destination: bool,
135 pub drifted: bool,
137 pub settled: bool,
139}
140
141#[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 assert_eq!(classify(signals()), Classification::Setup);
180 let settled = Signals {
183 settled: true,
184 ..signals()
185 };
186 assert_eq!(classify(settled), Classification::Migration);
187 let old = Signals {
189 installed: true,
190 ..signals()
191 };
192 assert_eq!(classify(old), Classification::Upgrade);
193 let drifted = Signals {
195 installed: true,
196 at_destination: true,
197 drifted: true,
198 ..signals()
199 };
200 assert_eq!(classify(drifted), Classification::Drift);
201 let current = Signals {
203 installed: true,
204 at_destination: true,
205 ..signals()
206 };
207 assert_eq!(classify(current), Classification::Current);
208 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 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 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}