Skip to main content

gen_types/
lock_lifecycle.rs

1//! Substrate-wide `LockLifecyclePrimitive` trait.
2//!
3//! The deterministic-lock pattern (transient build artifacts +
4//! explicit operator snapshots + typed diffs) generalizes across
5//! every package-manager adapter. cargo today, npm/bundler/poetry/
6//! gomod/helm tomorrow. This trait is the typed contract: implement
7//! these methods, get the `gen lock` CLI surface + substrate
8//! refusal-on-drift gating for free.
9//!
10//! ## Required substrate-side guarantees
11//!
12//! An implementor promises:
13//!
14//! 1. **`State` is a closed typed enum** with at least the canonical
15//!    four states — `Unlocked` / `Locked` / `Drifted` / `MissingLock`.
16//!    Concrete adapters may add more (e.g. `gen-npm` might add
17//!    `WorkspaceProtocolMismatch`). The state IS observable from
18//!    filesystem alone (no daemons, no network, no cached state).
19//!
20//! 2. **`LockDiff` is a structured value type** (not text). Two
21//!    consecutive `update` calls with no Cargo.lock/package-lock.json
22//!    movement between produce byte-equal diffs.
23//!
24//! 3. **`current_state` is pure** — same filesystem state → same
25//!    output. Idempotent and deterministic.
26//!
27//! 4. **`snapshot` / `update` / `reset` are explicit operator verbs**
28//!    — they NEVER fire as a side-effect of `current_state` or any
29//!    other read.
30//!
31//! ## Trait-boundary testing
32//!
33//! Each implementation provides a parallel mock impl (e.g.
34//! `MockCargoLifecycle` for tests) that lets gen-cli's dispatcher
35//! exercise every state transition hermetically — same shape as
36//! `PathDepResolver` + `MetadataSource` already use.
37
38use std::path::Path;
39
40use serde::{de::DeserializeOwned, Serialize};
41
42/// Typed lock-lifecycle primitive — one impl per adapter.
43///
44/// Implementors expose the deterministic-lock contract to
45/// substrate's lockfile-builder + gen-cli's `lock` subcommand.
46/// Concrete states are adapter-specific; the trait constrains
47/// only what substrate needs to dispatch on.
48pub trait LockLifecyclePrimitive: Send + Sync {
49    /// Adapter-specific state enum. Must include the canonical
50    /// four substrates (Unlocked / Locked / Drifted / MissingLock)
51    /// reachable via the `is_*` methods below. Each adapter is free
52    /// to add more states (workspace-protocol drift, registry-pin
53    /// staleness, etc.) — substrate dispatches via the canonical
54    /// flags so extra states don't break the gate.
55    type State: Clone + Serialize + DeserializeOwned + Send + Sync + 'static;
56
57    /// Adapter-specific structural diff. Substrate emitters
58    /// (release-notes builders, PR-body renderers) consume this
59    /// type directly.
60    type Diff: Clone + Serialize + DeserializeOwned + Send + Sync + 'static;
61
62    /// Short ecosystem identifier (e.g. `"cargo"`, `"npm"`). Used
63    /// for routing by gen-cli + substrate emitters.
64    fn ecosystem(&self) -> &'static str;
65
66    /// Read the current state from the filesystem. Pure — same
67    /// inputs always yield the same output. No subprocess calls
68    /// other than hashing the lockfile.
69    fn current_state(&self, root: &Path) -> Self::State;
70
71    /// Adapter-canonical projection: does this state require explicit
72    /// operator action before substrate can build?
73    fn requires_operator_action(&self, state: &Self::State) -> bool;
74
75    /// Adapter-canonical projection: is this state the byte-equal
76    /// "committed snapshot matches current source" condition?
77    fn is_locked(&self, state: &Self::State) -> bool;
78
79    /// Adapter-canonical projection: is the source-side lockfile
80    /// (Cargo.lock / package-lock.json / etc.) missing?
81    fn is_missing_lock(&self, state: &Self::State) -> bool;
82
83    /// Explicit operator snapshot — write the committed lock
84    /// artifact (Cargo.build-spec.json / equivalent). Errors when
85    /// the state is `MissingLock` or when the snapshot can't be
86    /// written.
87    fn snapshot(&self, root: &Path) -> Result<(), LockError>;
88
89    /// Explicit operator update — regenerate the committed lock +
90    /// compute the typed diff against the previous snapshot.
91    /// Returns the typed diff so callers can render it.
92    fn update(&self, root: &Path) -> Result<Self::Diff, LockError>;
93
94    /// Explicit operator reset — delete the committed lock artifact
95    /// (return to the `Unlocked` state). Errors only on filesystem
96    /// failure (a missing artifact is treated as success — idempotent).
97    fn reset(&self, root: &Path) -> Result<(), LockError>;
98}
99
100/// Typed errors emitted by any `LockLifecyclePrimitive`. Adapters
101/// that need richer context wrap their domain errors in `Source`.
102#[derive(Debug, thiserror::Error)]
103pub enum LockError {
104    /// Workspace lockfile (Cargo.lock / package-lock.json / etc.)
105    /// is missing — operator must run the ecosystem's bootstrap
106    /// before any lock action.
107    #[error("workspace lockfile missing at {}; run the ecosystem's bootstrap (`cargo generate-lockfile`, `npm install`, etc.) first", path.display())]
108    MissingLockfile { path: std::path::PathBuf },
109    /// Spec generation failed — wrap the adapter's domain error.
110    #[error("lock action `{action}` failed: {source}")]
111    SpecGeneration {
112        action: &'static str,
113        #[source]
114        source: Box<dyn std::error::Error + Send + Sync>,
115    },
116    /// Filesystem error (read/write/delete).
117    #[error("filesystem error during lock action `{action}` at {}: {source}", path.display())]
118    Io {
119        action: &'static str,
120        path: std::path::PathBuf,
121        #[source]
122        source: std::io::Error,
123    },
124}
125
126#[cfg(test)]
127mod tests {
128    use super::*;
129    use serde::{Deserialize, Serialize};
130
131    /// Hermetic mock implementor — pure lock-free state via
132    /// `AtomicU8`. No Mutex, no poison, no panic path. Production
133    /// adapter integration tests follow the same shape: no shared
134    /// mutable state that can fail to acquire.
135    #[derive(Default)]
136    struct MockLifecycle {
137        state: std::sync::atomic::AtomicU8,
138    }
139
140    // State encoding — small, totally typed, no failure mode for
141    // load/store.
142    const S_UNLOCKED: u8 = 0;
143    const S_LOCKED: u8 = 1;
144    const S_DRIFTED: u8 = 2;
145    const S_MISSING_LOCK: u8 = 3;
146
147    #[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
148    #[serde(tag = "kind", rename_all = "kebab-case")]
149    enum MockState {
150        #[default]
151        Unlocked,
152        Locked,
153        Drifted,
154        MissingLock,
155    }
156
157    impl MockState {
158        const fn encode(&self) -> u8 {
159            match self {
160                MockState::Unlocked => S_UNLOCKED,
161                MockState::Locked => S_LOCKED,
162                MockState::Drifted => S_DRIFTED,
163                MockState::MissingLock => S_MISSING_LOCK,
164            }
165        }
166        const fn decode(byte: u8) -> Self {
167            match byte {
168                S_LOCKED => MockState::Locked,
169                S_DRIFTED => MockState::Drifted,
170                S_MISSING_LOCK => MockState::MissingLock,
171                _ => MockState::Unlocked,
172            }
173        }
174    }
175
176    impl MockLifecycle {
177        fn with_state(s: MockState) -> Self {
178            Self {
179                state: std::sync::atomic::AtomicU8::new(s.encode()),
180            }
181        }
182        fn store(&self, s: MockState) {
183            self.state.store(s.encode(), std::sync::atomic::Ordering::SeqCst);
184        }
185        fn load(&self) -> MockState {
186            MockState::decode(self.state.load(std::sync::atomic::Ordering::SeqCst))
187        }
188    }
189
190    #[derive(Clone, Default, Serialize, Deserialize)]
191    struct MockDiff(usize);
192
193    impl LockLifecyclePrimitive for MockLifecycle {
194        type State = MockState;
195        type Diff = MockDiff;
196        fn ecosystem(&self) -> &'static str { "mock" }
197        fn current_state(&self, _: &Path) -> Self::State { self.load() }
198        fn requires_operator_action(&self, s: &Self::State) -> bool {
199            matches!(s, MockState::Drifted | MockState::MissingLock)
200        }
201        fn is_locked(&self, s: &Self::State) -> bool { matches!(s, MockState::Locked) }
202        fn is_missing_lock(&self, s: &Self::State) -> bool { matches!(s, MockState::MissingLock) }
203        fn snapshot(&self, _: &Path) -> Result<(), LockError> {
204            self.store(MockState::Locked);
205            Ok(())
206        }
207        fn update(&self, _: &Path) -> Result<Self::Diff, LockError> {
208            self.store(MockState::Locked);
209            Ok(MockDiff(0))
210        }
211        fn reset(&self, _: &Path) -> Result<(), LockError> {
212            self.store(MockState::Unlocked);
213            Ok(())
214        }
215    }
216
217    #[test]
218    fn mock_round_trips_state_transitions() {
219        let m = MockLifecycle::default();
220        let path = std::path::Path::new("/tmp/anything");
221        assert_eq!(m.current_state(path), MockState::Unlocked);
222        m.snapshot(path).unwrap();
223        assert_eq!(m.current_state(path), MockState::Locked);
224        assert!(m.is_locked(&MockState::Locked));
225        m.reset(path).unwrap();
226        assert_eq!(m.current_state(path), MockState::Unlocked);
227    }
228
229    #[test]
230    fn canonical_projections_classify_states_correctly() {
231        let m = MockLifecycle::default();
232        assert!(!m.requires_operator_action(&MockState::Unlocked));
233        assert!(!m.requires_operator_action(&MockState::Locked));
234        assert!(m.requires_operator_action(&MockState::Drifted));
235        assert!(m.requires_operator_action(&MockState::MissingLock));
236        assert!(m.is_missing_lock(&MockState::MissingLock));
237        assert!(!m.is_missing_lock(&MockState::Unlocked));
238    }
239
240    #[test]
241    fn ecosystem_identifier_is_static() {
242        let m = MockLifecycle::default();
243        assert_eq!(m.ecosystem(), "mock");
244    }
245}