Skip to main content

secunit_core/registry/
loader.rs

1//! Walk an org root and produce a `LoadedRegistry` plus per-file diagnostics.
2
3use std::collections::BTreeMap;
4use std::fs;
5use std::path::{Path, PathBuf};
6
7use serde_json::Value;
8
9use crate::model::{Config, Control, Inventory, LoadedRegistry, Schedule, State};
10use crate::schemas::Schema;
11
12/// Aggregated load + validation report. `errors` is fatal; `warnings` is
13/// informational (e.g. a control YAML parsed but a referenced policy file
14/// is missing).
15#[derive(Debug, Default)]
16pub struct LoadReport {
17    pub errors: Vec<Diagnostic>,
18    pub warnings: Vec<Diagnostic>,
19}
20
21#[derive(Debug, Clone)]
22pub struct Diagnostic {
23    pub path: PathBuf,
24    pub message: String,
25}
26
27impl LoadReport {
28    pub fn is_clean(&self) -> bool {
29        self.errors.is_empty()
30    }
31
32    fn err(&mut self, path: impl Into<PathBuf>, message: impl Into<String>) {
33        self.errors.push(Diagnostic {
34            path: path.into(),
35            message: message.into(),
36        });
37    }
38
39    fn warn(&mut self, path: impl Into<PathBuf>, message: impl Into<String>) {
40        self.warnings.push(Diagnostic {
41            path: path.into(),
42            message: message.into(),
43        });
44    }
45}
46
47/// Load the org tree at `root`. Returns the registry even when soft-validation
48/// surfaces issues; check `report.is_clean()` before relying on it.
49pub fn load(root: &Path) -> (LoadedRegistry, LoadReport) {
50    let mut report = LoadReport::default();
51    let controls = load_controls(root, &mut report);
52    let inventory = load_inventory(root, &mut report);
53    let schedule = load_schedule(root, &mut report);
54    let state = load_state(root, &mut report);
55    let config = load_config(root, &mut report);
56    cross_check(root, &controls, &inventory, &schedule, &mut report);
57    let registry = LoadedRegistry {
58        root: root.to_path_buf(),
59        controls,
60        inventory,
61        schedule,
62        state,
63        config,
64    };
65    (registry, report)
66}
67
68fn load_controls(root: &Path, report: &mut LoadReport) -> BTreeMap<String, Control> {
69    let dir = root.join("controls");
70    let mut out = BTreeMap::new();
71    let entries = match fs::read_dir(&dir) {
72        Ok(e) => e,
73        Err(e) => {
74            report.err(&dir, format!("read controls/: {e}"));
75            return out;
76        }
77    };
78    for entry in entries.flatten() {
79        let path = entry.path();
80        if path.extension().and_then(|s| s.to_str()) != Some("yaml") {
81            continue;
82        }
83        let value: Value = match read_yaml(&path) {
84            Ok(v) => v,
85            Err(e) => {
86                report.err(&path, e);
87                continue;
88            }
89        };
90        for msg in Schema::Control.validate(&value) {
91            report.err(&path, format!("schema: {msg}"));
92        }
93        let control: Control = match serde_json::from_value(value) {
94            Ok(c) => c,
95            Err(e) => {
96                report.err(&path, format!("deserialise: {e}"));
97                continue;
98            }
99        };
100        let expected = format!(
101            "{}.yaml",
102            path.file_stem()
103                .and_then(|s| s.to_str())
104                .unwrap_or_default()
105        );
106        if expected != format!("{}.yaml", control.id) {
107            report.warn(
108                &path,
109                format!(
110                    "filename `{}` does not match control id `{}`",
111                    expected, control.id
112                ),
113            );
114        }
115        if let Some(existing) = out.insert(control.id.clone(), control.clone()) {
116            report.err(
117                &path,
118                format!(
119                    "duplicate control id `{}` (also in earlier file)",
120                    existing.id
121                ),
122            );
123        }
124    }
125    out
126}
127
128fn load_inventory(root: &Path, report: &mut LoadReport) -> Inventory {
129    let path = root.join("inventory.yaml");
130    if !path.exists() {
131        return Inventory::default();
132    }
133    let value: Value = match read_yaml(&path) {
134        Ok(v) => v,
135        Err(e) => {
136            report.err(&path, e);
137            return Inventory::default();
138        }
139    };
140    for msg in Schema::Inventory.validate(&value) {
141        report.err(&path, format!("schema: {msg}"));
142    }
143    match serde_json::from_value::<Inventory>(value) {
144        Ok(inv) => {
145            for (kind, entries) in &inv.kinds {
146                let mut seen = std::collections::HashSet::new();
147                for e in entries {
148                    if !seen.insert(e.name.clone()) {
149                        report.err(
150                            &path,
151                            format!(
152                                "duplicate name `{}` within inventory kind `{}`",
153                                e.name, kind
154                            ),
155                        );
156                    }
157                }
158            }
159            inv
160        }
161        Err(e) => {
162            report.err(&path, format!("deserialise: {e}"));
163            Inventory::default()
164        }
165    }
166}
167
168fn load_schedule(root: &Path, report: &mut LoadReport) -> Schedule {
169    let path = root.join("schedule.yaml");
170    if !path.exists() {
171        return Schedule::default();
172    }
173    let value: Value = match read_yaml(&path) {
174        Ok(v) => v,
175        Err(e) => {
176            report.err(&path, e);
177            return Schedule::default();
178        }
179    };
180    for msg in Schema::Schedule.validate(&value) {
181        report.err(&path, format!("schema: {msg}"));
182    }
183    serde_json::from_value(value).unwrap_or_else(|e| {
184        report.err(&path, format!("deserialise: {e}"));
185        Schedule::default()
186    })
187}
188
189fn load_state(root: &Path, report: &mut LoadReport) -> State {
190    let path = root.join("state.json");
191    if !path.exists() {
192        return State::default();
193    }
194    let value: Value = match read_json(&path) {
195        Ok(v) => v,
196        Err(e) => {
197            report.err(&path, e);
198            return State::default();
199        }
200    };
201    for msg in Schema::State.validate(&value) {
202        report.err(&path, format!("schema: {msg}"));
203    }
204    serde_json::from_value(value).unwrap_or_else(|e| {
205        report.err(&path, format!("deserialise: {e}"));
206        State::default()
207    })
208}
209
210fn load_config(root: &Path, report: &mut LoadReport) -> Config {
211    let path = root.join("_config.yaml");
212    if !path.exists() {
213        return Config::default();
214    }
215    let value: Value = match read_yaml(&path) {
216        Ok(v) => v,
217        Err(e) => {
218            report.err(&path, e);
219            return Config::default();
220        }
221    };
222    for msg in Schema::Config.validate(&value) {
223        report.err(&path, format!("schema: {msg}"));
224    }
225    serde_json::from_value(value).unwrap_or_else(|e| {
226        report.err(&path, format!("deserialise: {e}"));
227        Config::default()
228    })
229}
230
231/// Cross-file checks the per-file loaders cannot do on their own.
232fn cross_check(
233    root: &Path,
234    controls: &BTreeMap<String, Control>,
235    inventory: &Inventory,
236    schedule: &Schedule,
237    report: &mut LoadReport,
238) {
239    for (id, ctrl) in controls {
240        // A control's skill resolves local-first, then bundled — so a
241        // control naming a standard-library runbook is valid with no
242        // file under skills/.
243        if !crate::skills::exists(root, &ctrl.skill) {
244            report.warn(
245                root.join("controls").join(format!("{id}.yaml")),
246                format!(
247                    "skill `{}` not found (no local skills/{}.md and not bundled)",
248                    ctrl.skill, ctrl.skill,
249                ),
250            );
251        }
252        if let Some(crate::model::Scope::Inventory(scope)) = &ctrl.scope {
253            if inventory.section_for(&scope.kind).is_none() && !inventory.kinds.is_empty() {
254                report.err(
255                    root.join("controls").join(format!("{id}.yaml")),
256                    format!(
257                        "scope.kind `{}` matches no inventory section (have: {:?})",
258                        scope.kind,
259                        inventory.kinds.keys().collect::<Vec<_>>()
260                    ),
261                );
262            }
263        }
264        let policy_path = root.join(&ctrl.policy);
265        if !policy_path.exists() && !ctrl.policy.starts_with("http") {
266            report.warn(
267                root.join("controls").join(format!("{id}.yaml")),
268                format!("policy file not found at {}", policy_path.display()),
269            );
270        }
271    }
272    for ov in &schedule.overrides {
273        let Some(ctrl) = controls.get(&ov.control_id) else {
274            report.err(
275                root.join("schedule.yaml"),
276                format!("override references unknown control `{}`", ov.control_id),
277            );
278            continue;
279        };
280        // A `due:` pin postpones at most one firing — the one
281        // immediately before it. On fast cadences that is rarely what
282        // the author meant, so say so up front rather than letting the
283        // pin sit mostly inert.
284        if ov.due.is_some()
285            && matches!(
286                ctrl.cadence,
287                crate::model::Cadence::Weekly | crate::model::Cadence::Monthly
288            )
289        {
290            report.warn(
291                root.join("schedule.yaml"),
292                format!(
293                    "`due:` on {} control `{}` only moves the single firing \
294                     immediately before it; earlier firings still occur — use \
295                     `skip:` to remove windows, or change the control's cadence \
296                     for a recurring shift",
297                    match ctrl.cadence {
298                        crate::model::Cadence::Weekly => "weekly",
299                        _ => "monthly",
300                    },
301                    ov.control_id,
302                ),
303            );
304        }
305    }
306}
307
308fn read_yaml(path: &Path) -> Result<Value, String> {
309    let text = fs::read_to_string(path).map_err(|e| format!("read: {e}"))?;
310    serde_yaml::from_str(&text).map_err(|e| format!("yaml: {e}"))
311}
312
313fn read_json(path: &Path) -> Result<Value, String> {
314    let text = fs::read_to_string(path).map_err(|e| format!("read: {e}"))?;
315    serde_json::from_str(&text).map_err(|e| format!("json: {e}"))
316}