1use serde::{Deserialize, Serialize};
15
16use crate::render::{Cell, Row, RowId};
17use crate::semantic::{Action, ActionState};
18use crate::surface::{Column, ColumnKind};
19
20pub const LENS_SCHEMA_VERSION: u32 = 1;
24
25#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
27pub struct GroupVersionKind {
28 #[serde(default)]
30 pub group: String,
31 pub version: String,
33 pub kind: String,
35}
36
37impl GroupVersionKind {
38 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#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
50#[serde(rename_all = "snake_case")]
51pub enum RuleOp {
52 Eq,
54 Ne,
56 Gt,
58 Gte,
60 Lt,
62 Lte,
64 Contains,
66}
67
68#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
75pub struct StatusRule {
76 pub field: String,
78 pub op: RuleOp,
80 pub value: serde_json::Value,
82 pub level: crate::render::StatusLevel,
84}
85
86#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
96pub struct ConditionRule {
97 pub condition_type: String,
99 pub status: String,
102 pub level: crate::render::StatusLevel,
104}
105
106#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
110pub struct LensAction {
111 pub id: String,
113 pub label_key: String,
115 #[serde(rename = "state", default = "default_action_state")]
118 pub state: String,
119}
120
121fn default_action_state() -> String {
122 "allowed".into()
123}
124
125#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
127pub struct ViewDefinition {
128 pub id: String,
130 pub api_version: u32,
133 pub target: GroupVersionKind,
135 #[serde(default)]
137 pub columns: Vec<Column>,
138 #[serde(default)]
140 pub status: Vec<StatusRule>,
141 #[serde(default)]
144 pub conditions: Vec<ConditionRule>,
145 #[serde(default)]
147 pub actions: Vec<LensAction>,
148}
149
150impl ViewDefinition {
151 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
179pub 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 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 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 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 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 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
299fn is_condition_status(status: &str) -> bool {
301 matches!(status, "True" | "False" | "Unknown")
302}
303
304fn 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
317fn 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 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
333fn 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
353pub 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
377pub 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
425fn cell_for_column(col: &Column, resource: &serde_json::Value, vd: &ViewDefinition) -> Cell {
427 if col.kind == ColumnKind::Status {
428 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 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
462fn 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
472fn 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
483fn 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 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 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
549fn valid_header_key(key: &str) -> bool {
552 key.split('.').all(is_identifier) && key.contains('.')
553}
554
555pub 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
584pub 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, ¬_ready),
861 Some(crate::render::StatusLevel::Error)
862 );
863 let other = serde_json::json!({
865 "status": {"conditions": [{"type": "Progressing", "status": "True"}]}
866 });
867 assert_eq!(evaluate_status(&vd, &other), None);
868 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 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 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 assert_eq!(row.id, RowId("abc-123".into()));
989 assert_eq!(row.cells.len(), 3);
990 assert_eq!(row.cells[0], Cell::Text { value: "pg".into() });
992 assert_eq!(row.cells[1], Cell::Number { value: 3 });
994 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 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}