Skip to main content

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 {}