Skip to content

Commit 6ce3b46

Browse files
feat(state): migrate framework, MockFs feature-gate, proptest round-trip (#8)
Chunk M1-W1-F of M1 plan — closes Plan §6.2. Migration framework (migrate.rs): - pub fn migrate(State) -> Result<State, StateError> bumps the schema_version field forward through one transformation per on-disk version. v0 → v1 is the only step at M1; future bumps add another `migrate_vN_to_vM` and one extra branch. - StateStore::read calls migrate so the read path automatically upgrades older files in memory before returning to the caller. - schema_version field gains #[serde(default)] so pre-versioned alpha files (no schema_version key) deserialize as 0 and migrate to 1. - 3 unit tests: v0_to_v1_adds_schema_version, current_version_is_a_no_op, future_version_rejected. MockFs (mock.rs): - Compiled in cargo test (no feature) and behind feature = "test-mocks" for downstream consumers (the engine's integration tests in M2+). - Backed by Arc<Mutex<BTreeMap<PathBuf, Vec<u8>>>>. - Failure injection via fail_next_rename() and fail_next_fsync_dir(), both self-clearing single-shots. - 4 own tests: seed_then_read_round_trip, read_missing_yields_not_found, injected_rename_failure_leaves_old_content, fail_injection_is_single_shot. - Module-level allow(clippy::expect_used) for the standard Mutex<T> unwrap idiom — MockFs is test-utility code. StateStore tests using MockFs (store.rs): - rename_failure_via_mockfs_leaves_old_state — atomic-write guarantee verified through the trait, not through a tempdir. - fsync_dir_failure_via_mockfs_propagates — error path. Property test (store.rs, proptest): - arbitrary_state_payload_round_trips_through_mockfs — randomized host, tool_version, managed.pacman/cargo, adopted.pacman survive a write→read cycle losslessly. Plan §6.2 acceptance: "property: arbitrary State round-trips losslessly (proptest-driven)." Cargo.toml: - New [features] block with test-mocks = []. - proptest = "1" added to [dev-dependencies]. Plan §6.2 "Done when" status: - Triple-fsync atomic write tested via MockFs failure injection. ✓ - toml_edit preserves [reserved] and any unknown fields verbatim. PARTIAL: [reserved] is preserved (BTreeMap<String, toml::Value>). Byte-level preservation of unknown fields outside [reserved] would require a toml_edit-based writer; deferred — the [reserved] namespace covers the documented forward-compat contract. - schema_version migration framework exists; trivial v0→v1 stub passes a fixture. ✓ - No std::fs::* calls outside io.rs. ✓ - pearlite-audit reports no §13 violations. ✓ - cargo bench shows full read+parse of a 1 MB state.toml under 50 ms. DEFERRED: a benches/ harness lands as a follow-up after the engine is up and we have a realistic 1 MB fixture. Verification: - cargo test -p pearlite-state (18 passed; 0 failed) - cargo clippy --workspace --all-targets -- -D warnings (zero warnings) - cargo fmt --all --check - scripts/ci/check-spdx.sh - pearlite-audit check . (1 check, 0 violations) Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent c15abb7 commit 6ce3b46

6 files changed

Lines changed: 391 additions & 10 deletions

File tree

crates/pearlite-state/Cargo.toml

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,10 @@ repository.workspace = true
1111
[lints]
1212
workspace = true
1313

14+
[features]
15+
# Exposes MockFs to downstream crates for engine integration tests.
16+
test-mocks = []
17+
1418
[dependencies]
1519
serde = { workspace = true }
1620
toml = { workspace = true }
@@ -22,3 +26,4 @@ tempfile = { workspace = true }
2226

2327
[dev-dependencies]
2428
tempfile = { workspace = true }
29+
proptest = "1"

crates/pearlite-state/src/lib.rs

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,9 @@ mod errors;
1212
mod failure;
1313
mod history;
1414
mod io;
15+
mod migrate;
16+
#[cfg(any(test, feature = "test-mocks"))]
17+
mod mock;
1518
mod reconciliation;
1619
mod schema;
1720
mod store;
@@ -26,3 +29,8 @@ pub use schema::{
2629
UserEnvRecord,
2730
};
2831
pub use store::StateStore;
32+
33+
pub use migrate::migrate;
34+
35+
#[cfg(feature = "test-mocks")]
36+
pub use mock::MockFs;
Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,98 @@
1+
// SPDX-License-Identifier: GPL-3.0-or-later
2+
// Copyright (C) 2026 Mohamed Hammad
3+
4+
//! `state.toml` schema-version migrations.
5+
//!
6+
//! Each migration is a pure transformation `State -> State` that bumps
7+
//! `schema_version` by one. [`migrate`] composes them in order so a v0
8+
//! file becomes vN through (N) function applications.
9+
//!
10+
//! At M1, only the v0 → v1 migration exists; it adds the
11+
//! `schema_version` field that pre-versioned alpha files lacked.
12+
13+
use crate::errors::StateError;
14+
use crate::schema::{SCHEMA_VERSION, State};
15+
16+
/// Migrate a freshly-deserialized [`State`] up to [`SCHEMA_VERSION`].
17+
///
18+
/// # Errors
19+
/// Returns [`StateError::UnsupportedSchemaVersion`] when the on-disk
20+
/// file claims a `schema_version` higher than this build supports.
21+
pub fn migrate(mut state: State) -> Result<State, StateError> {
22+
if state.schema_version == 0 {
23+
state = migrate_v0_to_v1(state);
24+
}
25+
if state.schema_version > SCHEMA_VERSION {
26+
return Err(StateError::UnsupportedSchemaVersion {
27+
found: state.schema_version,
28+
supported: SCHEMA_VERSION,
29+
});
30+
}
31+
Ok(state)
32+
}
33+
34+
/// v0 → v1: the only change is that `schema_version` becomes mandatory
35+
/// at the type level (it was implicitly `0` before).
36+
fn migrate_v0_to_v1(mut state: State) -> State {
37+
state.schema_version = 1;
38+
state
39+
}
40+
41+
#[cfg(test)]
42+
#[allow(
43+
clippy::expect_used,
44+
clippy::unwrap_used,
45+
clippy::panic,
46+
reason = "tests may use expect()/unwrap()/panic!() per Plan §4.2 + CLAUDE.md"
47+
)]
48+
mod tests {
49+
use super::*;
50+
use std::path::PathBuf;
51+
52+
fn empty_v0_state() -> State {
53+
State {
54+
schema_version: 0,
55+
host: "forge".to_owned(),
56+
tool_version: "0.1.0-alpha".to_owned(),
57+
config_dir: PathBuf::from("/home/mohamed/pearlite-config"),
58+
last_apply: None,
59+
last_modified: None,
60+
managed: crate::schema::Managed::default(),
61+
adopted: crate::schema::Adopted::default(),
62+
history: Vec::new(),
63+
reconciliations: Vec::new(),
64+
failures: Vec::new(),
65+
reserved: std::collections::BTreeMap::new(),
66+
}
67+
}
68+
69+
#[test]
70+
fn v0_to_v1_adds_schema_version() {
71+
let v0 = empty_v0_state();
72+
let v1 = migrate(v0.clone()).expect("migrate");
73+
assert_eq!(v1.schema_version, 1);
74+
// Every other field unchanged.
75+
assert_eq!(v1.host, v0.host);
76+
assert_eq!(v1.tool_version, v0.tool_version);
77+
assert_eq!(v1.config_dir, v0.config_dir);
78+
}
79+
80+
#[test]
81+
fn current_version_is_a_no_op() {
82+
let mut current = empty_v0_state();
83+
current.schema_version = SCHEMA_VERSION;
84+
let after = migrate(current.clone()).expect("migrate");
85+
assert_eq!(after, current);
86+
}
87+
88+
#[test]
89+
fn future_version_rejected() {
90+
let mut future = empty_v0_state();
91+
future.schema_version = SCHEMA_VERSION + 1;
92+
let err = migrate(future).expect_err("future version must fail");
93+
assert!(
94+
matches!(err, StateError::UnsupportedSchemaVersion { .. }),
95+
"got {err:?}"
96+
);
97+
}
98+
}

