Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions crates/pearlite-state/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -12,3 +12,11 @@ repository.workspace = true
workspace = true

[dependencies]
serde = { workspace = true }
toml = { workspace = true }
time = { workspace = true }
uuid = { workspace = true }
thiserror = { workspace = true }
schemars = { workspace = true }
# toml_edit lands in chunk M1-W1-E with the atomic-write implementation
# that needs it for unknown-field preservation.
42 changes: 42 additions & 0 deletions crates/pearlite-state/src/errors.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
// SPDX-License-Identifier: GPL-3.0-or-later
// Copyright (C) 2026 Mohamed Hammad

//! Errors emitted by `pearlite-state`.

use std::path::PathBuf;
use thiserror::Error;

/// Errors emitted while reading, writing, or migrating `state.toml`.
#[derive(Debug, Error)]
pub enum StateError {
/// The TOML failed to parse.
#[error("invalid TOML in state file: {0}")]
InvalidToml(#[from] toml::de::Error),
/// `state.toml` was not found at the expected path.
#[error("state file not found: {0}")]
NotFound(PathBuf),
/// I/O error while reading or writing the file.
#[error("I/O error on state file: {0}")]
Io(#[from] std::io::Error),
/// Unknown `schema_version` — the on-disk file is from a future
/// Pearlite release and cannot be read by this build.
#[error("unknown schema_version {found}; this build supports up to {supported}")]
UnsupportedSchemaVersion {
/// Version found in the file.
found: u32,
/// Highest version this build can read.
supported: u32,
},
/// `state.host` does not match the system `hostname()`.
#[error("state.host '{state_host}' does not match system hostname '{system_host}'")]
HostMismatch {
/// Host recorded in `state.toml`.
state_host: String,
/// Live `hostname(1)` value.
system_host: String,
},
/// TOML serialisation failed (should be unreachable for well-formed
/// `State` values).
#[error("could not serialize state: {0}")]
SerializeFailed(#[from] toml::ser::Error),
}
34 changes: 34 additions & 0 deletions crates/pearlite-state/src/failure.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
// SPDX-License-Identifier: GPL-3.0-or-later
// Copyright (C) 2026 Mohamed Hammad

//! `[[failures]]` records — pointers to per-plan failure JSON files.

use schemars::JsonSchema;
use serde::{Deserialize, Serialize};
use std::path::PathBuf;
use time::OffsetDateTime;
use uuid::Uuid;

/// One entry in `state.toml`'s `[[failures]]` array.
///
/// The full forensic record (stderr, `investigate_commands`,
/// `rollback_command`, …) lives in `/var/lib/pearlite/failures/<plan-id>.json`
/// per PRD §11.4. This entry holds only the pointer plus enough metadata
/// to render `pearlite gen list` without reading every JSON record.
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
pub struct FailureRef {
/// Plan UUID this failure pertains to.
#[schemars(with = "String")]
pub plan_id: Uuid,
/// UTC timestamp the apply failed.
#[serde(with = "time::serde::iso8601")]
#[schemars(with = "String")]
pub failed_at: OffsetDateTime,
/// Failure class per PRD §8.5: 1 preflight, 2 plan, 3 recoverable,
/// 4 incoherent, 5 catastrophic.
pub class: u8,
/// Process exit code at failure.
pub exit_code: u8,
/// Path to the JSON record under `/var/lib/pearlite/failures/`.
pub record_path: PathBuf,
}
54 changes: 54 additions & 0 deletions crates/pearlite-state/src/history.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
// SPDX-License-Identifier: GPL-3.0-or-later
// Copyright (C) 2026 Mohamed Hammad

//! `[[history]]` and snapshot-reference records.

use schemars::JsonSchema;
use serde::{Deserialize, Serialize};
use time::OffsetDateTime;
use uuid::Uuid;

/// One entry in `state.toml`'s `[[history]]` array — a summary of one
/// successful `apply`. The full plan and per-action breakdown live in
/// `/var/lib/pearlite/plans/<plan-id>.json`, referenced by `plan_id`.
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
pub struct HistoryEntry {
/// Plan UUID (canonical persistent identifier).
#[schemars(with = "String")]
pub plan_id: Uuid,
/// Host-scoped monotonic generation number.
pub generation: u64,
/// UTC timestamp the apply completed successfully.
#[serde(with = "time::serde::iso8601")]
#[schemars(with = "String")]
pub applied_at: OffsetDateTime,
/// Wall-clock duration of the apply, in milliseconds.
pub duration_ms: u64,
/// Pre-apply snapshot reference.
pub snapshot_pre: SnapshotRef,
/// Post-apply snapshot reference.
pub snapshot_post: SnapshotRef,
/// Number of actions executed in this apply.
pub actions_executed: u32,
/// Git revision of the config repo at apply time, if available.
#[serde(default)]
pub git_revision: Option<String>,
/// Whether the config repo had uncommitted changes at apply time.
#[serde(default)]
pub git_dirty: bool,
/// One-line summary, e.g. `"+8 -2 ~4 (8 installs, 2 removals, 4 config updates)"`.
pub summary: String,
}

/// Reference to a Snapper snapshot.
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
pub struct SnapshotRef {
/// Snapper snapshot ID.
pub id: u64,
/// Snapper snapshot label.
pub label: String,
/// UTC creation timestamp.
#[serde(with = "time::serde::iso8601")]
#[schemars(with = "String")]
pub created_at: OffsetDateTime,
}
20 changes: 20 additions & 0 deletions crates/pearlite-state/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2,3 +2,23 @@
// Copyright (C) 2026 Mohamed Hammad

//! Persistent Pearlite state: atomic read/write, migrations, history.
//!
//! [`State`] is the in-memory mirror of `/var/lib/pearlite/state.toml` —
//! the load-bearing artifact in PRD §7. The `read` / `write_atomic` /
//! `append_*` operations land in chunk M1-W1-E; this scaffold defines the
//! types and errors only.

mod errors;
mod failure;
mod history;
mod reconciliation;
mod schema;

pub use errors::StateError;
pub use failure::FailureRef;
pub use history::{HistoryEntry, SnapshotRef};
pub use reconciliation::{ReconciliationAction, ReconciliationEntry};
pub use schema::{
Adopted, ConfigFileRecord, KernelRecord, Managed, SCHEMA_VERSION, ServicesState, State,
UserEnvRecord,
};
37 changes: 37 additions & 0 deletions crates/pearlite-state/src/reconciliation.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
// SPDX-License-Identifier: GPL-3.0-or-later
// Copyright (C) 2026 Mohamed Hammad

//! `[[reconciliations]]` records — one per `pearlite reconcile --commit`.

use schemars::JsonSchema;
use serde::{Deserialize, Serialize};
use time::OffsetDateTime;
use uuid::Uuid;

/// One entry in `state.toml`'s `[[reconciliations]]` array.
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
pub struct ReconciliationEntry {
/// Plan UUID generated for this reconciliation.
#[schemars(with = "String")]
pub plan_id: Uuid,
/// UTC timestamp when the reconciliation committed.
#[serde(with = "time::serde::iso8601")]
#[schemars(with = "String")]
pub committed_at: OffsetDateTime,
/// Resolution chosen for the drift items at commit time.
pub action: ReconciliationAction,
/// Number of packages classified during this reconciliation.
pub package_count: u32,
}

/// How a reconciliation resolved its detected drift.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize, JsonSchema)]
#[serde(rename_all = "snake_case")]
pub enum ReconciliationAction {
/// Non-interactive `--adopt-all`: every drift item moved to `adopted`.
AdoptAll,
/// Interactive prompt path: per-package decisions taken.
Interactive,
/// Reconciliation aborted before any state was written.
Skipped,
}
163 changes: 163 additions & 0 deletions crates/pearlite-state/src/schema.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,163 @@
// SPDX-License-Identifier: GPL-3.0-or-later
// Copyright (C) 2026 Mohamed Hammad

//! Top-level [`State`] struct and the `[managed.*]` / `[adopted.*]` records
//! it owns.

use crate::failure::FailureRef;
use crate::history::HistoryEntry;
use crate::reconciliation::ReconciliationEntry;
use schemars::JsonSchema;
use serde::{Deserialize, Serialize};
use std::collections::BTreeMap;
use std::path::PathBuf;
use time::OffsetDateTime;

/// Current `schema_version` written by this build.
///
/// Incremented when the on-disk shape changes incompatibly. Migration
/// from older versions lands in `migrate.rs` (chunk M1-W1-F).
pub const SCHEMA_VERSION: u32 = 1;

/// In-memory mirror of `/var/lib/pearlite/state.toml`.
///
/// The full schema is specified in PRD §7.2. `State` is mutated only by
/// `pearlite-engine`; every other crate consumes it read-only.
///
/// `Eq` is intentionally **not** derived: the `reserved` field carries a
/// `toml::Value` map for forward-compatibility, and `toml::Value::Float`
/// wraps an `f64` that is not `Eq`. Use `PartialEq` (which is derived)
/// for equality checks in tests.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, JsonSchema)]
pub struct State {
/// Schema version of the file at last write.
pub schema_version: u32,
/// Hostname this state belongs to. Mismatch with `hostname()` is
/// preflight Class 1.
pub host: String,
/// Pearlite version that last wrote the file.
pub tool_version: String,
/// Path to the user's Pearlite config repository at last apply.
pub config_dir: PathBuf,
/// Timestamp of the last successful `apply`.
#[serde(with = "time::serde::iso8601::option", default)]
#[schemars(with = "Option<String>")]
pub last_apply: Option<OffsetDateTime>,
/// Timestamp of the last write of any kind.
#[serde(with = "time::serde::iso8601::option", default)]
#[schemars(with = "Option<String>")]
pub last_modified: Option<OffsetDateTime>,
/// Pearlite-managed packages, configs, services, kernel, and user envs.
#[serde(default)]
pub managed: Managed,
/// User-flagged "leave alone" packages.
#[serde(default)]
pub adopted: Adopted,
/// Append-only log of successful applies.
#[serde(default)]
pub history: Vec<HistoryEntry>,
/// Log of `pearlite reconcile --commit` invocations.
#[serde(default)]
pub reconciliations: Vec<ReconciliationEntry>,
/// Pointers to `/var/lib/pearlite/failures/<plan-id>.json` records.
#[serde(default)]
pub failures: Vec<FailureRef>,
/// Forward-compat namespace; arbitrary unknown keys preserved on
/// round-trip via the toml_edit-based atomic writer. Excluded from
/// the JSON Schema because its values are untyped by design.
#[serde(default)]
#[schemars(skip)]
pub reserved: BTreeMap<String, toml::Value>,
}

/// `[managed.*]` namespace: everything Pearlite has installed or written.
#[derive(Clone, Debug, PartialEq, Eq, Default, Serialize, Deserialize, JsonSchema)]
pub struct Managed {
/// pacman/AUR packages installed via Pearlite.
#[serde(default)]
pub pacman: Vec<String>,
/// Cargo crates installed via Pearlite.
#[serde(default)]
pub cargo: Vec<String>,
/// Per-file metadata for managed `/etc` config files.
#[serde(default)]
pub config_files: Vec<ConfigFileRecord>,
/// systemd unit state at last apply.
#[serde(default)]
pub services: ServicesState,
/// Per-user Home Manager generation and config hash.
#[serde(default)]
pub user_env: Vec<UserEnvRecord>,
/// Kernel package, version, cmdline, modules at last apply.
#[serde(default)]
pub kernel: Option<KernelRecord>,
}

/// One managed `/etc` config file's record.
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
pub struct ConfigFileRecord {
/// Absolute destination path on the host.
pub target: PathBuf,
/// Source path within the user's config repo.
pub source_in_repo: PathBuf,
/// SHA-256 of the file contents at last successful write, hex-encoded.
pub sha256: String,
/// File mode (`stat(2).st_mode & 0o7777`).
pub mode: u32,
/// Owning user.
pub owner: String,
/// Owning group.
pub group: String,
}

/// systemd-unit state recorded by Pearlite at apply time.
#[derive(Clone, Debug, PartialEq, Eq, Default, Serialize, Deserialize, JsonSchema)]
pub struct ServicesState {
/// Units enabled (and started) by Pearlite.
#[serde(default)]
pub enabled: Vec<String>,
/// Units disabled by Pearlite.
#[serde(default)]
pub disabled: Vec<String>,
/// Units masked by Pearlite.
#[serde(default)]
pub masked: Vec<String>,
}

/// One user's Home Manager generation pointer.
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
pub struct UserEnvRecord {
/// Login name.
pub user: String,
/// Home Manager generation number at last apply.
pub generation: u64,
/// SHA-256 of the user's HM config directory at last apply.
pub config_hash: String,
}

/// Kernel record: package, version, cmdline, modules at last apply.
#[derive(Clone, Debug, PartialEq, Eq, Default, Serialize, Deserialize, JsonSchema)]
pub struct KernelRecord {
/// Kernel package name (e.g. `linux-cachyos`).
pub package: String,
/// Installed kernel version string.
pub version: String,
/// Kernel command-line as last applied.
#[serde(default)]
pub cmdline: Vec<String>,
/// Loaded modules as last applied.
#[serde(default)]
pub modules: Vec<String>,
}

/// `[adopted.*]` namespace: drift items the user has explicitly told
/// Pearlite to leave alone.
#[derive(Clone, Debug, PartialEq, Eq, Default, Serialize, Deserialize, JsonSchema)]
pub struct Adopted {
/// Adopted pacman/AUR packages.
#[serde(default)]
pub pacman: Vec<String>,
/// Adopted cargo crates.
#[serde(default)]
pub cargo: Vec<String>,
}
Loading