use std::collections::BTreeMap;
use std::sync::Arc;
use axum::Json;
use axum::Router;
use axum::extract::{Query, RawPathParams, State};
use axum::http::StatusCode;
use axum::response::{IntoResponse, Response};
use axum::routing::{get, post};
use rto_graph::{
ConfigKey, EXTERNAL_REF_KIND, Edge, Follow, LINKS_REF, Node, NodeKind, Provenance, Store,
StoreError, Workspace, WorkspaceError, WorkspaceSet, debt, explain, external_ref_node,
external_ref_target, parse_qualified,
};
use serde::Deserialize;
use serde_json::{Value, json};
use crate::overview;
#[derive(Clone)]
struct AppState {
set: Arc<WorkspaceSet>,
default: Option<String>,
caps: Capabilities,
}
#[derive(Clone, serde::Serialize)]
pub struct Capabilities {
pub ask: bool,
pub models: Vec<String>,
}
impl Capabilities {
#[must_use]
pub fn explorer_only() -> Self {
Self {
ask: false,
models: Vec::new(),
}
}
}
type ApiResult = Result<Response, ApiError>;
type Subgraph = (Vec<Node>, Vec<Edge>);
const DEFAULT_NODE_LIMIT: usize = 100;
const DEFAULT_HOTSPOTS: usize = 20;
const MAX_DEPTH: usize = 5;
pub fn router(set: Arc<WorkspaceSet>, default: Option<String>) -> Router {
build_router(set, default, Capabilities::explorer_only())
}
#[cfg_attr(not(feature = "serve"), allow(dead_code))]
pub fn router_with_capabilities(
set: Arc<WorkspaceSet>,
default: Option<String>,
caps: Capabilities,
) -> Router {
build_router(set, default, caps)
}
fn build_router(set: Arc<WorkspaceSet>, default: Option<String>, caps: Capabilities) -> Router {
let state = AppState { set, default, caps };
Router::new()
.route("/v1/graph/capabilities", get(capabilities))
.route("/v1/graph/workspaces", get(workspaces))
.merge(graph_routes("/v1/graph"))
.merge(graph_routes("/v1/graph/workspaces/{ws}"))
.with_state(state)
}
async fn capabilities(State(st): State<AppState>) -> Response {
Json(st.caps).into_response()
}
fn graph_routes(prefix: &str) -> Router<AppState> {
Router::new()
.route(&format!("{prefix}/projects"), get(projects))
.route(&format!("{prefix}/topology"), get(topology))
.route(&format!("{prefix}/matrix"), get(matrix))
.route(&format!("{prefix}/links/write"), post(write_links))
.route(&format!("{prefix}/resolve"), get(resolve))
.route(&format!("{prefix}/follow"), get(follow))
.route(&format!("{prefix}/{{project}}"), get(project_graph))
.route(&format!("{prefix}/{{project}}/nodes"), get(project_nodes))
.route(&format!("{prefix}/{{project}}/links"), get(project_links))
.route(
&format!("{prefix}/{{project}}/node/{{*key}}"),
get(node_detail),
)
.route(
&format!("{prefix}/{{project}}/neighbourhood/{{*key}}"),
get(neighbourhood),
)
.route(&format!("{prefix}/{{project}}/debt"), get(project_debt))
.route(&format!("{prefix}/{{project}}/hotspots"), get(hotspots))
.route(&format!("{prefix}/{{project}}/coupling"), get(coupling))
}
fn param<'a>(params: &'a RawPathParams, name: &str) -> Option<&'a str> {
params.iter().find(|(k, _)| *k == name).map(|(_, v)| v)
}
fn select_ws<'a>(st: &'a AppState, params: &RawPathParams) -> Result<&'a Workspace, ApiError> {
match param(params, "ws") {
Some(name) => Ok(st.set.select(Some(name))?),
None => Ok(st.set.select(st.default.as_deref())?),
}
}
fn require_project(params: &RawPathParams) -> Result<&str, ApiError> {
param(params, "project")
.ok_or_else(|| ApiError::Internal("missing `project` path parameter".to_owned()))
}
fn require_key(params: &RawPathParams) -> Result<&str, ApiError> {
param(params, "key").ok_or_else(|| ApiError::NotFound("missing node key".to_owned()))
}
enum ApiError {
BadRequest(String),
NotFound(String),
Internal(String),
}
impl IntoResponse for ApiError {
fn into_response(self) -> Response {
let (status, message) = match self {
ApiError::BadRequest(m) => (StatusCode::BAD_REQUEST, m),
ApiError::NotFound(m) => (StatusCode::NOT_FOUND, m),
ApiError::Internal(m) => (StatusCode::INTERNAL_SERVER_ERROR, m),
};
(status, Json(json!({ "error": message }))).into_response()
}
}
impl From<WorkspaceError> for ApiError {
fn from(e: WorkspaceError) -> Self {
match e {
WorkspaceError::UnknownWorkspace { .. }
| WorkspaceError::UnknownProject { .. }
| WorkspaceError::NoGraph { .. } => ApiError::NotFound(e.to_string()),
WorkspaceError::Unqualified { .. }
| WorkspaceError::AmbiguousProject { .. }
| WorkspaceError::AmbiguousWorkspace { .. }
| WorkspaceError::Empty => ApiError::BadRequest(e.to_string()),
_ => ApiError::Internal(e.to_string()),
}
}
}
impl From<StoreError> for ApiError {
fn from(e: StoreError) -> Self {
ApiError::Internal(e.to_string())
}
}
#[derive(Deserialize)]
struct NodesQuery {
kinds: Option<String>,
provenance: Option<String>,
q: Option<String>,
limit: Option<usize>,
offset: Option<usize>,
}
#[derive(Deserialize)]
struct DepthQuery {
depth: Option<usize>,
}
#[derive(Deserialize)]
struct LimitQuery {
limit: Option<usize>,
}
#[derive(Deserialize)]
struct CouplingQuery {
limit: Option<usize>,
order: Option<String>,
}
#[derive(Deserialize)]
struct ResolveQuery {
qualified: Option<String>,
}
async fn workspaces(State(st): State<AppState>) -> ApiResult {
let mut out: Vec<Value> = Vec::new();
for name in st.set.names() {
let linked = st.set.linked(&name).unwrap_or(false);
let projects = st.set.select(Some(&name))?.names();
out.push(json!({ "name": name, "linked": linked, "projects": projects }));
}
Ok(Json(out).into_response())
}
async fn projects(State(st): State<AppState>, params: RawPathParams) -> ApiResult {
let ws = select_ws(&st, ¶ms)?;
Ok(Json(json!({
"projects": ws.names(),
"isMulti": ws.is_multi(),
}))
.into_response())
}
async fn project_graph(State(st): State<AppState>, params: RawPathParams) -> ApiResult {
let ws = select_ws(&st, ¶ms)?;
let project = require_project(¶ms)?;
let facts = ws.with_store(Some(project), Store::export_factset)??;
let (nodes, edges) = (facts.nodes.len(), facts.edges.len());
Ok(Json(json!({
"nodes": facts.nodes,
"edges": facts.edges,
"counts": { "nodes": nodes, "edges": edges },
}))
.into_response())
}
async fn project_links(State(st): State<AppState>, params: RawPathParams) -> ApiResult {
let ws = select_ws(&st, ¶ms)?;
let project = require_project(¶ms)?;
let refs = ws.with_store(Some(project), external_refs)??;
let spoke_cfg = ws.with_store(Some(project), config_by_node_key)??;
let mut links: Vec<Value> = Vec::new();
for ExternalRef {
src,
node,
provenance,
confidence,
} in &refs
{
let qualified = external_ref_target(node).unwrap_or_default();
let from_name = spoke_cfg
.get(src)
.map_or_else(|| src.clone(), |(key, _)| key.clone());
let resolved = resolve_link(ws, node)?;
let drift = resolved.is_none();
let to_name = resolved.map(|n| n.name);
links.push(json!({
"from": src,
"fromName": from_name,
"to": node.key,
"toQualified": qualified,
"toName": to_name,
"provenance": provenance.as_str(),
"confidence": confidence,
"drift": drift,
}));
}
Ok(Json(json!({ "project": project, "links": links })).into_response())
}
async fn project_nodes(
State(st): State<AppState>,
params: RawPathParams,
Query(p): Query<NodesQuery>,
) -> ApiResult {
let ws = select_ws(&st, ¶ms)?;
let project = require_project(¶ms)?;
let provenance = match p.provenance.as_deref() {
Some(s) => Some(parse_provenance(s)?),
None => None,
};
let kinds: Option<Vec<String>> = p.kinds.as_ref().map(|s| {
s.split(',')
.filter(|k| !k.is_empty())
.map(str::to_owned)
.collect()
});
let needle = p.q.map(|s| s.to_lowercase());
let offset = p.offset.unwrap_or(0);
let limit = p.limit.unwrap_or(DEFAULT_NODE_LIMIT);
let mut nodes = ws.with_store(Some(project), Store::all_nodes)??;
nodes.retain(|n| {
kinds
.as_ref()
.is_none_or(|ks| ks.iter().any(|k| k == n.kind.as_str()))
&& provenance.is_none_or(|pv| n.provenance == pv)
&& needle.as_ref().is_none_or(|q| {
n.name.to_lowercase().contains(q) || n.key.to_lowercase().contains(q)
})
});
let total = nodes.len();
let page: Vec<Node> = nodes.into_iter().skip(offset).take(limit).collect();
Ok(Json(json!({
"nodes": page,
"total": total,
"limit": limit,
"offset": offset,
}))
.into_response())
}
async fn node_detail(State(st): State<AppState>, params: RawPathParams) -> ApiResult {
let ws = select_ws(&st, ¶ms)?;
let project = require_project(¶ms)?;
let key = require_key(¶ms)?;
let detail = ws.with_store(Some(project), |s| -> Result<_, StoreError> {
let Some(explanation) = explain(s, key)? else {
return Ok(None);
};
let generated = match s.get_node(key)?.and_then(|n| n.blob_hash) {
Some(blob) => generated_for_blob(s, &blob)?,
None => Vec::new(),
};
Ok(Some((explanation, generated)))
})??;
match detail {
Some((explanation, generated)) => {
let mut body = serde_json::to_value(&explanation)
.map_err(|e| ApiError::Internal(format!("could not render node `{key}`: {e}")))?;
if let Some(object) = body.as_object_mut() {
object.insert("generated".to_owned(), Value::Array(generated));
}
Ok(Json(body).into_response())
}
None => Err(ApiError::NotFound(format!(
"no node `{key}` in project `{project}`"
))),
}
}
fn generated_for_blob(store: &Store, blob: &str) -> Result<Vec<Value>, StoreError> {
let records = store.media_records(&rto_graph::MediaFilter {
blob_id: Some(blob),
..rto_graph::MediaFilter::default()
})?;
Ok(records
.into_iter()
.map(|record| {
json!({
"generated": true,
"producer": record.producer_id.to_string(),
"model": record.producer.model,
"kind": record.producer.kind.as_str(),
"quantisation": record.producer.quantisation,
"prompt": record.producer.prompt,
"blob": record.blob_id,
"path": record.path,
"generation": record.generation,
"producedAt": record.produced_at,
"toolVersion": record.tool_version,
"text": record.outcome.text(),
"skipped": record.outcome.skip().map(|skip| json!({
"reason": skip.reason.as_str(),
"metric": skip.reason.metric(),
"value": skip.value,
"threshold": skip.threshold,
"explanation": skip.to_string(),
})),
"rebuild": format!("roteiro media build --blob {} --force", record.blob_id),
})
})
.collect())
}
async fn neighbourhood(
State(st): State<AppState>,
params: RawPathParams,
Query(dq): Query<DepthQuery>,
) -> ApiResult {
let ws = select_ws(&st, ¶ms)?;
let project = require_project(¶ms)?;
let key = require_key(¶ms)?;
let depth = dq.depth.unwrap_or(1).min(MAX_DEPTH);
let sub = ws.with_store(Some(project), |s| neighbourhood_subgraph(s, key, depth))??;
match sub {
Some((nodes, edges)) => Ok(Json(json!({
"root": key,
"depth": depth,
"nodes": nodes,
"edges": edges,
"counts": { "nodes": nodes.len(), "edges": edges.len() },
}))
.into_response()),
None => Err(ApiError::NotFound(format!(
"no node `{key}` in project `{project}`"
))),
}
}
async fn project_debt(State(st): State<AppState>, params: RawPathParams) -> ApiResult {
let ws = select_ws(&st, ¶ms)?;
let project = require_project(¶ms)?;
let ignore = crate::config::debt_ignore_for(ws, Some(project))
.map_err(|e| ApiError::Internal(e.to_string()))?;
let report = ws.with_store(Some(project), |s| debt(s, &[], &ignore))??;
Ok(Json(report).into_response())
}
async fn hotspots(
State(st): State<AppState>,
params: RawPathParams,
Query(lq): Query<LimitQuery>,
) -> ApiResult {
let ws = select_ws(&st, ¶ms)?;
let project = require_project(¶ms)?;
let limit = lq.limit.unwrap_or(DEFAULT_HOTSPOTS);
let ranked = ws.with_store(Some(project), |s| compute_hotspots(s, limit))??;
Ok(Json(json!({ "hotspots": ranked, "limit": limit })).into_response())
}
async fn coupling(
State(st): State<AppState>,
params: RawPathParams,
Query(cq): Query<CouplingQuery>,
) -> ApiResult {
let ws = select_ws(&st, ¶ms)?;
let project = require_project(¶ms)?;
let order = match cq.order.as_deref() {
None => rto_graph::CouplingOrder::default(),
Some(token) => rto_graph::CouplingOrder::from_token(token).ok_or_else(|| {
ApiError::BadRequest(format!(
"unknown order `{token}` (expected {})",
rto_graph::CouplingOrder::tokens().join("|")
))
})?,
};
let limit = cq.limit.unwrap_or(DEFAULT_HOTSPOTS);
let report = ws.with_store(Some(project), |s| rto_graph::coupling(s, order, limit))??;
Ok(Json(report).into_response())
}
async fn resolve(
State(st): State<AppState>,
params: RawPathParams,
Query(rq): Query<ResolveQuery>,
) -> ApiResult {
let ws = select_ws(&st, ¶ms)?;
let qualified = rq
.qualified
.ok_or_else(|| ApiError::BadRequest("missing `qualified` query parameter".to_owned()))?;
let target = ws.resolve_qualified(&qualified)?;
let drift = target.is_none();
Ok(Json(json!({ "target": target, "drift": drift })).into_response())
}
async fn follow(
State(st): State<AppState>,
params: RawPathParams,
Query(rq): Query<ResolveQuery>,
) -> ApiResult {
let ws = select_ws(&st, ¶ms)?;
let qualified = rq
.qualified
.ok_or_else(|| ApiError::BadRequest("missing `qualified` query parameter".to_owned()))?;
let (project, _) = parse_qualified(&qualified).ok_or_else(|| {
ApiError::BadRequest(format!(
"`{qualified}` is not a project-qualified `<proj>::<key>`"
))
})?;
let project = project.to_owned();
let workspace = st.set.select_name(param(¶ms, "ws"))?.to_owned();
let (target, kind, field) = match ws.follow_definition(&qualified)? {
Follow::StructField { node, field } => (Some(node), Some("struct_field"), Some(field)),
Follow::Node { node } => {
let kind = if node.kind.as_str() == "config_key" {
"config_key"
} else {
"struct_field"
};
(Some(node), Some(kind), None)
}
Follow::Drift => (None, None, None),
};
let drift = target.is_none();
Ok(Json(json!({
"target": target,
"kind": kind,
"field": field,
"workspace": workspace,
"project": project,
"drift": drift,
}))
.into_response())
}
async fn topology(State(st): State<AppState>, params: RawPathParams) -> ApiResult {
let ws = select_ws(&st, ¶ms)?;
let names = ws.names();
let hub = effective_hub(ws, &names)?;
let hub_keys = match &hub {
Some(h) => ws.with_store(Some(h), Store::config_keys)??,
None => Vec::new(),
};
let mut links: Vec<Value> = Vec::new();
let mut spokes: Vec<Value> = Vec::new();
for name in &names {
if Some(name) == hub.as_ref() {
continue;
}
let (refs, live_orphans) = spoke_correspondence(ws, name, hub.as_deref(), &hub_keys)?;
if refs.is_empty() && live_orphans.is_empty() {
continue; }
let key_count = ws.with_store(Some(name), |s| s.config_keys().map(|c| c.len()))??;
let mut drift_count = live_orphans.len();
for ExternalRef {
src,
node,
provenance,
confidence,
} in &refs
{
if let Some(target) = external_ref_target(node) {
links.push(json!({
"from": format!("{name}::{src}"),
"to": target,
"provenance": provenance.as_str(),
"confidence": confidence,
}));
}
if resolve_link(ws, node)?.is_none() {
drift_count += 1;
}
}
spokes.push(json!({
"name": name,
"label": name,
"keyCount": key_count,
"driftCount": drift_count,
}));
}
Ok(Json(json!({ "hub": hub, "spokes": spokes, "links": links })).into_response())
}
async fn matrix(State(st): State<AppState>, params: RawPathParams) -> ApiResult {
let ws = select_ws(&st, ¶ms)?;
let names = ws.names();
let Some(hub) = effective_hub(ws, &names)? else {
return Ok(Json(json!({
"hub": Value::Null, "spokes": [], "rows": [], "drift": []
}))
.into_response());
};
let hub_values = ws.with_store(Some(&hub), config_values)??;
let hub_keys = ws.with_store(Some(&hub), Store::config_keys)??;
let mut spokes: Vec<overview::SpokeInput> = Vec::new();
for name in &names {
if name == &hub {
continue;
}
let (refs, live_orphans) = spoke_correspondence(ws, name, Some(&hub), &hub_keys)?;
if refs.is_empty() && live_orphans.is_empty() {
continue;
}
let spoke_cfg = ws.with_store(Some(name), config_by_node_key)??;
let mut matches: Vec<overview::MatchInput> = Vec::new();
let mut orphans: Vec<(String, String)> = Vec::new();
for ExternalRef {
src,
node,
provenance,
confidence,
} in &refs
{
let (spoke_key, spoke_value) = spoke_cfg
.get(src)
.cloned()
.unwrap_or_else(|| (src.clone(), String::new()));
match resolve_link(ws, node)? {
Some(hub_node) => matches.push(overview::MatchInput {
file: cfgkey_file(&hub_node.key),
hub_key: hub_node.name,
spoke_key,
spoke_value,
confidence: confidence.unwrap_or(0.0),
provenance: *provenance,
}),
None => orphans.push((spoke_key, spoke_value)),
}
}
orphans.extend(live_orphans);
spokes.push(overview::SpokeInput {
name: name.clone(),
matches,
orphans,
});
}
let assembled = overview::build(&hub, &hub_values, spokes);
Ok(Json(assembled).into_response())
}
async fn write_links(State(st): State<AppState>, params: RawPathParams) -> ApiResult {
let ws = select_ws(&st, ¶ms)?;
let names = ws.names();
let Some(hub) = effective_hub(ws, &names)? else {
return Ok(Json(json!({
"hub": Value::Null,
"written": 0,
"spokes": [],
"note": "no cross-repo hub — nothing to infer",
}))
.into_response());
};
let hub_keys = ws.with_store(Some(&hub), Store::config_keys)??;
let mut total = 0usize;
let mut per_spoke: Vec<Value> = Vec::new();
for name in &names {
if name == &hub {
continue;
}
let spoke_keys = ws.with_store(Some(name), Store::config_keys)??;
let (matches, _orphans) = crate::infer_links::match_against_hub(&spoke_keys, &hub_keys);
let facts = crate::infer_links::link_facts(&hub, &matches);
let applied =
ws.with_store_mut(Some(name), |s| s.apply_import_layer(LINKS_REF, &facts))??;
total += applied.edges_applied;
per_spoke.push(json!({
"name": name,
"matches": matches.len(),
"written": applied.edges_applied,
}));
}
Ok(Json(json!({ "hub": hub, "written": total, "spokes": per_spoke })).into_response())
}
struct ExternalRef {
src: String,
node: Node,
provenance: Provenance,
confidence: Option<f64>,
}
fn external_refs(store: &Store) -> Result<Vec<ExternalRef>, StoreError> {
let placeholders = store.nodes_by_kind(&NodeKind::Other(EXTERNAL_REF_KIND.to_owned()))?;
let mut out = Vec::new();
for node in placeholders {
for edge in store.edges_to(&node.key)? {
if matches!(edge.provenance, Provenance::Inferred | Provenance::Authored) {
out.push(ExternalRef {
src: edge.src,
node: node.clone(),
provenance: edge.provenance,
confidence: edge.confidence,
});
}
}
}
Ok(out)
}
fn config_values(store: &Store) -> Result<BTreeMap<String, String>, StoreError> {
Ok(store
.config_keys()?
.into_iter()
.map(|c| (c.key, c.value))
.collect())
}
fn config_by_node_key(store: &Store) -> Result<BTreeMap<String, (String, String)>, StoreError> {
Ok(store
.config_keys()?
.into_iter()
.map(|c| (format!("cfgkey:{}#{}", c.file, c.key), (c.key, c.value)))
.collect())
}
fn cfgkey_file(key: &str) -> String {
key.strip_prefix("cfgkey:")
.map(|rest| rest.split_once('#').map_or(rest, |(file, _)| file))
.unwrap_or_default()
.to_owned()
}
fn resolve_link(ws: &Workspace, node: &Node) -> Result<Option<Node>, ApiError> {
match ws.follow_external_ref(node) {
Ok(target) => Ok(target),
Err(
WorkspaceError::UnknownProject { .. }
| WorkspaceError::NoGraph { .. }
| WorkspaceError::Unqualified { .. }
| WorkspaceError::AmbiguousProject { .. }
| WorkspaceError::Empty,
) => Ok(None),
Err(e) => Err(e.into()),
}
}
fn determine_hub(ws: &Workspace, names: &[String]) -> Result<Option<String>, ApiError> {
let hosted: std::collections::HashSet<&str> = names.iter().map(String::as_str).collect();
let mut targets: BTreeMap<String, usize> = BTreeMap::new();
for name in names {
for ExternalRef { node, .. } in ws.with_store(Some(name), external_refs)?? {
if let Some(qualified) = external_ref_target(&node)
&& let Some((project, _)) = parse_qualified(&qualified)
&& hosted.contains(project)
{
*targets.entry(project.to_owned()).or_default() += 1;
}
}
}
Ok(targets
.into_iter()
.max_by_key(|(_, count)| *count)
.map(|(p, _)| p))
}
fn effective_hub(ws: &Workspace, names: &[String]) -> Result<Option<String>, ApiError> {
if let Some(hub) = determine_hub(ws, names)? {
return Ok(Some(hub));
}
if workspace_has_external_refs(ws, names)? {
return Ok(None);
}
config_key_count_hub(ws, names)
}
fn workspace_has_external_refs(ws: &Workspace, names: &[String]) -> Result<bool, ApiError> {
for name in names {
if !ws.with_store(Some(name), external_refs)??.is_empty() {
return Ok(true);
}
}
Ok(false)
}
fn config_key_count_hub(ws: &Workspace, names: &[String]) -> Result<Option<String>, ApiError> {
let mut counts: Vec<(String, usize)> = Vec::new();
for name in names {
let n = ws.with_store(Some(name), |s| s.config_keys().map(|c| c.len()))??;
if n > 0 {
counts.push((name.clone(), n));
}
}
if counts.len() < 2 {
return Ok(None);
}
counts.sort_by(|a, b| b.1.cmp(&a.1).then_with(|| a.0.cmp(&b.0)));
Ok(counts.into_iter().next().map(|(name, _)| name))
}
type SpokeLinks = (Vec<ExternalRef>, Vec<(String, String)>);
fn spoke_correspondence(
ws: &Workspace,
name: &str,
hub: Option<&str>,
hub_keys: &[ConfigKey],
) -> Result<SpokeLinks, ApiError> {
let mut refs = ws.with_store(Some(name), external_refs)??;
let Some(hub) = hub else {
return Ok((refs, Vec::new()));
};
let persisted: std::collections::HashSet<String> = refs.iter().map(|r| r.src.clone()).collect();
let spoke_keys = ws.with_store(Some(name), Store::config_keys)??;
let (matches, key_orphans) = crate::infer_links::match_against_hub(&spoke_keys, hub_keys);
for m in &matches {
let src = format!("cfgkey:{}#{}", m.spoke_file, m.spoke_key);
if persisted.contains(&src) {
continue; }
let qualified = format!("{hub}::cfgkey:{}#{}", m.hub_file, m.hub_key);
refs.push(ExternalRef {
src,
node: external_ref_node(&qualified),
provenance: Provenance::Inferred,
confidence: Some(m.confidence),
});
}
let mut seen = std::collections::HashSet::new();
refs.retain(|r| {
seen.insert((
r.src.clone(),
external_ref_target(&r.node).unwrap_or_default(),
))
});
let orphans = key_orphans
.into_iter()
.filter(|o| !persisted.contains(&format!("cfgkey:{}#{}", o.file, o.key)))
.map(|o| (o.key, o.value))
.collect();
Ok((refs, orphans))
}
fn neighbourhood_subgraph(
store: &Store,
root: &str,
depth: usize,
) -> Result<Option<Subgraph>, StoreError> {
let Some(root_node) = store.get_node(root)? else {
return Ok(None);
};
let mut nodes: BTreeMap<String, Node> = BTreeMap::new();
let mut edges: BTreeMap<(String, String, String), Edge> = BTreeMap::new();
nodes.insert(root_node.key.clone(), root_node);
let mut frontier = vec![root.to_owned()];
for _ in 0..depth {
let mut next = Vec::new();
for key in &frontier {
let incident = store
.edges_from(key)?
.into_iter()
.chain(store.edges_to(key)?);
for edge in incident {
let other = if edge.src == *key {
edge.dst.clone()
} else {
edge.src.clone()
};
edges
.entry((
edge.src.clone(),
edge.dst.clone(),
edge.kind.as_str().to_owned(),
))
.or_insert(edge);
if !nodes.contains_key(&other) {
if let Some(n) = store.get_node(&other)? {
nodes.insert(other.clone(), n);
}
next.push(other);
}
}
}
frontier = next;
}
Ok(Some((
nodes.into_values().collect(),
edges.into_values().collect(),
)))
}
fn compute_hotspots(store: &Store, limit: usize) -> Result<Vec<Value>, StoreError> {
let mut degree: BTreeMap<String, u32> = BTreeMap::new();
for edge in store.all_edges()? {
*degree.entry(edge.src).or_default() += 1;
*degree.entry(edge.dst).or_default() += 1;
}
let mut ranked: Vec<(u32, Node)> = store
.all_nodes()?
.into_iter()
.map(|n| (degree.get(&n.key).copied().unwrap_or(0), n))
.collect();
ranked.sort_by(|a, b| b.0.cmp(&a.0).then_with(|| a.1.key.cmp(&b.1.key)));
Ok(ranked
.into_iter()
.take(limit)
.map(|(deg, n)| json!({ "key": n.key, "name": n.name, "kind": n.kind.as_str(), "degree": deg }))
.collect())
}
fn parse_provenance(s: &str) -> Result<Provenance, ApiError> {
match s {
"derived" => Ok(Provenance::Derived),
"authored" => Ok(Provenance::Authored),
"inferred" => Ok(Provenance::Inferred),
other => Err(ApiError::BadRequest(format!(
"unknown provenance `{other}` (expected derived|authored|inferred)"
))),
}
}
#[cfg(test)]
mod tests {
use super::*;
use axum::body::Body;
use axum::http::{Request, StatusCode};
use http_body_util::BodyExt as _;
use rto_graph::{Edge, EdgeKind, FactSet, external_ref_key, external_ref_node};
use tower::ServiceExt as _;
const HUB: &str = "hub";
const SPOKE: &str = "spoke";
fn cfg_node(file: &str, dotted: &str, value: &str) -> Node {
let mut node = Node::new(
format!("cfgkey:{file}#{dotted}"),
NodeKind::Other("config_key".to_owned()),
dotted.to_owned(),
);
node.path = Some(file.to_owned());
node.meta = json!({ "key": dotted, "value": value });
node
}
fn struct_node(name: &str, fields: &[&str]) -> Node {
let mut node = Node::new(
format!("sym:rust:crates/roteiro/src/config.rs#{name}"),
NodeKind::Struct,
name.to_owned(),
);
node.path = Some("crates/roteiro/src/config.rs".to_owned());
node.meta = json!({ "fields": fields });
node
}
fn bridge_hub_store() -> Store {
let store = Store::open_in_memory().expect("hub store");
let facts = FactSet::new()
.with_node(struct_node("ServeConfig", &["addr"]))
.with_node(cfg_node("config.toml", "serve.addr", "127.0.0.1:8017"))
.with_node(cfg_node("config.toml", "serve.tools", "true"));
apply(store, &facts)
}
fn bridge_spoke_store() -> Store {
let store = Store::open_in_memory().expect("spoke store");
let target = format!("{HUB}::cfgkey:config.toml#serve.addr");
let facts = FactSet::new()
.with_node(cfg_node("deploy.env", "SERVE_ADDR", "0.0.0.0:8443"))
.with_node(external_ref_node(&target))
.with_edge(Edge::inferred(
"cfgkey:deploy.env#SERVE_ADDR",
external_ref_key(&target),
EdgeKind::References,
0.9,
));
apply(store, &facts)
}
fn bridge_workspace() -> Workspace {
Workspace::from_stores([
(HUB.to_owned(), bridge_hub_store()),
(SPOKE.to_owned(), bridge_spoke_store()),
])
}
fn hub_store() -> Store {
let store = Store::open_in_memory().expect("hub store");
let facts = FactSet::new()
.with_node(Node::new("sym:main", NodeKind::Fn, "main"))
.with_node(Node::new("sym:helper", NodeKind::Fn, "helper"))
.with_node(cfg_node("config.toml", "serve.addr", "127.0.0.1:8017"))
.with_node(cfg_node("config.toml", "serve.tools", "true"))
.with_edge(Edge::derived("sym:main", "sym:helper", EdgeKind::Calls));
apply(store, &facts)
}
fn spoke_store() -> Store {
let store = Store::open_in_memory().expect("spoke store");
let live_target = format!("{HUB}::cfgkey:config.toml#serve.addr");
let dead_target = format!("{HUB}::cfgkey:config.toml#serve.legacy");
let facts = FactSet::new()
.with_node(cfg_node("deploy.env", "SERVE_ADDR", "0.0.0.0:8443"))
.with_node(cfg_node("deploy.env", "LEGACY_ADDR", "10.0.0.1:9000"))
.with_node(external_ref_node(&live_target))
.with_node(external_ref_node(&dead_target))
.with_edge(Edge::inferred(
"cfgkey:deploy.env#SERVE_ADDR",
external_ref_key(&live_target),
EdgeKind::References,
0.9,
))
.with_edge(Edge::inferred(
"cfgkey:deploy.env#LEGACY_ADDR",
external_ref_key(&dead_target),
EdgeKind::References,
0.8,
));
apply(store, &facts)
}
fn spoke_mixed_provenance() -> Store {
let store = Store::open_in_memory().expect("spoke store");
let inferred = format!("{HUB}::cfgkey:config.toml#serve.addr");
let authored = format!("{HUB}::cfgkey:config.toml#serve.tools");
let facts = FactSet::new()
.with_node(cfg_node("deploy.env", "SERVE_ADDR", "0.0.0.0:8443"))
.with_node(cfg_node("deploy.env", "SERVE_TOOLS", "false"))
.with_node(external_ref_node(&inferred))
.with_node(external_ref_node(&authored))
.with_edge(Edge::inferred(
"cfgkey:deploy.env#SERVE_ADDR",
external_ref_key(&inferred),
EdgeKind::References,
0.9,
))
.with_edge(Edge::authored(
"cfgkey:deploy.env#SERVE_TOOLS",
external_ref_key(&authored),
EdgeKind::References,
));
apply(store, &facts)
}
fn spoke_authored_inferred_drift() -> Store {
let store = Store::open_in_memory().expect("spoke store");
let inferred = format!("{HUB}::cfgkey:config.toml#serve.addr");
let authored = format!("{HUB}::cfgkey:config.toml#serve.tools");
let drift = format!("{HUB}::cfgkey:config.toml#serve.legacy");
let facts = FactSet::new()
.with_node(cfg_node("deploy.env", "SERVE_ADDR", "0.0.0.0:8443"))
.with_node(cfg_node("deploy.env", "SERVE_TOOLS", "false"))
.with_node(cfg_node("deploy.env", "LEGACY_ADDR", "10.0.0.1:9000"))
.with_node(external_ref_node(&inferred))
.with_node(external_ref_node(&authored))
.with_node(external_ref_node(&drift))
.with_edge(Edge::inferred(
"cfgkey:deploy.env#SERVE_ADDR",
external_ref_key(&inferred),
EdgeKind::References,
0.9,
))
.with_edge(Edge::authored(
"cfgkey:deploy.env#SERVE_TOOLS",
external_ref_key(&authored),
EdgeKind::References,
))
.with_edge(Edge::inferred(
"cfgkey:deploy.env#LEGACY_ADDR",
external_ref_key(&drift),
EdgeKind::References,
0.8,
));
apply(store, &facts)
}
fn spoke_shared_target_and_drift() -> Store {
let store = Store::open_in_memory().expect("spoke store");
let shared = format!("{HUB}::cfgkey:config.toml#serve.addr");
let drift = format!("{HUB}::cfgkey:config.toml#serve.legacy");
let facts = FactSet::new()
.with_node(cfg_node("deploy.env", "SERVE_ADDR", "0.0.0.0:8443"))
.with_node(cfg_node("deploy.env", "PROXY_ADDR", "0.0.0.0:9443"))
.with_node(cfg_node("deploy.env", "LEGACY_ADDR", "10.0.0.1:9000"))
.with_node(external_ref_node(&shared))
.with_node(external_ref_node(&drift))
.with_edge(Edge::inferred(
"cfgkey:deploy.env#SERVE_ADDR",
external_ref_key(&shared),
EdgeKind::References,
0.9,
))
.with_edge(Edge::authored(
"cfgkey:deploy.env#PROXY_ADDR",
external_ref_key(&shared),
EdgeKind::References,
))
.with_edge(Edge::inferred(
"cfgkey:deploy.env#LEGACY_ADDR",
external_ref_key(&drift),
EdgeKind::References,
0.8,
));
apply(store, &facts)
}
fn spoke_linking_unhosted(to_ghost: usize, link_hub: bool) -> Store {
let store = Store::open_in_memory().expect("spoke store");
let mut facts = FactSet::new();
if link_hub {
let live = format!("{HUB}::cfgkey:config.toml#serve.addr");
facts = facts
.with_node(cfg_node("deploy.env", "HUB_ADDR", "0.0.0.0:8443"))
.with_node(external_ref_node(&live))
.with_edge(Edge::inferred(
"cfgkey:deploy.env#HUB_ADDR",
external_ref_key(&live),
EdgeKind::References,
0.9,
));
}
for i in 0..to_ghost {
let spoke_key = format!("GHOST_{i}");
let ghost = format!("ghost::cfgkey:g.env#K{i}");
facts = facts
.with_node(cfg_node("deploy.env", &spoke_key, "x"))
.with_node(external_ref_node(&ghost))
.with_edge(Edge::inferred(
format!("cfgkey:deploy.env#{spoke_key}"),
external_ref_key(&ghost),
EdgeKind::References,
0.8,
));
}
apply(store, &facts)
}
fn solo_store() -> Store {
let store = Store::open_in_memory().expect("solo store");
let facts = FactSet::new().with_node(Node::new("sym:only", NodeKind::Fn, "only"));
apply(store, &facts)
}
fn infer_hub_store() -> Store {
let store = Store::open_in_memory().expect("hub store");
let facts = FactSet::new()
.with_node(cfg_node("config.toml", "serve.addr", "127.0.0.1:8017"))
.with_node(cfg_node("config.toml", "serve.tools", "true"))
.with_node(cfg_node("config.toml", "serve.workers", "4"));
apply(store, &facts)
}
fn infer_spoke_store() -> Store {
let store = Store::open_in_memory().expect("spoke store");
let facts = FactSet::new()
.with_node(cfg_node("deploy.env", "SERVE_ADDR", "0.0.0.0:8443"))
.with_node(cfg_node("deploy.env", "SERVE_TOOLS", "false"))
.with_node(cfg_node("deploy.env", "EXTRA_FLAG", "on"));
apply(store, &facts)
}
fn inferable_workspace() -> Workspace {
Workspace::from_stores([
(HUB.to_owned(), infer_hub_store()),
(SPOKE.to_owned(), infer_spoke_store()),
])
}
fn spoke_authored_plus_inferable() -> Store {
let store = Store::open_in_memory().expect("spoke store");
let authored = format!("{HUB}::cfgkey:config.toml#serve.tools");
let facts = FactSet::new()
.with_node(cfg_node("deploy.env", "SERVE_ADDR", "0.0.0.0:8443"))
.with_node(cfg_node("deploy.env", "SERVE_TOOLS", "false"))
.with_node(cfg_node("deploy.env", "EXTRA_FLAG", "on"))
.with_node(external_ref_node(&authored))
.with_edge(Edge::authored(
"cfgkey:deploy.env#SERVE_TOOLS",
external_ref_key(&authored),
EdgeKind::References,
));
apply(store, &facts)
}
fn apply(mut store: Store, facts: &FactSet) -> Store {
store.apply_factset(facts).expect("apply factset");
store
}
fn linked_workspace() -> Workspace {
Workspace::from_stores([
(HUB.to_owned(), hub_store()),
(SPOKE.to_owned(), spoke_store()),
])
}
fn single_set(ws: Workspace) -> WorkspaceSet {
WorkspaceSet::from_workspaces([("linked".to_owned(), ws, true)])
}
fn multi_set() -> WorkspaceSet {
WorkspaceSet::from_workspaces([
("linked".to_owned(), linked_workspace(), true),
(
"solo".to_owned(),
Workspace::single(HUB, solo_store()),
false,
),
])
}
async fn get(set: WorkspaceSet, default: Option<&str>, uri: &str) -> (StatusCode, Value) {
let resp = router(Arc::new(set), default.map(str::to_owned))
.oneshot(Request::builder().uri(uri).body(Body::empty()).unwrap())
.await
.unwrap();
let status = resp.status();
let bytes = resp.into_body().collect().await.unwrap().to_bytes();
let json = if bytes.is_empty() {
Value::Null
} else {
serde_json::from_slice(&bytes).unwrap()
};
(status, json)
}
async fn send(app: Router, method: &str, uri: &str) -> (StatusCode, Value) {
let resp = app
.oneshot(
Request::builder()
.method(method)
.uri(uri)
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
let status = resp.status();
let bytes = resp.into_body().collect().await.unwrap().to_bytes();
let json = if bytes.is_empty() {
Value::Null
} else {
serde_json::from_slice(&bytes).unwrap()
};
(status, json)
}
#[tokio::test]
async fn capabilities_report_ask_off_for_the_llama_free_explorer() {
let (status, json) = get(multi_set(), None, "/v1/graph/capabilities").await;
assert_eq!(status, StatusCode::OK);
assert_eq!(json["ask"], false, "explorer build cannot Ask");
assert_eq!(json["models"], json!([]), "no model is served");
}
#[tokio::test]
async fn capabilities_report_ask_on_with_served_models() {
let caps = Capabilities {
ask: true,
models: vec!["qwen3-0.6b".to_owned()],
};
let resp = router_with_capabilities(Arc::new(multi_set()), None, caps)
.oneshot(
Request::builder()
.uri("/v1/graph/capabilities")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
let bytes = resp.into_body().collect().await.unwrap().to_bytes();
let json: Value = serde_json::from_slice(&bytes).unwrap();
assert_eq!(json["ask"], true, "serve build can Ask");
assert_eq!(json["models"], json!(["qwen3-0.6b"]));
}
#[tokio::test]
async fn workspaces_lists_all_incl_standalone_singleton() {
let (status, json) = get(multi_set(), None, "/v1/graph/workspaces").await;
assert_eq!(status, StatusCode::OK);
let arr = json.as_array().expect("workspaces array");
assert_eq!(arr.len(), 2, "both workspaces are listed");
assert_eq!(arr[0]["name"], "linked");
assert_eq!(arr[0]["linked"], true, "the hub+spoke group is linked");
let linked_projects: Vec<String> =
serde_json::from_value(arr[0]["projects"].clone()).expect("projects");
assert!(
linked_projects.contains(&HUB.to_owned())
&& linked_projects.contains(&SPOKE.to_owned())
);
assert_eq!(arr[1]["name"], "solo");
assert_eq!(arr[1]["linked"], false);
assert_eq!(arr[1]["projects"], json!([HUB]));
}
#[tokio::test]
async fn nested_per_project_resolves_within_its_workspace() {
let (status, linked_hub) = get(multi_set(), None, "/v1/graph/workspaces/linked/hub").await;
assert_eq!(status, StatusCode::OK);
assert_eq!(linked_hub["counts"]["nodes"], 4);
let (_, solo_hub) = get(multi_set(), None, "/v1/graph/workspaces/solo/hub").await;
assert_eq!(
solo_hub["counts"]["nodes"], 1,
"the standalone `hub` is distinct"
);
}
#[tokio::test]
async fn nested_topology_is_scoped_to_the_workspace() {
let (status, linked) = get(multi_set(), None, "/v1/graph/workspaces/linked/topology").await;
assert_eq!(status, StatusCode::OK);
assert_eq!(linked["hub"], HUB);
assert_eq!(linked["spokes"].as_array().unwrap().len(), 1);
let (_, solo) = get(multi_set(), None, "/v1/graph/workspaces/solo/topology").await;
assert_eq!(solo["hub"], Value::Null);
}
#[tokio::test]
async fn nested_nodes_and_resolve_are_scoped() {
let (_, nodes) = get(
multi_set(),
None,
"/v1/graph/workspaces/linked/hub/nodes?kinds=fn",
)
.await;
assert_eq!(nodes["total"], 2);
let (_, live) = get(
multi_set(),
None,
"/v1/graph/workspaces/linked/resolve?qualified=hub::cfgkey:config.toml%23serve.addr",
)
.await;
assert_eq!(live["drift"], false);
assert_eq!(live["target"]["name"], "serve.addr");
let (_, drift) = get(
multi_set(),
None,
"/v1/graph/workspaces/solo/resolve?qualified=hub::cfgkey:config.toml%23serve.addr",
)
.await;
assert_eq!(drift["drift"], true);
assert_eq!(drift["target"], Value::Null);
}
#[tokio::test]
async fn follow_bridges_a_config_key_to_its_defining_struct_field() {
let (status, body) = get(
single_set(bridge_workspace()),
None,
"/v1/graph/follow?qualified=hub::cfgkey:config.toml%23serve.addr",
)
.await;
assert_eq!(status, StatusCode::OK);
assert_eq!(body["kind"], "struct_field");
assert_eq!(body["drift"], false);
assert_eq!(body["project"], "hub");
assert_eq!(body["field"], "addr");
assert_eq!(body["workspace"], "linked");
assert_eq!(
body["target"]["key"],
"sym:rust:crates/roteiro/src/config.rs#ServeConfig"
);
assert_eq!(body["target"]["kind"], "struct");
}
#[tokio::test]
async fn follow_falls_back_to_the_config_key_when_unbridgeable() {
let (status, body) = get(
single_set(bridge_workspace()),
None,
"/v1/graph/follow?qualified=hub::cfgkey:config.toml%23serve.tools",
)
.await;
assert_eq!(status, StatusCode::OK);
assert_eq!(body["kind"], "config_key");
assert_eq!(body["drift"], false);
assert_eq!(body["field"], Value::Null);
assert_eq!(body["target"]["key"], "cfgkey:config.toml#serve.tools");
assert_eq!(body["target"]["kind"], "config_key");
}
#[tokio::test]
async fn follow_reports_drift_for_an_orphan_target() {
let (status, body) = get(
single_set(bridge_workspace()),
None,
"/v1/graph/follow?qualified=hub::cfgkey:config.toml%23serve.legacy",
)
.await;
assert_eq!(status, StatusCode::OK);
assert_eq!(body["drift"], true);
assert_eq!(body["target"], Value::Null);
assert_eq!(body["kind"], Value::Null);
assert_eq!(body["project"], "hub");
}
#[tokio::test]
async fn follow_requires_a_qualified_key() {
let (status, _) = get(single_set(bridge_workspace()), None, "/v1/graph/follow").await;
assert_eq!(status, StatusCode::BAD_REQUEST);
let (status, _) = get(
single_set(bridge_workspace()),
None,
"/v1/graph/follow?qualified=notqualified",
)
.await;
assert_eq!(status, StatusCode::BAD_REQUEST);
}
#[tokio::test]
async fn follow_is_scoped_to_the_selected_workspace() {
let (status, linked) = get(
multi_set(),
None,
"/v1/graph/workspaces/linked/follow?qualified=hub::cfgkey:config.toml%23serve.addr",
)
.await;
assert_eq!(status, StatusCode::OK);
assert_eq!(linked["kind"], "config_key");
assert_eq!(linked["drift"], false);
assert_eq!(linked["workspace"], "linked");
let (_, solo) = get(
multi_set(),
None,
"/v1/graph/workspaces/solo/follow?qualified=hub::cfgkey:config.toml%23serve.addr",
)
.await;
assert_eq!(solo["drift"], true);
assert_eq!(solo["target"], Value::Null);
}
#[tokio::test]
async fn unknown_workspace_is_404() {
let (status, _) = get(multi_set(), None, "/v1/graph/workspaces/ghost/topology").await;
assert_eq!(status, StatusCode::NOT_FOUND);
}
#[tokio::test]
async fn flat_routes_serve_the_sole_workspace_by_default() {
let (status, json) = get(single_set(linked_workspace()), None, "/v1/graph/hub").await;
assert_eq!(status, StatusCode::OK);
assert_eq!(json["counts"]["nodes"], 4);
let (_, top) = get(single_set(linked_workspace()), None, "/v1/graph/topology").await;
assert_eq!(top["hub"], HUB);
}
#[tokio::test]
async fn flat_route_is_ambiguous_without_a_default() {
let (status, _) = get(multi_set(), None, "/v1/graph/topology").await;
assert_eq!(status, StatusCode::BAD_REQUEST);
}
#[tokio::test]
async fn flat_route_honours_the_named_default() {
let (status, json) = get(multi_set(), Some("solo"), "/v1/graph/hub").await;
assert_eq!(status, StatusCode::OK);
assert_eq!(json["counts"]["nodes"], 1);
}
#[tokio::test]
async fn projects_reports_names_and_multiplicity() {
let (status, json) = get(single_set(linked_workspace()), None, "/v1/graph/projects").await;
assert_eq!(status, StatusCode::OK);
assert_eq!(json["isMulti"], true);
let names: Vec<String> =
serde_json::from_value(json["projects"].clone()).expect("projects array");
assert!(names.contains(&HUB.to_owned()) && names.contains(&SPOKE.to_owned()));
let single = single_set(Workspace::single(HUB, hub_store()));
let (_, one) = get(single, None, "/v1/graph/projects").await;
assert_eq!(one["isMulti"], false);
}
#[tokio::test]
async fn project_graph_returns_nodes_edges_and_counts() {
let set = single_set(Workspace::single(HUB, hub_store()));
let (status, json) = get(set, None, "/v1/graph/hub").await;
assert_eq!(status, StatusCode::OK);
assert_eq!(json["counts"]["nodes"], 4);
assert_eq!(json["counts"]["edges"], 1);
assert_eq!(json["nodes"].as_array().unwrap().len(), 4);
}
#[tokio::test]
async fn unknown_project_is_404() {
let set = single_set(Workspace::single(HUB, hub_store()));
let (status, _) = get(set, None, "/v1/graph/nope").await;
assert_eq!(status, StatusCode::NOT_FOUND);
}
#[tokio::test]
async fn nodes_filter_by_kind_and_page() {
let (status, all) = get(
single_set(Workspace::single(HUB, hub_store())),
None,
"/v1/graph/hub/nodes?kinds=fn",
)
.await;
assert_eq!(status, StatusCode::OK);
assert_eq!(all["total"], 2);
assert_eq!(all["nodes"].as_array().unwrap().len(), 2);
let (_, page) = get(
single_set(Workspace::single(HUB, hub_store())),
None,
"/v1/graph/hub/nodes?kinds=fn&limit=1&offset=1",
)
.await;
assert_eq!(page["total"], 2, "total is pre-paging");
assert_eq!(page["nodes"].as_array().unwrap().len(), 1);
assert_eq!(page["limit"], 1);
assert_eq!(page["offset"], 1);
}
#[tokio::test]
async fn nodes_query_matches_name_substring() {
let (_, json) = get(
single_set(Workspace::single(HUB, hub_store())),
None,
"/v1/graph/hub/nodes?q=help",
)
.await;
assert_eq!(json["total"], 1);
assert_eq!(json["nodes"][0]["name"], "helper");
}
#[tokio::test]
async fn nodes_bad_provenance_is_400() {
let (status, _) = get(
single_set(Workspace::single(HUB, hub_store())),
None,
"/v1/graph/hub/nodes?provenance=bogus",
)
.await;
assert_eq!(status, StatusCode::BAD_REQUEST);
}
#[tokio::test]
async fn node_detail_explains_and_404s_unknown_key() {
let (status, json) = get(
single_set(Workspace::single(HUB, hub_store())),
None,
"/v1/graph/hub/node/sym:main",
)
.await;
assert_eq!(status, StatusCode::OK);
assert_eq!(json["node"]["key"], "sym:main");
assert_eq!(json["outgoing"].as_array().unwrap().len(), 1);
let (missing, _) = get(
single_set(Workspace::single(HUB, hub_store())),
None,
"/v1/graph/hub/node/sym:ghost",
)
.await;
assert_eq!(missing, StatusCode::NOT_FOUND);
}
#[tokio::test]
async fn node_detail_surfaces_generated_media_attributed_to_its_producer() {
let store = Store::open_in_memory().expect("store");
let mut clip = Node::new("file:assets/silence.wav", NodeKind::File, "silence.wav");
clip.path = Some("assets/silence.wav".to_owned());
clip.blob_hash = Some("blob-silence".to_owned());
let mut store = apply(store, &FactSet::new().with_node(clip));
let voxtral = rto_graph::Producer {
kind: rto_graph::MediaKind::Audio,
model: "voxtral-mini-3b".to_owned(),
model_digest: "4705be8e".to_owned(),
quantisation: "Q4_K_M".to_owned(),
mmproj_digest: "4f24c4ef".to_owned(),
prompt: "Transcribe this audio recording.".to_owned(),
temperature: 0.0,
max_tokens: 512,
};
let successor = rto_graph::Producer {
model: "voxtral-small-24b".to_owned(),
..voxtral.clone()
};
for (producer, outcome) in [
(
&voxtral,
rto_graph::MediaOutcome::Generated(rto_graph::GeneratedContent {
text: "Tonight I want to talk about world government.".to_owned(),
confidence: None,
}),
),
(
&successor,
rto_graph::MediaOutcome::Skipped(rto_graph::MediaSkip {
reason: rto_graph::GateReason::Silence,
value: 0.0,
threshold: 0.0001,
}),
),
] {
store
.record_media_content(&rto_graph::MediaWrite {
blob_id: "blob-silence",
path: "assets/silence.wav",
producer,
tool_version: "9.9.9",
outcome: &outcome,
replace: false,
})
.expect("record");
}
let (status, json) = get(
single_set(Workspace::single(HUB, store)),
None,
"/v1/graph/hub/node/file:assets/silence.wav",
)
.await;
assert_eq!(status, StatusCode::OK);
let records = json["generated"].as_array().expect("a generated array");
assert_eq!(records.len(), 2, "both producers' records must surface");
let transcript = records
.iter()
.find(|r| r["text"].is_string())
.expect("the generated record");
assert_eq!(transcript["generated"], true, "an unmissable marker");
assert_eq!(transcript["model"], "voxtral-mini-3b");
assert_eq!(transcript["kind"], "audio");
assert_eq!(transcript["quantisation"], "Q4_K_M");
assert_eq!(
transcript["producer"],
voxtral.id().to_string(),
"the full producer identity, not just the model name",
);
assert!(transcript["skipped"].is_null());
assert_eq!(
transcript["rebuild"],
"roteiro media build --blob blob-silence --force"
);
let refusal = records
.iter()
.find(|r| !r["skipped"].is_null())
.expect("the gated record");
assert!(
refusal["text"].is_null(),
"a gated skip carries no text to render",
);
assert_eq!(refusal["skipped"]["reason"], "silence");
assert_eq!(refusal["skipped"]["metric"], "rms");
assert_eq!(
refusal["skipped"]["explanation"], "below silence threshold (rms=0, threshold 0.0001)",
"the operator-facing line names the metric and its measured value",
);
assert!(
!json["meta"].to_string().contains("world government"),
"generated text must not reach the node's meta: {}",
json["meta"],
);
assert_eq!(json["node"]["key"], "file:assets/silence.wav");
}
#[tokio::test]
async fn a_node_without_media_reports_an_empty_generated_array() {
let (status, json) = get(
single_set(Workspace::single(HUB, hub_store())),
None,
"/v1/graph/hub/node/sym:main",
)
.await;
assert_eq!(status, StatusCode::OK);
assert_eq!(
json["generated"].as_array().map(Vec::len),
Some(0),
"the key is always present, so a consumer never has to guess",
);
}
#[tokio::test]
async fn neighbourhood_returns_root_and_neighbour() {
let (status, json) = get(
single_set(Workspace::single(HUB, hub_store())),
None,
"/v1/graph/hub/neighbourhood/sym:main",
)
.await;
assert_eq!(status, StatusCode::OK);
assert_eq!(json["root"], "sym:main");
let keys: Vec<String> = json["nodes"]
.as_array()
.unwrap()
.iter()
.map(|n| n["key"].as_str().unwrap().to_owned())
.collect();
assert!(keys.contains(&"sym:main".to_owned()) && keys.contains(&"sym:helper".to_owned()));
assert_eq!(json["counts"]["edges"], 1);
}
#[tokio::test]
async fn debt_report_has_expected_shape() {
let (status, json) = get(
single_set(Workspace::single(HUB, hub_store())),
None,
"/v1/graph/hub/debt",
)
.await;
assert_eq!(status, StatusCode::OK);
assert!(json["schema"].is_string());
assert!(json["total"].is_number());
assert!(json["items"].is_array());
}
fn marker_node(path: &str, line: u32) -> Node {
let mut node = Node::new(
format!("marker:{path}#{line}"),
NodeKind::Marker,
"TODO: finish".to_owned(),
);
node.path = Some(path.to_owned());
node.meta = json!({ "category": "todo", "text": "TODO: finish", "line": line });
node
}
fn debt_repo(dir: &std::path::Path, toml: Option<&str>, markers: &[&str]) {
std::fs::create_dir_all(dir).expect("mkdir repo");
let ok = std::process::Command::new("git")
.args(["init", "-q"])
.current_dir(dir)
.status()
.expect("run git init")
.success();
assert!(ok, "git init failed in {}", dir.display());
if let Some(toml) = toml {
std::fs::write(dir.join("roteiro.toml"), toml).expect("write roteiro.toml");
}
let store_dir = dir.join(".git").join("roteiro");
std::fs::create_dir_all(&store_dir).expect("mkdir store");
let mut store = Store::open(&store_dir.join("graph.db")).expect("open store");
let mut facts = FactSet::new();
for (i, path) in markers.iter().enumerate() {
facts = facts.with_node(marker_node(path, u32::try_from(i).unwrap() + 1));
}
store.apply_factset(&facts).expect("apply markers");
}
fn fresh_repo_root(name: &str) -> std::path::PathBuf {
let dir = std::env::temp_dir().join(format!(
"roteiro-api-debt-{}-{name}-{:?}",
std::process::id(),
std::thread::current().id()
));
std::fs::remove_dir_all(&dir).ok();
dir
}
#[tokio::test]
async fn debt_endpoint_applies_the_repos_own_exclusions() {
let root = fresh_repo_root("own");
let repo = root.join("app");
debt_repo(
&repo,
Some("[debt]\nignore = [\"docs/**\", \"CHANGELOG.md\"]\n"),
&["src/lib.rs", "docs/guide.md", "CHANGELOG.md"],
);
let ws = Workspace::from_repo_paths([&repo]).expect("workspace");
let (status, json) = get(single_set(ws), None, "/v1/graph/app/debt").await;
assert_eq!(status, StatusCode::OK);
assert_eq!(
json["total"], 1,
"excluded paths must not be counted: {json}"
);
let paths: Vec<&str> = json["items"]
.as_array()
.expect("items")
.iter()
.filter_map(|i| i["path"].as_str())
.collect();
assert_eq!(paths, vec!["src/lib.rs"], "kept the right marker");
std::fs::remove_dir_all(&root).ok();
}
#[tokio::test]
async fn each_repo_is_scanned_under_its_own_config_not_the_first_ones() {
let root = fresh_repo_root("per-repo");
let (a, b) = (root.join("alpha"), root.join("beta"));
let markers = ["src/lib.rs", "docs/guide.md", "vendor/dep.rs"];
debt_repo(&a, Some("[debt]\nignore = [\"docs/**\"]\n"), &markers);
debt_repo(&b, Some("[debt]\nignore = [\"vendor/**\"]\n"), &markers);
let ws = Workspace::from_repo_paths([&a, &b]).expect("workspace");
let set = WorkspaceSet::from_workspaces([("ws".to_owned(), ws, true)]);
let app = router(Arc::new(set), Some("ws".to_owned()));
let (sa, ja) = send(app.clone(), "GET", "/v1/graph/alpha/debt").await;
let (sb, jb) = send(app, "GET", "/v1/graph/beta/debt").await;
assert_eq!((sa, sb), (StatusCode::OK, StatusCode::OK));
let paths = |j: &Value| -> Vec<String> {
j["items"]
.as_array()
.expect("items")
.iter()
.filter_map(|i| i["path"].as_str().map(str::to_owned))
.collect()
};
assert_eq!(
paths(&ja),
vec!["src/lib.rs".to_owned(), "vendor/dep.rs".to_owned()],
"alpha uses alpha's config: {ja}"
);
assert_eq!(
paths(&jb),
vec!["docs/guide.md".to_owned(), "src/lib.rs".to_owned()],
"beta uses beta's config, not alpha's: {jb}"
);
std::fs::remove_dir_all(&root).ok();
}
#[tokio::test]
async fn debt_endpoint_reports_no_exclusions_for_a_repoless_project() {
let (status, json) = get(
single_set(Workspace::single(HUB, hub_store())),
None,
"/v1/graph/hub/debt",
)
.await;
assert_eq!(status, StatusCode::OK);
assert!(json["total"].is_number(), "still answers: {json}");
}
#[tokio::test]
async fn hotspots_ranks_by_degree() {
let (status, json) = get(
single_set(Workspace::single(HUB, hub_store())),
None,
"/v1/graph/hub/hotspots?limit=1",
)
.await;
assert_eq!(status, StatusCode::OK);
assert_eq!(json["limit"], 1);
let top = &json["hotspots"][0];
assert_eq!(top["degree"], 1);
assert_eq!(top["key"], "sym:helper");
}
#[tokio::test]
async fn coupling_endpoint_reports_the_direction_hotspots_discards() {
let set = || single_set(Workspace::single(HUB, hub_store()));
let (status, json) = get(set(), None, "/v1/graph/hub/coupling?order=fan_in&limit=1").await;
assert_eq!(status, StatusCode::OK);
assert_eq!(json["order"], "fan_in");
assert_eq!(json["edge_kind"], "calls");
assert_eq!(json["items"][0]["key"], "sym:helper", "the callee: {json}");
assert_eq!(json["items"][0]["fan_in"], 1);
assert_eq!(json["items"][0]["fan_out"], 0);
let (status, json) = get(set(), None, "/v1/graph/hub/coupling?order=fan_out&limit=1").await;
assert_eq!(status, StatusCode::OK);
assert_eq!(json["items"][0]["key"], "sym:main", "the caller: {json}");
assert_eq!(json["items"][0]["fan_out"], 1);
}
#[tokio::test]
async fn coupling_endpoint_rejects_an_unknown_order() {
let (status, _) = get(
single_set(Workspace::single(HUB, hub_store())),
None,
"/v1/graph/hub/coupling?order=degree",
)
.await;
assert_eq!(status, StatusCode::BAD_REQUEST);
}
#[tokio::test]
async fn resolve_returns_hub_node_for_a_live_key() {
let (status, json) = get(
single_set(linked_workspace()),
None,
"/v1/graph/resolve?qualified=hub::cfgkey:config.toml%23serve.addr",
)
.await;
assert_eq!(status, StatusCode::OK);
assert_eq!(json["drift"], false);
assert_eq!(json["target"]["name"], "serve.addr");
}
#[tokio::test]
async fn resolve_reports_drift_for_an_orphan() {
let (status, json) = get(
single_set(linked_workspace()),
None,
"/v1/graph/resolve?qualified=hub::cfgkey:config.toml%23serve.legacy",
)
.await;
assert_eq!(status, StatusCode::OK);
assert_eq!(json["drift"], true);
assert_eq!(json["target"], Value::Null);
}
#[tokio::test]
async fn resolve_unqualified_key_is_400() {
let (status, _) = get(
single_set(linked_workspace()),
None,
"/v1/graph/resolve?qualified=notqualified",
)
.await;
assert_eq!(status, StatusCode::BAD_REQUEST);
}
#[tokio::test]
async fn topology_shows_hub_spokes_and_links() {
let (status, json) = get(single_set(linked_workspace()), None, "/v1/graph/topology").await;
assert_eq!(status, StatusCode::OK);
assert_eq!(json["hub"], HUB);
let spokes = json["spokes"].as_array().unwrap();
assert_eq!(spokes.len(), 1);
assert_eq!(spokes[0]["name"], SPOKE);
assert_eq!(spokes[0]["driftCount"], 1);
assert_eq!(json["links"].as_array().unwrap().len(), 2);
}
#[tokio::test]
async fn matrix_pivots_overrides_and_lists_drift() {
let (status, json) = get(single_set(linked_workspace()), None, "/v1/graph/matrix").await;
assert_eq!(status, StatusCode::OK);
assert_eq!(json["hub"], HUB);
let rows = json["rows"].as_array().unwrap();
assert_eq!(rows.len(), 1);
assert_eq!(rows[0]["hub_key"], "serve.addr");
assert!(rows[0]["cells"][SPOKE]["differs"].as_bool().unwrap());
let drift = json["drift"].as_array().unwrap();
assert_eq!(drift.len(), 1);
assert_eq!(drift[0]["key"], "LEGACY_ADDR");
}
#[tokio::test]
async fn matrix_carries_real_per_cell_provenance() {
let ws = Workspace::from_stores([
(HUB.to_owned(), hub_store()),
(SPOKE.to_owned(), spoke_mixed_provenance()),
]);
let (status, json) = get(single_set(ws), None, "/v1/graph/matrix").await;
assert_eq!(status, StatusCode::OK);
let rows = json["rows"].as_array().unwrap();
let addr = rows.iter().find(|r| r["hub_key"] == "serve.addr").unwrap();
assert_eq!(
addr["cells"][SPOKE]["provenance"], "inferred",
"the confidence-scored match is inferred"
);
let tools = rows.iter().find(|r| r["hub_key"] == "serve.tools").unwrap();
assert_eq!(
tools["cells"][SPOKE]["provenance"], "authored",
"the declared link is authored, regardless of confidence"
);
assert_eq!(tools["cells"][SPOKE]["confidence"], json!(0.0));
}
#[tokio::test]
async fn topology_links_report_real_provenance() {
let ws = Workspace::from_stores([
(HUB.to_owned(), hub_store()),
(SPOKE.to_owned(), spoke_mixed_provenance()),
]);
let (status, json) = get(single_set(ws), None, "/v1/graph/topology").await;
assert_eq!(status, StatusCode::OK);
let provs: Vec<&str> = json["links"]
.as_array()
.unwrap()
.iter()
.filter_map(|l| l["provenance"].as_str())
.collect();
assert!(provs.contains(&"authored"), "got provenances: {provs:?}");
assert!(provs.contains(&"inferred"), "got provenances: {provs:?}");
}
#[tokio::test]
async fn topology_hub_is_always_a_hosted_project() {
let ws = Workspace::from_stores([
(HUB.to_owned(), hub_store()),
(SPOKE.to_owned(), spoke_linking_unhosted(2, true)),
]);
let (status, json) = get(single_set(ws), None, "/v1/graph/topology").await;
assert_eq!(status, StatusCode::OK);
assert_eq!(json["hub"], HUB, "hub is the hosted project, not `ghost`");
}
#[tokio::test]
async fn topology_unhosted_link_is_drift_not_404() {
let ws = Workspace::from_stores([
(HUB.to_owned(), hub_store()),
(SPOKE.to_owned(), spoke_linking_unhosted(2, false)),
]);
let (status, json) = get(single_set(ws), None, "/v1/graph/topology").await;
assert_eq!(status, StatusCode::OK, "an unhosted target must not 404");
assert_eq!(json["hub"], Value::Null, "no hosted project is referenced");
let spokes = json["spokes"].as_array().unwrap();
assert_eq!(spokes.len(), 1);
assert_eq!(spokes[0]["driftCount"], 2, "both unhosted links are drift");
assert_eq!(json["links"].as_array().unwrap().len(), 2);
}
#[tokio::test]
async fn topology_infers_cross_repo_links_live_without_persisted_edges() {
let (status, json) = get(
single_set(inferable_workspace()),
None,
"/v1/graph/topology",
)
.await;
assert_eq!(status, StatusCode::OK);
assert_eq!(
json["hub"], HUB,
"the config-key-rich repo is the inferred hub"
);
let spokes = json["spokes"].as_array().unwrap();
assert_eq!(spokes.len(), 1);
assert_eq!(spokes[0]["name"], SPOKE);
let links = json["links"].as_array().unwrap();
assert_eq!(links.len(), 2, "both matching keys infer a link");
assert!(
links.iter().all(|l| l["provenance"] == "inferred"),
"live-inferred links read slate"
);
assert_eq!(
spokes[0]["driftCount"], 1,
"the unmatched key is live drift"
);
}
#[tokio::test]
async fn matrix_infers_overrides_and_drift_live_without_persisted_edges() {
let (status, json) = get(single_set(inferable_workspace()), None, "/v1/graph/matrix").await;
assert_eq!(status, StatusCode::OK);
assert_eq!(json["hub"], HUB);
let rows = json["rows"].as_array().unwrap();
assert_eq!(rows.len(), 2);
let addr = rows.iter().find(|r| r["hub_key"] == "serve.addr").unwrap();
assert_eq!(
addr["cells"][SPOKE]["provenance"], "inferred",
"a live correspondence is inferred, not authored"
);
let drift = json["drift"].as_array().unwrap();
assert_eq!(drift.len(), 1);
assert_eq!(drift[0]["key"], "EXTRA_FLAG");
}
#[tokio::test]
async fn matrix_rows_carry_the_hub_source_file_for_tooling_classification() {
let hub = {
let store = Store::open_in_memory().expect("hub store");
let facts = FactSet::new()
.with_node(cfg_node("config.toml", "serve.addr", "127.0.0.1:8017"))
.with_node(cfg_node("Cargo.toml", "package.name", "roteiro"));
apply(store, &facts)
};
let spoke = {
let store = Store::open_in_memory().expect("spoke store");
let app_target = format!("{HUB}::cfgkey:config.toml#serve.addr");
let tooling_target = format!("{HUB}::cfgkey:Cargo.toml#package.name");
let facts = FactSet::new()
.with_node(cfg_node("deploy.env", "SERVE_ADDR", "0.0.0.0:8443"))
.with_node(cfg_node("deploy.env", "PACKAGE_NAME", "deploy"))
.with_node(external_ref_node(&app_target))
.with_node(external_ref_node(&tooling_target))
.with_edge(Edge::inferred(
"cfgkey:deploy.env#SERVE_ADDR",
external_ref_key(&app_target),
EdgeKind::References,
0.9,
))
.with_edge(Edge::inferred(
"cfgkey:deploy.env#PACKAGE_NAME",
external_ref_key(&tooling_target),
EdgeKind::References,
0.9,
));
apply(store, &facts)
};
let ws = Workspace::from_stores([(HUB.to_owned(), hub), (SPOKE.to_owned(), spoke)]);
let (status, json) = get(single_set(ws), None, "/v1/graph/matrix").await;
assert_eq!(status, StatusCode::OK);
assert_eq!(json["hub"], HUB);
let rows = json["rows"].as_array().unwrap();
let app_row = rows.iter().find(|r| r["hub_key"] == "serve.addr").unwrap();
let tooling_row = rows
.iter()
.find(|r| r["hub_key"] == "package.name")
.unwrap();
assert_eq!(app_row["file"], "config.toml");
assert_eq!(tooling_row["file"], "Cargo.toml");
assert!(!rto_graph::is_tooling_config_path(
app_row["file"].as_str().unwrap()
));
assert!(rto_graph::is_tooling_config_path(
tooling_row["file"].as_str().unwrap()
));
}
#[tokio::test]
async fn topology_merges_persisted_authored_with_live_inferred() {
let ws = Workspace::from_stores([
(HUB.to_owned(), hub_store()),
(SPOKE.to_owned(), spoke_authored_plus_inferable()),
]);
let (status, json) = get(single_set(ws), None, "/v1/graph/topology").await;
assert_eq!(status, StatusCode::OK);
assert_eq!(json["hub"], HUB);
let provs: std::collections::BTreeSet<&str> = json["links"]
.as_array()
.unwrap()
.iter()
.filter_map(|l| l["provenance"].as_str())
.collect();
assert_eq!(
provs,
["authored", "inferred"].into_iter().collect(),
"the authored link and the live-inferred one both render"
);
assert_eq!(
json["spokes"][0]["driftCount"], 1,
"the two matched keys resolve; the orphan drifts"
);
}
#[tokio::test]
async fn write_links_persists_inferred_edges_and_is_idempotent() {
let app = router(Arc::new(single_set(inferable_workspace())), None);
let (_, before) = send(
app.clone(),
"GET",
"/v1/graph/spoke/nodes?kinds=external_ref",
)
.await;
assert_eq!(before["total"], 0);
let (status, body) = send(app.clone(), "POST", "/v1/graph/links/write").await;
assert_eq!(status, StatusCode::OK);
assert_eq!(body["hub"], HUB);
assert_eq!(
body["written"], 2,
"both matching keys persist an inferred edge"
);
let (_, after) = send(
app.clone(),
"GET",
"/v1/graph/spoke/nodes?kinds=external_ref",
)
.await;
assert_eq!(
after["total"], 2,
"one external-ref node per persisted link"
);
let (_, again) = send(app.clone(), "POST", "/v1/graph/links/write").await;
assert_eq!(again["written"], body["written"]);
let (_, after2) = send(
app.clone(),
"GET",
"/v1/graph/spoke/nodes?kinds=external_ref",
)
.await;
assert_eq!(after2["total"], 2, "no duplicate nodes on re-write");
let (_, top) = send(app.clone(), "GET", "/v1/graph/topology").await;
assert_eq!(top["links"].as_array().unwrap().len(), 2);
assert_eq!(top["spokes"][0]["driftCount"], 1);
}
#[tokio::test]
async fn project_links_annotate_provenance_and_drift() {
let ws = Workspace::from_stores([
(HUB.to_owned(), hub_store()),
(SPOKE.to_owned(), spoke_authored_inferred_drift()),
]);
let (status, json) = get(single_set(ws), None, "/v1/graph/spoke/links").await;
assert_eq!(status, StatusCode::OK);
assert_eq!(json["project"], SPOKE);
let links = json["links"].as_array().unwrap();
assert_eq!(links.len(), 3, "all three links are reported");
let by = |name: &str| {
links
.iter()
.find(|l| l["fromName"] == name)
.unwrap_or_else(|| panic!("no link from {name}"))
};
let inferred = by("SERVE_ADDR");
assert_eq!(inferred["provenance"], "inferred");
assert_eq!(inferred["drift"], false);
assert_eq!(
inferred["toQualified"],
"hub::cfgkey:config.toml#serve.addr"
);
assert_eq!(inferred["toName"], "serve.addr");
assert_eq!(inferred["confidence"], json!(0.9));
let authored = by("SERVE_TOOLS");
assert_eq!(authored["provenance"], "authored");
assert_eq!(authored["drift"], false);
assert_eq!(authored["toName"], "serve.tools");
assert_eq!(authored["confidence"], Value::Null);
let drift = by("LEGACY_ADDR");
assert_eq!(drift["drift"], true);
assert_eq!(drift["toName"], Value::Null);
assert_eq!(drift["toQualified"], "hub::cfgkey:config.toml#serve.legacy");
}
#[tokio::test]
async fn project_links_are_empty_for_a_non_spoke() {
let (status, json) = get(single_set(linked_workspace()), None, "/v1/graph/hub/links").await;
assert_eq!(status, StatusCode::OK);
assert_eq!(json["project"], HUB);
assert_eq!(json["links"].as_array().unwrap().len(), 0);
}
#[tokio::test]
async fn project_links_are_scoped_to_the_named_workspace() {
let (status, json) =
get(multi_set(), None, "/v1/graph/workspaces/linked/spoke/links").await;
assert_eq!(status, StatusCode::OK);
let links = json["links"].as_array().unwrap();
assert_eq!(links.len(), 2);
let drift_count = links.iter().filter(|l| l["drift"] == true).count();
assert_eq!(drift_count, 1, "one link drifts, one resolves");
}
#[tokio::test]
async fn project_links_report_multiple_links_into_one_target_distinctly() {
let ws = Workspace::from_stores([
(HUB.to_owned(), hub_store()),
(SPOKE.to_owned(), spoke_shared_target_and_drift()),
]);
let (status, json) = get(single_set(ws), None, "/v1/graph/spoke/links").await;
assert_eq!(status, StatusCode::OK);
let links = json["links"].as_array().unwrap();
assert_eq!(
links.len(),
3,
"two links into the shared target + one drift"
);
let into_addr: Vec<&Value> = links
.iter()
.filter(|l| l["toQualified"] == "hub::cfgkey:config.toml#serve.addr")
.collect();
assert_eq!(
into_addr.len(),
2,
"both links into the one target are reported"
);
let froms: std::collections::BTreeSet<&str> = into_addr
.iter()
.filter_map(|l| l["fromName"].as_str())
.collect();
assert_eq!(
froms,
["PROXY_ADDR", "SERVE_ADDR"].into_iter().collect(),
"each link keeps its own source config key"
);
let provs: std::collections::BTreeSet<&str> = into_addr
.iter()
.filter_map(|l| l["provenance"].as_str())
.collect();
assert_eq!(
provs,
["authored", "inferred"].into_iter().collect(),
"each edge into the shared target keeps its own provenance"
);
assert!(into_addr.iter().all(|l| l["drift"] == false));
assert!(
into_addr
.iter()
.all(|l| l["to"] == "extref:hub::cfgkey:config.toml#serve.addr"),
"both links share the one external-ref node"
);
let drift = links
.iter()
.find(|l| l["fromName"] == "LEGACY_ADDR")
.unwrap();
assert_eq!(drift["drift"], true);
assert_eq!(drift["toName"], Value::Null);
}
fn explorer_router(set: WorkspaceSet, default: Option<&str>) -> Router {
router(Arc::new(set), default.map(str::to_owned)).merge(crate::explorer_app::router())
}
async fn get_app(router: Router, uri: &str) -> (StatusCode, String, String) {
let resp = router
.oneshot(Request::builder().uri(uri).body(Body::empty()).unwrap())
.await
.unwrap();
let status = resp.status();
let ct = resp
.headers()
.get(axum::http::header::CONTENT_TYPE)
.and_then(|v| v.to_str().ok())
.unwrap_or_default()
.to_owned();
let body = resp.into_body().collect().await.unwrap().to_bytes();
(status, ct, String::from_utf8_lossy(&body).into_owned())
}
fn standalone_only_set() -> WorkspaceSet {
WorkspaceSet::from_workspaces([(
"solo".to_owned(),
Workspace::single(HUB, solo_store()),
false,
)])
}
#[tokio::test]
async fn app_is_served_alongside_the_api_for_a_multi_workspace() {
let (status, ct, body) = get_app(explorer_router(multi_set(), None), "/").await;
assert_eq!(status, StatusCode::OK);
assert!(ct.starts_with("text/html"), "content-type was {ct}");
assert!(body.contains("<!doctype html>"));
assert!(body.contains("/app.js") && body.contains("/vendor/cytoscape.min.js"));
let (ws_status, ws_ct, ws_body) =
get_app(explorer_router(multi_set(), None), "/v1/graph/workspaces").await;
assert_eq!(ws_status, StatusCode::OK);
assert!(
ws_ct.contains("application/json"),
"content-type was {ws_ct}"
);
let arr: Value = serde_json::from_str(&ws_body).unwrap();
assert_eq!(arr.as_array().unwrap().len(), 2, "both workspaces listed");
}
#[tokio::test]
async fn app_and_assets_are_served_for_a_standalone_only_config() {
let (status, _, body) =
get_app(explorer_router(standalone_only_set(), Some("solo")), "/").await;
assert_eq!(status, StatusCode::OK);
assert!(body.contains("<!doctype html>"));
let (cy_status, cy_ct, cy_body) = get_app(
explorer_router(standalone_only_set(), Some("solo")),
"/vendor/cytoscape.min.js",
)
.await;
assert_eq!(cy_status, StatusCode::OK);
assert!(cy_ct.contains("javascript"), "content-type was {cy_ct}");
assert!(cy_body.len() > 100_000, "the UMD bundle is substantial");
let (js_status, _, _) = get_app(
explorer_router(standalone_only_set(), Some("solo")),
"/app.js",
)
.await;
assert_eq!(js_status, StatusCode::OK);
let (_, _, ws_body) = get_app(
explorer_router(standalone_only_set(), Some("solo")),
"/v1/graph/workspaces",
)
.await;
let arr: Value = serde_json::from_str(&ws_body).unwrap();
assert_eq!(arr.as_array().unwrap().len(), 1);
assert_eq!(arr[0]["linked"], false);
}
#[tokio::test]
async fn workspaces_payload_carries_both_shapes_the_selector_routes_on() {
let (status, _ct, body) =
get_app(explorer_router(multi_set(), None), "/v1/graph/workspaces").await;
assert_eq!(status, StatusCode::OK);
let arr: Value = serde_json::from_str(&body).unwrap();
let arr = arr.as_array().unwrap();
assert_eq!(arr.len(), 2, "both workspaces are offered in the selector");
let linked = arr.iter().find(|w| w["name"] == "linked").unwrap();
assert_eq!(linked["linked"], true);
assert!(
linked["projects"].as_array().unwrap().len() > 1,
"a hub has more than one project → routes to the cross-repo view"
);
let solo = arr.iter().find(|w| w["name"] == "solo").unwrap();
assert_eq!(solo["linked"], false);
assert_eq!(
solo["projects"].as_array().unwrap().len(),
1,
"a standalone repo has exactly one project → drills straight in"
);
}
#[tokio::test]
async fn standalone_only_is_a_single_one_project_workspace_for_auto_enter() {
let (status, _ct, body) = get_app(
explorer_router(standalone_only_set(), Some("solo")),
"/v1/graph/workspaces",
)
.await;
assert_eq!(status, StatusCode::OK);
let arr: Value = serde_json::from_str(&body).unwrap();
let arr = arr.as_array().unwrap();
assert_eq!(arr.len(), 1, "a single workspace → auto-enter, no selector");
assert_eq!(arr[0]["linked"], false);
assert_eq!(arr[0]["projects"].as_array().unwrap().len(), 1);
}
#[tokio::test]
async fn single_multi_repo_workspace_auto_enters_the_cross_repo_view() {
let (status, _ct, body) = get_app(
explorer_router(single_set(linked_workspace()), None),
"/v1/graph/workspaces",
)
.await;
assert_eq!(status, StatusCode::OK);
let arr: Value = serde_json::from_str(&body).unwrap();
let arr = arr.as_array().unwrap();
assert_eq!(arr.len(), 1, "a single workspace → auto-enter, no selector");
assert!(
arr[0]["projects"].as_array().unwrap().len() > 1,
"a lone hub still routes to the cross-repo workspace view"
);
}
}