crates/pearlite-state/src/mock.rs

Lines changed: 205 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,205 @@
1+
// SPDX-License-Identifier: GPL-3.0-or-later
2+
// Copyright (C) 2026 Mohamed Hammad
3+
4+
//! In-memory [`MockFs`] for unit tests and engine integration tests.
5+
//!
6+
//! Compiled both inside `cargo test` (without any feature flag) and when
7+
//! downstream crates enable the `test-mocks` feature so they can drive
8+
//! [`StateStore`](crate::StateStore) without touching the real
9+
//! filesystem.
10+
11+
#![allow(
12+
clippy::expect_used,
13+
reason = "MockFs is a test utility; .expect() on the mutex matches the \
14+
standard Mutex<T> idiom and is unreachable in any sane test."
15+
)]
16+
17+
use crate::io::FileSystem;
18+
use std::collections::BTreeMap;
19+
use std::io::{Error, ErrorKind};
20+
use std::path::{Path, PathBuf};
21+
use std::sync::{Arc, Mutex};
22+
23+
/// Failure-injection knobs the next operation will honour.
24+
#[derive(Clone, Copy, Debug, Default)]
25+
struct Inject {
26+
/// Make the next `write_temp_then_rename` fail before mutating the
27+
/// target file.
28+
fail_next_rename: bool,
29+
/// Make the next `fsync_dir` fail (the rename has already
30+
/// committed; this models the "data persisted but parent dir not
31+
/// yet flushed" window).
32+
fail_next_fsync_dir: bool,
33+
}
34+
35+
#[derive(Default)]
36+
struct Inner {
37+
files: BTreeMap<PathBuf, Vec<u8>>,
38+
inject: Inject,
39+
}
40+
41+
/// Shared, thread-safe in-memory filesystem.
42+
///
43+
/// Cloning gives a second handle to the same backing store — the same
44+
/// pattern as `Arc<Mutex<...>>`-backed handles in production code.
45+
#[derive(Clone, Default)]
46+
pub struct MockFs {
47+
inner: Arc<Mutex<Inner>>,
48+
}
49+
50+
impl std::fmt::Debug for MockFs {
51+
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
52+
f.debug_struct("MockFs").finish_non_exhaustive()
53+
}
54+
}
55+
56+
impl MockFs {
57+
/// Construct an empty [`MockFs`].
58+
#[must_use]
59+
pub fn new() -> Self {
60+
Self::default()
61+
}
62+
63+
/// Pre-seed the in-memory filesystem at `path` with `data`.
64+
pub fn seed(&self, path: impl Into<PathBuf>, data: impl Into<Vec<u8>>) {
65+
let mut inner = self
66+
.inner
67+
.lock()
68+
.expect("MockFs mutex must not be poisoned");
69+
inner.files.insert(path.into(), data.into());
70+
}
71+
72+
/// Read whatever is stored at `path`, if anything.
73+
#[must_use]
74+
pub fn snapshot(&self, path: &Path) -> Option<Vec<u8>> {
75+
self.inner
76+
.lock()
77+
.expect("MockFs mutex must not be poisoned")
78+
.files
79+
.get(path)
80+
.cloned()
81+
}
82+
83+
/// Cause the next `write_temp_then_rename` to fail before the
84+
/// target file is modified. Self-clearing — a single shot.
85+
pub fn fail_next_rename(&self) {
86+
self.inner
87+
.lock()
88+
.expect("MockFs mutex must not be poisoned")
89+
.inject
90+
.fail_next_rename = true;
91+
}
92+
93+
/// Cause the next `fsync_dir` to fail. Self-clearing.
94+
pub fn fail_next_fsync_dir(&self) {
95+
self.inner
96+
.lock()
97+
.expect("MockFs mutex must not be poisoned")
98+
.inject
99+
.fail_next_fsync_dir = true;
100+
}
101+
}
102+
103+
impl FileSystem for MockFs {
104+
fn read_string(&self, p: &Path) -> std::io::Result<String> {
105+
let inner = self
106+
.inner
107+
.lock()
108+
.expect("MockFs mutex must not be poisoned");
109+
match inner.files.get(p) {
110+
Some(bytes) => {
111+
String::from_utf8(bytes.clone()).map_err(|e| Error::new(ErrorKind::InvalidData, e))
112+
}
113+
None => Err(Error::new(ErrorKind::NotFound, "no such mock file")),
114+
}
115+
}
116+
117+
fn write_temp_then_rename(&self, p: &Path, data: &[u8]) -> std::io::Result<()> {
118+
let mut inner = self
119+
.inner
120+
.lock()
121+
.expect("MockFs mutex must not be poisoned");
122+
if inner.inject.fail_next_rename {
123+
inner.inject.fail_next_rename = false;
124+
return Err(Error::new(
125+
ErrorKind::Other,
126+
"MockFs: injected rename failure",
127+
));
128+
}
129+
inner.files.insert(p.to_path_buf(), data.to_vec());
130+
Ok(())
131+
}
132+
133+
fn fsync_dir(&self, _p: &Path) -> std::io::Result<()> {
134+
let mut inner = self
135+
.inner
136+
.lock()
137+
.expect("MockFs mutex must not be poisoned");
138+
if inner.inject.fail_next_fsync_dir {
139+
inner.inject.fail_next_fsync_dir = false;
140+
return Err(Error::new(
141+
ErrorKind::Other,
142+
"MockFs: injected fsync_dir failure",
143+
));
144+
}
145+
Ok(())
146+
}
147+
}
148+
149+
#[cfg(test)]
150+
#[allow(
151+
clippy::expect_used,
152+
clippy::unwrap_used,
153+
clippy::panic,
154+
reason = "tests may use expect()/unwrap()/panic!() per Plan §4.2 + CLAUDE.md"
155+
)]
156+
mod tests {
157+
use super::*;
158+
159+
#[test]
160+
fn seed_then_read_round_trip() {
161+
let fs = MockFs::new();
162+
fs.seed("/state.toml", "hello".as_bytes().to_vec());
163+
let read = fs.read_string(Path::new("/state.toml")).expect("read");
164+
assert_eq!(read, "hello");
165+
}
166+
167+
#[test]
168+
fn read_missing_yields_not_found() {
169+
let fs = MockFs::new();
170+
let err = fs
171+
.read_string(Path::new("/nope"))
172+
.expect_err("missing must fail");
173+
assert_eq!(err.kind(), ErrorKind::NotFound);
174+
}
175+
176+
#[test]
177+
fn injected_rename_failure_leaves_old_content() {
178+
let fs = MockFs::new();
179+
fs.seed("/state.toml", b"v1".to_vec());
180+
181+
fs.fail_next_rename();
182+
let err = fs
183+
.write_temp_then_rename(Path::new("/state.toml"), b"v2")
184+
.expect_err("rename should fail");
185+
assert_eq!(err.kind(), ErrorKind::Other);
186+
187+
// Subsequent read still returns v1.
188+
let read = fs.read_string(Path::new("/state.toml")).expect("read");
189+
assert_eq!(read, "v1");
190+
}
191+
192+
#[test]
193+
fn fail_injection_is_single_shot() {
194+
let fs = MockFs::new();
195+
fs.fail_next_rename();
196+
assert!(
197+
fs.write_temp_then_rename(Path::new("/x"), b"a").is_err(),
198+
"first call should fail"
199+
);
200+
// Second call should succeed.
201+
fs.write_temp_then_rename(Path::new("/x"), b"b")
202+
.expect("second call should succeed");
203+
assert_eq!(fs.snapshot(Path::new("/x")), Some(b"b".to_vec()));
204+
}
205+
}

crates/pearlite-state/src/schema.rs

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,10 @@ pub const SCHEMA_VERSION: u32 = 1;
3030
/// for equality checks in tests.
3131
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, JsonSchema)]
3232
pub struct State {
33-
/// Schema version of the file at last write.
33+
/// Schema version of the file at last write. Absent or `0` means a
34+
/// pre-versioned file written by an alpha build; the migration
35+
/// framework in `migrate.rs` upgrades it on read.
36+
#[serde(default)]
3437
pub schema_version: u32,
3538
/// Hostname this state belongs to. Mismatch with `hostname()` is
3639
/// preflight Class 1.

0 commit comments

Comments
 (0)