code_ranker_graph/registry/model.rs
1//! Registry data types — the leaf definitions the [`super::Engine`] and the
2//! evaluation helpers ([`super::eval`]) both build on.
3//!
4//! Kept in a dependency-free leaf module (it imports only external crates, never
5//! `super`) so that `eval` can depend on it and the parent can depend on both
6//! without forming a module cycle. Re-exported from `super` so existing call
7//! sites and the `use super::*` test modules see these types unchanged.
8
9use code_ranker_plugin_api::{
10 attrs::ValueType,
11 level::{AttributeSpec, Direction, Thresholds},
12};
13use serde::Deserialize;
14use std::sync::LazyLock;
15
16/// Where a metric is evaluated: per node (default) or once over a collection.
17#[derive(Debug, Clone, Copy, Default, Deserialize, PartialEq, Eq)]
18#[serde(rename_all = "snake_case")]
19pub enum Scope {
20 #[default]
21 Node,
22 Graph,
23}
24
25/// One metric definition: a CEL formula plus the spec fields needed to emit it
26/// as a first-class, sortable, delta-coloured attribute. Spec fields are
27/// optional so a quick user formula needs only `formula_cel`.
28#[derive(Debug, Clone, Default, Deserialize)]
29#[serde(deny_unknown_fields)]
30pub struct MetricDef {
31 /// CEL expression over other metric keys + the registered math functions.
32 pub formula_cel: String,
33 #[serde(default)]
34 pub scope: Scope,
35 #[serde(default = "default_value_type")]
36 pub value_type: String,
37 // (the `omit_at` field below also defaults from the registry `[defaults]`.)
38 pub label: Option<String>,
39 pub name: Option<String>,
40 pub short: Option<String>,
41 pub description: Option<String>,
42 /// How to fix a breach — the `fix` line in `check` diagnostics.
43 pub remediation: Option<String>,
44 /// Human-readable formula shown in the viewer (display only).
45 pub formula_pretty: Option<String>,
46 /// JS expression the viewer re-runs with the node's values to show the live
47 /// "formula = numbers" line. When omitted, a node-scope metric falls back to
48 /// its CEL `formula_cel` — valid JS for plain arithmetic / ternaries; if it
49 /// uses CEL-only host functions (`log2`, `pow`, …) the viewer simply skips the
50 /// line. Set `formula_js` explicitly to control it.
51 pub formula_js: Option<String>,
52 /// `lower_better` / `higher_better`.
53 pub direction: Option<String>,
54 pub group: Option<String>,
55 /// No-signal value at which the metric is omitted (the registry `[defaults]`
56 /// `omit_at` when unset).
57 #[serde(default = "default_omit_at")]
58 pub omit_at: f64,
59 /// Two-tier severity thresholds (the `warning` / `info` limits the scorecard
60 /// and viewer badge against, like a built-in metric). When either is set the
61 /// metric carries a [`Thresholds`] in its spec; the other tier falls back to
62 /// it. Distinct from the `[rules.thresholds.file]` single-tier `check` gate.
63 pub warning: Option<f64>,
64 pub info: Option<f64>,
65}
66
67/// The registry `[defaults]` block from `metrics/builtin.toml`: the field-omission
68/// fallbacks (`value_type` / `omit_at`) a metric entry inherits when it doesn't
69/// set the field. The SINGLE source of these values — no literal in Rust. Parsed
70/// independently of the full [`crate::builtin`] catalog (it reads only `[defaults]`,
71/// so it does not re-enter that catalog's lazy parse) and shared by both the
72/// built-in `[ast.*]`/`[fields.*]` entries and a user's `[metrics.<key>]`.
73static FIELD_DEFAULTS: LazyLock<FieldDefaults> = LazyLock::new(|| {
74 #[derive(Deserialize)]
75 struct Wrap {
76 defaults: FieldDefaults,
77 }
78 toml::from_str::<Wrap>(include_str!("../../metrics/builtin.toml"))
79 .expect("metrics/builtin.toml [defaults] parses")
80 .defaults
81});
82
83#[derive(Debug, Clone, Deserialize)]
84struct FieldDefaults {
85 value_type: String,
86 omit_at: f64,
87}
88
89/// Default `value_type` for a metric entry that omits it (registry `[defaults]`).
90pub(crate) fn default_value_type() -> String {
91 FIELD_DEFAULTS.value_type.clone()
92}
93
94/// Default `omit_at` for a metric entry that omits it (registry `[defaults]`).
95pub(crate) fn default_omit_at() -> f64 {
96 FIELD_DEFAULTS.omit_at
97}
98
99impl MetricDef {
100 /// The two-tier severity thresholds, if the metric declares either tier. A
101 /// missing tier mirrors the other, so `warning = 1.0` alone yields
102 /// `{ warning: 1.0, info: 1.0 }` (one effective tier).
103 fn thresholds(&self) -> Option<Thresholds> {
104 match (self.warning, self.info) {
105 (None, None) => None,
106 (w, i) => Some(Thresholds {
107 warning: w.or(i).unwrap_or(0.0),
108 info: i.or(w).unwrap_or(0.0),
109 }),
110 }
111 }
112
113 /// The viewer-facing [`AttributeSpec`] for this metric, so a config-defined
114 /// metric renders as a named, sortable, delta-coloured column like any
115 /// built-in — including the live "formula = numbers" tooltip line, driven by
116 /// `formula_js` (defaulted from the CEL `formula_cel` for node-scope metrics).
117 pub fn to_attribute_spec(&self) -> AttributeSpec {
118 let value_type = match self.value_type.as_str() {
119 "int" => ValueType::Int,
120 "bool" => ValueType::Bool,
121 "str" | "string" => ValueType::Str,
122 _ => ValueType::Float,
123 };
124 let direction = match self.direction.as_deref() {
125 Some("lower_better") => Direction::LowerBetter,
126 Some("higher_better") => Direction::HigherBetter,
127 _ => Direction::Neutral,
128 };
129 AttributeSpec {
130 value_type,
131 label: self.label.clone(),
132 name: self.name.clone(),
133 short: self.short.clone(),
134 description: self.description.clone(),
135 remediation: self.remediation.clone(),
136 formula: self.formula_pretty.clone(),
137 // Node-scope metric: default the live-derivation JS to the CEL formula
138 // (valid JS for arithmetic; the viewer no-ops if it can't run it). A
139 // graph aggregate isn't shown per node, so it carries no `calc`.
140 calc: self
141 .formula_js
142 .clone()
143 .or_else(|| (self.scope == Scope::Node).then(|| self.formula_cel.clone())),
144 direction,
145 abbreviate: None,
146 group: self.group.clone(),
147 thresholds: self.thresholds(),
148 omit_at: self.omit_at,
149 }
150 }
151}
152
153/// Errors surfaced when loading/compiling a registry — all caught at load time
154/// (not per node), so a bad user formula fails fast with a clear message.
155#[derive(Debug)]
156pub enum RegistryError {
157 /// A `formula_cel` failed to parse as CEL.
158 Parse { key: String, message: String },
159 /// The metric dependency graph has a cycle (`a` ← `b` ← `a`).
160 Cycle { keys: Vec<String> },
161}
162
163impl std::fmt::Display for RegistryError {
164 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
165 match self {
166 RegistryError::Parse { key, message } => {
167 write!(f, "metric `{key}`: invalid CEL formula: {message}")
168 }
169 RegistryError::Cycle { keys } => {
170 write!(
171 f,
172 "metric formulas form a dependency cycle: {}",
173 keys.join(" → ")
174 )
175 }
176 }
177 }
178}
179
180impl std::error::Error for RegistryError {}