use serde::{Deserialize, Serialize};
use crate::render::{Cell, Row, RowId};
use crate::surface::{Column, ColumnKind};
pub const LENS_SCHEMA_VERSION: u32 = 1;
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct GroupVersionKind {
#[serde(default)]
pub group: String,
pub version: String,
pub kind: String,
}
impl GroupVersionKind {
pub fn display(&self) -> String {
if self.group.is_empty() {
format!("{}/{}", self.version, self.kind)
} else {
format!("{}/{}/{}", self.group, self.version, self.kind)
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum RuleOp {
Eq,
Ne,
Gt,
Gte,
Lt,
Lte,
Contains,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct StatusRule {
pub field: String,
pub op: RuleOp,
pub value: serde_json::Value,
pub level: crate::render::StatusLevel,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct ConditionRule {
pub condition_type: String,
pub status: String,
pub level: crate::render::StatusLevel,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct LensAction {
pub id: String,
pub label_key: String,
#[serde(rename = "state", default = "default_action_state")]
pub state: String,
}
fn default_action_state() -> String {
"allowed".into()
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct ViewDefinition {
pub id: String,
pub api_version: u32,
pub target: GroupVersionKind,
#[serde(default)]
pub columns: Vec<Column>,
#[serde(default)]
pub status: Vec<StatusRule>,
#[serde(default)]
pub conditions: Vec<ConditionRule>,
#[serde(default)]
pub actions: Vec<LensAction>,
}
pub fn validate_viewdef(vd: &ViewDefinition) -> Vec<String> {
let mut problems = Vec::new();
if vd.id.trim().is_empty() {
problems.push("id: must not be empty".into());
} else if !vd.id.contains('.') {
problems.push(format!(
"id {:?}: must be reverse-DNS (e.g. \"com.example.cnpg-lens\")",
vd.id
));
}
if vd.api_version != LENS_SCHEMA_VERSION {
problems.push(format!(
"api_version: this release supports lens schema v{LENS_SCHEMA_VERSION}, but the \
lens declares v{} — a migration is required (docs/versioning.md)",
vd.api_version
));
}
if vd.target.version.trim().is_empty() {
problems.push("target.version: must not be empty".into());
}
if vd.target.kind.trim().is_empty() {
problems.push("target.kind: must not be empty".into());
}
let mut seen = std::collections::HashSet::new();
for col in &vd.columns {
if col.id.trim().is_empty() {
problems.push("columns: a column has an empty id".into());
} else if !seen.insert(col.id.as_str()) {
problems.push(format!("columns: duplicate column id {:?}", col.id));
}
if !valid_header_key(&col.header_key) {
problems.push(format!(
"columns.{:?}: header_key must be a dotted i18n key (e.g. \"col.name\")",
col.id
));
}
if col.kind != ColumnKind::Status && col.field.as_deref().is_none_or(str::is_empty) {
problems.push(format!(
"columns.{:?}: a non-status column needs a `field` (dotted JSON path) so \
its value is data-bound, not implicit (ADR-0012)",
col.id
));
} else if let Some(field) = col.field.as_deref()
&& !field.is_empty()
&& !valid_field_path(field)
{
problems.push(format!(
"columns.{:?}.field {:?}: not a dotted JSON path",
col.id, field
));
}
}
for (i, rule) in vd.status.iter().enumerate() {
if !valid_field_path(&rule.field) {
problems.push(format!(
"status[{i}].field {:?}: not a dotted JSON path",
rule.field
));
}
match rule.op {
RuleOp::Gt | RuleOp::Gte | RuleOp::Lt | RuleOp::Lte => {
if !rule.value.is_number() {
problems.push(format!(
"status[{i}].value: a numeric operator ({:?}) needs a numeric value",
rule.op
));
}
}
RuleOp::Contains => {
if !rule.value.is_string() {
problems.push(format!(
"status[{i}].value: `contains` needs a string value"
));
}
}
RuleOp::Eq | RuleOp::Ne => {}
}
}
let mut seen_actions = std::collections::HashSet::new();
for action in &vd.actions {
if action.id.trim().is_empty() || !seen_actions.insert(action.id.as_str()) {
problems.push(format!(
"actions: duplicate or empty action id {:?}",
action.id
));
}
}
for (i, rule) in vd.conditions.iter().enumerate() {
if rule.condition_type.trim().is_empty() {
problems.push(format!("conditions[{i}].condition_type: must not be empty"));
}
if !is_condition_status(&rule.status) {
problems.push(format!(
"conditions[{i}].status {:?}: must be one of \"True\", \"False\", \"Unknown\"",
rule.status
));
}
}
problems
}
fn is_condition_status(status: &str) -> bool {
matches!(status, "True" | "False" | "Unknown")
}
fn valid_field_path(field: &str) -> bool {
let mut parts = field.split('.');
let Some(first) = parts.next() else {
return false;
};
if !is_identifier(first) {
return false;
}
parts.all(is_segment)
}
fn resolve_field<'a>(root: &'a serde_json::Value, field: &str) -> Option<&'a serde_json::Value> {
let mut cur = root;
for segment in field.split('.') {
let (ident, subscripts) = split_subscripts(segment);
cur = cur.get(ident)?;
for sub in subscripts {
cur = cur.get(sub)?;
}
}
Some(cur)
}
fn split_subscripts(segment: &str) -> (&str, Vec<usize>) {
let mut idx = segment.len();
let mut subs = Vec::new();
while idx > 0 && segment[..idx].ends_with(']') {
if let Some(open) = segment[..idx].rfind('[') {
let inside = &segment[open + 1..idx - 1];
if let Ok(n) = inside.parse::<usize>() {
subs.push(n);
}
idx = open;
} else {
break;
}
}
subs.reverse();
(&segment[..idx], subs)
}
pub fn evaluate_status(
vd: &ViewDefinition,
resource: &serde_json::Value,
) -> Option<crate::render::StatusLevel> {
for rule in &vd.status {
if rule_matches(rule, resource) {
return Some(rule.level);
}
}
for rule in &vd.conditions {
if condition_matches(rule, resource) {
return Some(rule.level);
}
}
None
}
pub fn render_row(vd: &ViewDefinition, resource: &serde_json::Value) -> Row {
let id = resource
.get("metadata")
.and_then(|m| m.get("uid"))
.and_then(|u| u.as_str())
.map(|uid| RowId(uid.to_string()))
.unwrap_or_else(|| {
let name = resource
.get("metadata")
.and_then(|m| m.get("name"))
.and_then(|n| n.as_str())
.unwrap_or_default();
let ns = resource
.get("metadata")
.and_then(|m| m.get("namespace"))
.and_then(|n| n.as_str())
.unwrap_or_default();
RowId(if ns.is_empty() {
name.to_string()
} else {
format!("{ns}/{name}")
})
});
let cells = vd
.columns
.iter()
.map(|col| cell_for_column(col, resource, vd))
.collect();
Row { id, cells }
}
fn cell_for_column(col: &Column, resource: &serde_json::Value, vd: &ViewDefinition) -> Cell {
if col.kind == ColumnKind::Status {
let (level, label) = match evaluate_status(vd, resource) {
Some(level) => (level, level_label(level)),
None => (crate::render::StatusLevel::Info, "unknown".to_string()),
};
return Cell::Status {
level,
label_key: label,
};
}
let Some(field) = col.field.as_deref() else {
return empty_cell_for_kind(col.kind);
};
match resolve_field(resource, field) {
Some(serde_json::Value::Number(n)) if n.is_i64() => Cell::Number {
value: n.as_i64().unwrap_or(0),
},
Some(serde_json::Value::Number(n)) => Cell::Text {
value: n.to_string(),
},
Some(serde_json::Value::String(s)) => Cell::Text { value: s.clone() },
Some(serde_json::Value::Bool(b)) => Cell::Text {
value: b.to_string(),
},
Some(serde_json::Value::Null) | None => empty_cell_for_kind(col.kind),
Some(other) => Cell::Text {
value: other.to_string(),
},
}
}
fn empty_cell_for_kind(kind: ColumnKind) -> Cell {
match kind {
ColumnKind::Number => Cell::Number { value: 0 },
_ => Cell::Text {
value: String::new(),
},
}
}
fn level_label(level: crate::render::StatusLevel) -> String {
match level {
crate::render::StatusLevel::Ok => "status.ok".into(),
crate::render::StatusLevel::Info => "status.info".into(),
crate::render::StatusLevel::Warning => "status.warning".into(),
crate::render::StatusLevel::Error => "status.error".into(),
crate::render::StatusLevel::Pending => "status.pending".into(),
}
}
fn condition_matches(rule: &ConditionRule, resource: &serde_json::Value) -> bool {
let Some(conditions) = resource.get("status").and_then(|s| s.get("conditions")) else {
return false;
};
let Some(list) = conditions.as_array() else {
return false;
};
list.iter().any(|cond| {
cond.get("type").and_then(|t| t.as_str()) == Some(rule.condition_type.as_str())
&& cond.get("status").and_then(|s| s.as_str()) == Some(rule.status.as_str())
})
}
fn rule_matches(rule: &StatusRule, resource: &serde_json::Value) -> bool {
let Some(actual) = resolve_field(resource, &rule.field) else {
return false;
};
match rule.op {
RuleOp::Eq => actual == &rule.value,
RuleOp::Ne => actual != &rule.value,
RuleOp::Gt | RuleOp::Gte | RuleOp::Lt | RuleOp::Lte => {
let (Some(a), Some(b)) = (actual.as_i64(), rule.value.as_i64()) else {
return false;
};
match rule.op {
RuleOp::Gt => a > b,
RuleOp::Gte => a >= b,
RuleOp::Lt => a < b,
RuleOp::Lte => a <= b,
_ => unreachable!(),
}
}
RuleOp::Contains => match (actual.as_str(), rule.value.as_str()) {
(Some(a), Some(b)) => a.contains(b),
_ => false,
},
}
}
fn is_segment(seg: &str) -> bool {
let mut idx = seg.len();
while idx > 0 && seg[..idx].ends_with(']') {
let Some(open) = seg[..idx].rfind('[') else {
return false;
};
let inside = &seg[open + 1..idx - 1];
if inside.is_empty() || !inside.chars().all(|c| c.is_ascii_digit()) {
return false;
}
idx = open;
}
is_identifier(&seg[..idx])
}
fn is_identifier(s: &str) -> bool {
!s.is_empty()
&& s.chars()
.enumerate()
.all(|(i, c)| c.is_alphanumeric() || c == '_' || (i > 0 && c == '-'))
}
fn valid_header_key(key: &str) -> bool {
key.split('.').all(is_identifier) && key.contains('.')
}
pub fn example_cnpg_columns() -> Vec<Column> {
vec![
Column {
id: "name".into(),
header_key: "col.name".into(),
kind: ColumnKind::Text,
sortable: true,
field: Some("metadata.name".into()),
},
Column {
id: "instances".into(),
header_key: "col.instances".into(),
kind: ColumnKind::Number,
sortable: true,
field: Some("spec.instances".into()),
},
Column {
id: "status".into(),
header_key: "col.status".into(),
kind: ColumnKind::Status,
sortable: true,
field: None,
},
]
}
pub fn example_status_rule() -> StatusRule {
StatusRule {
field: "status.phase".into(),
op: RuleOp::Eq,
value: serde_json::json!("ClusterIsReady"),
level: crate::render::StatusLevel::Ok,
}
}
#[cfg(test)]
mod tests {
use super::*;
fn col(id: &str) -> Column {
Column {
id: id.into(),
header_key: format!("col.{id}"),
kind: ColumnKind::Text,
sortable: true,
field: Some(format!("metadata.{id}")),
}
}
fn status_col(id: &str) -> Column {
Column {
id: id.into(),
header_key: format!("col.{id}"),
kind: ColumnKind::Status,
sortable: true,
field: None,
}
}
fn action(id: &str) -> LensAction {
LensAction {
id: id.into(),
label_key: format!("action.{id}"),
state: "allowed".into(),
}
}
fn valid() -> ViewDefinition {
ViewDefinition {
id: "com.example.cnpg-lens".into(),
api_version: LENS_SCHEMA_VERSION,
target: GroupVersionKind {
group: "postgresql.cnpg.io".into(),
version: "v1".into(),
kind: "Cluster".into(),
},
columns: vec![col("name"), status_col("status")],
status: vec![example_status_rule()],
conditions: vec![],
actions: vec![action("describe")],
}
}
#[test]
fn valid_lens_has_no_problems() {
assert!(validate_viewdef(&valid()).is_empty());
}
#[test]
fn missing_reverse_dns_id_is_flagged() {
let mut vd = valid();
vd.id = "no-dot-here".into();
let problems = validate_viewdef(&vd);
assert!(problems.iter().any(|p| p.contains("reverse-DNS")));
}
#[test]
fn wrong_api_version_is_flagged() {
let mut vd = valid();
vd.api_version = 999;
let problems = validate_viewdef(&vd);
assert!(problems.iter().any(|p| p.contains("api_version")));
}
#[test]
fn duplicate_column_id_is_flagged() {
let mut vd = valid();
vd.columns = vec![col("name"), col("name")];
let problems = validate_viewdef(&vd);
assert!(problems.iter().any(|p| p.contains("duplicate column")));
}
#[test]
fn numeric_op_with_string_value_is_flagged() {
let mut vd = valid();
vd.status = vec![StatusRule {
field: "spec.replicas".into(),
op: RuleOp::Gt,
value: serde_json::json!("many"),
level: crate::render::StatusLevel::Warning,
}];
let problems = validate_viewdef(&vd);
assert!(problems.iter().any(|p| p.contains("numeric")));
}
#[test]
fn contains_op_with_numeric_value_is_flagged() {
let mut vd = valid();
vd.status = vec![StatusRule {
field: "status.phase".into(),
op: RuleOp::Contains,
value: serde_json::json!(3),
level: crate::render::StatusLevel::Warning,
}];
let problems = validate_viewdef(&vd);
assert!(problems.iter().any(|p| p.contains("contains")));
}
#[test]
fn malformed_field_path_is_flagged() {
let mut vd = valid();
vd.status = vec![StatusRule {
field: ".bad.path".into(),
op: RuleOp::Eq,
value: serde_json::json!("x"),
level: crate::render::StatusLevel::Ok,
}];
let problems = validate_viewdef(&vd);
assert!(problems.iter().any(|p| p.contains("field")));
}
#[test]
fn duplicate_action_id_is_flagged() {
let mut vd = valid();
vd.actions = vec![action("x"), action("x")];
let problems = validate_viewdef(&vd);
assert!(problems.iter().any(|p| p.contains("action")));
}
#[test]
fn field_path_validator_accepts_indexes() {
assert!(valid_field_path("status.phase"));
assert!(valid_field_path("spec.containers[0].name"));
assert!(valid_field_path("metadata.labels.app"));
assert!(!valid_field_path(""));
assert!(!valid_field_path(".phase"));
assert!(!valid_field_path("status..phase"));
}
#[test]
fn resolve_field_reads_nested_and_indexed_paths() {
let v = serde_json::json!({
"status": {"phase": "Running"},
"spec": {"containers": [{"name": "app"}]}
});
assert_eq!(
resolve_field(&v, "status.phase"),
Some(&serde_json::json!("Running"))
);
assert_eq!(
resolve_field(&v, "spec.containers[0].name"),
Some(&serde_json::json!("app"))
);
assert_eq!(resolve_field(&v, "status.nope"), None);
}
#[test]
fn evaluate_status_first_match_wins() {
let mut vd = valid();
vd.status = vec![
StatusRule {
field: "status.phase".into(),
op: RuleOp::Eq,
value: serde_json::json!("Running"),
level: crate::render::StatusLevel::Ok,
},
StatusRule {
field: "status.phase".into(),
op: RuleOp::Ne,
value: serde_json::json!("Running"),
level: crate::render::StatusLevel::Warning,
},
];
let running = serde_json::json!({"status": {"phase": "Running"}});
assert_eq!(
evaluate_status(&vd, &running),
Some(crate::render::StatusLevel::Ok)
);
let pending = serde_json::json!({"status": {"phase": "Pending"}});
assert_eq!(
evaluate_status(&vd, &pending),
Some(crate::render::StatusLevel::Warning)
);
let empty = serde_json::json!({});
assert_eq!(evaluate_status(&vd, &empty), None);
}
#[test]
fn numeric_rule_compares_numerically() {
let mut vd = valid();
vd.status = vec![StatusRule {
field: "spec.replicas".into(),
op: RuleOp::Gt,
value: serde_json::json!(1),
level: crate::render::StatusLevel::Warning,
}];
let three = serde_json::json!({"spec": {"replicas": 3}});
assert_eq!(
evaluate_status(&vd, &three),
Some(crate::render::StatusLevel::Warning)
);
let one = serde_json::json!({"spec": {"replicas": 1}});
assert_eq!(evaluate_status(&vd, &one), None);
}
#[test]
fn contains_rule_matches_substring() {
let mut vd = valid();
vd.status = vec![StatusRule {
field: "status.message".into(),
op: RuleOp::Contains,
value: serde_json::json!("back-off"),
level: crate::render::StatusLevel::Error,
}];
let msg = serde_json::json!({"status": {"message": "back-off pulling image"}});
assert_eq!(
evaluate_status(&vd, &msg),
Some(crate::render::StatusLevel::Error)
);
}
#[test]
fn condition_rule_matches_ready_true() {
let mut vd = valid();
vd.status = vec![];
vd.conditions = vec![
ConditionRule {
condition_type: "Ready".into(),
status: "True".into(),
level: crate::render::StatusLevel::Ok,
},
ConditionRule {
condition_type: "Ready".into(),
status: "False".into(),
level: crate::render::StatusLevel::Error,
},
];
let ready = serde_json::json!({
"status": {"conditions": [{"type": "Ready", "status": "True"}]}
});
assert_eq!(
evaluate_status(&vd, &ready),
Some(crate::render::StatusLevel::Ok)
);
let not_ready = serde_json::json!({
"status": {"conditions": [{"type": "Ready", "status": "False"}]}
});
assert_eq!(
evaluate_status(&vd, ¬_ready),
Some(crate::render::StatusLevel::Error)
);
let other = serde_json::json!({
"status": {"conditions": [{"type": "Progressing", "status": "True"}]}
});
assert_eq!(evaluate_status(&vd, &other), None);
assert_eq!(
evaluate_status(&vd, &serde_json::json!({"status": {}})),
None
);
}
#[test]
fn invalid_condition_status_is_flagged() {
let mut vd = valid();
vd.conditions = vec![ConditionRule {
condition_type: "Ready".into(),
status: "Yes".into(),
level: crate::render::StatusLevel::Ok,
}];
let problems = validate_viewdef(&vd);
assert!(problems.iter().any(|p| p.contains("conditions[0].status")));
}
#[test]
fn empty_condition_type_is_flagged() {
let mut vd = valid();
vd.conditions = vec![ConditionRule {
condition_type: "".into(),
status: "True".into(),
level: crate::render::StatusLevel::Ok,
}];
let problems = validate_viewdef(&vd);
assert!(
problems
.iter()
.any(|p| p.contains("conditions[0].condition_type"))
);
}
#[test]
fn non_status_column_without_field_is_flagged() {
let mut vd = valid();
vd.columns = vec![Column {
id: "name".into(),
header_key: "col.name".into(),
kind: ColumnKind::Text,
sortable: true,
field: None,
}];
let problems = validate_viewdef(&vd);
assert!(problems.iter().any(|p| p.contains("field")));
}
#[test]
fn malformed_column_field_is_flagged() {
let mut vd = valid();
vd.columns = vec![Column {
id: "name".into(),
header_key: "col.name".into(),
kind: ColumnKind::Text,
sortable: true,
field: Some(".bad.path".into()),
}];
let problems = validate_viewdef(&vd);
assert!(
problems
.iter()
.any(|p| p.contains("not a dotted JSON path"))
);
}
#[test]
fn render_row_maps_fields_and_infers_status() {
let vd = ViewDefinition {
id: "com.example.cnpg-lens".into(),
api_version: LENS_SCHEMA_VERSION,
target: GroupVersionKind {
group: "postgresql.cnpg.io".into(),
version: "v1".into(),
kind: "Cluster".into(),
},
columns: vec![
Column {
id: "name".into(),
header_key: "col.name".into(),
kind: ColumnKind::Text,
sortable: true,
field: Some("metadata.name".into()),
},
Column {
id: "instances".into(),
header_key: "col.instances".into(),
kind: ColumnKind::Number,
sortable: true,
field: Some("spec.instances".into()),
},
Column {
id: "status".into(),
header_key: "col.status".into(),
kind: ColumnKind::Status,
sortable: true,
field: None,
},
],
status: vec![StatusRule {
field: "status.phase".into(),
op: RuleOp::Eq,
value: serde_json::json!("ClusterIsReady"),
level: crate::render::StatusLevel::Ok,
}],
conditions: vec![],
actions: vec![],
};
let resource = serde_json::json!({
"metadata": {"uid": "abc-123", "name": "pg", "namespace": "db"},
"spec": {"instances": 3},
"status": {"phase": "ClusterIsReady"}
});
let row = render_row(&vd, &resource);
assert_eq!(row.id, RowId("abc-123".into()));
assert_eq!(row.cells.len(), 3);
assert_eq!(row.cells[0], Cell::Text { value: "pg".into() });
assert_eq!(row.cells[1], Cell::Number { value: 3 });
assert_eq!(
row.cells[2],
Cell::Status {
level: crate::render::StatusLevel::Ok,
label_key: "status.ok".into(),
}
);
}
#[test]
fn render_row_falls_back_to_ns_name_identity_and_info_status() {
let vd = ViewDefinition {
id: "com.example.t".into(),
api_version: LENS_SCHEMA_VERSION,
target: GroupVersionKind {
group: "example.io".into(),
version: "v1".into(),
kind: "Thing".into(),
},
columns: vec![Column {
id: "status".into(),
header_key: "col.status".into(),
kind: ColumnKind::Status,
sortable: true,
field: None,
}],
status: vec![],
conditions: vec![],
actions: vec![],
};
let resource = serde_json::json!({
"metadata": {"name": "x", "namespace": "n"}
});
let row = render_row(&vd, &resource);
assert_eq!(row.id, RowId("n/x".into()));
assert_eq!(
row.cells[0],
Cell::Status {
level: crate::render::StatusLevel::Info,
label_key: "unknown".into(),
}
);
}
}