code_ranker_plugin_api/level.rs
1//! Level descriptors + the **semantics dictionaries** that let the core handle
2//! unknown kinds/keys without hardcoding their names, and let the UI render any
3//! language/metric set purely from data: edge kinds ([`EdgeKindSpec`]),
4//! node/edge attributes ([`AttributeSpec`], grouped via [`AttributeGroup`]),
5//! node kinds ([`NodeKindSpec`]) and cycle kinds ([`CycleKindSpec`]).
6//!
7//! The dictionaries are **maps** keyed by the kind/attribute/group name; the
8//! spec value holds only the remaining metadata.
9
10use crate::attrs::ValueType;
11use serde::{Deserialize, Serialize};
12use std::collections::BTreeMap;
13
14/// Semantics of one edge kind. Keyed by the edge `kind` in
15/// [`Level::edge_kinds`]. `flow` is the single source of truth for "is this
16/// information flow": counted in coupling/cycles AND drawn when `true`;
17/// structural (e.g. `contains`) and excluded/hidden when `false`.
18#[derive(Debug, Clone, Serialize, Deserialize)]
19pub struct EdgeKindSpec {
20 pub flow: bool,
21 #[serde(default, skip_serializing_if = "Option::is_none")]
22 pub label: Option<String>,
23 /// Long human description (used as a UI tooltip).
24 #[serde(default, skip_serializing_if = "Option::is_none")]
25 pub description: Option<String>,
26}
27
28/// A named group of attributes (UI section). Keyed by group name in
29/// [`Level::attribute_groups`]; attributes reference it via
30/// [`AttributeSpec::group`]. Metadata only — storage stays flat.
31#[derive(Debug, Clone, Serialize, Deserialize)]
32pub struct AttributeGroup {
33 #[serde(default, skip_serializing_if = "Option::is_none")]
34 pub label: Option<String>,
35 #[serde(default, skip_serializing_if = "Option::is_none")]
36 pub description: Option<String>,
37}
38
39/// Two-tier per-metric thresholds (at/under `info` is fine; above `warning` is
40/// likely a problem). Carried on an [`AttributeSpec`]; produced by a plugin
41/// (language-calibrated), absent when a metric has no calibration.
42#[derive(Debug, Clone, Copy, Serialize, Deserialize)]
43pub struct Thresholds {
44 pub info: f64,
45 pub warning: f64,
46}
47
48/// Whether a metric's delta is "good" when it moves up or down — drives the
49/// green/red colouring in the viewer. `Neutral` (the default) means the metric
50/// has no agreed-good direction (raw sizes, structural counts) and is left
51/// uncoloured. `Neutral` is skipped on the wire, so a neutral metric serializes
52/// exactly as the old `Option<String>` form did: the `direction` field absent.
53#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
54#[serde(rename_all = "snake_case")]
55pub enum Direction {
56 #[default]
57 Neutral,
58 LowerBetter,
59 HigherBetter,
60}
61
62impl Direction {
63 /// `true` for the default (no opinion) — used by serde to omit the field.
64 pub fn is_neutral(&self) -> bool {
65 matches!(self, Direction::Neutral)
66 }
67}
68
69/// Describes one attribute key (on a node or an edge). Everything the UI needs
70/// to label, explain, format, compute and threshold the metric — so the viewer
71/// hardcodes no metric by name.
72#[derive(Debug, Clone, Serialize, Deserialize)]
73pub struct AttributeSpec {
74 pub value_type: ValueType,
75 /// Concise display label (table grouping, popup rows).
76 #[serde(default, skip_serializing_if = "Option::is_none")]
77 pub label: Option<String>,
78 /// Full name used as a tooltip title (falls back to `label`).
79 #[serde(default, skip_serializing_if = "Option::is_none")]
80 pub name: Option<String>,
81 /// Short label for narrow table headers (falls back to `label`).
82 #[serde(default, skip_serializing_if = "Option::is_none")]
83 pub short: Option<String>,
84 /// Long human description (tooltip body).
85 #[serde(default, skip_serializing_if = "Option::is_none")]
86 pub description: Option<String>,
87 /// Human-readable formula, e.g. `"sloc × (fan_in × fan_out)²"` (display only).
88 #[serde(default, skip_serializing_if = "Option::is_none")]
89 pub formula: Option<String>,
90 /// Evaluable JS expression over sibling attribute names + `Math`, e.g.
91 /// `"sloc * (fan_in * fan_out) ** 2"`. Lets the UI show the live derivation.
92 #[serde(default, skip_serializing_if = "Option::is_none")]
93 pub calc: Option<String>,
94 /// Whether higher or lower is "better" — drives delta colouring. `Neutral`
95 /// (the default) is omitted from the wire.
96 #[serde(default, skip_serializing_if = "Direction::is_neutral")]
97 pub direction: Direction,
98 /// Format large values with K/M suffixes.
99 #[serde(default, skip_serializing_if = "Option::is_none")]
100 pub abbreviate: Option<bool>,
101 /// Optional group this attribute belongs to, by [`AttributeGroup`] key.
102 #[serde(default, skip_serializing_if = "Option::is_none")]
103 pub group: Option<String>,
104 /// Optional two-tier thresholds (language-calibrated).
105 #[serde(default, skip_serializing_if = "Option::is_none")]
106 pub thresholds: Option<Thresholds>,
107 /// Value at which this metric carries no signal and is **omitted** from the
108 /// node (absent in the JSON, blank in the viewer). `0` for almost everything;
109 /// `1` for `cyclomatic` (McCabe's floor — a function-less file would otherwise
110 /// report a vacuous `1`). Published so the frontend knows what an absent cell
111 /// means (and may treat it as this value when sorting). Default `0` is omitted
112 /// from the wire.
113 #[serde(default, skip_serializing_if = "f64_is_zero")]
114 pub omit_at: f64,
115}
116
117/// `skip_serializing_if` helper: the default `omit_at` (`0`) stays off the wire.
118fn f64_is_zero(v: &f64) -> bool {
119 *v == 0.0
120}
121
122impl AttributeSpec {
123 /// A minimal spec with just a type + label (the common structural case).
124 pub fn new(value_type: ValueType, label: &str) -> Self {
125 Self {
126 value_type,
127 label: Some(label.to_string()),
128 name: None,
129 short: None,
130 description: None,
131 formula: None,
132 calc: None,
133 direction: Direction::Neutral,
134 abbreviate: None,
135 group: None,
136 thresholds: None,
137 omit_at: 0.0,
138 }
139 }
140}
141
142/// One row of a declarative attribute table: a flat, named-field description of
143/// an [`AttributeSpec`]. Empty `&str` fields become `None`; `direction` defaults
144/// to [`Direction::Neutral`]. Build a whole dictionary with [`attr_dict`].
145///
146/// This replaces both the per-field `spec.x = Some(...)` boilerplate and the
147/// positional metric-row tuples that the metric/coupling crates used to carry
148/// separately, so every centrally-computed spec is declared the same way.
149#[derive(Clone)]
150pub struct SpecRow {
151 pub group: &'static str,
152 pub value_type: ValueType,
153 pub label: &'static str,
154 pub name: &'static str,
155 pub short: &'static str,
156 pub description: &'static str,
157 pub formula: &'static str,
158 pub calc: &'static str,
159 pub direction: Direction,
160 pub abbreviate: bool,
161 /// See [`AttributeSpec::omit_at`]. Defaults to `0`.
162 pub omit_at: f64,
163}
164
165impl Default for SpecRow {
166 fn default() -> Self {
167 SpecRow {
168 group: "",
169 value_type: ValueType::Int,
170 label: "",
171 name: "",
172 short: "",
173 description: "",
174 formula: "",
175 calc: "",
176 direction: Direction::Neutral,
177 abbreviate: false,
178 omit_at: 0.0,
179 }
180 }
181}
182
183impl SpecRow {
184 fn into_spec(self) -> AttributeSpec {
185 let opt = |s: &str| (!s.is_empty()).then(|| s.to_string());
186 AttributeSpec {
187 value_type: self.value_type,
188 label: opt(self.label),
189 name: opt(self.name),
190 short: opt(self.short),
191 description: opt(self.description),
192 formula: opt(self.formula),
193 calc: opt(self.calc),
194 direction: self.direction,
195 abbreviate: self.abbreviate.then_some(true),
196 group: opt(self.group),
197 thresholds: None,
198 omit_at: self.omit_at,
199 }
200 }
201}
202
203/// Assemble a `key → AttributeSpec` dictionary from a declarative table.
204pub fn attr_dict(rows: Vec<(&'static str, SpecRow)>) -> BTreeMap<String, AttributeSpec> {
205 rows.into_iter()
206 .map(|(k, r)| (k.to_string(), r.into_spec()))
207 .collect()
208}
209
210/// Build an [`AttributeGroup`] from a label + description.
211pub fn group(label: &str, description: &str) -> AttributeGroup {
212 AttributeGroup {
213 label: Some(label.to_string()),
214 description: Some(description.to_string()),
215 }
216}
217
218/// Visual + label semantics of one node kind (`"file"` / `"external"` / …).
219/// Keyed by kind in [`Level::node_kinds`].
220#[derive(Debug, Clone, Serialize, Deserialize)]
221pub struct NodeKindSpec {
222 #[serde(default, skip_serializing_if = "Option::is_none")]
223 pub label: Option<String>,
224 #[serde(default, skip_serializing_if = "Option::is_none")]
225 pub plural: Option<String>,
226 #[serde(default, skip_serializing_if = "Option::is_none")]
227 pub fill: Option<String>,
228 #[serde(default, skip_serializing_if = "Option::is_none")]
229 pub stroke: Option<String>,
230 /// `true` marks a third-party node (a library); the UI derives "external
231 /// edge" from the endpoint kind, not from any edge flag.
232 #[serde(default, skip_serializing_if = "Option::is_none")]
233 pub external: Option<bool>,
234}
235
236/// Label + description of one cycle kind (`"mutual"` / `"chain"`).
237#[derive(Debug, Clone, Serialize, Deserialize)]
238pub struct CycleKindSpec {
239 #[serde(default, skip_serializing_if = "Option::is_none")]
240 pub label: Option<String>,
241 #[serde(default, skip_serializing_if = "Option::is_none")]
242 pub description: Option<String>,
243}
244
245/// How the viewer should cluster nodes in the diagram. Exactly one of `key`
246/// (group by the value of a node attribute, e.g. `crate`) or `function` (a named
247/// grouper the viewer implements, e.g. `dir` — derive the folder from the path).
248/// Absent → the viewer falls back to its default `dir` grouper.
249#[derive(Debug, Clone, Serialize, Deserialize)]
250pub struct Grouping {
251 #[serde(default, skip_serializing_if = "Option::is_none")]
252 pub key: Option<String>,
253 #[serde(default, skip_serializing_if = "Option::is_none")]
254 pub function: Option<String>,
255}
256
257/// An analysis level the plugin can produce, with the semantics needed to score
258/// and draw it. The orchestrator merges in centrally-computed attribute specs
259/// and the computed `ui` block before writing the snapshot.
260#[derive(Debug, Clone, Serialize, Deserialize)]
261pub struct Level {
262 pub name: String,
263 pub edge_kinds: BTreeMap<String, EdgeKindSpec>,
264 pub node_attributes: BTreeMap<String, AttributeSpec>,
265 pub edge_attributes: BTreeMap<String, AttributeSpec>,
266 pub attribute_groups: BTreeMap<String, AttributeGroup>,
267 /// Node-kind vocabulary (label/colour/external). Plugins seed it from
268 /// [`crate::default_node_kinds`] and may customize.
269 #[serde(default)]
270 pub node_kinds: BTreeMap<String, NodeKindSpec>,
271 /// Cycle-kind vocabulary. Plugins seed it from [`crate::default_cycle_kinds`].
272 #[serde(default)]
273 pub cycle_kinds: BTreeMap<String, CycleKindSpec>,
274 /// How the viewer should cluster nodes (defaults to `dir` when absent).
275 #[serde(default, skip_serializing_if = "Option::is_none")]
276 pub grouping: Option<Grouping>,
277}