Skip to content

Commit 9b9c8e0

Browse files
feat(schema): from_resolved_toml + validate (#4)
Chunk M1-W1-B of M1 plan. Lands the two pure functions Plan §6.1 calls out, plus four canonical fixtures and 12 unit tests. pub fn from_resolved_toml(s: &str) -> Result<DeclaredState, SchemaError>: - Single-shot toml::from_str into DeclaredState. The existing SchemaError::InvalidToml variant wraps toml::de::Error via #[from], so the implementation is a one-liner; the value is in fixtures + error-path tests. pub fn validate(d: &DeclaredState) -> Result<(), Vec<ContractViolation>>: - DUPLICATE_PACKAGES — every package name appears in at most one list across PackageSet and the two RemovePolicy lists. Violations name every list the duplicate appeared in. - KERNEL_MODULES_NOT_UNIQUE — kernel.modules and kernel.blacklist each contain unique entries, and the two are disjoint. - ARCH_LEVEL_MISMATCH — meta.arch_level = "v3" forbids a non-empty packages.cachyos-v4, and symmetrically. (PRD §6.2 makes this relationship explicit; Plan §6.1 listed only the first two contracts but this is in the same spirit.) validate() collects every violation rather than returning the first. Fixtures (fixtures/schema/): - host_minimal.toml — meta + kernel.package only. - host_full.toml — every section populated, two users (one with HM, one without), two config entries, custom snapshots.keep. - host_invalid_arch.toml — bogus arch_level = "v5". - host_malformed_mode.toml — mode as a string instead of an integer. Tests: - parse: parse_minimal_succeeds, parse_full_succeeds, reject_invalid_arch_level, reject_malformed_mode. - validate: validate_clean_minimal, validate_clean_full_fixture, validate_duplicate_packages, validate_kernel_modules_overlap, validate_kernel_modules_duplicate_within_list, validate_arch_level_v3_with_v4_packages, validate_arch_level_v4_with_v3_packages, validate_collects_multiple_violations. What's NOT in this chunk: - Property tests (chunk M1-W1-C with proptest). - JsonSchema Draft-2020-12 fixture (chunk M1-W1-C). Verification: - cargo test -p pearlite-schema (12 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 31a5ed2 commit 9b9c8e0

8 files changed

Lines changed: 426 additions & 1 deletion

File tree

crates/pearlite-schema/src/errors.rs

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@ pub enum SchemaError {
1717
MissingHostFile(PathBuf),
1818
}
1919

20-
/// One contract violation produced by `validate` (lands in chunk M1-W1-B).
20+
/// One contract violation produced by [`validate`](crate::validate).
2121
#[derive(Clone, Debug, PartialEq, Eq)]
2222
pub struct ContractViolation {
2323
/// Stable contract identifier (e.g. `DUPLICATE_PACKAGES`).
@@ -26,6 +26,17 @@ pub struct ContractViolation {
2626
pub message: String,
2727
}
2828

29+
impl ContractViolation {
30+
/// A package name appears in more than one declared list.
31+
pub const DUPLICATE_PACKAGES: &'static str = "DUPLICATE_PACKAGES";
32+
/// A kernel module appears more than once, or appears in both
33+
/// `kernel.modules` and `kernel.blacklist`.
34+
pub const KERNEL_MODULES_NOT_UNIQUE: &'static str = "KERNEL_MODULES_NOT_UNIQUE";
35+
/// `meta.arch_level` does not match the declared per-feature-level
36+
/// repository: e.g. `arch_level = "v3"` with non-empty `packages.cachyos-v4`.
37+
pub const ARCH_LEVEL_MISMATCH: &'static str = "ARCH_LEVEL_MISMATCH";
38+
}
39+
2940
impl std::fmt::Display for ContractViolation {
3041
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
3142
write!(f, "{}: {}", self.id, self.message)

crates/pearlite-schema/src/lib.rs

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,20 +15,24 @@ mod declared;
1515
mod errors;
1616
mod host;
1717
mod packages;
18+
mod parse;
1819
mod probed;
1920
mod services;
2021
mod snapshots;
2122
mod users;
23+
mod validate;
2224

2325
pub use config::{ConfigEntry, RemovePolicy};
2426
pub use declared::DeclaredState;
2527
pub use errors::{ContractViolation, SchemaError};
2628
pub use host::{ArchLevel, HostMeta, KernelDecl};
2729
pub use packages::PackageSet;
30+
pub use parse::from_resolved_toml;
2831
pub use probed::{
2932
CargoInventory, ConfigFileInventory, ConfigFileMeta, HostInfo, KernelInfo, PacmanInventory,
3033
ProbedState, ServiceInventory,
3134
};
3235
pub use services::ServicesDecl;
3336
pub use snapshots::SnapshotPolicy;
3437
pub use users::{HomeManagerDecl, HomeManagerMode, UserDecl};
38+
pub use validate::validate;
Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,85 @@
1+
// SPDX-License-Identifier: GPL-3.0-or-later
2+
// Copyright (C) 2026 Mohamed Hammad
3+
4+
//! Resolved-TOML to [`DeclaredState`] parsing.
5+
6+
use crate::declared::DeclaredState;
7+
use crate::errors::SchemaError;
8+
9+
/// Parse a resolved-TOML host configuration into [`DeclaredState`].
10+
///
11+
/// The input is the stdout of `nickel export -f toml <host_file>`, captured
12+
/// by `pearlite-nickel`. This function is pure — it never reads the
13+
/// filesystem or spawns a subprocess. Cross-field invariants are not
14+
/// checked here; call [`crate::validate`] on the result before relying on
15+
/// the value.
16+
///
17+
/// # Errors
18+
/// Returns [`SchemaError::InvalidToml`] when the input is not well-formed
19+
/// TOML or when a field type or enum variant does not match the schema.
20+
pub fn from_resolved_toml(s: &str) -> Result<DeclaredState, SchemaError> {
21+
toml::from_str::<DeclaredState>(s).map_err(SchemaError::from)
22+
}
23+
24+
#[cfg(test)]
25+
#[allow(
26+
clippy::expect_used,
27+
clippy::unwrap_used,
28+
clippy::panic,
29+
reason = "tests may use expect()/unwrap()/panic!() per Plan §4.2 + CLAUDE.md"
30+
)]
31+
mod tests {
32+
use super::*;
33+
use crate::host::ArchLevel;
34+
35+
const MINIMAL: &str = include_str!("../../../fixtures/schema/host_minimal.toml");
36+
const FULL: &str = include_str!("../../../fixtures/schema/host_full.toml");
37+
const INVALID_ARCH: &str = include_str!("../../../fixtures/schema/host_invalid_arch.toml");
38+
const MALFORMED_MODE: &str = include_str!("../../../fixtures/schema/host_malformed_mode.toml");
39+
40+
#[test]
41+
fn parse_minimal_succeeds() {
42+
let d = from_resolved_toml(MINIMAL).expect("minimal fixture parses");
43+
assert_eq!(d.host.hostname, "forge");
44+
assert_eq!(d.host.arch_level, ArchLevel::V4);
45+
assert_eq!(d.kernel.package, "linux-cachyos");
46+
assert!(d.packages.core.is_empty());
47+
assert!(d.config_files.is_empty());
48+
assert!(d.users.is_empty());
49+
}
50+
51+
#[test]
52+
fn parse_full_succeeds() {
53+
let d = from_resolved_toml(FULL).expect("full fixture parses");
54+
assert_eq!(d.host.hostname, "forge");
55+
assert_eq!(d.host.locale, "en_GB.UTF-8");
56+
assert_eq!(d.kernel.cmdline.len(), 3);
57+
assert_eq!(d.kernel.modules.len(), 4);
58+
assert_eq!(d.kernel.blacklist, vec!["nouveau".to_owned()]);
59+
assert_eq!(d.packages.core.len(), 3);
60+
assert_eq!(d.packages.cachyos_v4.len(), 3);
61+
assert_eq!(d.packages.aur.len(), 2);
62+
assert_eq!(d.config_files.len(), 2);
63+
assert_eq!(d.config_files[0].mode, 384); // 0o600
64+
assert_eq!(d.config_files[1].mode, 0o644); // default
65+
assert_eq!(d.services.enabled.len(), 2);
66+
assert_eq!(d.users.len(), 2);
67+
assert!(d.users[0].home_manager.is_some());
68+
assert!(d.users[1].home_manager.is_none());
69+
assert_eq!(d.remove.packages, vec!["xterm".to_owned()]);
70+
assert_eq!(d.snapshots.keep, 30);
71+
}
72+
73+
#[test]
74+
fn reject_invalid_arch_level() {
75+
let err = from_resolved_toml(INVALID_ARCH).expect_err("v5 must be rejected at parse time");
76+
assert!(matches!(err, SchemaError::InvalidToml(_)), "got {err:?}");
77+
}
78+
79+
#[test]
80+
fn reject_malformed_mode() {
81+
let err = from_resolved_toml(MALFORMED_MODE)
82+
.expect_err("string mode must be rejected at parse time");
83+
assert!(matches!(err, SchemaError::InvalidToml(_)), "got {err:?}");
84+
}
85+
}
Lines changed: 233 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,233 @@
1+
// SPDX-License-Identifier: GPL-3.0-or-later
2+
// Copyright (C) 2026 Mohamed Hammad
3+
4+
//! Cross-field contract checks for [`DeclaredState`].
5+
6+
use crate::declared::DeclaredState;
7+
use crate::errors::ContractViolation;
8+
use crate::host::ArchLevel;
9+
use std::collections::BTreeMap;
10+
11+
/// Check cross-field contracts on a parsed host configuration.
12+
///
13+
/// Serde catches type and enum errors at parse time. This function adds
14+
/// the invariants serde cannot express:
15+
///
16+
/// - **`DUPLICATE_PACKAGES`** — every package name appears in at most one
17+
/// list across [`PackageSet`](crate::PackageSet),
18+
/// [`RemovePolicy::packages`](crate::RemovePolicy), and
19+
/// [`RemovePolicy::ignore`](crate::RemovePolicy).
20+
/// - **`KERNEL_MODULES_NOT_UNIQUE`** — `kernel.modules` and
21+
/// `kernel.blacklist` each contain unique entries, and the two sets are
22+
/// disjoint.
23+
/// - **`ARCH_LEVEL_MISMATCH`** — `meta.arch_level = "v3"` forbids a
24+
/// non-empty `packages.cachyos-v4`, and symmetrically.
25+
///
26+
/// # Errors
27+
/// Returns every violation found, not just the first.
28+
pub fn validate(d: &DeclaredState) -> Result<(), Vec<ContractViolation>> {
29+
let mut violations = Vec::new();
30+
check_duplicate_packages(d, &mut violations);
31+
check_kernel_modules_unique(d, &mut violations);
32+
check_arch_level_matches_packages(d, &mut violations);
33+
if violations.is_empty() {
34+
Ok(())
35+
} else {
36+
Err(violations)
37+
}
38+
}
39+
40+
fn check_duplicate_packages(d: &DeclaredState, violations: &mut Vec<ContractViolation>) {
41+
let p = &d.packages;
42+
let lists: [(&str, &[String]); 8] = [
43+
("packages.core", &p.core),
44+
("packages.cachyos", &p.cachyos),
45+
("packages.cachyos-v3", &p.cachyos_v3),
46+
("packages.cachyos-v4", &p.cachyos_v4),
47+
("packages.aur", &p.aur),
48+
("packages.cargo", &p.cargo),
49+
("remove.packages", &d.remove.packages),
50+
("remove.ignore", &d.remove.ignore),
51+
];
52+
53+
let mut seen: BTreeMap<String, Vec<&str>> = BTreeMap::new();
54+
for (label, items) in lists {
55+
for name in items {
56+
seen.entry(name.clone()).or_default().push(label);
57+
}
58+
}
59+
60+
for (name, sources) in seen {
61+
if sources.len() > 1 {
62+
violations.push(ContractViolation {
63+
id: ContractViolation::DUPLICATE_PACKAGES,
64+
message: format!("'{}' appears in {}", name, sources.join(", ")),
65+
});
66+
}
67+
}
68+
}
69+
70+
fn check_kernel_modules_unique(d: &DeclaredState, violations: &mut Vec<ContractViolation>) {
71+
let modules = &d.kernel.modules;
72+
let blacklist = &d.kernel.blacklist;
73+
74+
if let Some(dup) = first_duplicate(modules) {
75+
violations.push(ContractViolation {
76+
id: ContractViolation::KERNEL_MODULES_NOT_UNIQUE,
77+
message: format!("'{dup}' appears more than once in kernel.modules"),
78+
});
79+
}
80+
if let Some(dup) = first_duplicate(blacklist) {
81+
violations.push(ContractViolation {
82+
id: ContractViolation::KERNEL_MODULES_NOT_UNIQUE,
83+
message: format!("'{dup}' appears more than once in kernel.blacklist"),
84+
});
85+
}
86+
87+
for name in modules {
88+
if blacklist.contains(name) {
89+
violations.push(ContractViolation {
90+
id: ContractViolation::KERNEL_MODULES_NOT_UNIQUE,
91+
message: format!("'{name}' appears in both kernel.modules and kernel.blacklist"),
92+
});
93+
}
94+
}
95+
}
96+
97+
fn check_arch_level_matches_packages(d: &DeclaredState, violations: &mut Vec<ContractViolation>) {
98+
match d.host.arch_level {
99+
ArchLevel::V3 if !d.packages.cachyos_v4.is_empty() => {
100+
violations.push(ContractViolation {
101+
id: ContractViolation::ARCH_LEVEL_MISMATCH,
102+
message: "meta.arch_level = 'v3' but packages.cachyos-v4 is non-empty".to_owned(),
103+
});
104+
}
105+
ArchLevel::V4 if !d.packages.cachyos_v3.is_empty() => {
106+
violations.push(ContractViolation {
107+
id: ContractViolation::ARCH_LEVEL_MISMATCH,
108+
message: "meta.arch_level = 'v4' but packages.cachyos-v3 is non-empty".to_owned(),
109+
});
110+
}
111+
_ => {}
112+
}
113+
}
114+
115+
fn first_duplicate(items: &[String]) -> Option<&str> {
116+
let mut seen = std::collections::BTreeSet::new();
117+
for item in items {
118+
if !seen.insert(item.as_str()) {
119+
return Some(item.as_str());
120+
}
121+
}
122+
None
123+
}
124+
125+
#[cfg(test)]
126+
#[allow(
127+
clippy::expect_used,
128+
clippy::unwrap_used,
129+
clippy::panic,
130+
reason = "tests may use expect()/unwrap()/panic!() per Plan §4.2 + CLAUDE.md"
131+
)]
132+
mod tests {
133+
use super::*;
134+
use crate::parse::from_resolved_toml;
135+
136+
const MINIMAL: &str = include_str!("../../../fixtures/schema/host_minimal.toml");
137+
const FULL: &str = include_str!("../../../fixtures/schema/host_full.toml");
138+
139+
#[test]
140+
fn validate_clean_minimal() {
141+
let d = from_resolved_toml(MINIMAL).expect("parse");
142+
assert!(validate(&d).is_ok());
143+
}
144+
145+
#[test]
146+
fn validate_clean_full_fixture() {
147+
let d = from_resolved_toml(FULL).expect("parse");
148+
validate(&d).expect("full fixture must satisfy every contract");
149+
}
150+
151+
#[test]
152+
fn validate_duplicate_packages() {
153+
let mut d = from_resolved_toml(MINIMAL).expect("parse");
154+
d.packages.core.push("htop".to_owned());
155+
d.packages.aur.push("htop".to_owned());
156+
157+
let err = validate(&d).expect_err("duplicate must be flagged");
158+
assert_eq!(err.len(), 1);
159+
assert_eq!(err[0].id, ContractViolation::DUPLICATE_PACKAGES);
160+
assert!(err[0].message.contains("htop"));
161+
assert!(err[0].message.contains("packages.core"));
162+
assert!(err[0].message.contains("packages.aur"));
163+
}
164+
165+
#[test]
166+
fn validate_kernel_modules_overlap() {
167+
let mut d = from_resolved_toml(MINIMAL).expect("parse");
168+
d.kernel.modules = vec!["nvidia".to_owned(), "nouveau".to_owned()];
169+
d.kernel.blacklist = vec!["nouveau".to_owned()];
170+
171+
let err = validate(&d).expect_err("overlap must be flagged");
172+
assert!(
173+
err.iter()
174+
.any(|v| v.id == ContractViolation::KERNEL_MODULES_NOT_UNIQUE
175+
&& v.message.contains("nouveau")
176+
&& v.message.contains("kernel.modules")
177+
&& v.message.contains("kernel.blacklist"))
178+
);
179+
}
180+
181+
#[test]
182+
fn validate_kernel_modules_duplicate_within_list() {
183+
let mut d = from_resolved_toml(MINIMAL).expect("parse");
184+
d.kernel.modules = vec!["nvidia".to_owned(), "nvidia".to_owned()];
185+
186+
let err = validate(&d).expect_err("duplicate must be flagged");
187+
assert!(
188+
err.iter()
189+
.any(|v| v.id == ContractViolation::KERNEL_MODULES_NOT_UNIQUE
190+
&& v.message.contains("nvidia"))
191+
);
192+
}
193+
194+
#[test]
195+
fn validate_arch_level_v3_with_v4_packages() {
196+
let mut d = from_resolved_toml(MINIMAL).expect("parse");
197+
d.host.arch_level = ArchLevel::V3;
198+
d.packages.cachyos_v4 = vec!["firefox".to_owned()];
199+
200+
let err = validate(&d).expect_err("arch-level mismatch must be flagged");
201+
assert_eq!(err.len(), 1);
202+
assert_eq!(err[0].id, ContractViolation::ARCH_LEVEL_MISMATCH);
203+
}
204+
205+
#[test]
206+
fn validate_arch_level_v4_with_v3_packages() {
207+
let mut d = from_resolved_toml(MINIMAL).expect("parse");
208+
d.host.arch_level = ArchLevel::V4;
209+
d.packages.cachyos_v3 = vec!["openssl".to_owned()];
210+
211+
let err = validate(&d).expect_err("arch-level mismatch must be flagged");
212+
assert_eq!(err.len(), 1);
213+
assert_eq!(err[0].id, ContractViolation::ARCH_LEVEL_MISMATCH);
214+
}
215+
216+
#[test]
217+
fn validate_collects_multiple_violations() {
218+
let mut d = from_resolved_toml(MINIMAL).expect("parse");
219+
d.packages.core.push("htop".to_owned());
220+
d.packages.aur.push("htop".to_owned());
221+
d.kernel.modules = vec!["x".to_owned(), "x".to_owned()];
222+
223+
let err = validate(&d).expect_err("two violations expected");
224+
assert!(
225+
err.iter()
226+
.any(|v| v.id == ContractViolation::DUPLICATE_PACKAGES)
227+
);
228+
assert!(
229+
err.iter()
230+
.any(|v| v.id == ContractViolation::KERNEL_MODULES_NOT_UNIQUE)
231+
);
232+
}
233+
}

0 commit comments

Comments
 (0)