1use std::path::Path;
16
17use rto_graph::{GraphSource, Repo, Store};
18use serde::Serialize;
19
20use crate::check::{CheckReport, validate};
21use crate::layer::authored_layer;
22
23pub const TOOL_CHECK_SCHEMA: &str = "roteiro.check/v1";
25
26#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
33#[serde(rename_all = "kebab-case")]
34pub enum Gate {
35 Pass,
37 Fail,
39 NotRun,
41}
42
43#[derive(Debug, Clone, Serialize)]
45pub struct CheckedAgainst {
46 pub source: &'static str,
48 pub tree: String,
52}
53
54#[derive(Debug, Clone, Serialize)]
66pub struct ToolCheck {
67 pub schema: &'static str,
69 pub gate: Gate,
71 #[serde(skip_serializing_if = "Option::is_none")]
73 pub report: Option<CheckReport>,
74 #[serde(skip_serializing_if = "Option::is_none")]
76 pub checked_against: Option<CheckedAgainst>,
77 #[serde(skip_serializing_if = "Option::is_none")]
79 pub not_run_reason: Option<String>,
80}
81
82impl ToolCheck {
83 fn not_run(reason: String) -> Self {
85 Self {
86 schema: TOOL_CHECK_SCHEMA,
87 gate: Gate::NotRun,
88 report: None,
89 checked_against: None,
90 not_run_reason: Some(reason),
91 }
92 }
93}
94
95pub fn tool_check(store: &Store, root: Option<&Path>) -> Result<ToolCheck, rto_graph::StoreError> {
144 let Some(root) = root else {
145 return Ok(ToolCheck::not_run(
146 "this project has no repository on disk to read the authored layer from \
147 (the graph was opened directly), so `check` cannot run"
148 .to_owned(),
149 ));
150 };
151 let repo = match Repo::discover(root) {
152 Ok(repo) => repo,
153 Err(e) => {
154 return Ok(ToolCheck::not_run(format!(
155 "cannot open the repository at {}: {e}",
156 root.display()
157 )));
158 }
159 };
160 let head = match repo.head_tree_id() {
161 Ok(tree) => tree,
162 Err(e) => {
163 return Ok(ToolCheck::not_run(format!(
164 "cannot read the HEAD tree of {}: {e}",
165 root.display()
166 )));
167 }
168 };
169 match store.sync_state()? {
170 Some(synced) if synced == head => {}
171 Some(synced) => {
172 return Ok(ToolCheck::not_run(format!(
173 "the graph was synced from `{synced}` but HEAD is `{head}`, so a drift \
174 verdict would describe neither tree — run `roteiro sync` (or restart \
175 the server) and ask again"
176 )));
177 }
178 None => {
179 return Ok(ToolCheck::not_run(
180 "the graph records no synced tree, so there is nothing to check the \
181 authored layer against — run `roteiro sync`"
182 .to_owned(),
183 ));
184 }
185 }
186
187 let layer = match authored_layer(&repo, GraphSource::Committed) {
188 Ok(layer) => layer,
189 Err(e) => {
190 return Ok(ToolCheck::not_run(format!(
191 "cannot read the authored layer from {}: {e}",
192 root.display()
193 )));
194 }
195 };
196 let mut validation = validate(store, &layer.docs, &layer.blueprints, &layer.annotations)?;
197 validation.report.violations.extend(layer.malformed);
199
200 let gate = if validation.report.has_violations() {
201 Gate::Fail
202 } else {
203 Gate::Pass
204 };
205 Ok(ToolCheck {
206 schema: TOOL_CHECK_SCHEMA,
207 gate,
208 report: Some(validation.report),
209 checked_against: Some(CheckedAgainst {
210 source: GraphSource::Committed.as_str(),
211 tree: head,
212 }),
213 not_run_reason: None,
214 })
215}
216
217#[cfg(test)]
218mod tests {
219 use super::{Gate, TOOL_CHECK_SCHEMA, ToolCheck, tool_check};
220 use rto_graph::{FactSet, Node, NodeKind, Repo, Store};
221 use std::path::{Path, PathBuf};
222
223 fn repo_with(dir: &Path, files: &[(&str, &str)]) -> String {
225 std::fs::remove_dir_all(dir).ok();
226 std::fs::create_dir_all(dir).unwrap();
227 let git = |args: &[&str]| {
228 let status = std::process::Command::new("git")
229 .args([
230 "-c",
231 "init.defaultBranch=main",
232 "-c",
233 "user.email=t@example.com",
234 "-c",
235 "user.name=T",
236 "-c",
237 "commit.gpgsign=false",
238 ])
239 .args(args)
240 .current_dir(dir)
241 .status()
242 .expect("run git");
243 assert!(status.success(), "git {args:?} failed in {}", dir.display());
244 };
245 git(&["init", "-q"]);
246 for (path, body) in files {
247 let full = dir.join(path);
248 std::fs::create_dir_all(full.parent().unwrap()).unwrap();
249 std::fs::write(&full, body).unwrap();
250 }
251 git(&["add", "-A"]);
252 git(&["commit", "-q", "-m", "seed"]);
253 Repo::discover(dir).unwrap().head_tree_id().unwrap()
254 }
255
256 fn tmp(name: &str) -> PathBuf {
257 std::env::temp_dir().join(format!("rto-toolcheck-{name}-{}", std::process::id()))
258 }
259
260 fn synced(facts: &FactSet, tree: &str) -> Store {
263 let mut store = Store::open_in_memory().expect("store");
264 store.rebuild(facts, Some(tree)).expect("rebuild");
265 store
266 }
267
268 const ADR_OK: &str = "---\nadr-id: \"0001\"\nstatus: Accepted\n---\n\n# ADR-0001\n\n ## Design\n\nUses [[src/store.rs#Store]].\n";
269 const ADR_BROKEN: &str = "---\nadr-id: \"0001\"\nstatus: Accepted\n---\n\n# ADR-0001\n\n ## Design\n\nUses [[src/store.rs#Ghost]].\n";
270
271 fn derived() -> FactSet {
272 FactSet::new()
273 .with_node(Node::new("file:src/store.rs", NodeKind::File, "store.rs"))
274 .with_node(Node::new(
275 "sym:rust:src/store.rs#Store",
276 NodeKind::Struct,
277 "Store",
278 ))
279 }
280
281 #[test]
282 fn a_clean_repository_passes_and_says_what_it_checked() {
283 let dir = tmp("pass");
284 let tree = repo_with(
285 &dir,
286 &[
287 ("src/store.rs", "pub struct Store;\n"),
288 ("docs/adr/0001.md", ADR_OK),
289 ],
290 );
291 let store = synced(&derived(), &tree);
292
293 let out = tool_check(&store, Some(&dir)).expect("tool_check");
294 assert_eq!(out.gate, Gate::Pass, "{out:?}");
295 let report = out.report.expect("a check that ran has a report");
296 assert_eq!(report.adrs, 1);
297 assert_eq!(report.links_ok, 1, "{:?}", report.violations);
298 assert!(report.violations.is_empty(), "{:?}", report.violations);
299 let against = out.checked_against.expect("checked_against");
300 assert_eq!(against.source, "committed");
301 assert_eq!(against.tree, tree);
302 assert!(out.not_run_reason.is_none());
303
304 std::fs::remove_dir_all(&dir).ok();
305 }
306
307 #[test]
308 fn drift_fails_the_gate_and_is_reported_in_full() {
309 let dir = tmp("fail");
310 let tree = repo_with(
311 &dir,
312 &[
313 ("src/store.rs", "pub struct Store;\n"),
314 ("docs/adr/0001.md", ADR_BROKEN),
315 ],
316 );
317 let store = synced(&derived(), &tree);
318
319 let out = tool_check(&store, Some(&dir)).expect("tool_check");
320 assert_eq!(out.gate, Gate::Fail, "{out:?}");
321 let report = out.report.expect("report");
322 assert_eq!(report.violations.len(), 1, "{:?}", report.violations);
323 assert_eq!(
324 report.violations[0].kind,
325 crate::ViolationKind::BrokenLink,
326 "{:?}",
327 report.violations
328 );
329
330 std::fs::remove_dir_all(&dir).ok();
331 }
332
333 #[test]
337 fn checking_writes_nothing_to_the_store() {
338 let dir = tmp("readonly");
339 let tree = repo_with(
340 &dir,
341 &[
342 ("src/store.rs", "pub struct Store;\n"),
343 ("docs/adr/0001.md", ADR_OK),
344 ],
345 );
346 let store = synced(&derived(), &tree);
347 let before = (
348 store.node_count().unwrap(),
349 store.edge_count().unwrap(),
350 store.all_edges().unwrap(),
351 );
352
353 let out = tool_check(&store, Some(&dir)).expect("tool_check");
354 assert_eq!(out.gate, Gate::Pass);
355 assert_eq!(store.node_count().unwrap(), before.0, "nodes changed");
356 assert_eq!(store.edge_count().unwrap(), before.1, "edges changed");
357 assert_eq!(store.all_edges().unwrap(), before.2, "edges changed");
358 assert!(
359 store.get_node("adr:0001").unwrap().is_none(),
360 "the ADR node must not have been applied by a read-only check",
361 );
362
363 std::fs::remove_dir_all(&dir).ok();
364 }
365
366 #[test]
369 fn a_stale_graph_refuses_rather_than_reporting_drift_against_the_wrong_tree() {
370 let dir = tmp("stale");
371 let tree = repo_with(
372 &dir,
373 &[
374 ("src/store.rs", "pub struct Store;\n"),
375 ("docs/adr/0001.md", ADR_OK),
376 ],
377 );
378 let store = synced(&derived(), "0000000000000000000000000000000000000000");
379
380 let out = tool_check(&store, Some(&dir)).expect("tool_check");
381 assert_eq!(out.gate, Gate::NotRun, "{out:?}");
382 assert!(out.report.is_none(), "a not-run check has no report");
383 let reason = out.not_run_reason.expect("reason");
384 assert!(reason.contains(&tree), "names HEAD's tree: {reason}");
385 assert!(
386 reason.contains("0000000"),
387 "names the synced tree: {reason}"
388 );
389
390 std::fs::remove_dir_all(&dir).ok();
391 }
392
393 #[test]
394 fn a_project_with_no_repository_reports_not_run_and_no_report() {
395 let store = Store::open_in_memory().expect("store");
396 let out = tool_check(&store, None).expect("tool_check");
397 assert_eq!(out.gate, Gate::NotRun);
398 assert!(out.report.is_none(), "a not-run check has no report");
399 assert!(
400 out.not_run_reason
401 .as_deref()
402 .is_some_and(|r| r.contains("no repository on disk")),
403 "{:?}",
404 out.not_run_reason
405 );
406 }
407
408 #[test]
412 fn an_unsynced_graph_refuses_rather_than_reporting_a_clean_repository() {
413 let dir = tmp("unsynced");
414 repo_with(&dir, &[("src/store.rs", "pub struct Store;\n")]);
415 let store = Store::open_in_memory().expect("store");
416
417 let out = tool_check(&store, Some(&dir)).expect("tool_check");
418 assert_eq!(out.gate, Gate::NotRun, "{out:?}");
419 assert!(
420 out.not_run_reason
421 .as_deref()
422 .is_some_and(|r| r.contains("no synced tree")),
423 "{:?}",
424 out.not_run_reason
425 );
426 assert!(out.report.is_none());
427
428 std::fs::remove_dir_all(&dir).ok();
429 }
430
431 #[test]
435 fn a_not_run_document_cannot_be_read_as_zero_violations() {
436 let out = ToolCheck::not_run("nope".to_owned());
437 let json: serde_json::Value = serde_json::to_value(&out).expect("json");
438 assert_eq!(json["schema"], TOOL_CHECK_SCHEMA);
439 assert_eq!(json["gate"], "not-run");
440 assert!(
441 json.get("report").is_none(),
442 "`report` must be absent, not an empty report: {json}"
443 );
444 assert!(
445 json.pointer("/report/violations").is_none(),
446 "`violations` must be unreachable in a not-run document: {json}"
447 );
448 assert!(json.get("checked_against").is_none(), "{json}");
449 }
450}