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::render::{Cell, Row, RowId};
17use crate::surface::{Column, ColumnKind};
18
19/// The lens schema version this release validates. Bumped on a breaking change to the
20/// lens schema (see `docs/versioning.md`); a lens declaring a different `api_version` is
21/// refused with a migration error.
22pub const LENS_SCHEMA_VERSION: u32 = 1;
23
24/// The resource a lens describes: `group` (empty for core), `version`, `kind`.
25#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
26pub struct GroupVersionKind {
27    /// API group, e.g. `""` (core), `"apps"`, `"postgresql.cnpg.io"`.
28    #[serde(default)]
29    pub group: String,
30    /// API version, e.g. `"v1"`.
31    pub version: String,
32    /// Resource kind, e.g. `"Pod"`, `"Cluster"` (CNPG), `"VirtualMachine"` (KubeVirt).
33    pub kind: String,
34}
35
36impl GroupVersionKind {
37    /// A compact `group/version/kind` string for diagnostics (group omitted for core).
38    pub fn display(&self) -> String {
39        if self.group.is_empty() {
40            format!("{}/{}", self.version, self.kind)
41        } else {
42            format!("{}/{}/{}", self.group, self.version, self.kind)
43        }
44    }
45}
46
47/// A comparison operator in a status-inference rule.
48#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
49#[serde(rename_all = "snake_case")]
50pub enum RuleOp {
51    /// `field == value` (string/number/bool equality).
52    Eq,
53    /// `field != value`.
54    Ne,
55    /// `field > value` (numeric).
56    Gt,
57    /// `field >= value` (numeric).
58    Gte,
59    /// `field < value` (numeric).
60    Lt,
61    /// `field <= value` (numeric).
62    Lte,
63    /// `field contains value` (substring).
64    Contains,
65}
66
67/// A status-inference rule: when `field` `op` `value` holds, assign `level`.
68///
69/// The `field` is a dotted JSON path (e.g. `status.phase`, `spec.replicas`); the core
70/// resolves it against the live object. This is declarative, so the schema is
71/// structural — no free-form expression language (that lands with the full lens engine
72/// in later M2.2 work).
73#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
74pub struct StatusRule {
75    /// Dotted JSON path to the field, e.g. `"status.phase"`.
76    pub field: String,
77    /// The comparison.
78    pub op: RuleOp,
79    /// The value to compare against (string, number, or bool).
80    pub value: serde_json::Value,
81    /// The level to assign when the rule matches.
82    pub level: crate::render::StatusLevel,
83}
84
85/// A status-inference rule over Kubernetes conditions (`status.conditions[]`).
86///
87/// The scalar [`StatusRule`] cannot express how the majority of modern CRDs signal
88/// readiness — via a typed condition (`type` + `status`) rather than a bare phase. This
89/// rule matches the first condition whose `type` equals [`Self::condition_type`]; if
90/// that condition's `status` equals [`Self::status`], the rule fires at [`Self::level`].
91/// This is what lets Strimzi Kafka, KubeVirt VirtualMachine, cert-manager Certificate,
92/// Keycloak, Tekton PipelineRun, Karpenter NodePool, and Knative Service all be declared
93/// as data (ADR-0012's "prove the schema against the hardest lenses" test).
94#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
95pub struct ConditionRule {
96    /// The condition `type` to match, e.g. `"Ready"`, `"ReconciliationSucceeded"`.
97    pub condition_type: String,
98    /// The condition `status` to match: `"True"`, `"False"`, or `"Unknown"` (the three
99    /// canonical Kubernetes condition statuses).
100    pub status: String,
101    /// The level to assign when the condition matches.
102    pub level: crate::render::StatusLevel,
103}
104
105/// An action a lens declares. This is the lens-native form (snake_case `state`) — it
106/// maps to the render contract's `semantic::Action` at evaluation time. It is a separate
107/// type so the lens schema stays its own clean, user-authored contract.
108#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
109pub struct LensAction {
110    /// Stable action id, e.g. `"describe"`.
111    pub id: String,
112    /// Message key resolved by the frontend for i18n.
113    pub label_key: String,
114    /// Initial RBAC-preflight state (`allowed` — the core will grey it out if preflight
115    /// denies it).
116    #[serde(rename = "state", default = "default_action_state")]
117    pub state: String,
118}
119
120fn default_action_state() -> String {
121    "allowed".into()
122}
123
124/// The view-definition (lens) document.
125#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
126pub struct ViewDefinition {
127    /// Unique reverse-DNS id, e.g. `"com.example.cnpg-lens"`.
128    pub id: String,
129    /// The lens schema version this document was written against. Must equal
130    /// [`LENS_SCHEMA_VERSION`] (this release refuses anything else).
131    pub api_version: u32,
132    /// The resource kind this lens describes.
133    pub target: GroupVersionKind,
134    /// Columns to render (each is a view-model `Column`; semantics, not geometry).
135    #[serde(default)]
136    pub columns: Vec<Column>,
137    /// Optional status-inference rules, evaluated in order (first match wins).
138    #[serde(default)]
139    pub status: Vec<StatusRule>,
140    /// Optional condition-based status rules (`status.conditions[]`), evaluated after
141    /// `status` (first match wins within the whole sequence).
142    #[serde(default)]
143    pub conditions: Vec<ConditionRule>,
144    /// Actions this lens makes available, with their RBAC-preflight state.
145    #[serde(default)]
146    pub actions: Vec<LensAction>,
147}
148
149/// Validate a view definition, returning a list of problems (empty = valid).
150///
151/// This is the "reviewable in PRs" gate: a lens that fails validation is refused, never
152/// silently ignored — exactly like a prod-regex typo in the config.
153pub fn validate_viewdef(vd: &ViewDefinition) -> Vec<String> {
154    let mut problems = Vec::new();
155
156    if vd.id.trim().is_empty() {
157        problems.push("id: must not be empty".into());
158    } else if !vd.id.contains('.') {
159        problems.push(format!(
160            "id {:?}: must be reverse-DNS (e.g. \"com.example.cnpg-lens\")",
161            vd.id
162        ));
163    }
164
165    if vd.api_version != LENS_SCHEMA_VERSION {
166        problems.push(format!(
167            "api_version: this release supports lens schema v{LENS_SCHEMA_VERSION}, but the \
168             lens declares v{} — a migration is required (docs/versioning.md)",
169            vd.api_version
170        ));
171    }
172
173    if vd.target.version.trim().is_empty() {
174        problems.push("target.version: must not be empty".into());
175    }
176    if vd.target.kind.trim().is_empty() {
177        problems.push("target.kind: must not be empty".into());
178    }
179
180    // Column ids must be unique and non-empty.
181    let mut seen = std::collections::HashSet::new();
182    for col in &vd.columns {
183        if col.id.trim().is_empty() {
184            problems.push("columns: a column has an empty id".into());
185        } else if !seen.insert(col.id.as_str()) {
186            problems.push(format!("columns: duplicate column id {:?}", col.id));
187        }
188        if !valid_header_key(&col.header_key) {
189            problems.push(format!(
190                "columns.{:?}: header_key must be a dotted i18n key (e.g. \"col.name\")",
191                col.id
192            ));
193        }
194        // A data column's value must come from somewhere: either a `field` path, or a
195        // `Status` kind (whose value is *inferred* by the status/condition rules).
196        if col.kind != ColumnKind::Status && col.field.as_deref().is_none_or(str::is_empty) {
197            problems.push(format!(
198                "columns.{:?}: a non-status column needs a `field` (dotted JSON path) so \
199                 its value is data-bound, not implicit (ADR-0012)",
200                col.id
201            ));
202        } else if let Some(field) = col.field.as_deref()
203            && !field.is_empty()
204            && !valid_field_path(field)
205        {
206            problems.push(format!(
207                "columns.{:?}.field {:?}: not a dotted JSON path",
208                col.id, field
209            ));
210        }
211    }
212
213    // Status rules: field must be a dotted path; numeric ops need a numeric value.
214    for (i, rule) in vd.status.iter().enumerate() {
215        if !valid_field_path(&rule.field) {
216            problems.push(format!(
217                "status[{i}].field {:?}: not a dotted JSON path",
218                rule.field
219            ));
220        }
221        match rule.op {
222            RuleOp::Gt | RuleOp::Gte | RuleOp::Lt | RuleOp::Lte => {
223                if !rule.value.is_number() {
224                    problems.push(format!(
225                        "status[{i}].value: a numeric operator ({:?}) needs a numeric value",
226                        rule.op
227                    ));
228                }
229            }
230            RuleOp::Contains => {
231                if !rule.value.is_string() {
232                    problems.push(format!(
233                        "status[{i}].value: `contains` needs a string value"
234                    ));
235                }
236            }
237            RuleOp::Eq | RuleOp::Ne => {}
238        }
239    }
240
241    // Action ids must be unique.
242    let mut seen_actions = std::collections::HashSet::new();
243    for action in &vd.actions {
244        if action.id.trim().is_empty() || !seen_actions.insert(action.id.as_str()) {
245            problems.push(format!(
246                "actions: duplicate or empty action id {:?}",
247                action.id
248            ));
249        }
250    }
251
252    // Condition rules: the type must be non-empty and the status must be one of the
253    // canonical Kubernetes condition statuses (True/False/Unknown).
254    for (i, rule) in vd.conditions.iter().enumerate() {
255        if rule.condition_type.trim().is_empty() {
256            problems.push(format!("conditions[{i}].condition_type: must not be empty"));
257        }
258        if !is_condition_status(&rule.status) {
259            problems.push(format!(
260                "conditions[{i}].status {:?}: must be one of \"True\", \"False\", \"Unknown\"",
261                rule.status
262            ));
263        }
264    }
265
266    problems
267}
268
269/// A canonical Kubernetes condition status: `True`, `False`, or `Unknown`.
270fn is_condition_status(status: &str) -> bool {
271    matches!(status, "True" | "False" | "Unknown")
272}
273
274/// A dotted JSON field path: leading identifier, then `.identifier` segments (or
275/// `[0]`-style numeric indexes).
276fn valid_field_path(field: &str) -> bool {
277    let mut parts = field.split('.');
278    let Some(first) = parts.next() else {
279        return false;
280    };
281    if !is_identifier(first) {
282        return false;
283    }
284    parts.all(is_segment)
285}
286
287/// Resolve a dotted field path (with `[i]` subscripts) against a JSON object. Returns
288/// `None` when the path is absent or a segment does not exist. Used by status-rule
289/// evaluation so a lens can read `status.phase` or `spec.containers[0].name`.
290fn resolve_field<'a>(root: &'a serde_json::Value, field: &str) -> Option<&'a serde_json::Value> {
291    let mut cur = root;
292    for segment in field.split('.') {
293        // Split a `name[0][1]` segment into the identifier and its subscripts.
294        let (ident, subscripts) = split_subscripts(segment);
295        cur = cur.get(ident)?;
296        for sub in subscripts {
297            cur = cur.get(sub)?;
298        }
299    }
300    Some(cur)
301}
302
303/// Split `"containers[0][1]"` into `("containers", [0, 1])`. A segment with no
304/// subscripts yields an empty index list.
305fn split_subscripts(segment: &str) -> (&str, Vec<usize>) {
306    let mut idx = segment.len();
307    let mut subs = Vec::new();
308    while idx > 0 && segment[..idx].ends_with(']') {
309        if let Some(open) = segment[..idx].rfind('[') {
310            let inside = &segment[open + 1..idx - 1];
311            if let Ok(n) = inside.parse::<usize>() {
312                subs.push(n);
313            }
314            idx = open;
315        } else {
316            break;
317        }
318    }
319    subs.reverse();
320    (&segment[..idx], subs)
321}
322
323/// Evaluate a lens's status rules against a live resource's JSON representation,
324/// returning the first matching level, or `None` when no rule matches. This is the
325/// "status inference" half of the lens engine (ADR-0012): the frontend colors the status
326/// chip from the level; the lens declares the meaning.
327///
328/// Scalar `status` rules are evaluated first, then `conditions` rules (first match wins
329/// across the whole sequence).
330pub fn evaluate_status(
331    vd: &ViewDefinition,
332    resource: &serde_json::Value,
333) -> Option<crate::render::StatusLevel> {
334    for rule in &vd.status {
335        if rule_matches(rule, resource) {
336            return Some(rule.level);
337        }
338    }
339    for rule in &vd.conditions {
340        if condition_matches(rule, resource) {
341            return Some(rule.level);
342        }
343    }
344    None
345}
346
347/// Render a lens + a live resource into the render contract's `Row` (ADR-0005).
348///
349/// This is the "status-rule *rendering*" half of M2.2: it maps a `ViewDefinition`'s
350/// columns onto a resource's JSON, so a frontend (TUI/GUI/browser/headless) consumes the
351/// *same* `Row`/`Cell` for the same input. Column semantics:
352///
353/// - A column whose `field` is set resolves that dotted path against the resource and
354///   emits a typed cell (numbers → `Number`, strings/bools/null → `Text`).
355/// - A `Status`-kind column's value is **inferred** via [`evaluate_status`]: the lens's
356///   status/condition rules decide the `StatusLevel` and the chip label.
357/// - A `field` that is absent/`None` on a `Text` column renders an empty cell; a
358///   missing field on a `Status` column renders an `Info` chip (no rule matched).
359///
360/// The stable `RowId` is the resource `metadata.uid` when present, else
361/// `namespace/name` (the same identity contract as `kaptein-integration`).
362pub fn render_row(vd: &ViewDefinition, resource: &serde_json::Value) -> Row {
363    let id = resource
364        .get("metadata")
365        .and_then(|m| m.get("uid"))
366        .and_then(|u| u.as_str())
367        .map(|uid| RowId(uid.to_string()))
368        .unwrap_or_else(|| {
369            let name = resource
370                .get("metadata")
371                .and_then(|m| m.get("name"))
372                .and_then(|n| n.as_str())
373                .unwrap_or_default();
374            let ns = resource
375                .get("metadata")
376                .and_then(|m| m.get("namespace"))
377                .and_then(|n| n.as_str())
378                .unwrap_or_default();
379            RowId(if ns.is_empty() {
380                name.to_string()
381            } else {
382                format!("{ns}/{name}")
383            })
384        });
385
386    let cells = vd
387        .columns
388        .iter()
389        .map(|col| cell_for_column(col, resource, vd))
390        .collect();
391
392    Row { id, cells }
393}
394
395/// Build the `Cell` for a single lens column against a resource.
396fn cell_for_column(col: &Column, resource: &serde_json::Value, vd: &ViewDefinition) -> Cell {
397    if col.kind == ColumnKind::Status {
398        // The status chip is *inferred* (not read from a single field): the lens's
399        // rules decide the level and label.
400        let (level, label) = match evaluate_status(vd, resource) {
401            Some(level) => (level, level_label(level)),
402            None => (crate::render::StatusLevel::Info, "unknown".to_string()),
403        };
404        return Cell::Status {
405            level,
406            label_key: label,
407        };
408    }
409
410    // Data columns read a dotted field path; a missing/unset field is an empty cell.
411    let Some(field) = col.field.as_deref() else {
412        return empty_cell_for_kind(col.kind);
413    };
414    match resolve_field(resource, field) {
415        Some(serde_json::Value::Number(n)) if n.is_i64() => Cell::Number {
416            value: n.as_i64().unwrap_or(0),
417        },
418        Some(serde_json::Value::Number(n)) => Cell::Text {
419            value: n.to_string(),
420        },
421        Some(serde_json::Value::String(s)) => Cell::Text { value: s.clone() },
422        Some(serde_json::Value::Bool(b)) => Cell::Text {
423            value: b.to_string(),
424        },
425        Some(serde_json::Value::Null) | None => empty_cell_for_kind(col.kind),
426        Some(other) => Cell::Text {
427            value: other.to_string(),
428        },
429    }
430}
431
432/// An empty cell matching a column's kind (empty text, or `0` for numbers).
433fn empty_cell_for_kind(kind: ColumnKind) -> Cell {
434    match kind {
435        ColumnKind::Number => Cell::Number { value: 0 },
436        _ => Cell::Text {
437            value: String::new(),
438        },
439    }
440}
441
442/// A stable, i18n-facing label for a status level (the frontend resolves the key).
443fn level_label(level: crate::render::StatusLevel) -> String {
444    match level {
445        crate::render::StatusLevel::Ok => "status.ok".into(),
446        crate::render::StatusLevel::Info => "status.info".into(),
447        crate::render::StatusLevel::Warning => "status.warning".into(),
448        crate::render::StatusLevel::Error => "status.error".into(),
449        crate::render::StatusLevel::Pending => "status.pending".into(),
450    }
451}
452
453/// Match a condition rule: find `status.conditions[]` and look for a condition whose
454/// `type` equals the rule's type and whose `status` equals the rule's status.
455fn condition_matches(rule: &ConditionRule, resource: &serde_json::Value) -> bool {
456    let Some(conditions) = resource.get("status").and_then(|s| s.get("conditions")) else {
457        return false;
458    };
459    let Some(list) = conditions.as_array() else {
460        return false;
461    };
462    list.iter().any(|cond| {
463        cond.get("type").and_then(|t| t.as_str()) == Some(rule.condition_type.as_str())
464            && cond.get("status").and_then(|s| s.as_str()) == Some(rule.status.as_str())
465    })
466}
467
468fn rule_matches(rule: &StatusRule, resource: &serde_json::Value) -> bool {
469    let Some(actual) = resolve_field(resource, &rule.field) else {
470        return false;
471    };
472    match rule.op {
473        RuleOp::Eq => actual == &rule.value,
474        RuleOp::Ne => actual != &rule.value,
475        RuleOp::Gt | RuleOp::Gte | RuleOp::Lt | RuleOp::Lte => {
476            // Numeric comparison; non-numeric operands never match.
477            let (Some(a), Some(b)) = (actual.as_i64(), rule.value.as_i64()) else {
478                return false;
479            };
480            match rule.op {
481                RuleOp::Gt => a > b,
482                RuleOp::Gte => a >= b,
483                RuleOp::Lt => a < b,
484                RuleOp::Lte => a <= b,
485                _ => unreachable!(),
486            }
487        }
488        RuleOp::Contains => match (actual.as_str(), rule.value.as_str()) {
489            (Some(a), Some(b)) => a.contains(b),
490            _ => false,
491        },
492    }
493}
494
495fn is_segment(seg: &str) -> bool {
496    // A segment is an identifier, optionally followed by one or more `[index]`
497    // subscripts: `containers`, `containers[0]`, `containers[0][1]`.
498    let mut idx = seg.len();
499    while idx > 0 && seg[..idx].ends_with(']') {
500        let Some(open) = seg[..idx].rfind('[') else {
501            return false;
502        };
503        let inside = &seg[open + 1..idx - 1];
504        if inside.is_empty() || !inside.chars().all(|c| c.is_ascii_digit()) {
505            return false;
506        }
507        idx = open;
508    }
509    is_identifier(&seg[..idx])
510}
511
512fn is_identifier(s: &str) -> bool {
513    !s.is_empty()
514        && s.chars()
515            .enumerate()
516            .all(|(i, c)| c.is_alphanumeric() || c == '_' || (i > 0 && c == '-'))
517}
518
519/// A dotted i18n header key (e.g. `"col.name"`): leading identifier, then `.identifier`
520/// segments. Reuses the identifier rules of the view-model's message keys.
521fn valid_header_key(key: &str) -> bool {
522    key.split('.').all(is_identifier) && key.contains('.')
523}
524
525/// A concrete example: a built-in `Column` set for a CNPG `Cluster` (the hardest-lens
526/// acceptance test from ADR-0012). This is data, not code — it is what a lens file
527/// declares, and it validates cleanly.
528pub fn example_cnpg_columns() -> Vec<Column> {
529    vec![
530        Column {
531            id: "name".into(),
532            header_key: "col.name".into(),
533            kind: ColumnKind::Text,
534            sortable: true,
535            field: Some("metadata.name".into()),
536        },
537        Column {
538            id: "instances".into(),
539            header_key: "col.instances".into(),
540            kind: ColumnKind::Number,
541            sortable: true,
542            field: Some("spec.instances".into()),
543        },
544        Column {
545            id: "status".into(),
546            header_key: "col.status".into(),
547            kind: ColumnKind::Status,
548            sortable: true,
549            field: None,
550        },
551    ]
552}
553
554/// A concrete example status rule (CNPG): "phase == ClusterIsReady" → Ok.
555pub fn example_status_rule() -> StatusRule {
556    StatusRule {
557        field: "status.phase".into(),
558        op: RuleOp::Eq,
559        value: serde_json::json!("ClusterIsReady"),
560        level: crate::render::StatusLevel::Ok,
561    }
562}
563
564#[cfg(test)]
565mod tests {
566    use super::*;
567
568    fn col(id: &str) -> Column {
569        Column {
570            id: id.into(),
571            header_key: format!("col.{id}"),
572            kind: ColumnKind::Text,
573            sortable: true,
574            field: Some(format!("metadata.{id}")),
575        }
576    }
577
578    fn status_col(id: &str) -> Column {
579        Column {
580            id: id.into(),
581            header_key: format!("col.{id}"),
582            kind: ColumnKind::Status,
583            sortable: true,
584            field: None,
585        }
586    }
587
588    fn action(id: &str) -> LensAction {
589        LensAction {
590            id: id.into(),
591            label_key: format!("action.{id}"),
592            state: "allowed".into(),
593        }
594    }
595
596    fn valid() -> ViewDefinition {
597        ViewDefinition {
598            id: "com.example.cnpg-lens".into(),
599            api_version: LENS_SCHEMA_VERSION,
600            target: GroupVersionKind {
601                group: "postgresql.cnpg.io".into(),
602                version: "v1".into(),
603                kind: "Cluster".into(),
604            },
605            columns: vec![col("name"), status_col("status")],
606            status: vec![example_status_rule()],
607            conditions: vec![],
608            actions: vec![action("describe")],
609        }
610    }
611
612    #[test]
613    fn valid_lens_has_no_problems() {
614        assert!(validate_viewdef(&valid()).is_empty());
615    }
616
617    #[test]
618    fn missing_reverse_dns_id_is_flagged() {
619        let mut vd = valid();
620        vd.id = "no-dot-here".into();
621        let problems = validate_viewdef(&vd);
622        assert!(problems.iter().any(|p| p.contains("reverse-DNS")));
623    }
624
625    #[test]
626    fn wrong_api_version_is_flagged() {
627        let mut vd = valid();
628        vd.api_version = 999;
629        let problems = validate_viewdef(&vd);
630        assert!(problems.iter().any(|p| p.contains("api_version")));
631    }
632
633    #[test]
634    fn duplicate_column_id_is_flagged() {
635        let mut vd = valid();
636        vd.columns = vec![col("name"), col("name")];
637        let problems = validate_viewdef(&vd);
638        assert!(problems.iter().any(|p| p.contains("duplicate column")));
639    }
640
641    #[test]
642    fn numeric_op_with_string_value_is_flagged() {
643        let mut vd = valid();
644        vd.status = vec![StatusRule {
645            field: "spec.replicas".into(),
646            op: RuleOp::Gt,
647            value: serde_json::json!("many"),
648            level: crate::render::StatusLevel::Warning,
649        }];
650        let problems = validate_viewdef(&vd);
651        assert!(problems.iter().any(|p| p.contains("numeric")));
652    }
653
654    #[test]
655    fn contains_op_with_numeric_value_is_flagged() {
656        let mut vd = valid();
657        vd.status = vec![StatusRule {
658            field: "status.phase".into(),
659            op: RuleOp::Contains,
660            value: serde_json::json!(3),
661            level: crate::render::StatusLevel::Warning,
662        }];
663        let problems = validate_viewdef(&vd);
664        assert!(problems.iter().any(|p| p.contains("contains")));
665    }
666
667    #[test]
668    fn malformed_field_path_is_flagged() {
669        let mut vd = valid();
670        vd.status = vec![StatusRule {
671            field: ".bad.path".into(),
672            op: RuleOp::Eq,
673            value: serde_json::json!("x"),
674            level: crate::render::StatusLevel::Ok,
675        }];
676        let problems = validate_viewdef(&vd);
677        assert!(problems.iter().any(|p| p.contains("field")));
678    }
679
680    #[test]
681    fn duplicate_action_id_is_flagged() {
682        let mut vd = valid();
683        vd.actions = vec![action("x"), action("x")];
684        let problems = validate_viewdef(&vd);
685        assert!(problems.iter().any(|p| p.contains("action")));
686    }
687
688    #[test]
689    fn field_path_validator_accepts_indexes() {
690        assert!(valid_field_path("status.phase"));
691        assert!(valid_field_path("spec.containers[0].name"));
692        assert!(valid_field_path("metadata.labels.app"));
693        assert!(!valid_field_path(""));
694        assert!(!valid_field_path(".phase"));
695        assert!(!valid_field_path("status..phase"));
696    }
697
698    #[test]
699    fn resolve_field_reads_nested_and_indexed_paths() {
700        let v = serde_json::json!({
701            "status": {"phase": "Running"},
702            "spec": {"containers": [{"name": "app"}]}
703        });
704        assert_eq!(
705            resolve_field(&v, "status.phase"),
706            Some(&serde_json::json!("Running"))
707        );
708        assert_eq!(
709            resolve_field(&v, "spec.containers[0].name"),
710            Some(&serde_json::json!("app"))
711        );
712        assert_eq!(resolve_field(&v, "status.nope"), None);
713    }
714
715    #[test]
716    fn evaluate_status_first_match_wins() {
717        let mut vd = valid();
718        vd.status = vec![
719            StatusRule {
720                field: "status.phase".into(),
721                op: RuleOp::Eq,
722                value: serde_json::json!("Running"),
723                level: crate::render::StatusLevel::Ok,
724            },
725            StatusRule {
726                field: "status.phase".into(),
727                op: RuleOp::Ne,
728                value: serde_json::json!("Running"),
729                level: crate::render::StatusLevel::Warning,
730            },
731        ];
732        let running = serde_json::json!({"status": {"phase": "Running"}});
733        assert_eq!(
734            evaluate_status(&vd, &running),
735            Some(crate::render::StatusLevel::Ok)
736        );
737        let pending = serde_json::json!({"status": {"phase": "Pending"}});
738        assert_eq!(
739            evaluate_status(&vd, &pending),
740            Some(crate::render::StatusLevel::Warning)
741        );
742        let empty = serde_json::json!({});
743        assert_eq!(evaluate_status(&vd, &empty), None);
744    }
745
746    #[test]
747    fn numeric_rule_compares_numerically() {
748        let mut vd = valid();
749        vd.status = vec![StatusRule {
750            field: "spec.replicas".into(),
751            op: RuleOp::Gt,
752            value: serde_json::json!(1),
753            level: crate::render::StatusLevel::Warning,
754        }];
755        let three = serde_json::json!({"spec": {"replicas": 3}});
756        assert_eq!(
757            evaluate_status(&vd, &three),
758            Some(crate::render::StatusLevel::Warning)
759        );
760        let one = serde_json::json!({"spec": {"replicas": 1}});
761        assert_eq!(evaluate_status(&vd, &one), None);
762    }
763
764    #[test]
765    fn contains_rule_matches_substring() {
766        let mut vd = valid();
767        vd.status = vec![StatusRule {
768            field: "status.message".into(),
769            op: RuleOp::Contains,
770            value: serde_json::json!("back-off"),
771            level: crate::render::StatusLevel::Error,
772        }];
773        let msg = serde_json::json!({"status": {"message": "back-off pulling image"}});
774        assert_eq!(
775            evaluate_status(&vd, &msg),
776            Some(crate::render::StatusLevel::Error)
777        );
778    }
779
780    #[test]
781    fn condition_rule_matches_ready_true() {
782        let mut vd = valid();
783        vd.status = vec![];
784        vd.conditions = vec![
785            ConditionRule {
786                condition_type: "Ready".into(),
787                status: "True".into(),
788                level: crate::render::StatusLevel::Ok,
789            },
790            ConditionRule {
791                condition_type: "Ready".into(),
792                status: "False".into(),
793                level: crate::render::StatusLevel::Error,
794            },
795        ];
796        let ready = serde_json::json!({
797            "status": {"conditions": [{"type": "Ready", "status": "True"}]}
798        });
799        assert_eq!(
800            evaluate_status(&vd, &ready),
801            Some(crate::render::StatusLevel::Ok)
802        );
803        let not_ready = serde_json::json!({
804            "status": {"conditions": [{"type": "Ready", "status": "False"}]}
805        });
806        assert_eq!(
807            evaluate_status(&vd, &not_ready),
808            Some(crate::render::StatusLevel::Error)
809        );
810        // A condition of a different type must not match.
811        let other = serde_json::json!({
812            "status": {"conditions": [{"type": "Progressing", "status": "True"}]}
813        });
814        assert_eq!(evaluate_status(&vd, &other), None);
815        // Missing conditions must not match.
816        assert_eq!(
817            evaluate_status(&vd, &serde_json::json!({"status": {}})),
818            None
819        );
820    }
821
822    #[test]
823    fn invalid_condition_status_is_flagged() {
824        let mut vd = valid();
825        vd.conditions = vec![ConditionRule {
826            condition_type: "Ready".into(),
827            status: "Yes".into(),
828            level: crate::render::StatusLevel::Ok,
829        }];
830        let problems = validate_viewdef(&vd);
831        assert!(problems.iter().any(|p| p.contains("conditions[0].status")));
832    }
833
834    #[test]
835    fn empty_condition_type_is_flagged() {
836        let mut vd = valid();
837        vd.conditions = vec![ConditionRule {
838            condition_type: "".into(),
839            status: "True".into(),
840            level: crate::render::StatusLevel::Ok,
841        }];
842        let problems = validate_viewdef(&vd);
843        assert!(
844            problems
845                .iter()
846                .any(|p| p.contains("conditions[0].condition_type"))
847        );
848    }
849
850    #[test]
851    fn non_status_column_without_field_is_flagged() {
852        let mut vd = valid();
853        // A text column with no `field` cannot be data-bound.
854        vd.columns = vec![Column {
855            id: "name".into(),
856            header_key: "col.name".into(),
857            kind: ColumnKind::Text,
858            sortable: true,
859            field: None,
860        }];
861        let problems = validate_viewdef(&vd);
862        assert!(problems.iter().any(|p| p.contains("field")));
863    }
864
865    #[test]
866    fn malformed_column_field_is_flagged() {
867        let mut vd = valid();
868        vd.columns = vec![Column {
869            id: "name".into(),
870            header_key: "col.name".into(),
871            kind: ColumnKind::Text,
872            sortable: true,
873            field: Some(".bad.path".into()),
874        }];
875        let problems = validate_viewdef(&vd);
876        assert!(
877            problems
878                .iter()
879                .any(|p| p.contains("not a dotted JSON path"))
880        );
881    }
882
883    #[test]
884    fn render_row_maps_fields_and_infers_status() {
885        // A lens with a name (data), instances (number), and status (inferred) column.
886        let vd = ViewDefinition {
887            id: "com.example.cnpg-lens".into(),
888            api_version: LENS_SCHEMA_VERSION,
889            target: GroupVersionKind {
890                group: "postgresql.cnpg.io".into(),
891                version: "v1".into(),
892                kind: "Cluster".into(),
893            },
894            columns: vec![
895                Column {
896                    id: "name".into(),
897                    header_key: "col.name".into(),
898                    kind: ColumnKind::Text,
899                    sortable: true,
900                    field: Some("metadata.name".into()),
901                },
902                Column {
903                    id: "instances".into(),
904                    header_key: "col.instances".into(),
905                    kind: ColumnKind::Number,
906                    sortable: true,
907                    field: Some("spec.instances".into()),
908                },
909                Column {
910                    id: "status".into(),
911                    header_key: "col.status".into(),
912                    kind: ColumnKind::Status,
913                    sortable: true,
914                    field: None,
915                },
916            ],
917            status: vec![StatusRule {
918                field: "status.phase".into(),
919                op: RuleOp::Eq,
920                value: serde_json::json!("ClusterIsReady"),
921                level: crate::render::StatusLevel::Ok,
922            }],
923            conditions: vec![],
924            actions: vec![],
925        };
926
927        let resource = serde_json::json!({
928            "metadata": {"uid": "abc-123", "name": "pg", "namespace": "db"},
929            "spec": {"instances": 3},
930            "status": {"phase": "ClusterIsReady"}
931        });
932
933        let row = render_row(&vd, &resource);
934        // Stable identity is metadata.uid.
935        assert_eq!(row.id, RowId("abc-123".into()));
936        assert_eq!(row.cells.len(), 3);
937        // name → Text("pg")
938        assert_eq!(row.cells[0], Cell::Text { value: "pg".into() });
939        // instances → Number(3)
940        assert_eq!(row.cells[1], Cell::Number { value: 3 });
941        // status → inferred Ok chip
942        assert_eq!(
943            row.cells[2],
944            Cell::Status {
945                level: crate::render::StatusLevel::Ok,
946                label_key: "status.ok".into(),
947            }
948        );
949    }
950
951    #[test]
952    fn render_row_falls_back_to_ns_name_identity_and_info_status() {
953        let vd = ViewDefinition {
954            id: "com.example.t".into(),
955            api_version: LENS_SCHEMA_VERSION,
956            target: GroupVersionKind {
957                group: "example.io".into(),
958                version: "v1".into(),
959                kind: "Thing".into(),
960            },
961            columns: vec![Column {
962                id: "status".into(),
963                header_key: "col.status".into(),
964                kind: ColumnKind::Status,
965                sortable: true,
966                field: None,
967            }],
968            status: vec![],
969            conditions: vec![],
970            actions: vec![],
971        };
972        // No uid → ns/name identity; no matching rule → Info "unknown" chip.
973        let resource = serde_json::json!({
974            "metadata": {"name": "x", "namespace": "n"}
975        });
976        let row = render_row(&vd, &resource);
977        assert_eq!(row.id, RowId("n/x".into()));
978        assert_eq!(
979            row.cells[0],
980            Cell::Status {
981                level: crate::render::StatusLevel::Info,
982                label_key: "unknown".into(),
983            }
984        );
985    }
986}