use serde::{Deserialize, Serialize};
use thiserror::Error;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case")]
pub enum Classification {
Setup,
Migration,
Upgrade,
Drift,
Current,
Invalid,
}
impl Classification {
#[must_use]
pub const fn as_str(self) -> &'static str {
match self {
Self::Setup => "setup",
Self::Migration => "migration",
Self::Upgrade => "upgrade",
Self::Drift => "drift",
Self::Current => "current",
Self::Invalid => "invalid",
}
}
}
impl std::fmt::Display for Classification {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(self.as_str())
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Intent {
Reconcile,
Init,
Upgrade,
Assess,
}
#[derive(Debug, Clone, PartialEq, Eq, Error)]
#[error("{verb} does not serve a {found} target; run {next}")]
pub struct IntentRefused {
pub verb: &'static str,
pub found: Classification,
pub next: &'static str,
}
impl Intent {
pub const fn accepts(self, found: Classification) -> Result<(), IntentRefused> {
match (self, found) {
(Self::Reconcile | Self::Assess, _)
| (
Self::Init,
Classification::Setup
| Classification::Upgrade
| Classification::Drift
| Classification::Current,
)
| (
Self::Upgrade,
Classification::Upgrade | Classification::Drift | Classification::Current,
) => Ok(()),
(Self::Init, _) => Err(IntentRefused {
verb: "sdd init",
found,
next: "sdd reconcile plan",
}),
(Self::Upgrade, _) => Err(IntentRefused {
verb: "sdd upgrade",
found,
next: "sdd reconcile plan",
}),
}
}
}
#[expect(
clippy::struct_excessive_bools,
reason = "each field is one independent observation, and folding them would move the classification into the caller"
)]
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct Signals {
pub invalid: bool,
pub installed: bool,
pub at_destination: bool,
pub drifted: bool,
pub settled: bool,
}
#[must_use]
pub const fn classify(signals: Signals) -> Classification {
if signals.invalid {
return Classification::Invalid;
}
if !signals.installed {
if signals.settled {
return Classification::Migration;
}
return Classification::Setup;
}
if !signals.at_destination {
return Classification::Upgrade;
}
if signals.drifted {
return Classification::Drift;
}
Classification::Current
}
#[cfg(test)]
mod tests {
use super::*;
const fn signals() -> Signals {
Signals {
invalid: false,
installed: false,
at_destination: false,
drifted: false,
settled: false,
}
}
#[test]
fn each_of_the_seven_target_states_classifies_correctly() {
assert_eq!(classify(signals()), Classification::Setup);
let settled = Signals {
settled: true,
..signals()
};
assert_eq!(classify(settled), Classification::Migration);
let old = Signals {
installed: true,
..signals()
};
assert_eq!(classify(old), Classification::Upgrade);
let drifted = Signals {
installed: true,
at_destination: true,
drifted: true,
..signals()
};
assert_eq!(classify(drifted), Classification::Drift);
let current = Signals {
installed: true,
at_destination: true,
..signals()
};
assert_eq!(classify(current), Classification::Current);
let invalid = Signals {
invalid: true,
installed: true,
..signals()
};
assert_eq!(classify(invalid), Classification::Invalid);
}
#[test]
fn malformed_metadata_is_invalid_and_never_absence() {
let invalid = Signals {
invalid: true,
..signals()
};
assert_eq!(classify(invalid), Classification::Invalid);
assert_ne!(classify(invalid), Classification::Setup);
}
#[test]
fn init_intent_cannot_force_a_settled_target_to_setup() {
let error = Intent::Init.accepts(Classification::Migration).unwrap_err();
assert_eq!(error.verb, "sdd init");
assert!(error.to_string().contains("sdd reconcile plan"));
assert!(Intent::Init.accepts(Classification::Setup).is_ok());
assert!(Intent::Init.accepts(Classification::Current).is_ok());
assert!(Intent::Init.accepts(Classification::Upgrade).is_ok());
assert!(Intent::Init.accepts(Classification::Invalid).is_err());
}
#[test]
fn upgrade_intent_cannot_turn_an_absent_instance_into_an_upgrade() {
assert!(Intent::Upgrade.accepts(Classification::Setup).is_err());
assert!(Intent::Upgrade.accepts(Classification::Migration).is_err());
assert!(Intent::Upgrade.accepts(Classification::Upgrade).is_ok());
assert!(Intent::Upgrade.accepts(Classification::Current).is_ok());
}
#[test]
fn reconcile_and_assess_serve_whatever_the_target_is() {
for found in [
Classification::Setup,
Classification::Migration,
Classification::Upgrade,
Classification::Drift,
Classification::Current,
Classification::Invalid,
] {
assert!(Intent::Reconcile.accepts(found).is_ok());
assert!(Intent::Assess.accepts(found).is_ok());
}
}
}