nornir 0.4.34

Companion to cargo: dependency tracking, release gating, deploy, benchmarks, and documentation assembly. Project-agnostic.
//! Code introspection — derive structural docs from the *built* code.
//!
//! All extraction targets the compiled / parsed artifact, never
//! hand-maintained sources. The published documentation is then a
//! pure function of the code:
//!
//! ```text
//!     source code  ──cargo──►  rustdoc JSON ┐
//!     workspace    ──cargo──►  metadata     ├─► nornir::introspect ─► graphs
//!     src/**/*.rs  ──syn ───►  AST          ┘                       │
//!//!                                                             nornir::docs
//!//!//!                                                    README / CLAUDE / docs/*
//! ```

pub mod api;
pub mod artifact;
pub mod callgraph;
pub mod callgraph_dwarf;
pub mod callgraph_llvm;
pub mod depgraph;
pub mod persist;

use serde::{Deserialize, Serialize};

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Edge {
    pub from: String,
    pub to: String,
    pub kind: EdgeKind,
}

#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
pub enum EdgeKind {
    Calls,
    DependsOn,
    Reexports,
    Implements,
}

#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct Graph {
    pub nodes: Vec<String>,
    pub edges: Vec<Edge>,
}

impl Graph {
    /// Render the graph as a **self-contained, static SVG** — a layered
    /// left-to-right DAG. No JavaScript, no diagram engine, no build step:
    /// the markup is emitted directly in Rust so it is available in every
    /// feature set (no `docs-export`/typst dependency) and renders verbatim
    /// in any markdown/web viewer (NUKE-MERMAID house style).
    pub fn to_svg(&self) -> String {
        render_svg(&self.nodes, &self.edges)
    }

    /// Render the graph as a plain-text adjacency list — the no-pixels
    /// fallback used where an inline SVG is undesirable (e.g. terminal output
    /// or `.nornir/` source docs). Nodes first, then `from → to` edges with the
    /// edge kind noted inline.
    pub fn to_text(&self) -> String {
        let mut s = String::new();
        s.push_str("nodes:\n");
        for n in &self.nodes {
            s.push_str("  ");
            s.push_str(n);
            s.push('\n');
        }
        if self.edges.is_empty() {
            s.push_str("edges: (none)\n");
        } else {
            s.push_str("edges:\n");
            for e in &self.edges {
                let kind = match e.kind {
                    EdgeKind::Calls => "calls",
                    EdgeKind::DependsOn => "depends-on",
                    EdgeKind::Reexports => "reexports",
                    EdgeKind::Implements => "implements",
                };
                s.push_str(&format!("  {}{} ({kind})\n", e.from, e.to));
            }
        }
        s
    }
}

/// XML-escape a label for safe inclusion in SVG text.
fn xml_escape(s: &str) -> String {
    s.replace('&', "&amp;")
        .replace('<', "&lt;")
        .replace('>', "&gt;")
        .replace('"', "&quot;")
}

/// Compute layered columns (BFS levels) for a layered-DAG layout. Layer 0 holds
/// the sources (no incoming edge); cycles are broken deterministically so the
/// output is idempotent.
fn svg_layers(nodes: &[String], edges: &[Edge]) -> Vec<Vec<usize>> {
    use std::collections::{HashMap, HashSet};
    let n = nodes.len();
    let idx: HashMap<&str, usize> =
        nodes.iter().enumerate().map(|(i, s)| (s.as_str(), i)).collect();
    let mut adj: Vec<Vec<usize>> = vec![Vec::new(); n];
    let mut indeg: Vec<usize> = vec![0; n];
    for e in edges {
        if let (Some(&f), Some(&t)) = (idx.get(e.from.as_str()), idx.get(e.to.as_str())) {
            if f != t {
                adj[f].push(t);
                indeg[t] += 1;
            }
        }
    }
    let mut layer_of = vec![0usize; n];
    let mut remaining: HashSet<usize> = (0..n).collect();
    let mut level = 0usize;
    while !remaining.is_empty() {
        let ready: Vec<usize> =
            remaining.iter().copied().filter(|&i| indeg[i] == 0).collect();
        if ready.is_empty() {
            for &i in &remaining {
                layer_of[i] = level;
            }
            break;
        }
        for &i in &ready {
            layer_of[i] = level;
            remaining.remove(&i);
        }
        for &i in &ready {
            for &j in &adj[i] {
                if indeg[j] > 0 {
                    indeg[j] -= 1;
                }
            }
        }
        level += 1;
    }
    let max_level = *layer_of.iter().max().unwrap_or(&0);
    let mut layers: Vec<Vec<usize>> = vec![Vec::new(); max_level + 1];
    let mut order: Vec<usize> = (0..n).collect();
    order.sort_by(|&a, &b| nodes[a].cmp(&nodes[b]));
    for i in order {
        layers[layer_of[i]].push(i);
    }
    layers
}

