Skip to main content

codedash_schemas/
analyze.rs

1//! Evaluated analysis output schema (`codedash analyze -o json`).
2//!
3//! These types represent the **evaluated** output from the codedash analysis
4//! pipeline — the result after raw AST metrics have been normalized and mapped
5//! to visual percept channels (hue, size, border, opacity, clarity).
6//!
7//! This is distinct from [`crate::AstData`], which captures the raw parse output.
8//! `AnalyzeResult` is what downstream consumers (e.g. egui-cha UI components)
9//! should depend on for visualization.
10
11use serde::{Deserialize, Serialize};
12
13/// Top-level output from `codedash analyze -o json`.
14///
15/// Contains evaluated entries with both raw metrics and visual encoding
16/// values, plus metadata (bindings, groups, totals).
17#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
18#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
19#[non_exhaustive]
20pub struct AnalyzeResult {
21    /// Metric-to-percept bindings defining how code metrics map to visual channels.
22    pub bindings: Vec<Binding>,
23    /// Evaluated code units with raw metrics and visual encoding values.
24    pub entries: Vec<EvalEntry>,
25    /// Domain groups with count and percentage.
26    #[serde(default)]
27    pub groups: Vec<Group>,
28    /// Total number of analyzed nodes.
29    pub total: u32,
30    /// Number of excluded nodes.
31    #[serde(default)]
32    pub excluded: u32,
33}
34
35impl AnalyzeResult {
36    /// Create a new [`AnalyzeResult`].
37    pub fn new(bindings: Vec<Binding>, entries: Vec<EvalEntry>, total: u32) -> Self {
38        Self {
39            bindings,
40            entries,
41            groups: Vec::new(),
42            total,
43            excluded: 0,
44        }
45    }
46}
47
48/// A binding maps a code metric (index) to a visual channel (percept).
49///
50/// For example, `{ index: "cyclomatic", percept: "hue" }` means cyclomatic
51/// complexity is encoded as the hue channel in the visualization.
52#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
53#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
54#[non_exhaustive]
55pub struct Binding {
56    /// Metric name (e.g. `"cyclomatic"`, `"lines"`, `"params"`, `"depth"`, `"coverage"`).
57    pub index: String,
58    /// Visual channel name (e.g. `"hue"`, `"size"`, `"border"`, `"opacity"`, `"clarity"`).
59    pub percept: String,
60}
61
62impl Binding {
63    /// Create a new [`Binding`].
64    pub fn new(index: String, percept: String) -> Self {
65        Self { index, percept }
66    }
67}
68
69/// A single evaluated code unit with raw metrics and visual encoding values.
70///
71/// Combines source identity, raw metrics from AST parsing/enrichment, and
72/// the normalized + percept values computed by the eval pipeline.
73#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
74#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
75#[non_exhaustive]
76pub struct EvalEntry {
77    // ── Source identity ──
78    /// Node kind: `"function"`, `"struct"`, `"enum"`, `"impl"`, `"method"`, etc.
79    pub kind: String,
80    /// Node name (identifier).
81    pub name: String,
82    /// Fully qualified name (e.g. `"src/app::MyStruct.method"`).
83    pub full_name: String,
84    /// Source file path (relative, without extension).
85    pub file: String,
86    /// First line of the node (1-based).
87    pub start_line: u32,
88    /// Last line of the node (1-based, inclusive).
89    pub end_line: u32,
90    /// Total line count.
91    pub lines: u32,
92    /// Whether this node is exported / publicly visible.
93    #[serde(default)]
94    pub exported: bool,
95    /// Visibility qualifier (e.g. `"pub"`, `"private"`).
96    #[serde(default)]
97    pub visibility: String,
98
99    // ── Raw metrics ──
100    /// Number of parameters (functions/methods).
101    #[serde(default, skip_serializing_if = "Option::is_none")]
102    pub params: Option<u32>,
103    /// Cyclomatic complexity.
104    #[serde(default, skip_serializing_if = "Option::is_none")]
105    pub cyclomatic: Option<u32>,
106    /// Maximum nesting depth.
107    #[serde(default, skip_serializing_if = "Option::is_none")]
108    pub depth: Option<u32>,
109    /// Number of fields (structs/enums).
110    #[serde(default, skip_serializing_if = "Option::is_none")]
111    pub field_count: Option<u32>,
112    /// Git commit count within the churn period.
113    #[serde(default, skip_serializing_if = "Option::is_none")]
114    pub git_churn_30d: Option<u32>,
115    /// Region coverage ratio (0.0–1.0).
116    #[serde(default, skip_serializing_if = "Option::is_none")]
117    pub coverage: Option<f64>,
118
119    // ── Evaluated values ──
120    /// Normalized values (0.0–1.0 range) for each percept channel.
121    pub normalized: PerceptValues,
122    /// Final percept values after mapping (may exceed 0.0–1.0 depending on the percept).
123    pub percept: PerceptValues,
124}
125
126impl EvalEntry {
127    /// Create a new [`EvalEntry`] with required fields.
128    ///
129    /// Optional metric fields default to `None`.
130    #[allow(clippy::too_many_arguments)]
131    pub fn new(
132        kind: String,
133        name: String,
134        full_name: String,
135        file: String,
136        start_line: u32,
137        end_line: u32,
138        lines: u32,
139        normalized: PerceptValues,
140        percept: PerceptValues,
141    ) -> Self {
142        Self {
143            kind,
144            name,
145            full_name,
146            file,
147            start_line,
148            end_line,
149            lines,
150            exported: false,
151            visibility: String::new(),
152            params: None,
153            cyclomatic: None,
154            depth: None,
155            field_count: None,
156            git_churn_30d: None,
157            coverage: None,
158            normalized,
159            percept,
160        }
161    }
162}
163
164/// Visual encoding values computed by the eval pipeline.
165///
166/// Each field corresponds to a percept channel. The `hue`, `size`, `border`,
167/// and `opacity` channels are always present. The `clarity` channel is only
168/// present when coverage data is available.
169#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
170#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
171#[non_exhaustive]
172pub struct PerceptValues {
173    /// Hue channel value (mapped from cyclomatic complexity by default).
174    #[serde(default)]
175    pub hue: f64,
176    /// Size channel value (mapped from lines by default).
177    #[serde(default)]
178    pub size: f64,
179    /// Border channel value (mapped from params by default).
180    #[serde(default)]
181    pub border: f64,
182    /// Opacity channel value (mapped from depth by default).
183    #[serde(default)]
184    pub opacity: f64,
185    /// Clarity channel value (mapped from coverage when available).
186    #[serde(default, skip_serializing_if = "Option::is_none")]
187    pub clarity: Option<f64>,
188}
189
190impl PerceptValues {
191    /// Create a new [`PerceptValues`] with the four core channels.
192    pub fn new(hue: f64, size: f64, border: f64, opacity: f64) -> Self {
193        Self {
194            hue,
195            size,
196            border,
197            opacity,
198            clarity: None,
199        }
200    }
201
202    /// Create a new [`PerceptValues`] with all channels including clarity.
203    pub fn with_clarity(hue: f64, size: f64, border: f64, opacity: f64, clarity: f64) -> Self {
204        Self {
205            hue,
206            size,
207            border,
208            opacity,
209            clarity: Some(clarity),
210        }
211    }
212}
213
214impl Default for PerceptValues {
215    fn default() -> Self {
216        Self::new(0.0, 0.0, 0.0, 0.0)
217    }
218}
219
220/// A domain group with count and percentage.
221///
222/// Groups categorize analyzed nodes by their containing module/domain.
223#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
224#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
225#[non_exhaustive]
226pub struct Group {
227    /// Group name (e.g. domain/module name).
228    pub name: String,
229    /// Number of nodes in this group.
230    pub count: u32,
231    /// Percentage of total nodes (0.0–100.0).
232    pub pct: f64,
233}
234
235impl Group {
236    /// Create a new [`Group`].
237    pub fn new(name: String, count: u32, pct: f64) -> Self {
238        Self { name, count, pct }
239    }
240}
241
242#[cfg(test)]
243mod tests {
244    use super::*;
245
246    #[test]
247    fn analyze_result_roundtrip() {
248        let result = AnalyzeResult {
249            bindings: vec![
250                Binding::new("cyclomatic".into(), "hue".into()),
251                Binding::new("lines".into(), "size".into()),
252            ],
253            entries: vec![EvalEntry {
254                kind: "function".into(),
255                name: "main".into(),
256                full_name: "src/main::main".into(),
257                file: "src/main".into(),
258                start_line: 1,
259                end_line: 10,
260                lines: 10,
261                exported: false,
262                visibility: "private".into(),
263                params: Some(0),
264                cyclomatic: Some(3),
265                depth: Some(2),
266                field_count: None,
267                git_churn_30d: Some(5),
268                coverage: None,
269                normalized: PerceptValues::new(0.5, 0.3, 0.1, 0.2),
270                percept: PerceptValues::new(60.0, 0.8, 0.5, 0.7),
271            }],
272            groups: vec![Group::new("src".into(), 10, 50.0)],
273            total: 20,
274            excluded: 0,
275        };
276
277        let json = serde_json::to_string(&result).unwrap();
278        let parsed: AnalyzeResult = serde_json::from_str(&json).unwrap();
279
280        assert_eq!(parsed.bindings.len(), 2);
281        assert_eq!(parsed.bindings[0].index, "cyclomatic");
282        assert_eq!(parsed.entries.len(), 1);
283        assert_eq!(parsed.entries[0].name, "main");
284        assert_eq!(parsed.entries[0].cyclomatic, Some(3));
285        assert_eq!(parsed.groups.len(), 1);
286        assert_eq!(parsed.total, 20);
287    }
288
289    #[test]
290    fn deserialize_actual_codedash_output_entry() {
291        // Matches the actual `codedash analyze -o json` output format
292        let json = r#"{
293            "bindings": [
294                {"index": "cyclomatic", "percept": "hue"},
295                {"index": "lines", "percept": "size"},
296                {"index": "params", "percept": "border"},
297                {"index": "depth", "percept": "opacity"},
298                {"index": "coverage", "percept": "clarity"}
299            ],
300            "entries": [{
301                "cyclomatic": 1,
302                "depth": 1,
303                "end_line": 11,
304                "exported": false,
305                "field_count": 0,
306                "file": "codedash-schemas/examples/generate_schema",
307                "full_name": "codedash-schemas/examples/generate_schema::main",
308                "git_churn_30d": 2,
309                "kind": "function",
310                "lines": 5,
311                "name": "main",
312                "normalized": {"border": 0.346, "hue": 0.0, "opacity": 0.232, "size": 0.071},
313                "params": 0,
314                "percept": {"border": 1.038, "hue": 120.0, "opacity": 0.790, "size": 0.542},
315                "start_line": 7,
316                "visibility": "private"
317            }],
318            "groups": [],
319            "total": 437,
320            "excluded": 0
321        }"#;
322
323        let parsed: AnalyzeResult = serde_json::from_str(json).unwrap();
324
325        assert_eq!(parsed.bindings.len(), 5);
326        assert_eq!(parsed.total, 437);
327        assert_eq!(parsed.entries.len(), 1);
328
329        let entry = &parsed.entries[0];
330        assert_eq!(entry.kind, "function");
331        assert_eq!(entry.name, "main");
332        assert_eq!(entry.cyclomatic, Some(1));
333        assert_eq!(entry.params, Some(0));
334        assert_eq!(entry.field_count, Some(0));
335        assert_eq!(entry.normalized.hue, 0.0);
336        assert!((entry.percept.hue - 120.0).abs() < f64::EPSILON);
337        assert!(entry.normalized.clarity.is_none());
338    }
339
340    #[test]
341    fn deserialize_with_missing_optional_fields() {
342        let json = r#"{
343            "bindings": [],
344            "entries": [{
345                "kind": "struct",
346                "name": "Foo",
347                "full_name": "src/lib::Foo",
348                "file": "src/lib",
349                "start_line": 1,
350                "end_line": 5,
351                "lines": 5,
352                "normalized": {"hue": 0.0, "size": 0.0, "border": 0.0, "opacity": 0.0},
353                "percept": {"hue": 0.0, "size": 0.0, "border": 0.0, "opacity": 0.0}
354            }],
355            "total": 1
356        }"#;
357
358        let parsed: AnalyzeResult = serde_json::from_str(json).unwrap();
359        let entry = &parsed.entries[0];
360
361        assert!(!entry.exported);
362        assert!(entry.visibility.is_empty());
363        assert!(entry.params.is_none());
364        assert!(entry.cyclomatic.is_none());
365        assert!(entry.coverage.is_none());
366        assert_eq!(parsed.excluded, 0);
367        assert!(parsed.groups.is_empty());
368    }
369
370    #[test]
371    fn percept_values_with_clarity() {
372        let pv = PerceptValues::with_clarity(120.0, 0.5, 1.0, 0.8, 0.9);
373        let json = serde_json::to_string(&pv).unwrap();
374        let parsed: PerceptValues = serde_json::from_str(&json).unwrap();
375
376        assert!((parsed.hue - 120.0).abs() < f64::EPSILON);
377        assert_eq!(parsed.clarity, Some(0.9));
378    }
379
380    #[test]
381    fn percept_values_without_clarity_omits_field() {
382        let pv = PerceptValues::new(0.5, 0.3, 0.1, 0.2);
383        let json = serde_json::to_value(&pv).unwrap();
384
385        assert!(json.get("clarity").is_none());
386        assert!(json.get("hue").is_some());
387    }
388
389    #[test]
390    fn group_serialization() {
391        let group = Group::new("domain".into(), 42, 33.5);
392        let json = serde_json::to_value(&group).unwrap();
393
394        assert_eq!(json["name"], "domain");
395        assert_eq!(json["count"], 42);
396        assert!((json["pct"].as_f64().unwrap() - 33.5).abs() < f64::EPSILON);
397    }
398
399    #[test]
400    fn binding_eq() {
401        let a = Binding::new("cyclomatic".into(), "hue".into());
402        let b = Binding::new("cyclomatic".into(), "hue".into());
403        let c = Binding::new("lines".into(), "size".into());
404        assert_eq!(a, b);
405        assert_ne!(a, c);
406    }
407
408    #[test]
409    fn constructors_produce_correct_defaults() {
410        let result = AnalyzeResult::new(vec![], vec![], 0);
411        assert!(result.groups.is_empty());
412        assert_eq!(result.excluded, 0);
413
414        let entry = EvalEntry::new(
415            "function".into(),
416            "f".into(),
417            "mod::f".into(),
418            "mod".into(),
419            1,
420            5,
421            5,
422            PerceptValues::default(),
423            PerceptValues::default(),
424        );
425        assert!(!entry.exported);
426        assert!(entry.visibility.is_empty());
427        assert!(entry.params.is_none());
428        assert!(entry.coverage.is_none());
429    }
430}
431
432#[cfg(all(test, feature = "schema"))]
433mod schema_snapshot {
434    use super::*;
435
436    #[test]
437    fn analyze_result_json_schema() {
438        let schema = schemars::schema_for!(AnalyzeResult);
439        insta::assert_json_snapshot!("analyze-result-schema", schema);
440    }
441}
442
443#[cfg(test)]
444mod proptests {
445    use super::*;
446    use proptest::prelude::*;
447
448    fn arb_binding() -> impl Strategy<Value = Binding> {
449        (
450            prop_oneof!["cyclomatic", "lines", "params", "depth", "coverage"],
451            prop_oneof!["hue", "size", "border", "opacity", "clarity"],
452        )
453            .prop_map(|(index, percept)| Binding { index, percept })
454    }
455
456    // Note: f64 fields use integer-derived values to avoid ULP precision
457    // loss during JSON roundtrip. Deterministic tests cover fractional f64.
458    fn arb_percept_values() -> impl Strategy<Value = PerceptValues> {
459        (0i32..360, 0i32..100, 0i32..100, 0i32..100).prop_map(|(hue, size, border, opacity)| {
460            PerceptValues {
461                hue: f64::from(hue),
462                size: f64::from(size) / 100.0,
463                border: f64::from(border) / 100.0,
464                opacity: f64::from(opacity) / 100.0,
465                clarity: None,
466            }
467        })
468    }
469
470    fn arb_eval_entry() -> impl Strategy<Value = EvalEntry> {
471        (
472            "[a-z]{1,8}",
473            "[a-z]{1,8}",
474            "[a-z/]{1,15}::[a-z]{1,8}",
475            "[a-z/]{1,15}",
476            1u32..10000,
477            1u32..500,
478            any::<bool>(),
479            arb_percept_values(),
480            arb_percept_values(),
481        )
482            .prop_map(
483                |(kind, name, full_name, file, start, delta, exported, normalized, percept)| {
484                    let end = start + delta;
485                    let lines = delta + 1;
486                    EvalEntry {
487                        kind,
488                        name,
489                        full_name,
490                        file,
491                        start_line: start,
492                        end_line: end,
493                        lines,
494                        exported,
495                        visibility: "private".into(),
496                        params: None,
497                        cyclomatic: None,
498                        depth: None,
499                        field_count: None,
500                        git_churn_30d: None,
501                        coverage: None,
502                        normalized,
503                        percept,
504                    }
505                },
506            )
507    }
508
509    fn arb_group() -> impl Strategy<Value = Group> {
510        ("[a-z]{1,8}", 0u32..1000, 0u32..1000).prop_map(|(name, count, pct_raw)| Group {
511            name,
512            count,
513            pct: f64::from(pct_raw) / 10.0,
514        })
515    }
516
517    fn arb_analyze_result() -> impl Strategy<Value = AnalyzeResult> {
518        (
519            proptest::collection::vec(arb_binding(), 0..6),
520            proptest::collection::vec(arb_eval_entry(), 0..4),
521            proptest::collection::vec(arb_group(), 0..4),
522            0u32..1000,
523            0u32..100,
524        )
525            .prop_map(
526                |(bindings, entries, groups, total, excluded)| AnalyzeResult {
527                    bindings,
528                    entries,
529                    groups,
530                    total,
531                    excluded,
532                },
533            )
534    }
535
536    proptest! {
537        #[test]
538        fn analyze_result_serde_roundtrip(data in arb_analyze_result()) {
539            let json = serde_json::to_string(&data).unwrap();
540            let parsed: AnalyzeResult = serde_json::from_str(&json).unwrap();
541            prop_assert_eq!(data, parsed);
542        }
543
544        #[test]
545        fn eval_entry_serde_roundtrip(entry in arb_eval_entry()) {
546            let json = serde_json::to_string(&entry).unwrap();
547            let parsed: EvalEntry = serde_json::from_str(&json).unwrap();
548            prop_assert_eq!(entry, parsed);
549        }
550
551        #[test]
552        fn percept_values_serde_roundtrip(pv in arb_percept_values()) {
553            let json = serde_json::to_string(&pv).unwrap();
554            let parsed: PerceptValues = serde_json::from_str(&json).unwrap();
555            prop_assert_eq!(pv, parsed);
556        }
557    }
558}