//! 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, } /// 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) -> Result { 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 } }