Skip to main content

code_split_core/
graph.rs

1use serde::{Deserialize, Serialize};
2use std::collections::HashSet;
3
4fn round_sig3(x: f64) -> f64 {
5    if !x.is_finite() {
6        return 0.0; // NaN / ±Inf → 0 (JSON has no NaN, serde_json would emit null)
7    }
8    if x == 0.0 {
9        return 0.0;
10    }
11    let abs = x.abs();
12    let sign = if x < 0.0 { -1.0 } else { 1.0 };
13    let truncated = if abs >= 1.0 {
14        // truncate to 3 decimal places
15        (abs * 1000.0).floor() / 1000.0
16    } else {
17        // truncate to 3 significant digits after leading zeros (3 sig figs total)
18        let d = abs.log10().floor() as i32;
19        let factor = 10f64.powi(2 - d);
20        (abs * factor).floor() / factor
21    };
22    truncated * sign
23}
24
25fn sig3<S: serde::Serializer>(v: &f64, s: S) -> Result<S::Ok, S::Error> {
26    let x = round_sig3(*v);
27    if x.fract() == 0.0 && x.abs() < i64::MAX as f64 {
28        s.serialize_i64(x as i64)
29    } else {
30        s.serialize_f64(x)
31    }
32}
33
34fn is_zero_f64(v: &f64) -> bool {
35    *v == 0.0
36}
37
38fn is_zero_u32(v: &u32) -> bool {
39    *v == 0
40}
41
42pub type NodeId = String;
43
44#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
45#[serde(rename_all = "snake_case")]
46pub enum NodeKind {
47    /// A source file — the only node kind that appears in snapshot output.
48    File,
49    /// An external dependency (library / crate), recorded at depth 1: one node
50    /// per library, never expanded into its internals.
51    External,
52    /// Internal-only kinds used by the Rust syntactic stage (`code-split-syn`)
53    /// while building the module tree. They are collapsed into `File`/`External`
54    /// by the Rust plugin before the snapshot is written and never serialized.
55    Crate,
56    Module,
57    Trait,
58}
59
60/// Structural cycle kind assigned to every node that participates in an SCC
61/// of size ≥ 2 in its projected graph.
62#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
63#[serde(rename_all = "snake_case")]
64pub enum CycleKind {
65    /// Rust-specific: a `#[cfg(test)] mod tests { use super::* }` pattern.
66    /// The parent→child `contains` edge combined with the child→parent `uses`
67    /// edge forms a cycle that is a language feature, not an architecture smell.
68    TestEmbed,
69    /// Two nodes that directly depend on each other (SCC size = 2, no test node).
70    Mutual,
71    /// Three or more nodes in a dependency cycle (no test node).
72    Chain,
73}
74
75/// One strongly-connected component with ≥ 2 nodes, together with its
76/// classification.
77#[derive(Debug, Clone, Serialize, Deserialize)]
78pub struct CycleGroup {
79    pub kind: CycleKind,
80    pub nodes: Vec<NodeId>,
81}
82
83/// Coupling averages stored in `GraphStats` (f64 counterpart of `Coupling`).
84#[derive(Debug, Clone, Serialize, Deserialize, Default)]
85pub struct AvgCoupling {
86    #[serde(default, serialize_with = "sig3", skip_serializing_if = "is_zero_f64")]
87    pub fan_in: f64,
88    #[serde(default, serialize_with = "sig3", skip_serializing_if = "is_zero_f64")]
89    pub fan_out: f64,
90    #[serde(default, serialize_with = "sig3", skip_serializing_if = "is_zero_f64")]
91    pub hk: f64,
92}
93
94/// Per-graph average metrics, mirroring the `complexity` node structure.
95#[derive(Debug, Clone, Serialize, Deserialize, Default)]
96pub struct GraphStats {
97    #[serde(default, serialize_with = "sig3", skip_serializing_if = "is_zero_f64")]
98    pub cyclomatic: f64,
99    #[serde(default, serialize_with = "sig3", skip_serializing_if = "is_zero_f64")]
100    pub cognitive: f64,
101    #[serde(default, skip_serializing_if = "Option::is_none")]
102    pub coupling: Option<AvgCoupling>,
103    #[serde(default, skip_serializing_if = "Option::is_none")]
104    pub maintainability: Option<Maintainability>,
105    #[serde(default, skip_serializing_if = "Option::is_none")]
106    pub loc: Option<Loc>,
107    #[serde(default, skip_serializing_if = "Option::is_none")]
108    pub halstead: Option<Halstead>,
109}
110
111#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
112#[serde(rename_all = "snake_case")]
113pub enum EdgeKind {
114    /// Internal-only: structural ownership in the Rust module tree. Used during
115    /// construction/collapse; not present between files in snapshot output.
116    Contains,
117    Uses,
118    Reexports,
119}
120
121/// Visibility of a node. Serialised as a plain string for simple variants,
122/// or as `{"restricted": "<path>"}` for the `Restricted` variant.
123///
124/// Deserialisation supports both:
125///   - new format: `"public"`, `"private"`, `"crate"`, `"super"`,
126///     `{"restricted": "some::path"}`
127///   - old (tagged) format: `{"kind": "public"}`, `{"kind": "restricted",
128///     "path": "some::path"}`
129#[derive(Debug, Clone, PartialEq, Eq)]
130pub enum Visibility {
131    Public,
132    Crate,
133    Super,
134    Restricted { path: String },
135    Private,
136}
137
138impl Serialize for Visibility {
139    fn serialize<S: serde::Serializer>(&self, s: S) -> Result<S::Ok, S::Error> {
140        use serde::ser::SerializeMap;
141        match self {
142            Visibility::Public => s.serialize_str("public"),
143            Visibility::Private => s.serialize_str("private"),
144            Visibility::Crate => s.serialize_str("crate"),
145            Visibility::Super => s.serialize_str("super"),
146            Visibility::Restricted { path } => {
147                let mut map = s.serialize_map(Some(1))?;
148                map.serialize_entry("restricted", path)?;
149                map.end()
150            }
151        }
152    }
153}
154
155impl<'de> Deserialize<'de> for Visibility {
156    fn deserialize<D: serde::Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
157        use serde::de::{self, MapAccess, Visitor};
158        use std::fmt;
159
160        struct VisVisitor;
161
162        impl<'de> Visitor<'de> for VisVisitor {
163            type Value = Visibility;
164
165            fn expecting(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
166                f.write_str(
167                    r#"a visibility string ("public", "private", "crate", "super") \
168                    or an object {"restricted": "<path>"} / {"kind": "<kind>", ...}"#,
169                )
170            }
171
172            // New format: plain string
173            fn visit_str<E: de::Error>(self, v: &str) -> Result<Visibility, E> {
174                match v {
175                    "public" => Ok(Visibility::Public),
176                    "private" => Ok(Visibility::Private),
177                    "crate" => Ok(Visibility::Crate),
178                    "super" => Ok(Visibility::Super),
179                    other => Err(E::unknown_variant(
180                        other,
181                        &["public", "private", "crate", "super"],
182                    )),
183                }
184            }
185
186            // Object format — handles both new `{"restricted": "..."}` and old
187            // tagged `{"kind": "public"}` / `{"kind": "restricted", "path": "..."}`
188            fn visit_map<A: MapAccess<'de>>(self, mut map: A) -> Result<Visibility, A::Error> {
189                let mut kind: Option<String> = None;
190                let mut path: Option<String> = None;
191                let mut restricted: Option<String> = None;
192
193                while let Some(key) = map.next_key::<String>()? {
194                    match key.as_str() {
195                        // new format key
196                        "restricted" => restricted = Some(map.next_value()?),
197                        // old tagged-enum keys
198                        "kind" => kind = Some(map.next_value()?),
199                        "path" => path = Some(map.next_value()?),
200                        _ => {
201                            let _: serde::de::IgnoredAny = map.next_value()?;
202                        }
203                    }
204                }
205
206                if let Some(r) = restricted {
207                    return Ok(Visibility::Restricted { path: r });
208                }
209
210                match kind.as_deref() {
211                    Some("public") => Ok(Visibility::Public),
212                    Some("private") => Ok(Visibility::Private),
213                    Some("crate") => Ok(Visibility::Crate),
214                    Some("super") => Ok(Visibility::Super),
215                    Some("restricted") => {
216                        let p = path.ok_or_else(|| de::Error::missing_field("path"))?;
217                        Ok(Visibility::Restricted { path: p })
218                    }
219                    Some(other) => Err(de::Error::unknown_variant(
220                        other,
221                        &["public", "private", "crate", "super", "restricted"],
222                    )),
223                    None => Err(de::Error::missing_field("kind")),
224                }
225            }
226        }
227
228        d.deserialize_any(VisVisitor)
229    }
230}
231
232// ── Nested complexity sub-types ───────────────────────────────────────────────
233
234#[derive(Debug, Clone, Serialize, Deserialize)]
235pub struct Loc {
236    /// sloc — lines containing source code
237    #[serde(serialize_with = "sig3")]
238    pub source: f64,
239    /// lloc — logical lines (statements)
240    #[serde(default, serialize_with = "sig3", skip_serializing_if = "is_zero_f64")]
241    pub logical: f64,
242    /// cloc — lines containing comments
243    #[serde(default, serialize_with = "sig3", skip_serializing_if = "is_zero_f64")]
244    pub comments: f64,
245    /// blank lines
246    #[serde(default, serialize_with = "sig3", skip_serializing_if = "is_zero_f64")]
247    pub blank: f64,
248}
249
250#[derive(Debug, Clone, Serialize, Deserialize)]
251pub struct Halstead {
252    #[serde(serialize_with = "sig3")]
253    pub length: f64,
254    #[serde(serialize_with = "sig3")]
255    pub vocabulary: f64,
256    #[serde(serialize_with = "sig3")]
257    pub volume: f64,
258    #[serde(serialize_with = "sig3")]
259    pub effort: f64,
260    #[serde(serialize_with = "sig3")]
261    pub time: f64,
262    #[serde(serialize_with = "sig3")]
263    pub bugs: f64,
264}
265
266#[derive(Debug, Clone, Serialize, Deserialize)]
267pub struct Maintainability {
268    #[serde(serialize_with = "sig3")]
269    pub mi: f64,
270    #[serde(default, serialize_with = "sig3", skip_serializing_if = "is_zero_f64")]
271    pub mi_sei: f64,
272}
273
274#[derive(Debug, Clone, Default, Serialize, Deserialize)]
275pub struct Coupling {
276    #[serde(default, skip_serializing_if = "is_zero_u32")]
277    pub fan_in: u32,
278    #[serde(default, skip_serializing_if = "is_zero_u32")]
279    pub fan_out: u32,
280    /// Outgoing edges to external libraries (depth-1 deps). Tracked separately
281    /// so it is visible without inflating HK, which uses internal coupling only.
282    #[serde(default, skip_serializing_if = "is_zero_u32")]
283    pub fan_out_external: u32,
284    #[serde(default, serialize_with = "sig3", skip_serializing_if = "is_zero_f64")]
285    pub hk: f64,
286}
287
288fn coupling_is_trivial(c: &Option<Coupling>) -> bool {
289    c.as_ref()
290        .is_none_or(|c| c.fan_in == 0 && c.fan_out == 0 && c.fan_out_external == 0)
291}
292
293fn complexity_is_empty(c: &Option<Complexity>) -> bool {
294    c.as_ref().is_none_or(|c| {
295        c.cyclomatic == 0.0
296            && c.cognitive == 0.0
297            && c.exits == 0.0
298            && c.args == 0.0
299            && c.functions == 0.0
300            && c.closures == 0.0
301            && coupling_is_trivial(&c.coupling)
302            && c.maintainability.is_none()
303            && c.loc.is_none()
304            && c.halstead.is_none()
305    })
306}
307
308/// Full complexity metrics for a node (fn/method/file/module).
309/// Computed by rust-code-analysis; absent when the node has no source or
310/// the file could not be parsed.
311#[derive(Debug, Clone, Default, Serialize, Deserialize)]
312pub struct Complexity {
313    #[serde(default, serialize_with = "sig3", skip_serializing_if = "is_zero_f64")]
314    pub cyclomatic: f64,
315    #[serde(default, serialize_with = "sig3", skip_serializing_if = "is_zero_f64")]
316    pub cognitive: f64,
317    #[serde(default, serialize_with = "sig3", skip_serializing_if = "is_zero_f64")]
318    pub exits: f64,
319    #[serde(default, serialize_with = "sig3", skip_serializing_if = "is_zero_f64")]
320    pub args: f64,
321    #[serde(default, serialize_with = "sig3", skip_serializing_if = "is_zero_f64")]
322    pub functions: f64,
323    #[serde(default, serialize_with = "sig3", skip_serializing_if = "is_zero_f64")]
324    pub closures: f64,
325    #[serde(default, skip_serializing_if = "coupling_is_trivial")]
326    pub coupling: Option<Coupling>,
327    #[serde(default, skip_serializing_if = "Option::is_none")]
328    pub maintainability: Option<Maintainability>,
329    #[serde(default, skip_serializing_if = "Option::is_none")]
330    pub loc: Option<Loc>,
331    #[serde(default, skip_serializing_if = "Option::is_none")]
332    pub halstead: Option<Halstead>,
333}
334
335#[derive(Debug, Clone, Serialize, Deserialize)]
336pub struct Node {
337    pub id: NodeId,
338    pub kind: NodeKind,
339    pub name: String,
340    pub path: String,
341    #[serde(default, skip_serializing_if = "Option::is_none")]
342    pub parent: Option<NodeId>,
343    #[serde(default, skip_serializing_if = "Option::is_none")]
344    pub external: Option<bool>,
345    /// Resolved package version (semver). Set on Rust crate / `External`
346    /// library nodes from `cargo metadata`; absent on file nodes.
347    #[serde(default, skip_serializing_if = "Option::is_none")]
348    pub version: Option<String>,
349    #[serde(default, skip_serializing_if = "Option::is_none")]
350    pub visibility: Option<Visibility>,
351    /// Structural line-count for the file/module (not a complexity metric).
352    #[serde(default, skip_serializing_if = "Option::is_none")]
353    pub loc: Option<u32>,
354    /// Line number where this fn/method is declared (1-based).
355    #[serde(default, skip_serializing_if = "Option::is_none")]
356    pub line: Option<u32>,
357    #[serde(default, skip_serializing_if = "Option::is_none")]
358    pub item_count: Option<u32>,
359    /// For traits: number of method items declared on the trait.
360    #[serde(default, skip_serializing_if = "Option::is_none")]
361    pub method_count: Option<u32>,
362    #[serde(default, skip_serializing_if = "complexity_is_empty")]
363    pub complexity: Option<Complexity>,
364    /// Set when this node is part of a cycle (SCC with ≥ 2 members).
365    #[serde(default, skip_serializing_if = "Option::is_none")]
366    pub cycle_kind: Option<CycleKind>,
367}
368
369#[derive(Debug, Clone, Serialize, Deserialize)]
370pub struct Edge {
371    pub from: NodeId,
372    pub to: NodeId,
373    pub kind: EdgeKind,
374    #[serde(default, skip_serializing_if = "Option::is_none")]
375    pub unresolved: Option<bool>,
376    #[serde(default, skip_serializing_if = "Option::is_none")]
377    pub external: Option<bool>,
378    #[serde(default, skip_serializing_if = "Option::is_none")]
379    pub visibility: Option<Visibility>,
380}
381
382#[derive(Debug, Clone, Default, Serialize, Deserialize)]
383pub struct Graph {
384    pub nodes: Vec<Node>,
385    pub edges: Vec<Edge>,
386    /// All SCCs with ≥ 2 members, classified by kind.
387    #[serde(default, skip_serializing_if = "Vec::is_empty")]
388    pub cycles: Vec<CycleGroup>,
389    /// Aggregate statistics computed after all annotations (hk, cycles) are applied.
390    #[serde(default, skip_serializing_if = "Option::is_none")]
391    pub stats: Option<GraphStats>,
392}
393
394impl Graph {
395    pub fn new() -> Self {
396        Self::default()
397    }
398
399    pub fn is_empty(&self) -> bool {
400        self.nodes.is_empty()
401    }
402
403    pub fn project(&self, node_kinds: &[NodeKind], edge_kinds: &[EdgeKind]) -> Graph {
404        let kept_ids: HashSet<&NodeId> = self
405            .nodes
406            .iter()
407            .filter(|n| node_kinds.contains(&n.kind))
408            .map(|n| &n.id)
409            .collect();
410        let nodes = self
411            .nodes
412            .iter()
413            .filter(|n| node_kinds.contains(&n.kind))
414            .cloned()
415            .collect();
416        let edges = self
417            .edges
418            .iter()
419            .filter(|e| {
420                edge_kinds.contains(&e.kind)
421                    && kept_ids.contains(&e.from)
422                    && kept_ids.contains(&e.to)
423            })
424            .cloned()
425            .collect();
426        Graph {
427            nodes,
428            edges,
429            cycles: Vec::new(),
430            stats: None,
431        }
432    }
433}
434
435#[cfg(test)]
436mod tests {
437    use super::*;
438
439    fn n(id: &str, kind: NodeKind) -> Node {
440        Node {
441            id: id.into(),
442            kind,
443            name: id.into(),
444            path: String::new(),
445            parent: None,
446            external: None,
447            version: None,
448            visibility: None,
449            loc: None,
450            line: None,
451            item_count: None,
452            method_count: None,
453            complexity: None,
454            cycle_kind: None,
455        }
456    }
457
458    fn e(from: &str, to: &str, kind: EdgeKind) -> Edge {
459        Edge {
460            from: from.into(),
461            to: to.into(),
462            kind,
463            unresolved: None,
464            external: None,
465            visibility: None,
466        }
467    }
468
469    #[test]
470    fn project_keeps_only_matching_node_kinds() {
471        let g = Graph {
472            nodes: vec![n("c", NodeKind::Crate), n("m", NodeKind::Module)],
473            edges: vec![],
474            cycles: vec![],
475            stats: None,
476        };
477        let p = g.project(&[NodeKind::Crate], &[]);
478        assert_eq!(p.nodes.len(), 1);
479        assert_eq!(p.nodes[0].id, "c");
480    }
481
482    #[test]
483    fn project_drops_edges_to_filtered_out_nodes() {
484        let g = Graph {
485            nodes: vec![n("a", NodeKind::Crate), n("b", NodeKind::Module)],
486            edges: vec![e("a", "b", EdgeKind::Contains)],
487            cycles: vec![],
488            stats: None,
489        };
490        let p = g.project(&[NodeKind::Crate], &[EdgeKind::Contains]);
491        assert!(p.edges.is_empty());
492    }
493
494    #[test]
495    fn project_keeps_edges_between_kept_nodes() {
496        let g = Graph {
497            nodes: vec![n("a", NodeKind::Crate), n("b", NodeKind::Crate)],
498            edges: vec![e("a", "b", EdgeKind::Uses)],
499            cycles: vec![],
500            stats: None,
501        };
502        let p = g.project(&[NodeKind::Crate], &[EdgeKind::Uses]);
503        assert_eq!(p.edges.len(), 1);
504    }
505
506    #[test]
507    fn visibility_roundtrip_new_format() {
508        let cases: &[(&str, Visibility)] = &[
509            ("\"public\"", Visibility::Public),
510            ("\"private\"", Visibility::Private),
511            ("\"crate\"", Visibility::Crate),
512            ("\"super\"", Visibility::Super),
513            (
514                r#"{"restricted":"some::path"}"#,
515                Visibility::Restricted {
516                    path: "some::path".into(),
517                },
518            ),
519        ];
520        for (json, expected) in cases {
521            let got: Visibility = serde_json::from_str(json).unwrap();
522            assert_eq!(got, expected.clone());
523            let re = serde_json::to_string(&got).unwrap();
524            let back: Visibility = serde_json::from_str(&re).unwrap();
525            assert_eq!(back, expected.clone());
526        }
527    }
528
529    #[test]
530    fn visibility_deserialize_old_tagged_format() {
531        let cases: &[(&str, Visibility)] = &[
532            (r#"{"kind":"public"}"#, Visibility::Public),
533            (r#"{"kind":"private"}"#, Visibility::Private),
534            (r#"{"kind":"crate"}"#, Visibility::Crate),
535            (r#"{"kind":"super"}"#, Visibility::Super),
536            (
537                r#"{"kind":"restricted","path":"some::path"}"#,
538                Visibility::Restricted {
539                    path: "some::path".into(),
540                },
541            ),
542        ];
543        for (json, expected) in cases {
544            let got: Visibility = serde_json::from_str(json).unwrap();
545            assert_eq!(got, expected.clone());
546        }
547    }
548
549    #[test]
550    fn complexity_coupling_roundtrip() {
551        let cx = Complexity {
552            cyclomatic: 3.0,
553            coupling: Some(Coupling {
554                fan_in: 2,
555                fan_out: 4,
556                hk: 576.0,
557                ..Default::default()
558            }),
559            ..Default::default()
560        };
561        let json = serde_json::to_string(&cx).unwrap();
562        let back: Complexity = serde_json::from_str(&json).unwrap();
563        assert_eq!(back.cyclomatic, 3.0);
564        let c = back.coupling.unwrap();
565        assert_eq!(c.fan_in, 2);
566        assert_eq!(c.fan_out, 4);
567    }
568}