codesearch 0.1.15

A fast, intelligent CLI tool with multiple search modes (regex, fuzzy, semantic), code analysis, and dead code detection for popular programming languages
Documentation
//! SVG Graph Export Module
//!
//! Generates interactive, self-contained SVG visualizations of code graphs.
//! Pure Rust — no JavaScript or web stack required.

use std::collections::HashMap;

pub const NODE_WIDTH: f64 = 180.0;
pub const NODE_HEIGHT: f64 = 50.0;
pub const HORIZONTAL_GAP: f64 = 60.0;
pub const VERTICAL_GAP: f64 = 80.0;
pub const MARGIN: f64 = 40.0;

#[derive(Debug, Clone)]
pub struct SvgNode {
    pub id: String,
    pub label: String,
    pub x: f64,
    pub y: f64,
    pub width: f64,
    pub height: f64,
    pub fill: String,
    pub stroke: String,
    pub text_color: String,
    pub detail: Option<String>,
}

#[derive(Debug, Clone)]
pub struct SvgEdge {
    pub id: String,
    pub source: String,
    pub target: String,
    pub label: Option<String>,
    pub color: String,
}

/// Simple hierarchical layout engine.
/// Assigns nodes to levels via topological sort, then positions them in tiers.
pub struct LayoutEngine {
    pub nodes: Vec<SvgNode>,
    pub edges: Vec<SvgEdge>,
}

impl Default for LayoutEngine {
    fn default() -> Self {
        Self::new()
    }
}

impl LayoutEngine {
    pub fn new() -> Self {
        Self {
            nodes: Vec::new(),
            edges: Vec::new(),
        }
    }

    pub fn layout(&mut self) {
        if self.nodes.is_empty() {
            return;
        }

        // Build adjacency and compute levels via BFS from roots
        let mut outgoing: HashMap<String, Vec<String>> = HashMap::new();
        let mut incoming: HashMap<String, Vec<String>> = HashMap::new();
        for edge in &self.edges {
            outgoing
                .entry(edge.source.clone())
                .or_default()
                .push(edge.target.clone());
            incoming
                .entry(edge.target.clone())
                .or_default()
                .push(edge.source.clone());
        }

        // Compute level for each node (longest path from any root)
        let mut levels: HashMap<String, usize> = HashMap::new();
        let mut queue: Vec<String> = Vec::new();

        for node in &self.nodes {
            let has_incoming = incoming.contains_key(&node.id) && !incoming[&node.id].is_empty();
            if !has_incoming {
                levels.insert(node.id.clone(), 0);
                queue.push(node.id.clone());
            }
        }

        // If no roots (cycle), pick arbitrary starting nodes
        if queue.is_empty() {
            for node in &self.nodes {
                levels.insert(node.id.clone(), 0);
                queue.push(node.id.clone());
            }
        }

        let mut processed = 0;
        while processed < queue.len() {
            let current = queue[processed].clone();
            processed += 1;
            let current_level = *levels.get(&current).unwrap_or(&0);
            if let Some(neighbors) = outgoing.get(&current) {
                for neighbor in neighbors {
                    let entry = levels.entry(neighbor.clone()).or_insert(0);
                    *entry = (*entry).max(current_level + 1);
                    if !queue.contains(neighbor) {
                        queue.push(neighbor.clone());
                    }
                }
            }
        }

        // Ensure all nodes have a level
        for node in &self.nodes {
            levels.entry(node.id.clone()).or_insert(0);
        }

        // Group nodes by level
        let max_level = levels.values().copied().max().unwrap_or(0);
        let mut level_groups: Vec<Vec<String>> = vec![Vec::new(); max_level + 1];
        for (id, level) in &levels {
            level_groups[*level].push(id.clone());
        }

        // Position nodes
        let node_positions: HashMap<String, (f64, f64)> = self
            .nodes
            .iter()
            .map(|n| {
                let level = *levels.get(&n.id).unwrap_or(&0);
                let nodes_in_level = &level_groups[level];
                let index = nodes_in_level.iter().position(|x| x == &n.id).unwrap_or(0);
                let _total_width =
                    nodes_in_level.len() as f64 * (NODE_WIDTH + HORIZONTAL_GAP) - HORIZONTAL_GAP;
                let start_x = MARGIN + (index as f64) * (NODE_WIDTH + HORIZONTAL_GAP);
                let x = start_x;
                let y = MARGIN + (level as f64) * (NODE_HEIGHT + VERTICAL_GAP);
                (n.id.clone(), (x, y))
            })
            .collect();

        for node in &mut self.nodes {
            if let Some(&(x, y)) = node_positions.get(&node.id) {
                node.x = x;
                node.y = y;
            }
        }
    }

