Skip to content

Commit f104d88

Browse files
feat(state): land State, Managed, history, reconciliation, failure types (#6)
* feat(state): land State, Managed, history, reconciliation, failure types Chunk M1-W1-D of M1 plan: pearlite-state type scaffolding only, no I/O yet. Types match PRD §7.2's state.toml schema verbatim. Public types (re-exported from lib.rs): - State — top-level aggregate; the in-memory mirror of state.toml. - SCHEMA_VERSION (= 1) — current on-disk shape; bumps go through migrate.rs in chunk M1-W1-F. - Managed — [managed.*] namespace: pacman, cargo, config_files, services, user_env, kernel. - ConfigFileRecord, ServicesState, UserEnvRecord, KernelRecord. - Adopted — [adopted.*] namespace: pacman + cargo "leave alone" lists. - HistoryEntry, SnapshotRef — [[history]] entries (one per successful apply; full plan lives at /var/lib/pearlite/plans/<plan-id>.json). - ReconciliationEntry, ReconciliationAction — [[reconciliations]]. - FailureRef — [[failures]] pointers; full record JSON lives at /var/lib/pearlite/failures/<plan-id>.json per PRD §11.4. - StateError (thiserror). Internal modules: schema, history, reconciliation, failure, errors — one per type cluster. Design notes: - `State` derives PartialEq but not Eq because the `reserved` forward-compat map (BTreeMap<String, toml::Value>) carries `toml::Value::Float` which wraps a non-Eq f64. Tests use PartialEq exclusively; Hash is not required. Documented inline. - `reserved` is excluded from the emitted JSON Schema via #[schemars(skip)]; its values are untyped by design (forward-compat for fields this Pearlite build doesn't yet know about). - Every OffsetDateTime field uses #[serde(with = "time::serde::iso8601")] per Plan §11. Optional timestamps use the matching ::option helper. - Uuid fields use the workspace v7 + serde features and are described as String in the JSON Schema via #[schemars(with = "String")]. What's NOT in this chunk: - FileSystem trait (chunk M1-W1-E). - StateStore + read/write_atomic + append_* (chunk M1-W1-E). - Migration framework (chunk M1-W1-F). - MockFs feature-gate (chunk M1-W1-F). - Property tests (chunk M1-W1-F). Verification: - cargo build -p pearlite-state - cargo clippy --workspace --all-targets -- -D 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> * fix(state): drop unused toml_edit dep — arrives in chunk M1-W1-E cargo-machete flagged toml_edit as unused in chunk M1-W1-D's type-only scope. The dep is needed for the atomic-write implementation's unknown-field preservation, which lands in chunk M1-W1-E. Comment in Cargo.toml records the intent. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent a6d8358 commit f104d88

7 files changed

Lines changed: 358 additions & 0 deletions

File tree

crates/pearlite-state/Cargo.toml

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,3 +12,11 @@ repository.workspace = true
1212
workspace = true
1313

