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