use std::path::Path;
use rto_graph::{GraphSource, Repo, Store};
use serde::Serialize;
use crate::check::{CheckReport, validate};
use crate::layer::authored_layer;
pub const TOOL_CHECK_SCHEMA: &str = "roteiro.check/v1";
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
#[serde(rename_all = "kebab-case")]
pub enum Gate {
Pass,
Fail,
NotRun,
}
#[derive(Debug, Clone, Serialize)]
pub struct CheckedAgainst {
pub source: &'static str,
pub tree: String,
}
#[derive(Debug, Clone, Serialize)]
pub struct ToolCheck {
pub schema: &'static str,
pub gate: Gate,
#[serde(skip_serializing_if = "Option::is_none")]
pub report: Option<CheckReport>,
#[serde(skip_serializing_if = "Option::is_none")]
pub checked_against: Option<CheckedAgainst>,
#[serde(skip_serializing_if = "Option::is_none")]
pub not_run_reason: Option<String>,
}
impl ToolCheck {
fn not_run(reason: String) -> Self {
Self {
schema: TOOL_CHECK_SCHEMA,
gate: Gate::NotRun,
report: None,
checked_against: None,
not_run_reason: Some(reason),
}
}
}
pub fn tool_check(store: &Store, root: Option<&Path>) -> Result<ToolCheck, rto_graph::StoreError> {
let Some(root) = root else {
return Ok(ToolCheck::not_run(
"this project has no repository on disk to read the authored layer from \
(the graph was opened directly), so `check` cannot run"
.to_owned(),
));
};
let repo = match Repo::discover(root) {
Ok(repo) => repo,
Err(e) => {
return Ok(ToolCheck::not_run(format!(
"cannot open the repository at {}: {e}",
root.display()
)));
}
};
let head = match repo.head_tree_id() {
Ok(tree) => tree,
Err(e) => {
return Ok(ToolCheck::not_run(format!(
"cannot read the HEAD tree of {}: {e}",
root.display()
)));
}
};
match store.sync_state()? {
Some(synced) if synced == head => {}
Some(synced) => {
return Ok(ToolCheck::not_run(format!(
"the graph was synced from `{synced}` but HEAD is `{head}`, so a drift \
verdict would describe neither tree — run `roteiro sync` (or restart \
the server) and ask again"
)));
}
None => {
return Ok(ToolCheck::not_run(
"the graph records no synced tree, so there is nothing to check the \
authored layer against — run `roteiro sync`"
.to_owned(),
));
}
}
let layer = match authored_layer(&repo, GraphSource::Committed) {
Ok(layer) => layer,
Err(e) => {
return Ok(ToolCheck::not_run(format!(
"cannot read the authored layer from {}: {e}",
root.display()
)));
}
};
let mut validation = validate(store, &layer.docs, &layer.blueprints, &layer.annotations)?;
validation.report.violations.extend(layer.malformed);
let gate = if validation.report.has_violations() {
Gate::Fail
} else {
Gate::Pass
};
Ok(ToolCheck {
schema: TOOL_CHECK_SCHEMA,
gate,
report: Some(validation.report),
checked_against: Some(CheckedAgainst {
source: GraphSource::Committed.as_str(),
tree: head,
}),
not_run_reason: None,
})
}
#[cfg(test)]
mod tests {
use super::{Gate, TOOL_CHECK_SCHEMA, ToolCheck, tool_check};
use rto_graph::{FactSet, Node, NodeKind, Repo, Store};
use std::path::{Path, PathBuf};
fn repo_with(dir: &Path, files: &[(&str, &str)]) -> String {
std::fs::remove_dir_all(dir).ok();
std::fs::create_dir_all(dir).unwrap();
let git = |args: &[&str]| {
let status = std::process::Command::new("git")
.args([
"-c",
"init.defaultBranch=main",
"-c",
"user.email=t@example.com",
"-c",
"user.name=T",
"-c",
"commit.gpgsign=false",
])
.args(args)
.current_dir(dir)
.status()
.expect("run git");
assert!(status.success(), "git {args:?} failed in {}", dir.display());
};
git(&["init", "-q"]);
for (path, body) in files {
let full = dir.join(path);
std::fs::create_dir_all(full.parent().unwrap()).unwrap();
std::fs::write(&full, body).unwrap();
}
git(&["add", "-A"]);
git(&["commit", "-q", "-m", "seed"]);
Repo::discover(dir).unwrap().head_tree_id().unwrap()
}
fn tmp(name: &str) -> PathBuf {
std::env::temp_dir().join(format!("rto-toolcheck-{name}-{}", std::process::id()))
}
fn synced(facts: &FactSet, tree: &str) -> Store {
let mut store = Store::open_in_memory().expect("store");
store.rebuild(facts, Some(tree)).expect("rebuild");
store
}
const ADR_OK: &str = "---\nadr-id: \"0001\"\nstatus: Accepted\n---\n\n# ADR-0001\n\n ## Design\n\nUses [[src/store.rs#Store]].\n";
const ADR_BROKEN: &str = "---\nadr-id: \"0001\"\nstatus: Accepted\n---\n\n# ADR-0001\n\n ## Design\n\nUses [[src/store.rs#Ghost]].\n";
fn derived() -> FactSet {
FactSet::new()
.with_node(Node::new("file:src/store.rs", NodeKind::File, "store.rs"))
.with_node(Node::new(
"sym:rust:src/store.rs#Store",
NodeKind::Struct,
"Store",
))
}
#[test]
fn a_clean_repository_passes_and_says_what_it_checked() {
let dir = tmp("pass");
let tree = repo_with(
&dir,
&[
("src/store.rs", "pub struct Store;\n"),
("docs/adr/0001.md", ADR_OK),
],
);
let store = synced(&derived(), &tree);
let out = tool_check(&store, Some(&dir)).expect("tool_check");
assert_eq!(out.gate, Gate::Pass, "{out:?}");
let report = out.report.expect("a check that ran has a report");
assert_eq!(report.adrs, 1);
assert_eq!(report.links_ok, 1, "{:?}", report.violations);
assert!(report.violations.is_empty(), "{:?}", report.violations);
let against = out.checked_against.expect("checked_against");
assert_eq!(against.source, "committed");
assert_eq!(against.tree, tree);
assert!(out.not_run_reason.is_none());
std::fs::remove_dir_all(&dir).ok();
}
#[test]
fn drift_fails_the_gate_and_is_reported_in_full() {
let dir = tmp("fail");
let tree = repo_with(
&dir,
&[
("src/store.rs", "pub struct Store;\n"),
("docs/adr/0001.md", ADR_BROKEN),
],
);
let store = synced(&derived(), &tree);
let out = tool_check(&store, Some(&dir)).expect("tool_check");
assert_eq!(out.gate, Gate::Fail, "{out:?}");
let report = out.report.expect("report");
assert_eq!(report.violations.len(), 1, "{:?}", report.violations);
assert_eq!(
report.violations[0].kind,
crate::ViolationKind::BrokenLink,
"{:?}",
report.violations
);
std::fs::remove_dir_all(&dir).ok();
}
#[test]
fn checking_writes_nothing_to_the_store() {
let dir = tmp("readonly");
let tree = repo_with(
&dir,
&[
("src/store.rs", "pub struct Store;\n"),
("docs/adr/0001.md", ADR_OK),
],
);
let store = synced(&derived(), &tree);
let before = (
store.node_count().unwrap(),
store.edge_count().unwrap(),
store.all_edges().unwrap(),
);
let out = tool_check(&store, Some(&dir)).expect("tool_check");
assert_eq!(out.gate, Gate::Pass);
assert_eq!(store.node_count().unwrap(), before.0, "nodes changed");
assert_eq!(store.edge_count().unwrap(), before.1, "edges changed");
assert_eq!(store.all_edges().unwrap(), before.2, "edges changed");
assert!(
store.get_node("adr:0001").unwrap().is_none(),
"the ADR node must not have been applied by a read-only check",
);
std::fs::remove_dir_all(&dir).ok();
}
#[test]
fn a_stale_graph_refuses_rather_than_reporting_drift_against_the_wrong_tree() {
let dir = tmp("stale");
let tree = repo_with(
&dir,
&[
("src/store.rs", "pub struct Store;\n"),
("docs/adr/0001.md", ADR_OK),
],
);
let store = synced(&derived(), "0000000000000000000000000000000000000000");
let out = tool_check(&store, Some(&dir)).expect("tool_check");
assert_eq!(out.gate, Gate::NotRun, "{out:?}");
assert!(out.report.is_none(), "a not-run check has no report");
let reason = out.not_run_reason.expect("reason");
assert!(reason.contains(&tree), "names HEAD's tree: {reason}");
assert!(
reason.contains("0000000"),
"names the synced tree: {reason}"
);
std::fs::remove_dir_all(&dir).ok();
}
#[test]
fn a_project_with_no_repository_reports_not_run_and_no_report() {
let store = Store::open_in_memory().expect("store");
let out = tool_check(&store, None).expect("tool_check");
assert_eq!(out.gate, Gate::NotRun);
assert!(out.report.is_none(), "a not-run check has no report");
assert!(
out.not_run_reason
.as_deref()
.is_some_and(|r| r.contains("no repository on disk")),
"{:?}",
out.not_run_reason
);
}
#[test]
fn an_unsynced_graph_refuses_rather_than_reporting_a_clean_repository() {
let dir = tmp("unsynced");
repo_with(&dir, &[("src/store.rs", "pub struct Store;\n")]);
let store = Store::open_in_memory().expect("store");
let out = tool_check(&store, Some(&dir)).expect("tool_check");
assert_eq!(out.gate, Gate::NotRun, "{out:?}");
assert!(
out.not_run_reason
.as_deref()
.is_some_and(|r| r.contains("no synced tree")),
"{:?}",
out.not_run_reason
);
assert!(out.report.is_none());
std::fs::remove_dir_all(&dir).ok();
}
#[test]
fn a_not_run_document_cannot_be_read_as_zero_violations() {
let out = ToolCheck::not_run("nope".to_owned());
let json: serde_json::Value = serde_json::to_value(&out).expect("json");
assert_eq!(json["schema"], TOOL_CHECK_SCHEMA);
assert_eq!(json["gate"], "not-run");
assert!(
json.get("report").is_none(),
"`report` must be absent, not an empty report: {json}"
);
assert!(
json.pointer("/report/violations").is_none(),
"`violations` must be unreachable in a not-run document: {json}"
);
assert!(json.get("checked_against").is_none(), "{json}");
}
}