/// Emit a self-contained SVG for a layered DAG. Pure string building — no
/// external renderer.
pub(crate) fn render_svg(nodes: &[String], edges: &[Edge]) -> String {
    use std::collections::HashMap;
    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 layers = svg_layers(nodes, edges);
    let col_w = 180.0f64;
    let row_h = 40.0f64;
    let box_w = 150.0f64;
    let box_h = 24.0f64;
    let margin = 14.0f64;

    let cols = layers.len().max(1);
    let rows = layers.iter().map(|l| l.len()).max().unwrap_or(1).max(1);
    let width = margin * 2.0 + col_w * cols as f64;
    let height = margin * 2.0 + row_h * rows as f64;

    let mut pos: HashMap<usize, (f64, f64)> = HashMap::new();
    for (ci, layer) in layers.iter().enumerate() {
        let total_h = layer.len() as f64 * row_h;
        let start_y = margin + (height - margin * 2.0 - total_h) / 2.0;
        for (ri, &node_i) in layer.iter().enumerate() {
            let x = margin + ci as f64 * col_w;
            let y = start_y + ri as f64 * row_h;
            pos.insert(node_i, (x, y));
        }
    }
    let name_to_idx: HashMap<&str, usize> =
        nodes.iter().enumerate().map(|(i, s)| (s.as_str(), i)).collect();

    let mut s = String::new();
    s.push_str(&format!(
        "<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"
    ));
    s.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 edges {
        let (Some(&fi), Some(&ti)) =
            (name_to_idx.get(e.from.as_str()), name_to_idx.get(e.to.as_str()))
        else {
            continue;
        };
        let (Some(&(fx, fy)), Some(&(tx, ty))) = (pos.get(&fi), pos.get(&ti)) else {
            continue;
        };
        let x1 = fx + box_w;
        let y1 = fy + box_h / 2.0;
        let x2 = tx;
        let y2 = ty + box_h / 2.0;
        let dash = match e.kind {
            EdgeKind::DependsOn => " stroke-dasharray=\"4 3\"",
            _ => "",
        };
        let color = match e.kind {
            EdgeKind::Calls => "#2850a0",
            EdgeKind::DependsOn => "#787882",
            EdgeKind::Reexports => "#28823c",
            EdgeKind::Implements => "#a05028",
        };
        s.push_str(&format!(
            "<line x1=\"{x1:.0}\" y1=\"{y1:.0}\" x2=\"{x2:.0}\" y2=\"{y2:.0}\" \
             stroke=\"{color}\" stroke-width=\"1\"{dash} marker-end=\"url(#arrow)\"/>\n"
        ));
    }
    for (i, name) in nodes.iter().enumerate() {
        let Some(&(x, y)) = pos.get(&i) else { continue };
        let label = xml_escape(name);
        s.push_str(&format!(
            "<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"
        ));
        s.push_str(&format!(
            "<text x=\"{:.0}\" y=\"{:.0}\" text-anchor=\"middle\">{label}</text>\n",
            x + box_w / 2.0,
            y + box_h / 2.0 + 4.0,
        ));
    }
    s.push_str("</svg>\n");
    s
}

#[cfg(test)]
mod tests {
    use super::*;

    fn g() -> Graph {
        Graph {
            nodes: vec!["a-1".into(), "b".into(), "c".into()],
            edges: vec![
                Edge { from: "a-1".into(), to: "b".into(), kind: EdgeKind::DependsOn },
                Edge { from: "b".into(), to: "c".into(), kind: EdgeKind::Calls },
            ],
        }
    }

    #[test]
    fn to_svg_is_self_contained_and_has_nodes() {
        let svg = g().to_svg();
        assert!(svg.starts_with("<svg"), "{svg}");
        assert!(svg.contains("</svg>"));
        // No mermaid anywhere; labels are the raw node names (xml-escaped).
        assert!(!svg.contains("mermaid"), "{svg}");
        assert!(svg.contains(">a-1<"), "node label a-1: {svg}");
        assert!(svg.contains(">c<"), "node label c: {svg}");
        // Edges become <line> elements.
        assert!(svg.contains("<line "), "edges: {svg}");
    }

    #[test]
    fn to_svg_empty_graph_placeholder() {
        let svg = Graph::default().to_svg();
        assert!(svg.starts_with("<svg"), "{svg}");
        assert!(svg.contains("empty graph"), "{svg}");
    }

    #[test]
    fn to_text_lists_nodes_and_edges() {
        let t = g().to_text();
        assert!(t.contains("a-1\n"), "{t}");
        assert!(t.contains("a-1 → b (depends-on)"), "{t}");
        assert!(t.contains("b → c (calls)"), "{t}");
        assert!(!t.contains("mermaid"));
    }
}