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 mermaid(g: &WorkspaceGraph) -> String {
use std::fmt::Write;
let mut out = String::from("graph LR\n");
for name in g.component_names() {
let _ = writeln!(out, " {}[\"{}\"]", mermaid_id(&name), name);
}
for e in &g.edges {
let via: Vec<&str> = e.via.iter().map(String::as_str).collect();
let _ = writeln!(
out,
" {} -->|\"{}\"| {}",
mermaid_id(&e.from),
via.join(", "),
mermaid_id(&e.to),
);
}
out
}
fn mermaid_id(name: &str) -> String {
name.chars().map(|c| if c.is_ascii_alphanumeric() { c } else { '_' }).collect()
}
#[cfg(test)]
mod tests {
use super::*;
use crate::warehouse::dep_graph::CrossRepoEdge;
use std::collections::BTreeSet;
#[test]
fn mermaid_id_sanitizes_non_alnum() {
assert_eq!(mermaid_id("snippy-core"), "snippy_core");
assert_eq!(mermaid_id("a.b/c"), "a_b_c");
assert_eq!(mermaid_id("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 mm = mermaid(&g);
for repo in ["njord", "facett", "egui_kernel"] {
assert!(mm.contains(&format!("[\"{repo}\"]")), "mermaid missing node {repo}: {mm}");
}
assert!(mm.contains("njord -->"), "mermaid 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!(mermaid(&g).contains("[\"njord\"]"), "mermaid declares njord");
}
}