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}