use std::path::Path;
use serde::{de::DeserializeOwned, Serialize};
pub trait LockLifecyclePrimitive: Send + Sync {
type State: Clone + Serialize + DeserializeOwned + Send + Sync + 'static;
type Diff: Clone + Serialize + DeserializeOwned + Send + Sync + 'static;
fn ecosystem(&self) -> &'static str;
fn current_state(&self, root: &Path) -> Self::State;
fn requires_operator_action(&self, state: &Self::State) -> bool;
fn is_locked(&self, state: &Self::State) -> bool;
fn is_missing_lock(&self, state: &Self::State) -> bool;
fn snapshot(&self, root: &Path) -> Result<(), LockError>;
fn update(&self, root: &Path) -> Result<Self::Diff, LockError>;
fn reset(&self, root: &Path) -> Result<(), LockError>;
}
#[derive(Debug, thiserror::Error)]
pub enum LockError {
#[error("workspace lockfile missing at {}; run the ecosystem's bootstrap (`cargo generate-lockfile`, `npm install`, etc.) first", path.display())]
MissingLockfile { path: std::path::PathBuf },
#[error("lock action `{action}` failed: {source}")]
SpecGeneration {
action: &'static str,
#[source]
source: Box<dyn std::error::Error + Send + Sync>,
},
#[error("filesystem error during lock action `{action}` at {}: {source}", path.display())]
Io {
action: &'static str,
path: std::path::PathBuf,
#[source]
source: std::io::Error,
},
}
#[cfg(test)]
mod tests {
use super::*;
use serde::{Deserialize, Serialize};
#[derive(Default)]
struct MockLifecycle {
state: std::sync::atomic::AtomicU8,
}
const S_UNLOCKED: u8 = 0;
const S_LOCKED: u8 = 1;
const S_DRIFTED: u8 = 2;
const S_MISSING_LOCK: u8 = 3;
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(tag = "kind", rename_all = "kebab-case")]
enum MockState {
#[default]
Unlocked,
Locked,
Drifted,
MissingLock,
}
impl MockState {
const fn encode(&self) -> u8 {
match self {
MockState::Unlocked => S_UNLOCKED,
MockState::Locked => S_LOCKED,
MockState::Drifted => S_DRIFTED,
MockState::MissingLock => S_MISSING_LOCK,
}
}
const fn decode(byte: u8) -> Self {
match byte {
S_LOCKED => MockState::Locked,
S_DRIFTED => MockState::Drifted,
S_MISSING_LOCK => MockState::MissingLock,
_ => MockState::Unlocked,
}
}
}
impl MockLifecycle {
fn with_state(s: MockState) -> Self {
Self {
state: std::sync::atomic::AtomicU8::new(s.encode()),
}
}
fn store(&self, s: MockState) {
self.state.store(s.encode(), std::sync::atomic::Ordering::SeqCst);
}
fn load(&self) -> MockState {
MockState::decode(self.state.load(std::sync::atomic::Ordering::SeqCst))
}
}
#[derive(Clone, Default, Serialize, Deserialize)]
struct MockDiff(usize);
impl LockLifecyclePrimitive for MockLifecycle {
type State = MockState;
type Diff = MockDiff;
fn ecosystem(&self) -> &'static str { "mock" }
fn current_state(&self, _: &Path) -> Self::State { self.load() }
fn requires_operator_action(&self, s: &Self::State) -> bool {
matches!(s, MockState::Drifted | MockState::MissingLock)
}
fn is_locked(&self, s: &Self::State) -> bool { matches!(s, MockState::Locked) }
fn is_missing_lock(&self, s: &Self::State) -> bool { matches!(s, MockState::MissingLock) }
fn snapshot(&self, _: &Path) -> Result<(), LockError> {
self.store(MockState::Locked);
Ok(())
}
fn update(&self, _: &Path) -> Result<Self::Diff, LockError> {
self.store(MockState::Locked);
Ok(MockDiff(0))
}
fn reset(&self, _: &Path) -> Result<(), LockError> {
self.store(MockState::Unlocked);
Ok(())
}
}
#[test]
fn mock_round_trips_state_transitions() {
let m = MockLifecycle::default();
let path = std::path::Path::new("/tmp/anything");
assert_eq!(m.current_state(path), MockState::Unlocked);
m.snapshot(path).unwrap();
assert_eq!(m.current_state(path), MockState::Locked);
assert!(m.is_locked(&MockState::Locked));
m.reset(path).unwrap();
assert_eq!(m.current_state(path), MockState::Unlocked);
}
#[test]
fn canonical_projections_classify_states_correctly() {
let m = MockLifecycle::default();
assert!(!m.requires_operator_action(&MockState::Unlocked));
assert!(!m.requires_operator_action(&MockState::Locked));
assert!(m.requires_operator_action(&MockState::Drifted));
assert!(m.requires_operator_action(&MockState::MissingLock));
assert!(m.is_missing_lock(&MockState::MissingLock));
assert!(!m.is_missing_lock(&MockState::Unlocked));
}
#[test]
fn ecosystem_identifier_is_static() {
let m = MockLifecycle::default();
assert_eq!(m.ecosystem(), "mock");
}
}