Skip to main content

kaptein_viewmodel/
lens.rs

1//! View-definition (lens) schema — the declarative, **data-first** extension tier
2//! (ADR-0004 tier 1, ADR-0012).
3//!
4//! A lens binds a CRD (or built-in resource) to columns, status inference, and actions
5//! with **no code** — it is a YAML/JSON document checked into Git and PR-reviewed. This
6//! module is the renderer-agnostic *semantics* of a lens: the data model and the
7//! validation that decide whether a lens is well-formed. The frontends render it; the
8//! core evaluates it. This module is wasm-pure (serde only, no `kube`/`tokio`), so the
9//! browser UI and the headless agent share the same validation as the CLI.
10//!
11//! The **schema** (the JSON Schema document + example lenses) is MIT/Apache-2.0 per
12//! ADR-0004's licensing split; this Rust implementation lives in the BUSL core.
13
14use serde::{Deserialize, Serialize};
15
16use crate::surface::{Column, ColumnKind};
17
18/// The lens schema version this release validates. Bumped on a breaking change to the
19/// lens schema (see `docs/versioning.md`); a lens declaring a different `api_version` is
20/// refused with a migration error.
21pub const LENS_SCHEMA_VERSION: u32 = 1;
22
23/// The resource a lens describes: `group` (empty for core), `version`, `kind`.
24#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
25pub struct GroupVersionKind {
26    /// API group, e.g. `""` (core), `"apps"`, `"postgresql.cnpg.io"`.
27    #[serde(default)]
28    pub group: String,
29    /// API version, e.g. `"v1"`.
30    pub version: String,
31    /// Resource kind, e.g. `"Pod"`, `"Cluster"` (CNPG), `"VirtualMachine"` (KubeVirt).
32    pub kind: String,
33}
34
35impl GroupVersionKind {
36    /// A compact `group/version/kind` string for diagnostics (group omitted for core).
37    pub fn display(&self) -> String {
38        if self.group.is_empty() {
39            format!("{}/{}", self.version, self.kind)
40        } else {
41            format!("{}/{}/{}", self.group, self.version, self.kind)
42        }
43    }
44}
45
46/// A comparison operator in a status-inference rule.
47#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
48#[serde(rename_all = "snake_case")]
49pub enum RuleOp {
50    /// `field == value` (string/number/bool equality).
51    Eq,
52    /// `field != value`.
53    Ne,
54    /// `field > value` (numeric).
55    Gt,
56    /// `field >= value` (numeric).
57    Gte,
58    /// `field < value` (numeric).
59    Lt,
60    /// `field <= value` (numeric).
61    Lte,
62    /// `field contains value` (substring).
63    Contains,
64}
65
66/// A status-inference rule: when `field` `op` `value` holds, assign `level`.
67///
68/// The `field` is a dotted JSON path (e.g. `status.phase`, `spec.replicas`); the core
69/// resolves it against the live object. This is declarative, so the schema is
70/// structural — no free-form expression language (that lands with the full lens engine
71/// in later M2.2 work).
72#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
73pub struct StatusRule {
74    /// Dotted JSON path to the field, e.g. `"status.phase"`.
75    pub field: String,
76    /// The comparison.
77    pub op: RuleOp,
78    /// The value to compare against (string, number, or bool).
79    pub value: serde_json::Value,
80    /// The level to assign when the rule matches.
81    pub level: crate::render::StatusLevel,
82}
83
84/// An action a lens declares. This is the lens-native form (snake_case `state`) — it
85/// maps to the render contract's `semantic::Action` at evaluation time. It is a separate
86/// type so the lens schema stays its own clean, user-authored contract.
87#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
88pub struct LensAction {
89    /// Stable action id, e.g. `"describe"`.
90    pub id: String,
91    /// Message key resolved by the frontend for i18n.
92    pub label_key: String,
93    /// Initial RBAC-preflight state (`allowed` — the core will grey it out if preflight
94    /// denies it).
95    #[serde(rename = "state", default = "default_action_state")]
96    pub state: String,
97}
98
99fn default_action_state() -> String {
100    "allowed".into()
101}
102
103/// The view-definition (lens) document.
104#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
105pub struct ViewDefinition {
106    /// Unique reverse-DNS id, e.g. `"com.example.cnpg-lens"`.
107    pub id: String,
108    /// The lens schema version this document was written against. Must equal
109    /// [`LENS_SCHEMA_VERSION`] (this release refuses anything else).
110    pub api_version: u32,
111    /// The resource kind this lens describes.
112    pub target: GroupVersionKind,
113    /// Columns to render (each is a view-model `Column`; semantics, not geometry).
114    #[serde(default)]
115    pub columns: Vec<Column>,
116    /// Optional status-inference rules, evaluated in order (first match wins).
117    #[serde(default)]
118    pub status: Vec<StatusRule>,
119    /// Actions this lens makes available, with their RBAC-preflight state.
120    #[serde(default)]
121    pub actions: Vec<LensAction>,
122}
123
124/// Validate a view definition, returning a list of problems (empty = valid).
125///
126/// This is the "reviewable in PRs" gate: a lens that fails validation is refused, never
127/// silently ignored — exactly like a prod-regex typo in the config.
128pub fn validate_viewdef(vd: &ViewDefinition) -> Vec<String> {
129    let mut problems = Vec::new();
130
131    if vd.id.trim().is_empty() {
132        problems.push("id: must not be empty".into());
133    } else if !vd.id.contains('.') {
134        problems.push(format!(
135            "id {:?}: must be reverse-DNS (e.g. \"com.example.cnpg-lens\")",
136            vd.id
137        ));
138    }
139
140    if vd.api_version != LENS_SCHEMA_VERSION {
141        problems.push(format!(
142            "api_version: this release supports lens schema v{LENS_SCHEMA_VERSION}, but the \
143             lens declares v{} — a migration is required (docs/versioning.md)",
144            vd.api_version
145        ));
146    }
147
148    if vd.target.version.trim().is_empty() {
149        problems.push("target.version: must not be empty".into());
150    }
151    if vd.target.kind.trim().is_empty() {
152        problems.push("target.kind: must not be empty".into());
153    }
154
155    // Column ids must be unique and non-empty.
156    let mut seen = std::collections::HashSet::new();
157    for col in &vd.columns {
158        if col.id.trim().is_empty() {
159            problems.push("columns: a column has an empty id".into());
160        } else if !seen.insert(col.id.as_str()) {
161            problems.push(format!("columns: duplicate column id {:?}", col.id));
162        }
163        if !valid_header_key(&col.header_key) {
164            problems.push(format!(
165                "columns.{:?}: header_key must be a dotted i18n key (e.g. \"col.name\")",
166                col.id
167            ));
168        }
169    }
170
171    // Status rules: field must be a dotted path; numeric ops need a numeric value.
172    for (i, rule) in vd.status.iter().enumerate() {
173        if !valid_field_path(&rule.field) {
174            problems.push(format!(
175                "status[{i}].field {:?}: not a dotted JSON path",
176                rule.field
177            ));
178        }
179        match rule.op {
180            RuleOp::Gt | RuleOp::Gte | RuleOp::Lt | RuleOp::Lte => {
181                if !rule.value.is_number() {
182                    problems.push(format!(
183                        "status[{i}].value: a numeric operator ({:?}) needs a numeric value",
184                        rule.op
185                    ));
186                }
187            }
188            RuleOp::Contains => {
189                if !rule.value.is_string() {
190                    problems.push(format!(
191                        "status[{i}].value: `contains` needs a string value"
192                    ));
193                }
194            }
195            RuleOp::Eq | RuleOp::Ne => {}
196        }
197    }
198
199    // Action ids must be unique.
200    let mut seen_actions = std::collections::HashSet::new();
201    for action in &vd.actions {
202        if action.id.trim().is_empty() || !seen_actions.insert(action.id.as_str()) {
203            problems.push(format!(
204                "actions: duplicate or empty action id {:?}",
205                action.id
206            ));
207        }
208    }
209
210    problems
211}
212
213/// A dotted JSON field path: leading identifier, then `.identifier` segments (or
214/// `[0]`-style numeric indexes).
215fn valid_field_path(field: &str) -> bool {
216    let mut parts = field.split('.');
217    let Some(first) = parts.next() else {
218        return false;
219    };
220    if !is_identifier(first) {
221        return false;
222    }
223    parts.all(is_segment)
224}
225
226/// Resolve a dotted field path (with `[i]` subscripts) against a JSON object. Returns
227/// `None` when the path is absent or a segment does not exist. Used by status-rule
228/// evaluation so a lens can read `status.phase` or `spec.containers[0].name`.
229fn resolve_field<'a>(root: &'a serde_json::Value, field: &str) -> Option<&'a serde_json::Value> {
230    let mut cur = root;
231    for segment in field.split('.') {
232        // Split a `name[0][1]` segment into the identifier and its subscripts.
233        let (ident, subscripts) = split_subscripts(segment);
234        cur = cur.get(ident)?;
235        for sub in subscripts {
236            cur = cur.get(sub)?;
237        }
238    }
239    Some(cur)
240}
241
242/// Split `"containers[0][1]"` into `("containers", [0, 1])`. A segment with no
243/// subscripts yields an empty index list.
244fn split_subscripts(segment: &str) -> (&str, Vec<usize>) {
245    let mut idx = segment.len();
246    let mut subs = Vec::new();
247    while idx > 0 && segment[..idx].ends_with(']') {
248        if let Some(open) = segment[..idx].rfind('[') {
249            let inside = &segment[open + 1..idx - 1];
250            if let Ok(n) = inside.parse::<usize>() {
251                subs.push(n);
252            }
253            idx = open;
254        } else {
255            break;
256        }
257    }
258    subs.reverse();
259    (&segment[..idx], subs)
260}
261
262/// Evaluate a lens's status rules against a live resource's JSON representation,
263/// returning the first matching level, or `None` when no rule matches. This is the
264/// "status inference" half of the lens engine (ADR-0012): the frontend colors the status
265/// chip from the level; the lens declares the meaning.
266pub fn evaluate_status(
267    vd: &ViewDefinition,
268    resource: &serde_json::Value,
269) -> Option<crate::render::StatusLevel> {
270    for rule in &vd.status {
271        if rule_matches(rule, resource) {
272            return Some(rule.level);
273        }
274    }
275    None
276}
277
278fn rule_matches(rule: &StatusRule, resource: &serde_json::Value) -> bool {
279    let Some(actual) = resolve_field(resource, &rule.field) else {
280        return false;
281    };
282    match rule.op {
283        RuleOp::Eq => actual == &rule.value,
284        RuleOp::Ne => actual != &rule.value,
285        RuleOp::Gt | RuleOp::Gte | RuleOp::Lt | RuleOp::Lte => {
286            // Numeric comparison; non-numeric operands never match.
287            let (Some(a), Some(b)) = (actual.as_i64(), rule.value.as_i64()) else {
288                return false;
289            };
290            match rule.op {
291                RuleOp::Gt => a > b,
292                RuleOp::Gte => a >= b,
293                RuleOp::Lt => a < b,
294                RuleOp::Lte => a <= b,
295                _ => unreachable!(),
296            }
297        }
298        RuleOp::Contains => match (actual.as_str(), rule.value.as_str()) {
299            (Some(a), Some(b)) => a.contains(b),
300            _ => false,
301        },
302    }
303}
304
305fn is_segment(seg: &str) -> bool {
306    // A segment is an identifier, optionally followed by one or more `[index]`
307    // subscripts: `containers`, `containers[0]`, `containers[0][1]`.
308    let mut idx = seg.len();
309    while idx > 0 && seg[..idx].ends_with(']') {
310        let Some(open) = seg[..idx].rfind('[') else {
311            return false;
312        };
313        let inside = &seg[open + 1..idx - 1];
314        if inside.is_empty() || !inside.chars().all(|c| c.is_ascii_digit()) {
315            return false;
316        }
317        idx = open;
318    }
319    is_identifier(&seg[..idx])
320}
321
322fn is_identifier(s: &str) -> bool {
323    !s.is_empty()
324        && s.chars()
325            .enumerate()
326            .all(|(i, c)| c.is_alphanumeric() || c == '_' || (i > 0 && c == '-'))
327}
328
329/// A dotted i18n header key (e.g. `"col.name"`): leading identifier, then `.identifier`
330/// segments. Reuses the identifier rules of the view-model's message keys.
331fn valid_header_key(key: &str) -> bool {
332    key.split('.').all(is_identifier) && key.contains('.')
333}
334
335/// A concrete example: a built-in `Column` set for a CNPG `Cluster` (the hardest-lens
336/// acceptance test from ADR-0012). This is data, not code — it is what a lens file
337/// declares, and it validates cleanly.
338pub fn example_cnpg_columns() -> Vec<Column> {
339    vec![
340        Column {
341            id: "name".into(),
342            header_key: "col.name".into(),
343            kind: ColumnKind::Text,
344            sortable: true,
345        },
346        Column {
347            id: "instances".into(),
348            header_key: "col.instances".into(),
349            kind: ColumnKind::Number,
350            sortable: true,
351        },
352        Column {
353            id: "status".into(),
354            header_key: "col.status".into(),
355            kind: ColumnKind::Status,
356            sortable: true,
357        },
358    ]
359}
360
361/// A concrete example status rule (CNPG): "phase == ClusterIsReady" → Ok.
362pub fn example_status_rule() -> StatusRule {
363    StatusRule {
364        field: "status.phase".into(),
365        op: RuleOp::Eq,
366        value: serde_json::json!("ClusterIsReady"),
367        level: crate::render::StatusLevel::Ok,
368    }
369}
370
371#[cfg(test)]
372mod tests {
373    use super::*;
374
375    fn col(id: &str) -> Column {
376        Column {
377            id: id.into(),
378            header_key: format!("col.{id}"),
379            kind: ColumnKind::Text,
380            sortable: true,
381        }
382    }
383
384    fn action(id: &str) -> LensAction {
385        LensAction {
386            id: id.into(),
387            label_key: format!("action.{id}"),
388            state: "allowed".into(),
389        }
390    }
391
392    fn valid() -> ViewDefinition {
393        ViewDefinition {
394            id: "com.example.cnpg-lens".into(),
395            api_version: LENS_SCHEMA_VERSION,
396            target: GroupVersionKind {
397                group: "postgresql.cnpg.io".into(),
398                version: "v1".into(),
399                kind: "Cluster".into(),
400            },
401            columns: vec![col("name"), col("status")],
402            status: vec![example_status_rule()],
403            actions: vec![action("describe")],
404        }
405    }
406
407    #[test]
408    fn valid_lens_has_no_problems() {
409        assert!(validate_viewdef(&valid()).is_empty());
410    }
411
412    #[test]
413    fn missing_reverse_dns_id_is_flagged() {
414        let mut vd = valid();
415        vd.id = "no-dot-here".into();
416        let problems = validate_viewdef(&vd);
417        assert!(problems.iter().any(|p| p.contains("reverse-DNS")));
418    }
419
420    #[test]
421    fn wrong_api_version_is_flagged() {
422        let mut vd = valid();
423        vd.api_version = 999;
424        let problems = validate_viewdef(&vd);
425        assert!(problems.iter().any(|p| p.contains("api_version")));
426    }
427
428    #[test]
429    fn duplicate_column_id_is_flagged() {
430        let mut vd = valid();
431        vd.columns = vec![col("name"), col("name")];
432        let problems = validate_viewdef(&vd);
433        assert!(problems.iter().any(|p| p.contains("duplicate column")));
434    }
435
436    #[test]
437    fn numeric_op_with_string_value_is_flagged() {
438        let mut vd = valid();
439        vd.status = vec![StatusRule {
440            field: "spec.replicas".into(),
441            op: RuleOp::Gt,
442            value: serde_json::json!("many"),
443            level: crate::render::StatusLevel::Warning,
444        }];
445        let problems = validate_viewdef(&vd);
446        assert!(problems.iter().any(|p| p.contains("numeric")));
447    }
448
449    #[test]
450    fn contains_op_with_numeric_value_is_flagged() {
451        let mut vd = valid();
452        vd.status = vec![StatusRule {
453            field: "status.phase".into(),
454            op: RuleOp::Contains,
455            value: serde_json::json!(3),
456            level: crate::render::StatusLevel::Warning,
457        }];
458        let problems = validate_viewdef(&vd);
459        assert!(problems.iter().any(|p| p.contains("contains")));
460    }
461
462    #[test]
463    fn malformed_field_path_is_flagged() {
464        let mut vd = valid();
465        vd.status = vec![StatusRule {
466            field: ".bad.path".into(),
467            op: RuleOp::Eq,
468            value: serde_json::json!("x"),
469            level: crate::render::StatusLevel::Ok,
470        }];
471        let problems = validate_viewdef(&vd);
472        assert!(problems.iter().any(|p| p.contains("field")));
473    }
474
475    #[test]
476    fn duplicate_action_id_is_flagged() {
477        let mut vd = valid();
478        vd.actions = vec![action("x"), action("x")];
479        let problems = validate_viewdef(&vd);
480        assert!(problems.iter().any(|p| p.contains("action")));
481    }
482
483    #[test]
484    fn field_path_validator_accepts_indexes() {
485        assert!(valid_field_path("status.phase"));
486        assert!(valid_field_path("spec.containers[0].name"));
487        assert!(valid_field_path("metadata.labels.app"));
488        assert!(!valid_field_path(""));
489        assert!(!valid_field_path(".phase"));
490        assert!(!valid_field_path("status..phase"));
491    }
492
493    #[test]
494    fn resolve_field_reads_nested_and_indexed_paths() {
495        let v = serde_json::json!({
496            "status": {"phase": "Running"},
497            "spec": {"containers": [{"name": "app"}]}
498        });
499        assert_eq!(
500            resolve_field(&v, "status.phase"),
501            Some(&serde_json::json!("Running"))
502        );
503        assert_eq!(
504            resolve_field(&v, "spec.containers[0].name"),
505            Some(&serde_json::json!("app"))
506        );
507        assert_eq!(resolve_field(&v, "status.nope"), None);
508    }
509
510    #[test]
511    fn evaluate_status_first_match_wins() {
512        let mut vd = valid();
513        vd.status = vec![
514            StatusRule {
515                field: "status.phase".into(),
516                op: RuleOp::Eq,
517                value: serde_json::json!("Running"),
518                level: crate::render::StatusLevel::Ok,
519            },
520            StatusRule {
521                field: "status.phase".into(),
522                op: RuleOp::Ne,
523                value: serde_json::json!("Running"),
524                level: crate::render::StatusLevel::Warning,
525            },
526        ];
527        let running = serde_json::json!({"status": {"phase": "Running"}});
528        assert_eq!(
529            evaluate_status(&vd, &running),
530            Some(crate::render::StatusLevel::Ok)
531        );
532        let pending = serde_json::json!({"status": {"phase": "Pending"}});
533        assert_eq!(
534            evaluate_status(&vd, &pending),
535            Some(crate::render::StatusLevel::Warning)
536        );
537        let empty = serde_json::json!({});
538        assert_eq!(evaluate_status(&vd, &empty), None);
539    }
540
541    #[test]
542    fn numeric_rule_compares_numerically() {
543        let mut vd = valid();
544        vd.status = vec![StatusRule {
545            field: "spec.replicas".into(),
546            op: RuleOp::Gt,
547            value: serde_json::json!(1),
548            level: crate::render::StatusLevel::Warning,
549        }];
550        let three = serde_json::json!({"spec": {"replicas": 3}});
551        assert_eq!(
552            evaluate_status(&vd, &three),
553            Some(crate::render::StatusLevel::Warning)
554        );
555        let one = serde_json::json!({"spec": {"replicas": 1}});
556        assert_eq!(evaluate_status(&vd, &one), None);
557    }
558
559    #[test]
560    fn contains_rule_matches_substring() {
561        let mut vd = valid();
562        vd.status = vec![StatusRule {
563            field: "status.message".into(),
564            op: RuleOp::Contains,
565            value: serde_json::json!("back-off"),
566            level: crate::render::StatusLevel::Error,
567        }];
568        let msg = serde_json::json!({"status": {"message": "back-off pulling image"}});
569        assert_eq!(
570            evaluate_status(&vd, &msg),
571            Some(crate::render::StatusLevel::Error)
572        );
573    }
574}