use std::path::{Path, PathBuf};
use anyhow::{bail, Result};
use serde_json::{json, Value};
use crate::config::Loaded;
use crate::warehouse::dep_graph::WorkspaceGraph;
use crate::warehouse::iceberg::IcebergWarehouse;
use crate::workspace::descriptor::WorkspaceDescriptor;
pub fn resolve_descriptor(loaded: &Loaded) -> Result<PathBuf> {
resolve_descriptor_at(&loaded.workspace_root, &loaded.config_path)
}
pub fn resolve_descriptor_at(workspace_root: &Path, config_path: &Path) -> Result<PathBuf> {
let mut candidates = vec![
workspace_root.join("nornir-workspace.toml"),
workspace_root.join("workspace_holger/nornir-workspace.toml"),
];
if let Some(dir) = config_path.parent() {
candidates.push(dir.join("nornir-workspace.toml"));
}
for c in &candidates {
if c.exists() {
return Ok(c.clone());
}
}
bail!(
"no nornir-workspace.toml found (set NORNIR_WORKSPACE or create one); searched: {}",
candidates.iter().map(|p| p.display().to_string()).collect::<Vec<_>>().join(", ")
)
}
pub fn build_graph(loaded: &Loaded) -> Result<(WorkspaceGraph, String)> {
build_graph_at(&loaded.workspace_root, &loaded.config_path)
}
pub fn build_graph_at(workspace_root: &Path, config_path: &Path) -> Result<(WorkspaceGraph, String)> {
let path = resolve_descriptor_at(workspace_root, config_path)?;
let desc = WorkspaceDescriptor::load(&path)?;
let graph = WorkspaceGraph::build(&desc)?;
let name = desc.workspace.name.clone();
Ok((graph, name))
}
pub fn build_graph_from_warehouse(
wh: &IcebergWarehouse,
workspace_name: &str,
members: &[String],
) -> Result<Option<WorkspaceGraph>> {
use crate::warehouse::dep_graph::{query_dep_graph_snapshots, RepoFacts};
use std::collections::BTreeMap;
let snaps = wh.block_on(query_dep_graph_snapshots(wh, workspace_name, Some(1)))?;
let edges: Vec<_> = snaps
.into_iter()
.next_back()
.map(|s| s.edges)
.unwrap_or_default()
.into_iter()
.filter(|e| !e.from.is_empty() && !e.to.is_empty())
.collect();
if edges.is_empty() && members.is_empty() {
return Ok(None);
}
let mut facts: BTreeMap<String, RepoFacts> = BTreeMap::new();
for m in members {
if m.is_empty() {
continue;
}
facts.insert(
m.clone(),
RepoFacts {
name: m.clone(),
root: Default::default(),
produces: Default::default(),
consumes: Default::default(),
},
);
}
let graph = WorkspaceGraph::from_query_parts(facts, edges);
Ok(Some(graph))
}
fn ensure_repo(g: &WorkspaceGraph, repo: &str) -> Result<()> {
if g.has_component(repo) {
Ok(())
} else {
let known = g.component_names();
bail!("unknown repo `{repo}`; known repos: {}", known.join(", "))
}
}
pub fn deps_of(g: &WorkspaceGraph, repo: &str, transitive: bool) -> Result<Value> {
ensure_repo(g, repo)?;
Ok(if transitive {
json!({
"repo": repo, "transitive": true,
"dependencies": g.deps_transitive(repo).into_iter().collect::<Vec<_>>(),
})
} else {
let direct: Vec<_> = g
.dependencies_of(repo)
.into_iter()
.map(|e| json!({ "repo": e.to, "via": e.via.iter().cloned().collect::<Vec<_>>() }))
.collect();
json!({ "repo": repo, "transitive": false, "dependencies": direct })
})
}
pub fn dependents_of(g: &WorkspaceGraph, repo: &str, transitive: bool) -> Result<Value> {
ensure_repo(g, repo)?;
Ok(if transitive {
json!({
"repo": repo, "transitive": true,
"dependents": g.dependents_transitive(repo).into_iter().collect::<Vec<_>>(),
})
} else {
let direct: Vec<_> = g
.dependents_of(repo)
.into_iter()
.map(|e| json!({ "repo": e.from, "via": e.via.iter().cloned().collect::<Vec<_>>() }))
.collect();
json!({ "repo": repo, "transitive": false, "dependents": direct })
})
}
pub fn affected_by_change(g: &WorkspaceGraph, repos: &[String]) -> Result<Value> {
for r in repos {
ensure_repo(g, r)?;
}
Ok(json!({ "changed": repos, "affected": g.affected_by_change(repos) }))
}
pub fn build_order(g: &WorkspaceGraph) -> Result<Value> {
Ok(json!({ "build_order": g.build_order()? }))
}
pub fn dep_path(g: &WorkspaceGraph, from: &str, to: &str) -> Result<Value> {
ensure_repo(g, from)?;
ensure_repo(g, to)?;
Ok(match g.dep_path(from, to) {
Some(path) => {
let hops: Vec<_> = path
.windows(2)
.map(|w| {
let via: Vec<String> = g
.dependencies_of(&w[0])
.into_iter()
.find(|e| e.to == w[1])
.map(|e| e.via.iter().cloned().collect())
.unwrap_or_default();
json!({ "from": w[0], "to": w[1], "via": via })
})
.collect();
json!({ "from": from, "to": to, "path": path, "hops": hops })
}
None => json!({ "from": from, "to": to, "path": null, "hops": [] }),
})
}
pub fn external_dep_users(g: &WorkspaceGraph, krate: &str) -> Value {
json!({ "crate": krate, "users": g.external_dep_users(krate) })
}
pub fn repo_overview(g: &WorkspaceGraph, wh: &IcebergWarehouse, repo: &str) -> Result<Value> {
ensure_repo(g, repo)?;
let depends_on: Vec<_> = g
.dependencies_of(repo)
.into_iter()
.map(|e| json!({ "repo": e.to, "via": e.via.iter().cloned().collect::<Vec<_>>() }))
.collect();
let dependents: Vec<_> = g
.dependents_of(repo)
.into_iter()
.map(|e| json!({ "repo": e.from, "via": e.via.iter().cloned().collect::<Vec<_>>() }))
.collect();
let build_order_index = g
.build_order()
.ok()
.and_then(|order| order.iter().position(|r| r == repo));
let knowledge = crate::knowledge::query::load_latest(wh, repo).ok().map(|view| {
let sample: Vec<String> = view.symbols.iter().take(15).map(|s| s.item_name.clone()).collect();
json!({
"symbols": view.symbols.len(),
"call_edges": view.calls.len(),
"sample_symbols": sample,
})
});
Ok(json!({
"repo": repo,
"depends_on": depends_on,
"dependents": dependents,
"build_order_index": build_order_index,
"knowledge": knowledge,
}))
}
pub fn svg(g: &WorkspaceGraph) -> String {
use std::collections::HashMap;
use std::fmt::Write;
let nodes = g.component_names();
if nodes.is_empty() {
return String::from(
"<svg xmlns=\"http://www.w3.org/2000/svg\" width=\"160\" height=\"40\">\
<text x=\"8\" y=\"24\" font-family=\"sans-serif\" font-size=\"12\">(empty graph)</text></svg>\n",
);
}
let idx: HashMap<&str, usize> =
nodes.iter().enumerate().map(|(i, s)| (s.as_str(), i)).collect();
let n = nodes.len();
let mut col = vec![0usize; n];
for _ in 0..n {
let mut changed = false;
for e in &g.edges {
if let (Some(&f), Some(&t)) = (idx.get(e.from.as_str()), idx.get(e.to.as_str())) {
if f != t && col[t] <= col[f] {
col[t] = col[f] + 1;
changed = true;
}
}
}
if !changed {
break;
}
}
let cols = col.iter().copied().max().unwrap_or(0) + 1;
let mut next_row = vec![0usize; cols];
let mut row = vec![0usize; n];
for i in 0..n {
row[i] = next_row[col[i]];
next_row[col[i]] += 1;
}
let rows = next_row.iter().copied().max().unwrap_or(1).max(1);
let col_w = 200.0f64;
let row_h = 44.0f64;
let box_w = 150.0f64;
let box_h = 26.0f64;
let margin = 14.0f64;
let width = margin * 2.0 + col_w * cols as f64;
let height = margin * 2.0 + row_h * rows as f64;
let pos = |i: usize| -> (f64, f64) {
(margin + col[i] as f64 * col_w, margin + row[i] as f64 * row_h)
};
let mut out = String::new();
let _ = write!(
out,
"<svg xmlns=\"http://www.w3.org/2000/svg\" width=\"{width:.0}\" height=\"{height:.0}\" \
viewBox=\"0 0 {width:.0} {height:.0}\" font-family=\"sans-serif\" font-size=\"11\">\n"
);
out.push_str(
"<defs><marker id=\"arrow\" markerWidth=\"8\" markerHeight=\"8\" refX=\"7\" refY=\"3\" \
orient=\"auto\"><path d=\"M0,0 L7,3 L0,6 Z\" fill=\"#5a6876\"/></marker></defs>\n",
);
for e in &g.edges {
let (Some(&fi), Some(&ti)) = (idx.get(e.from.as_str()), idx.get(e.to.as_str())) else {
continue;
};
let (fx, fy) = pos(fi);
let (tx, ty) = pos(ti);
let x1 = fx + box_w;
let y1 = fy + box_h / 2.0;
let x2 = tx;
let y2 = ty + box_h / 2.0;
let _ = write!(
out,
"<line x1=\"{x1:.0}\" y1=\"{y1:.0}\" x2=\"{x2:.0}\" y2=\"{y2:.0}\" \
stroke=\"#2850a0\" stroke-width=\"1\" marker-end=\"url(#arrow)\"/>\n"
);
let via: Vec<&str> = e.via.iter().map(String::as_str).collect();
if !via.is_empty() {
let _ = write!(
out,
"<text x=\"{:.0}\" y=\"{:.0}\" text-anchor=\"middle\" fill=\"#5a6876\" font-size=\"9\">{}</text>\n",
(x1 + x2) / 2.0,
(y1 + y2) / 2.0 - 3.0,
svg_escape(&via.join(", ")),
);
}
}
for (i, name) in nodes.iter().enumerate() {
let (x, y) = pos(i);
let _ = write!(
out,
"<rect x=\"{x:.0}\" y=\"{y:.0}\" width=\"{box_w:.0}\" height=\"{box_h:.0}\" rx=\"3\" \
fill=\"#f5f7fc\" stroke=\"#3c3c50\" stroke-width=\"0.7\"/>\n"
);
let _ = write!(
out,
"<text x=\"{:.0}\" y=\"{:.0}\" text-anchor=\"middle\">{}</text>\n",
x + box_w / 2.0,
y + box_h / 2.0 + 4.0,
svg_escape(name),
);
}
out.push_str("</svg>\n");
out
}
fn svg_escape(s: &str) -> String {
s.replace('&', "&")
.replace('<', "<")
.replace('>', ">")
.replace('"', """)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::warehouse::dep_graph::CrossRepoEdge;
use std::collections::BTreeSet;
#[test]
fn svg_escape_handles_xml_specials() {
assert_eq!(svg_escape("a&b"), "a&b");
assert_eq!(svg_escape("x<y>z"), "x<y>z");
assert_eq!(svg_escape("plain"), "plain");
}
fn edge(from: &str, to: &str, via: &[&str]) -> CrossRepoEdge {
CrossRepoEdge {
from: from.into(),
to: to.into(),
via: via.iter().map(|s| s.to_string()).collect(),
}
}
#[test]
fn edges_only_graph_answers_queries_without_facts() {
let edges = vec![
edge("njord", "facett", &["facett"]),
edge("facett", "egui_kernel", &["egui_kernel"]),
];
let g = WorkspaceGraph::from_query_parts(Default::default(), edges);
assert!(g.facts.is_empty(), "fallback graph carries no cargo facts");
assert!(ensure_repo(&g, "njord").is_ok(), "njord is a known component");
assert!(ensure_repo(&g, "nope").is_err(), "unknown repo still rejected");
let direct = deps_of(&g, "njord", false).unwrap();
let deps = direct["dependencies"].as_array().unwrap();
assert_eq!(deps.len(), 1);
assert_eq!(deps[0]["repo"], "facett");
let trans = deps_of(&g, "njord", true).unwrap();
let tset: BTreeSet<String> = trans["dependencies"]
.as_array()
.unwrap()
.iter()
.map(|v| v.as_str().unwrap().to_string())
.collect();
assert!(tset.contains("facett") && tset.contains("egui_kernel"), "closure = {tset:?}");
let users = dependents_of(&g, "facett", false).unwrap();
let u = users["dependents"].as_array().unwrap();
assert!(u.iter().any(|v| v["repo"] == "njord"), "njord depends on facett");
let svg = svg(&g);
assert!(svg.starts_with("<svg"), "svg root: {svg}");
assert!(!svg.contains("mermaid"), "no mermaid: {svg}");
for repo in ["njord", "facett", "egui_kernel"] {
assert!(svg.contains(&format!(">{repo}</text>")), "svg missing node {repo}: {svg}");
}
assert!(svg.contains("<line "), "svg has the njord→facett edge");
}
#[test]
fn single_repo_workspace_resolves_via_member_seed() {
let mut facts = std::collections::BTreeMap::new();
facts.insert(
"njord".to_string(),
crate::warehouse::dep_graph::RepoFacts {
name: "njord".into(),
root: Default::default(),
produces: Default::default(),
consumes: Default::default(),
},
);
let g = WorkspaceGraph::from_query_parts(facts, Vec::new());
assert!(g.has_component("njord"), "seeded member is a known component");
assert!(ensure_repo(&g, "njord").is_ok(), "deps-of njord must not error");
let d = deps_of(&g, "njord", false).unwrap();
assert_eq!(d["dependencies"].as_array().unwrap().len(), 0, "njord has no deps");
assert!(svg(&g).contains(">njord</text>"), "svg declares njord");
}
}