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))
}
fn ensure_repo(g: &WorkspaceGraph, repo: &str) -> Result<()> {
if g.facts.contains_key(repo) {
Ok(())
} else {
let known: Vec<&str> = g.facts.keys().map(String::as_str).collect();
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.facts.keys() {
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::mermaid_id;
#[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");
}
}