1use 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#[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
47pub 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
231fn 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 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 if !controls.contains_key(&ov.control_id) {
274 report.err(
275 root.join("schedule.yaml"),
276 format!("override references unknown control `{}`", ov.control_id),
277 );
278 }
279 }
280}
281
282fn read_yaml(path: &Path) -> Result<Value, String> {
283 let text = fs::read_to_string(path).map_err(|e| format!("read: {e}"))?;
284 serde_yaml::from_str(&text).map_err(|e| format!("yaml: {e}"))
285}
286
287fn read_json(path: &Path) -> Result<Value, String> {
288 let text = fs::read_to_string(path).map_err(|e| format!("read: {e}"))?;
289 serde_json::from_str(&text).map_err(|e| format!("json: {e}"))
290}