    pub fn to_svg(&self) -> String {
        if self.nodes.is_empty() {
            return r###"<svg xmlns="http://www.w3.org/2000/svg" width="400" height="200" viewBox="0 0 400 200">
  <rect width="100%" height="100%" fill="#0f172a"/>
  <text x="50%" y="50%" text-anchor="middle" fill="#94a3b8" font-family="sans-serif" font-size="14">Empty graph</text>
</svg>"###.to_string();
        }

        let max_x = self.nodes.iter().map(|n| n.x + n.width).fold(0.0, f64::max) + MARGIN;
        let max_y = self
            .nodes
            .iter()
            .map(|n| n.y + n.height)
            .fold(0.0, f64::max)
            + MARGIN;

        let mut svg = String::new();
        svg.push_str(&format!(
            r##"<svg xmlns="http://www.w3.org/2000/svg" width="{}" height="{}" viewBox="0 0 {} {}">
"##,
            max_x as i32, max_y as i32, max_x as i32, max_y as i32
        ));

        // Background
        svg.push_str(
            r##"  <rect width="100%" height="100%" fill="#0f172a"/>
"##,
        );

        // Defs for arrow marker
        svg.push_str(
            r##"  <defs>
    <marker id="arrowhead" markerWidth="10" markerHeight="7" refX="9" refY="3.5" orient="auto">
      <polygon points="0 0, 10 3.5, 0 7" fill="#64748b"/>
    </marker>
  </defs>
"##,
        );

        // Edges
        let node_map: HashMap<String, &SvgNode> =
            self.nodes.iter().map(|n| (n.id.clone(), n)).collect();
        for edge in &self.edges {
            if let (Some(src), Some(dst)) = (node_map.get(&edge.source), node_map.get(&edge.target))
            {
                let x1 = src.x + src.width / 2.0;
                let y1 = src.y + src.height;
                let x2 = dst.x + dst.width / 2.0;
                let y2 = dst.y;

                // Simple curved path
                let mid_y = (y1 + y2) / 2.0;
                svg.push_str(&format!(
                    r##"  <path d="M {:.1} {:.1} C {:.1} {:.1}, {:.1} {:.1}, {:.1} {:.1}" fill="none" stroke="{}" stroke-width="1.5" marker-end="url(#arrowhead)"/>
"##,
                    x1, y1, x1, mid_y, x2, mid_y, x2, y2 - 2.0, edge.color
                ));

                if let Some(ref label) = edge.label {
                    let lx = (x1 + x2) / 2.0;
                    let ly = mid_y;
                    svg.push_str(&format!(
                        r##"  <rect x="{:.1}" y="{:.1}" width="{}" height="16" rx="3" fill="#0f172a" stroke="none"/>
  <text x="{:.1}" y="{:.1}" text-anchor="middle" fill="#94a3b8" font-family="sans-serif" font-size="10">{}</text>
"##,
                        lx - label.len() as f64 * 3.0,
                        ly - 8.0,
                        label.len() * 6 + 8,
                        lx,
                        ly + 3.0,
                        escape_xml(label)
                    ));
                }
            }
        }

        // Nodes
        for node in &self.nodes {
            let rx = 6.0;
            // Shadow
            svg.push_str(&format!(
                r##"  <rect x="{:.1}" y="{:.1}" width="{}" height="{}" rx="{:.1}" fill="#000000" opacity="0.3"/>
"##,
                node.x + 2.0,
                node.y + 2.0,
                node.width as i32,
                node.height as i32,
                rx
            ));
            // Main rect
            svg.push_str(&format!(
                r##"  <rect x="{:.1}" y="{:.1}" width="{}" height="{}" rx="{:.1}" fill="{}" stroke="{}" stroke-width="2"/>
"##,
                node.x,
                node.y,
                node.width as i32,
                node.height as i32,
                rx,
                node.fill,
                node.stroke
            ));

            // Truncate label if too long
            let max_chars = ((node.width - 20.0) / 7.0) as usize;
            let display_label = if node.label.len() > max_chars {
                format!("{}...", &node.label[..max_chars.saturating_sub(3)])
            } else {
                node.label.clone()
            };

            svg.push_str(&format!(
                r##"  <text x="{:.1}" y="{:.1}" text-anchor="middle" fill="{}" font-family="ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace" font-size="12" font-weight="600">{}</text>
"##,
                node.x + node.width / 2.0,
                node.y + node.height / 2.0 + 4.0,
                node.text_color,
                escape_xml(&display_label)
            ));

            // Tooltip via title element
            if let Some(ref detail) = node.detail {
                let tooltip = format!("{}\n{}", node.label, detail);
                svg.push_str(&format!(
                    r##"  <title>{}</title>
"##,
                    escape_xml(&tooltip)
                ));
            } else {
                svg.push_str(&format!(
                    r##"  <title>{}</title>
"##,
                    escape_xml(&node.label)
                ));
            }
        }

        svg.push_str("</svg>\n");
        svg
    }
}

fn escape_xml(s: &str) -> String {
    s.replace('&', "&amp;")
        .replace('<', "&lt;")
        .replace('>', "&gt;")
        .replace('"', "&quot;")
}

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

    #[test]
    fn test_empty_svg() {
        let engine = LayoutEngine::new();
        let svg = engine.to_svg();
        assert!(svg.contains("Empty graph"));
    }

    #[test]
    fn test_layout_and_svg() {
        let mut engine = LayoutEngine::new();
        engine.nodes.push(SvgNode {
            id: "a".to_string(),
            label: "main".to_string(),
            x: 0.0,
            y: 0.0,
            width: NODE_WIDTH,
            height: NODE_HEIGHT,
            fill: "#1e3a5f".to_string(),
            stroke: "#3b82f6".to_string(),
            text_color: "#e2e8f0".to_string(),
            detail: None,
        });
        engine.nodes.push(SvgNode {
            id: "b".to_string(),
            label: "helper".to_string(),
            x: 0.0,
            y: 0.0,
            width: NODE_WIDTH,
            height: NODE_HEIGHT,
            fill: "#1e3a5f".to_string(),
            stroke: "#3b82f6".to_string(),
            text_color: "#e2e8f0".to_string(),
            detail: None,
        });
        engine.edges.push(SvgEdge {
            id: "e1".to_string(),
            source: "a".to_string(),
            target: "b".to_string(),
            label: Some("calls".to_string()),
            color: "#64748b".to_string(),
        });

        engine.layout();
        let svg = engine.to_svg();
        assert!(svg.contains("main"));
        assert!(svg.contains("helper"));
        assert!(svg.contains("calls"));
        assert!(svg.contains("<svg"));
    }
}