srcgraph-metrics 0.1.0

Graph-theoretic code-analysis metrics (SCC, LCOM4, betweenness, instability, …)
Documentation
//! Halstead complexity — V (volume), D (difficulty), E (effort), B (bug
//! estimate) computed from the four raw token counts (η₁, η₂, N₁, N₂)
//! already extracted onto each class node.
//!
//!   V = N × log₂(η)              information content in bits
//!   D = (η₁ / 2) × (N₂ / η₂)    mental effort to understand
//!   E = D × V                    total mental effort
//!   B = V / 3000                 expected defects
//!
//! Edge cases mirror the Python reference: η = 0 → V = 0, η₂ = 0 → D = 0.
//!
//! See `DESIGN.md` (Phase 2) at the workspace root.

use petgraph::Graph;
use serde::{Deserialize, Serialize};

use srcgraph_core::{ClassNode, EdgeKind};

/// Halstead readout for a single class.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Halstead {
    pub class_id: String,
    /// Vocabulary η = η₁ + η₂.
    pub eta: u32,
    /// Length N = N₁ + N₂.
    pub n: u32,
    /// Volume in bits.
    pub volume: f64,
    pub difficulty: f64,
    pub effort: f64,
    pub bugs: f64,
}

/// Compute Halstead metrics from raw counts.
pub fn halstead_metrics(eta1: u32, eta2: u32, n1: u32, n2: u32) -> (f64, f64, f64, f64, u32, u32) {
    let eta = eta1 + eta2;
    let n = n1 + n2;
    let volume = if eta > 0 {
        n as f64 * (eta as f64).log2()
    } else {
        0.0
    };
    let difficulty = if eta2 > 0 {
        (eta1 as f64 / 2.0) * (n2 as f64 / eta2 as f64)
    } else {
        0.0
    };
    let effort = difficulty * volume;
    let bugs = volume / 3000.0;
    (volume, difficulty, effort, bugs, eta, n)
}

/// Compute Halstead V/D/E/B for every class node.
pub fn compute_halstead<N, E>(graph: &Graph<N, E>) -> Vec<Halstead>
where
    N: ClassNode,
    E: EdgeKind,
{
    graph
        .node_indices()
        .map(|nx| {
            let node = &graph[nx];
            let (volume, difficulty, effort, bugs, eta, n) = halstead_metrics(
                node.halstead_eta1(),
                node.halstead_eta2(),
                node.halstead_n1(),
                node.halstead_n2(),
            );
            Halstead {
                class_id: node.id().to_owned(),
                eta,
                n,
                volume,
                difficulty,
                effort,
                bugs,
            }
        })
        .collect()
}

#[cfg(test)]
mod tests {
    use super::*;
    use srcgraph_core::{EdgeType, OwnedClassNode, OwnedGraph};
    use petgraph::Graph;

    fn class(id: &str, eta1: u32, eta2: u32, n1: u32, n2: u32) -> OwnedClassNode {
        OwnedClassNode {
            id: id.to_owned(),
            name: id.to_owned(),
            namespace: "test".to_owned(),
            line_count: 10,
            method_count: 1,
            halstead_eta1: eta1,
            halstead_eta2: eta2,
            halstead_n1: n1,
            halstead_n2: n2,
            method_connectivity: None,
            method_fingerprints: None,
            method_tokens: None,
            call_sequences: None,
            cyclomatic_complexity: None,
            path_conditions: None,
            invariants: None,
            error_messages: None,
            magic_numbers: None,
            dead_code: None,
            tenant_branches: None,
            state_transitions: None,
        }
    }

    #[test]
    fn halstead_basic_formula() {
        // Canonical worked example: η₁=10, η₂=7, N₁=27, N₂=15.
        // η=17, N=42, V = 42·log2(17) ≈ 171.6.
        let (v, d, e, b, eta, n) = halstead_metrics(10, 7, 27, 15);
        assert_eq!(eta, 17);
        assert_eq!(n, 42);
        assert!((v - 42.0 * 17_f64.log2()).abs() < 1e-9);
        // D = (10/2) × (15/7) = 5 × 2.142857… ≈ 10.714
        assert!((d - (5.0 * 15.0 / 7.0)).abs() < 1e-9);
        assert!((e - d * v).abs() < 1e-9);
        assert!((b - v / 3000.0).abs() < 1e-9);
    }

    #[test]
    fn halstead_zero_vocabulary_yields_zero_volume() {
        let (v, d, e, b, eta, n) = halstead_metrics(0, 0, 0, 0);
        assert_eq!(eta, 0);
        assert_eq!(n, 0);
        assert_eq!(v, 0.0);
        assert_eq!(d, 0.0);
        assert_eq!(e, 0.0);
        assert_eq!(b, 0.0);
    }

    #[test]
    fn halstead_zero_distinct_operands_difficulty_zero() {
        // No operands at all — D undefined, treated as 0 per Python reference.
        let (v, d, ..) = halstead_metrics(3, 0, 5, 0);
        assert!(v > 0.0);
        assert_eq!(d, 0.0);
    }

    #[test]
    fn halstead_walks_graph() {
        let mut g: OwnedGraph = Graph::new();
        let a = g.add_node(class("A", 10, 7, 27, 15));
        let b = g.add_node(class("B", 0, 0, 0, 0)); // syntax-only / no data
        g.add_edge(a, b, EdgeType::MethodCall);

        let rows = compute_halstead(&g);
        assert_eq!(rows.len(), 2);

        let ra = rows.iter().find(|r| r.class_id == "A").unwrap();
        assert_eq!(ra.eta, 17);
        assert_eq!(ra.n, 42);
        assert!(ra.volume > 0.0);

        let rb = rows.iter().find(|r| r.class_id == "B").unwrap();
        assert_eq!(rb.eta, 0);
        assert_eq!(rb.volume, 0.0);
        assert_eq!(rb.difficulty, 0.0);
    }
}