1414
[dependencies]
15+
serde = { workspace = true }
16+
toml = { workspace = true }
17+
time = { workspace = true }
18+
uuid = { workspace = true }
19+
thiserror = { workspace = true }
20+
schemars = { workspace = true }
21+
# toml_edit lands in chunk M1-W1-E with the atomic-write implementation
22+
# that needs it for unknown-field preservation.
Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
// SPDX-License-Identifier: GPL-3.0-or-later
2+
// Copyright (C) 2026 Mohamed Hammad
3+
4+
//! Errors emitted by `pearlite-state`.
5+
6+
use std::path::PathBuf;
7+
use thiserror::Error;
8+
9+
/// Errors emitted while reading, writing, or migrating `state.toml`.
10+
#[derive(Debug, Error)]
11+
pub enum StateError {
12+
/// The TOML failed to parse.
13+
#[error("invalid TOML in state file: {0}")]
14+
InvalidToml(#[from] toml::de::Error),
15+
/// `state.toml` was not found at the expected path.
16+
#[error("state file not found: {0}")]
17+
NotFound(PathBuf),
18+
/// I/O error while reading or writing the file.
19+
#[error("I/O error on state file: {0}")]
20+
Io(#[from] std::io::Error),
21+
/// Unknown `schema_version` — the on-disk file is from a future
22+
/// Pearlite release and cannot be read by this build.
23+
#[error("unknown schema_version {found}; this build supports up to {supported}")]
24+
UnsupportedSchemaVersion {
25+
/// Version found in the file.
26+
found: u32,
27+
/// Highest version this build can read.
28+
supported: u32,
29+
},
30+
/// `state.host` does not match the system `hostname()`.
31+
#[error("state.host '{state_host}' does not match system hostname '{system_host}'")]
32+
HostMismatch {
33+
/// Host recorded in `state.toml`.
34+
state_host: String,
35+
/// Live `hostname(1)` value.
36+
system_host: String,
37+
},
38+
/// TOML serialisation failed (should be unreachable for well-formed
39+
/// `State` values).
40+
#[error("could not serialize state: {0}")]
41+
SerializeFailed(#[from] toml::ser::Error),
42+
}
Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
// SPDX-License-Identifier: GPL-3.0-or-later
2+
// Copyright (C) 2026 Mohamed Hammad
3+
4+
//! `[[failures]]` records — pointers to per-plan failure JSON files.
5+
6+
use schemars::JsonSchema;
7+
use serde::{Deserialize, Serialize};
8+
use std::path::PathBuf;
9+
use time::OffsetDateTime;
10+
use uuid::Uuid;
11+
12+
/// One entry in `state.toml`'s `[[failures]]` array.
13+
///
14+
/// The full forensic record (stderr, `investigate_commands`,
15+
/// `rollback_command`, …) lives in `/var/lib/pearlite/failures/<plan-id>.json`
16+
/// per PRD §11.4. This entry holds only the pointer plus enough metadata
17+
/// to render `pearlite gen list` without reading every JSON record.
18+
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
19+
pub struct FailureRef {
20+
/// Plan UUID this failure pertains to.
21+
#[schemars(with = "String")]
22+
pub plan_id: Uuid,
23+
/// UTC timestamp the apply failed.
24+
#[serde(with = "time::serde::iso8601")]
25+
#[schemars(with = "String")]
26+
pub failed_at: OffsetDateTime,
27+
/// Failure class per PRD §8.5: 1 preflight, 2 plan, 3 recoverable,
28+
/// 4 incoherent, 5 catastrophic.
29+
pub class: u8,
30+
/// Process exit code at failure.
31+
pub exit_code: u8,
32+
/// Path to the JSON record under `/var/lib/pearlite/failures/`.
33+
pub record_path: PathBuf,
34+
}
Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
// SPDX-License-Identifier: GPL-3.0-or-later
2+
// Copyright (C) 2026 Mohamed Hammad
3+
4+
//! `[[history]]` and snapshot-reference records.
5+
6+
use schemars::JsonSchema;
7+
use serde::{Deserialize, Serialize};
8+
use time::OffsetDateTime;
9+
use uuid::Uuid;
10+
11+
/// One entry in `state.toml`'s `[[history]]` array — a summary of one
12+
/// successful `apply`. The full plan and per-action breakdown live in
13+
/// `/var/lib/pearlite/plans/<plan-id>.json`, referenced by `plan_id`.
14+
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
15+
pub struct HistoryEntry {
16+
/// Plan UUID (canonical persistent identifier).
17+
#[schemars(with = "String")]
18+
pub plan_id: Uuid,
19+
/// Host-scoped monotonic generation number.
20+
pub generation: u64,
21+
/// UTC timestamp the apply completed successfully.
22+
#[serde(with = "time::serde::iso8601")]
23+
#[schemars(with = "String")]
24+
pub applied_at: OffsetDateTime,
25+
/// Wall-clock duration of the apply, in milliseconds.
26+
pub duration_ms: u64,
27+
/// Pre-apply snapshot reference.
28+
pub snapshot_pre: SnapshotRef,
29+
/// Post-apply snapshot reference.
30+
pub snapshot_post: SnapshotRef,
31+
/// Number of actions executed in this apply.
32+
pub actions_executed: u32,
33+
/// Git revision of the config repo at apply time, if available.
34+
#[serde(default)]
35+
pub git_revision: Option<String>,
36+
/// Whether the config repo had uncommitted changes at apply time.
37+
#[serde(default)]
38+
pub git_dirty: bool,
39+
/// One-line summary, e.g. `"+8 -2 ~4 (8 installs, 2 removals, 4 config updates)"`.
40+
pub summary: String,
41+
}
42+
43+
/// Reference to a Snapper snapshot.
44+
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
45+
pub struct SnapshotRef {
46+
/// Snapper snapshot ID.
47+
pub id: u64,
48+
/// Snapper snapshot label.
49+
pub label: String,
50+
/// UTC creation timestamp.
51+
#[serde(with = "time::serde::iso8601")]
52+
#[schemars(with = "String")]
53+
pub created_at: OffsetDateTime,
54+
}

crates/pearlite-state/src/lib.rs

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,3 +2,23 @@
22
// Copyright (C) 2026 Mohamed Hammad
33

44
//! Persistent Pearlite state: atomic read/write, migrations, history.
5+
//!
6+
//! [`State`] is the in-memory mirror of `/var/lib/pearlite/state.toml` —
7+
//! the load-bearing artifact in PRD §7. The `read` / `write_atomic` /
8+
//! `append_*` operations land in chunk M1-W1-E; this scaffold defines the
9+
//! types and errors only.
10+
11+
mod errors;
12+
mod failure;
13+
mod history;
14+
mod reconciliation;
15+
mod schema;
16+
17+
pub use errors::StateError;
18+
pub use failure::FailureRef;
19+
pub use history::{HistoryEntry, SnapshotRef};
20+
pub use reconciliation::{ReconciliationAction, ReconciliationEntry};
21+
pub use schema::{
22+
Adopted, ConfigFileRecord, KernelRecord, Managed, SCHEMA_VERSION, ServicesState, State,
23+
UserEnvRecord,
24+
};
Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
// SPDX-License-Identifier: GPL-3.0-or-later
2+
// Copyright (C) 2026 Mohamed Hammad
3+
4+
//! `[[reconciliations]]` records — one per `pearlite reconcile --commit`.
5+
6+
use schemars::JsonSchema;
7+
use serde::{Deserialize, Serialize};
8+
use time::OffsetDateTime;
9+
use uuid::Uuid;
10+
11+
/// One entry in `state.toml`'s `[[reconciliations]]` array.
12+
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
13+
pub struct ReconciliationEntry {
14+
/// Plan UUID generated for this reconciliation.
15+
#[schemars(with = "String")]
16+
pub plan_id: Uuid,
17+
/// UTC timestamp when the reconciliation committed.
18+
#[serde(with = "time::serde::iso8601")]
19+
#[schemars(with = "String")]
20+
pub committed_at: OffsetDateTime,
21+
/// Resolution chosen for the drift items at commit time.
22+
pub action: ReconciliationAction,
23+
/// Number of packages classified during this reconciliation.
24+
pub package_count: u32,
25+
}
26+
27+
/// How a reconciliation resolved its detected drift.
28+
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize, JsonSchema)]
29+
#[serde(rename_all = "snake_case")]
30+
pub enum ReconciliationAction {
31+
/// Non-interactive `--adopt-all`: every drift item moved to `adopted`.
32+
AdoptAll,
33+
/// Interactive prompt path: per-package decisions taken.
34+
Interactive,
35+
/// Reconciliation aborted before any state was written.
36+
Skipped,
37+
}
Lines changed: 163 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,163 @@
1+
// SPDX-License-Identifier: GPL-3.0-or-later
2+
// Copyright (C) 2026 Mohamed Hammad
3+
4+
//! Top-level [`State`] struct and the `[managed.*]` / `[adopted.*]` records
5+
//! it owns.
6+
7+
use crate::failure::FailureRef;
8+
use crate::history::HistoryEntry;
9+
use crate::reconciliation::ReconciliationEntry;
10+
use schemars::JsonSchema;
11+
use serde::{Deserialize, Serialize};
12+
use std::collections::BTreeMap;
13+
use std::path::PathBuf;
14+
use time::OffsetDateTime;
15+
16+
/// Current `schema_version` written by this build.
17+
///
18+
/// Incremented when the on-disk shape changes incompatibly. Migration
19+
/// from older versions lands in `migrate.rs` (chunk M1-W1-F).
20+
pub const SCHEMA_VERSION: u32 = 1;
21+
22+
/// In-memory mirror of `/var/lib/pearlite/state.toml`.
23+
///
24+
/// The full schema is specified in PRD §7.2. `State` is mutated only by
25+
/// `pearlite-engine`; every other crate consumes it read-only.
26+
///
27+
/// `Eq` is intentionally **not** derived: the `reserved` field carries a
28+
/// `toml::Value` map for forward-compatibility, and `toml::Value::Float`
29+
/// wraps an `f64` that is not `Eq`. Use `PartialEq` (which is derived)
30+
/// for equality checks in tests.
31+
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, JsonSchema)]
32+
pub struct State {
33+
/// Schema version of the file at last write.
34+
pub schema_version: u32,
35+
/// Hostname this state belongs to. Mismatch with `hostname()` is
36+
/// preflight Class 1.
37+
pub host: String,
38+
/// Pearlite version that last wrote the file.
39+
pub tool_version: String,
40+
/// Path to the user's Pearlite config repository at last apply.
41+
pub config_dir: PathBuf,
42+
/// Timestamp of the last successful `apply`.
43+
#[serde(with = "time::serde::iso8601::option", default)]
44+
#[schemars(with = "Option<String>")]
45+
pub last_apply: Option<OffsetDateTime>,
46+
/// Timestamp of the last write of any kind.
47+
#[serde(with = "time::serde::iso8601::option", default)]
48+
#[schemars(with = "Option<String>")]
49+
pub last_modified: Option<OffsetDateTime>,
50+
/// Pearlite-managed packages, configs, services, kernel, and user envs.
51+
#[serde(default)]
52+
pub managed: Managed,
53+
/// User-flagged "leave alone" packages.
54+
#[serde(default)]
55+
pub adopted: Adopted,
56+
/// Append-only log of successful applies.
57+
#[serde(default)]
58+
pub history: Vec<HistoryEntry>,
59+
/// Log of `pearlite reconcile --commit` invocations.
60+
#[serde(default)]
61+
pub reconciliations: Vec<ReconciliationEntry>,
62+
/// Pointers to `/var/lib/pearlite/failures/<plan-id>.json` records.
63+
#[serde(default)]
64+
pub failures: Vec<FailureRef>,
65+
/// Forward-compat namespace; arbitrary unknown keys preserved on
66+
/// round-trip via the toml_edit-based atomic writer. Excluded from
67+
/// the JSON Schema because its values are untyped by design.
68+
#[serde(default)]
69+
#[schemars(skip)]
70+
pub reserved: BTreeMap<String, toml::Value>,
71+
}
72+
73+
/// `[managed.*]` namespace: everything Pearlite has installed or written.
74+
#[derive(Clone, Debug, PartialEq, Eq, Default, Serialize, Deserialize, JsonSchema)]
75+
pub struct Managed {
76+
/// pacman/AUR packages installed via Pearlite.
77+
#[serde(default)]
78+
pub pacman: Vec<String>,
79+
/// Cargo crates installed via Pearlite.
80+
#[serde(default)]
81+
pub cargo: Vec<String>,
82+
/// Per-file metadata for managed `/etc` config files.
83+
#[serde(default)]
84+
pub config_files: Vec<ConfigFileRecord>,
85+
/// systemd unit state at last apply.
86+
#[serde(default)]
87+
pub services: ServicesState,
88+
/// Per-user Home Manager generation and config hash.
89+
#[serde(default)]
90+
pub user_env: Vec<UserEnvRecord>,
91+
/// Kernel package, version, cmdline, modules at last apply.
92+
#[serde(default)]
93+
pub kernel: Option<KernelRecord>,
94+
}
95+
96+
/// One managed `/etc` config file's record.
97+
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
98+
pub struct ConfigFileRecord {
99+
/// Absolute destination path on the host.
100+
pub target: PathBuf,
101+
/// Source path within the user's config repo.
102+
pub source_in_repo: PathBuf,
103+
/// SHA-256 of the file contents at last successful write, hex-encoded.
104+
pub sha256: String,
105+
/// File mode (`stat(2).st_mode & 0o7777`).
106+
pub mode: u32,
107+
/// Owning user.
108+
pub owner: String,
109+
/// Owning group.
110+
pub group: String,
111+
}
112+
113+
/// systemd-unit state recorded by Pearlite at apply time.
114+
#[derive(Clone, Debug, PartialEq, Eq, Default, Serialize, Deserialize, JsonSchema)]
115+
pub struct ServicesState {
116+
/// Units enabled (and started) by Pearlite.
117+
#[serde(default)]
118+
pub enabled: Vec<String>,
119+
/// Units disabled by Pearlite.
120+
#[serde(default)]
121+
pub disabled: Vec<String>,
122+
/// Units masked by Pearlite.
123+
#[serde(default)]
124+
pub masked: Vec<String>,
125+
}
126+
127+
/// One user's Home Manager generation pointer.
128+
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
129+
pub struct UserEnvRecord {
130+
/// Login name.
131+
pub user: String,
132+
/// Home Manager generation number at last apply.
133+
pub generation: u64,
134+
/// SHA-256 of the user's HM config directory at last apply.
135+
pub config_hash: String,
136+
}
137+
138+
/// Kernel record: package, version, cmdline, modules at last apply.
139+
#[derive(Clone, Debug, PartialEq, Eq, Default, Serialize, Deserialize, JsonSchema)]
140+
pub struct KernelRecord {
141+
/// Kernel package name (e.g. `linux-cachyos`).
142+
pub package: String,
143+
/// Installed kernel version string.
144+
pub version: String,
145+
/// Kernel command-line as last applied.
146+
#[serde(default)]
147+
pub cmdline: Vec<String>,
148+
/// Loaded modules as last applied.
149+
#[serde(default)]
150+
pub modules: Vec<String>,
151+
}
152+
153+
/// `[adopted.*]` namespace: drift items the user has explicitly told
154+
/// Pearlite to leave alone.
155+
#[derive(Clone, Debug, PartialEq, Eq, Default, Serialize, Deserialize, JsonSchema)]
156+
pub struct Adopted {
157+
/// Adopted pacman/AUR packages.
158+
#[serde(default)]
159+
pub pacman: Vec<String>,
160+
/// Adopted cargo crates.
161+
#[serde(default)]
162+
pub cargo: Vec<String>,
163+
}

0 commit comments

Comments
 (0)