use std::collections::BTreeSet;
use rto_graph::{NodeContext, NodeKind, Store, StoreError, build_context, dependents};
use serde::Serialize;
pub const REVIEW_SCHEMA: &str = "roteiro.review/v1";
#[derive(Debug, Serialize)]
pub struct ReviewReport {
pub schema: &'static str,
pub changed_files: usize,
pub files: Vec<FileReview>,
pub drift: Vec<DriftItem>,
pub impacted: Vec<Impacted>,
}
impl ReviewReport {
#[must_use]
pub fn has_drift(&self) -> bool {
!self.drift.is_empty()
}
}
#[derive(Debug, Serialize)]
pub struct FileReview {
pub path: String,
pub status: &'static str,
pub symbols: Vec<SymbolReview>,
pub debt: Vec<String>,
}
#[derive(Debug, Serialize)]
pub struct SymbolReview {
pub key: String,
pub name: String,
pub kind: String,
pub callers: Vec<String>,
pub callees: Vec<String>,
pub governed_by: Vec<String>,
pub related: Vec<Related>,
}
#[derive(Debug, Serialize)]
pub struct Related {
pub node: String,
pub confidence: Option<f64>,
}
#[derive(Debug, Serialize)]
pub struct DriftItem {
pub kind: String,
pub message: String,
}
#[derive(Debug, Serialize)]
pub struct Impacted {
pub key: String,
pub name: String,
pub kind: String,
}
pub fn build(
store: &Store,
changed: &[rto_graph::ChangedFile],
violations: &[rto_spec::Violation],
) -> Result<ReviewReport, StoreError> {
let changed_paths: BTreeSet<&str> = changed.iter().map(|c| c.path.as_str()).collect();
let mut files = Vec::new();
let mut changed_keys: Vec<String> = Vec::new();
for cf in changed {
if cf.deleted {
files.push(FileReview {
path: cf.path.clone(),
status: "deleted",
symbols: Vec::new(),
debt: Vec::new(),
});
continue;
}
let mut symbols = Vec::new();
let mut debt = Vec::new();
for node in store.nodes_by_path(&cf.path)? {
match node.kind {
NodeKind::File => continue,
NodeKind::Marker => {
debt.push(node.name.clone());
continue;
}
_ => {}
}
changed_keys.push(node.key.clone());
let ctx = build_context(store, &node.key)?;
symbols.push(symbol_review(&node, ctx.as_ref()));
}
files.push(FileReview {
path: cf.path.clone(),
status: "modified",
symbols,
debt,
});
}
let mut drift = Vec::new();
for v in violations {
if violation_touches(store, v, &changed_paths)? {
drift.push(DriftItem {
kind: v.kind.label().to_owned(),
message: v.message.clone(),
});
}
}
let changed_set: BTreeSet<&str> = changed_keys.iter().map(String::as_str).collect();
let mut impacted = Vec::new();
for key in dependents(store, &changed_keys)? {
if changed_set.contains(key.as_str()) {
continue;
}
let Some(node) = store.get_node(&key)? else {
continue;
};
if node
.path
.as_deref()
.is_some_and(|p| changed_paths.contains(p))
{
continue;
}
impacted.push(Impacted {
key: node.key,
name: node.name,
kind: node.kind.as_str().to_owned(),
});
}
Ok(ReviewReport {
schema: REVIEW_SCHEMA,
changed_files: changed.len(),
files,
drift,
impacted,
})
}
fn violation_touches(
store: &Store,
violation: &rto_spec::Violation,
changed_paths: &BTreeSet<&str>,
) -> Result<bool, StoreError> {
if changed_paths.iter().any(|p| violation.message.contains(p)) {
return Ok(true);
}
if let Some((key, _)) = violation.message.split_once(": ")
&& let Some(node) = store.get_node(key)?
{
return Ok(node
.path
.as_deref()
.is_some_and(|p| changed_paths.contains(p)));
}
Ok(false)
}
#[allow(clippy::similar_names)]
fn symbol_review(node: &rto_graph::Node, ctx: Option<&NodeContext>) -> SymbolReview {
let mut callers = Vec::new();
let mut callees = Vec::new();
let mut governed_by = Vec::new();
let mut related = Vec::new();
if let Some(ctx) = ctx {
for e in &ctx.incoming {
if e.kind == "calls" {
callers.push(e.node.clone());
}
if e.provenance == "authored" {
governed_by.push(e.node.clone());
}
if e.kind == "related" {
related.push(Related {
node: e.node.clone(),
confidence: e.confidence,
});
}
}
for e in &ctx.outgoing {
if e.kind == "calls" {
callees.push(e.node.clone());
}
if e.kind == "related" {
related.push(Related {
node: e.node.clone(),
confidence: e.confidence,
});
}
}
}
SymbolReview {
key: node.key.clone(),
name: node.name.clone(),
kind: node.kind.as_str().to_owned(),
callers,
callees,
governed_by,
related,
}
}
#[cfg(test)]
mod tests {
#[test]
fn json_schema_tags_are_frozen() {
assert_eq!(super::REVIEW_SCHEMA, "roteiro.review/v1");
assert_eq!(rto_graph::SCHEMA, "roteiro.query/v1");
assert_eq!(rto_graph::ARTIFACT_SCHEMA, "roteiro.graph/v1");
assert_eq!(rto_graph::ORACLE_SCHEMA, "roteiro.oracle/v1");
assert_eq!(rto_spec::SPEC_SCHEMA, "roteiro.spec/v1");
}
}