Some checks failed
lakehouse/auditor 4 blocking issues: todo!() macro call in tests/real-world/scrum_master_pipeline.ts
Phase 42 PRD (docs/CONTROL_PLANE_PRD.md:144): "truth/ dir at repo
root — rule files, versioned in git." Didn't exist. Landing both the
dir + its loader.
New files:
truth/
README.md — documents file format, rule shape,
composition model (file rules are
additive on top of in-code default_
truth_store), explicit non-goals
(no hot reload, no inheritance)
staffing.fill.toml — 2 staffing.fill rules:
endorsed-count-matches-target,
city-required (both Reject via
FieldEmpty)
staffing.any.toml — 1 staffing.any rule:
no-destructive-sql-in-context via
FieldContainsAny (parallel to the
queryd SQL gate we already ship)
crates/truth/src/loader.rs — load_from_dir(store, dir)
— 5 tests: happy path, duplicate-ID
rejection within files, duplicate-ID
rejection against in-code rules,
non-toml files skipped, missing-dir
error. Alphabetical file order for
reproducible error messages.
crates/truth/src/lib.rs — new pub fn all_rule_ids() helper on
TruthStore so the loader can detect
collisions without breaching the
private `rules` field.
crates/truth/Cargo.toml — adds `toml` workspace dep.
Composition model: file rules are ADDITIVE on top of what
default_truth_store() registers in code. Operators can tune
thresholds/needles/descriptions at the file layer without a code
deploy. Schema changes (new RuleCondition variants) still need a
code bump.
Integration hook (not in this commit, flagged for follow-up):
main.rs should call loader::load_from_dir(&mut store, "truth/")
after default_truth_store() so file-backed rules take effect on
gateway boot. Deliberately separate: this commit lands the
machinery; wiring it on happens when the team is ready to own
the rule file lifecycle.
Total: 37 truth tests green (was 32). Workspace warnings still 0.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
188 lines
5.6 KiB
Rust
188 lines
5.6 KiB
Rust
//! File-backed TruthRule loader (Phase 42 PRD).
|
|
//!
|
|
//! PRD: "truth/ dir at repo root — rule files, versioned in git."
|
|
//! This module walks a directory, parses every `*.toml` file it finds,
|
|
//! and registers the rules into a caller-supplied store. Rule IDs must
|
|
//! be unique across the combined set — duplicate-ID collisions are
|
|
//! load-time errors.
|
|
//!
|
|
//! The TOML format matches the shape at `truth/README.md`. The same
|
|
//! `RuleCondition` + `RuleAction` enums used by the in-code registrars
|
|
//! deserialize directly from `condition = { type = "FieldEquals", ... }`
|
|
//! thanks to `#[serde(tag = "type")]`.
|
|
|
|
use std::fs;
|
|
use std::path::Path;
|
|
use serde::Deserialize;
|
|
|
|
use crate::{TruthRule, TruthStore};
|
|
|
|
/// Deserialization wrapper — a TOML file is a list of [[rule]] blocks.
|
|
#[derive(Deserialize)]
|
|
struct RuleFile {
|
|
#[serde(default)]
|
|
rule: Vec<TruthRule>,
|
|
}
|
|
|
|
/// Load every `*.toml` file in `dir` and add its rules to `store`.
|
|
/// Returns the number of rules loaded across all files.
|
|
///
|
|
/// Errors:
|
|
/// - directory doesn't exist or can't be read
|
|
/// - any `.toml` file fails to parse
|
|
/// - any rule ID collides with an existing rule (same ID already
|
|
/// registered in the store)
|
|
///
|
|
/// Non-goals: recursive walk (flat dir only), hot reload (one-shot load).
|
|
pub fn load_from_dir(store: &mut TruthStore, dir: impl AsRef<Path>) -> Result<usize, String> {
|
|
let dir = dir.as_ref();
|
|
let entries = fs::read_dir(dir)
|
|
.map_err(|e| format!("read_dir {}: {e}", dir.display()))?;
|
|
|
|
let mut loaded_ids = store.all_rule_ids();
|
|
let mut count = 0usize;
|
|
|
|
let mut paths: Vec<_> = entries
|
|
.filter_map(|e| e.ok())
|
|
.map(|e| e.path())
|
|
.filter(|p| p.extension().and_then(|s| s.to_str()) == Some("toml"))
|
|
.collect();
|
|
// Deterministic order — alphabetical by filename. Matters when a
|
|
// cross-file ID collision happens; the earlier filename wins
|
|
// nothing (both error), but the error message is reproducible.
|
|
paths.sort();
|
|
|
|
for path in paths {
|
|
let raw = fs::read_to_string(&path)
|
|
.map_err(|e| format!("read {}: {e}", path.display()))?;
|
|
let file: RuleFile = toml::from_str(&raw)
|
|
.map_err(|e| format!("parse {}: {e}", path.display()))?;
|
|
for rule in file.rule {
|
|
if !loaded_ids.insert(rule.id.clone()) {
|
|
return Err(format!(
|
|
"duplicate rule id '{}' from {}",
|
|
rule.id,
|
|
path.display()
|
|
));
|
|
}
|
|
store.add_rule(rule);
|
|
count += 1;
|
|
}
|
|
}
|
|
|
|
Ok(count)
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
use std::io::Write;
|
|
|
|
fn write_file(dir: &Path, name: &str, content: &str) {
|
|
let path = dir.join(name);
|
|
let mut f = fs::File::create(&path).unwrap();
|
|
f.write_all(content.as_bytes()).unwrap();
|
|
}
|
|
|
|
#[test]
|
|
fn loads_rules_from_toml_files() {
|
|
let tmp = tempdir_for("loader_test");
|
|
write_file(&tmp, "a.toml", r#"
|
|
[[rule]]
|
|
id = "a-rule"
|
|
task_class = "test"
|
|
description = "test rule"
|
|
action = { type = "Pass" }
|
|
|
|
[rule.condition]
|
|
type = "Always"
|
|
"#);
|
|
let mut store = TruthStore::new();
|
|
let n = load_from_dir(&mut store, &tmp).unwrap();
|
|
assert_eq!(n, 1);
|
|
assert_eq!(store.get_rules("test").len(), 1);
|
|
let _ = fs::remove_dir_all(&tmp);
|
|
}
|
|
|
|
#[test]
|
|
fn rejects_duplicate_rule_ids() {
|
|
let tmp = tempdir_for("dup_ids");
|
|
write_file(&tmp, "a.toml", r#"
|
|
[[rule]]
|
|
id = "same"
|
|
task_class = "t"
|
|
description = ""
|
|
action = { type = "Pass" }
|
|
[rule.condition]
|
|
type = "Always"
|
|
"#);
|
|
write_file(&tmp, "b.toml", r#"
|
|
[[rule]]
|
|
id = "same"
|
|
task_class = "t"
|
|
description = ""
|
|
action = { type = "Pass" }
|
|
[rule.condition]
|
|
type = "Always"
|
|
"#);
|
|
let mut store = TruthStore::new();
|
|
let err = load_from_dir(&mut store, &tmp).unwrap_err();
|
|
assert!(err.contains("duplicate"), "got: {err}");
|
|
let _ = fs::remove_dir_all(&tmp);
|
|
}
|
|
|
|
#[test]
|
|
fn duplicate_with_in_code_rule_is_rejected() {
|
|
// Existing in-store IDs count as "already registered." Operator
|
|
// can't shadow an in-code rule by file without changing the ID.
|
|
let tmp = tempdir_for("dup_in_code");
|
|
write_file(&tmp, "conflict.toml", r#"
|
|
[[rule]]
|
|
id = "worker-active"
|
|
task_class = "staffing.fill"
|
|
description = "file attempt"
|
|
action = { type = "Pass" }
|
|
[rule.condition]
|
|
type = "Always"
|
|
"#);
|
|
// staffing_rules registers "worker-active"
|
|
let mut store = crate::staffing::staffing_rules(TruthStore::new());
|
|
let err = load_from_dir(&mut store, &tmp).unwrap_err();
|
|
assert!(err.contains("duplicate") && err.contains("worker-active"));
|
|
let _ = fs::remove_dir_all(&tmp);
|
|
}
|
|
|
|
#[test]
|
|
fn skips_non_toml_files() {
|
|
let tmp = tempdir_for("skip_non_toml");
|
|
write_file(&tmp, "a.toml", r#"
|
|
[[rule]]
|
|
id = "x"
|
|
task_class = "t"
|
|
description = ""
|
|
action = { type = "Pass" }
|
|
[rule.condition]
|
|
type = "Always"
|
|
"#);
|
|
write_file(&tmp, "README.md", "not a toml file");
|
|
let mut store = TruthStore::new();
|
|
let n = load_from_dir(&mut store, &tmp).unwrap();
|
|
assert_eq!(n, 1); // README.md ignored
|
|
let _ = fs::remove_dir_all(&tmp);
|
|
}
|
|
|
|
#[test]
|
|
fn missing_dir_returns_error() {
|
|
let mut store = TruthStore::new();
|
|
let err = load_from_dir(&mut store, "/nonexistent/path/here").unwrap_err();
|
|
assert!(err.contains("read_dir"));
|
|
}
|
|
|
|
fn tempdir_for(tag: &str) -> std::path::PathBuf {
|
|
let dir = std::env::temp_dir().join(format!("truth_loader_{}_{}", tag,
|
|
std::process::id()));
|
|
fs::create_dir_all(&dir).unwrap();
|
|
dir
|
|
}
|
|
}
|