use std::collections::HashMap;
use std::sync::LazyLock;
use serde_json::Value;
use crate::atlassian::adf_schema::AdfSchemaViolation;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum AttrPresence {
Required,
Optional,
}
#[derive(Debug, Clone, PartialEq)]
pub enum AttrType {
Enum(&'static [&'static str]),
IntRange(i64, i64),
NumRange(f64, f64),
CondNumRange {
sibling: &'static str,
equals: &'static str,
when_true: (f64, f64),
when_false: (f64, f64),
},
Bool,
String,
Url,
Object,
Free,
}
#[derive(Debug, Clone, PartialEq)]
pub enum AttrProblem {
NotInEnum {
allowed: Vec<&'static str>,
actual: String,
},
OutOfRange {
lo: i64,
hi: i64,
actual: i64,
},
OutOfRangeF {
lo: f64,
hi: f64,
actual: f64,
},
WrongType {
expected: &'static str,
},
BadFormat {
reason: &'static str,
},
}
impl std::fmt::Display for AttrProblem {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::NotInEnum { allowed, actual } => {
let allowed_str = allowed
.iter()
.map(|a| format!("'{a}'"))
.collect::<Vec<_>>()
.join(", ");
write!(
f,
"value '{actual}' is not in the allowed set ({allowed_str})"
)
}
Self::OutOfRange { lo, hi, actual } => {
write!(
f,
"value {actual} is outside the allowed range [{lo}, {hi}]"
)
}
Self::OutOfRangeF { lo, hi, actual } => {
write!(
f,
"value {actual} is outside the allowed range [{lo}, {hi}]"
)
}
Self::WrongType { expected } => {
write!(f, "value has wrong type (expected {expected})")
}
Self::BadFormat { reason } => write!(f, "{reason}"),
}
}
}
#[derive(Debug, Clone)]
pub struct AttrSchema {
pub fields: &'static [(&'static str, AttrType, AttrPresence)],
}
const ENUM_PANEL_TYPE: &[&str] = &["info", "note", "warning", "success", "error", "custom"];
const ENUM_TASK_STATE: &[&str] = &["TODO", "DONE"];
const ENUM_DECISION_STATE: &[&str] = &["DECIDED", "UNDECIDED"];
const ENUM_MEDIA_TYPE: &[&str] = &["file", "link", "external"];
const ENUM_MEDIA_SINGLE_LAYOUT: &[&str] = &[
"align-end",
"align-start",
"center",
"full-width",
"wide",
"wrap-left",
"wrap-right",
];
const ENUM_STATUS_COLOR: &[&str] = &["neutral", "purple", "blue", "red", "yellow", "green"];
const ENUM_MENTION_USER_TYPE: &[&str] = &["DEFAULT", "SPECIAL", "APP", "TEAM"];
const ENUM_EXTENSION_LAYOUT: &[&str] = &["default", "wide", "full-width"];
type AttrEntry = (&'static str, AttrSchema);
const ATTR_ENTRIES: &[AttrEntry] = &[
(
"blockCard",
AttrSchema {
fields: &[
("url", AttrType::Url, AttrPresence::Optional),
("data", AttrType::Object, AttrPresence::Optional),
],
},
),
(
"bodiedExtension",
AttrSchema {
fields: &[
("extensionType", AttrType::String, AttrPresence::Required),
("extensionKey", AttrType::String, AttrPresence::Required),
(
"layout",
AttrType::Enum(ENUM_EXTENSION_LAYOUT),
AttrPresence::Optional,
),
("parameters", AttrType::Object, AttrPresence::Optional),
("text", AttrType::String, AttrPresence::Optional),
],
},
),
(
"codeBlock",
AttrSchema {
fields: &[("language", AttrType::String, AttrPresence::Optional)],
},
),
(
"date",
AttrSchema {
fields: &[("timestamp", AttrType::String, AttrPresence::Required)],
},
),
(
"decisionItem",
AttrSchema {
fields: &[
("localId", AttrType::String, AttrPresence::Required),
(
"state",
AttrType::Enum(ENUM_DECISION_STATE),
AttrPresence::Required,
),
],
},
),
(
"decisionList",
AttrSchema {
fields: &[("localId", AttrType::String, AttrPresence::Required)],
},
),
(
"embedCard",
AttrSchema {
fields: &[
("url", AttrType::Url, AttrPresence::Required),
(
"layout",
AttrType::Enum(ENUM_EXTENSION_LAYOUT),
AttrPresence::Optional,
),
(
"width",
AttrType::NumRange(0.0, 100.0),
AttrPresence::Optional,
),
(
"originalHeight",
AttrType::NumRange(0.0, f64::MAX),
AttrPresence::Optional,
),
(
"originalWidth",
AttrType::NumRange(0.0, f64::MAX),
AttrPresence::Optional,
),
],
},
),
(
"emoji",
AttrSchema {
fields: &[
("shortName", AttrType::String, AttrPresence::Required),
("id", AttrType::String, AttrPresence::Optional),
("text", AttrType::String, AttrPresence::Optional),
],
},
),
(
"expand",
AttrSchema {
fields: &[("title", AttrType::String, AttrPresence::Optional)],
},
),
(
"extension",
AttrSchema {
fields: &[
("extensionType", AttrType::String, AttrPresence::Required),
("extensionKey", AttrType::String, AttrPresence::Required),
(
"layout",
AttrType::Enum(ENUM_EXTENSION_LAYOUT),
AttrPresence::Optional,
),
("parameters", AttrType::Object, AttrPresence::Optional),
("text", AttrType::String, AttrPresence::Optional),
],
},
),
(
"heading",
AttrSchema {
fields: &[("level", AttrType::IntRange(1, 6), AttrPresence::Required)],
},
),
(
"inlineCard",
AttrSchema {
fields: &[
("url", AttrType::Url, AttrPresence::Optional),
("data", AttrType::Object, AttrPresence::Optional),
],
},
),
(
"layoutColumn",
AttrSchema {
fields: &[(
"width",
AttrType::NumRange(0.0, 100.0),
AttrPresence::Required,
)],
},
),
(
"media",
AttrSchema {
fields: &[
(
"type",
AttrType::Enum(ENUM_MEDIA_TYPE),
AttrPresence::Required,
),
("id", AttrType::String, AttrPresence::Optional),
("collection", AttrType::String, AttrPresence::Optional),
("url", AttrType::String, AttrPresence::Optional),
("alt", AttrType::String, AttrPresence::Optional),
(
"width",
AttrType::NumRange(0.0, f64::MAX),
AttrPresence::Optional,
),
(
"height",
AttrType::NumRange(0.0, f64::MAX),
AttrPresence::Optional,
),
("occurrenceKey", AttrType::String, AttrPresence::Optional),
],
},
),
(
"mediaSingle",
AttrSchema {
fields: &[
(
"layout",
AttrType::Enum(ENUM_MEDIA_SINGLE_LAYOUT),
AttrPresence::Optional,
),
(
"width",
AttrType::CondNumRange {
sibling: "widthType",
equals: "pixel",
when_true: (0.0, f64::MAX),
when_false: (0.0, 100.0),
},
AttrPresence::Optional,
),
("widthType", AttrType::String, AttrPresence::Optional),
],
},
),
(
"mention",
AttrSchema {
fields: &[
("id", AttrType::String, AttrPresence::Required),
("text", AttrType::String, AttrPresence::Optional),
(
"userType",
AttrType::Enum(ENUM_MENTION_USER_TYPE),
AttrPresence::Optional,
),
("accessLevel", AttrType::String, AttrPresence::Optional),
],
},
),
(
"nestedExpand",
AttrSchema {
fields: &[("title", AttrType::String, AttrPresence::Optional)],
},
),
(
"orderedList",
AttrSchema {
fields: &[(
"order",
AttrType::IntRange(0, i64::MAX),
AttrPresence::Optional,
)],
},
),
(
"panel",
AttrSchema {
fields: &[(
"panelType",
AttrType::Enum(ENUM_PANEL_TYPE),
AttrPresence::Required,
)],
},
),
(
"status",
AttrSchema {
fields: &[
("text", AttrType::String, AttrPresence::Required),
(
"color",
AttrType::Enum(ENUM_STATUS_COLOR),
AttrPresence::Required,
),
("localId", AttrType::String, AttrPresence::Optional),
("style", AttrType::String, AttrPresence::Optional),
],
},
),
(
"taskItem",
AttrSchema {
fields: &[
("localId", AttrType::String, AttrPresence::Required),
(
"state",
AttrType::Enum(ENUM_TASK_STATE),
AttrPresence::Required,
),
],
},
),
(
"taskList",
AttrSchema {
fields: &[("localId", AttrType::String, AttrPresence::Required)],
},
),
];
static ATTR_SCHEMAS: LazyLock<HashMap<&'static str, &'static AttrSchema>> = LazyLock::new(|| {
ATTR_ENTRIES
.iter()
.map(|(node_type, schema)| (*node_type, schema))
.collect()
});
#[must_use]
pub fn attr_schema(node_type: &str) -> Option<&'static AttrSchema> {
ATTR_SCHEMAS.get(node_type).copied()
}
pub fn validate_attrs(
node_type: &str,
attrs: Option<&Value>,
path: &[usize],
out: &mut Vec<AdfSchemaViolation>,
) {
let Some(schema) = attr_schema(node_type) else {
return;
};
let attr_obj = match attrs {
Some(Value::Object(map)) => Some(map),
Some(Value::Null) | None => None,
Some(_other) => {
for (field, _ty, presence) in schema.fields {
if *presence == AttrPresence::Required {
out.push(AdfSchemaViolation::MissingAttr {
node_type: node_type.to_string(),
attr_name: (*field).to_string(),
path: path.to_vec(),
});
}
}
return;
}
};
for (field, ty, presence) in schema.fields {
let value = attr_obj.and_then(|m| m.get(*field));
let value = match value {
Some(Value::Null) | None => None,
Some(v) => Some(v),
};
match (value, *presence) {
(None, AttrPresence::Required) => {
out.push(AdfSchemaViolation::MissingAttr {
node_type: node_type.to_string(),
attr_name: (*field).to_string(),
path: path.to_vec(),
});
}
(None, AttrPresence::Optional) => {
}
(Some(v), _) => {
let effective = resolve_attr_type(ty, attr_obj);
if let Some(problem) = check_value(&effective, v) {
out.push(AdfSchemaViolation::InvalidAttr {
node_type: node_type.to_string(),
attr_name: (*field).to_string(),
problem,
path: path.to_vec(),
});
}
}
}
}
}
fn resolve_attr_type(ty: &AttrType, attrs: Option<&serde_json::Map<String, Value>>) -> AttrType {
match ty {
AttrType::CondNumRange {
sibling,
equals,
when_true,
when_false,
} => {
let selected =
attrs.and_then(|m| m.get(*sibling)).and_then(Value::as_str) == Some(*equals);
let (lo, hi) = if selected { *when_true } else { *when_false };
AttrType::NumRange(lo, hi)
}
other => other.clone(),
}
}
#[must_use]
pub fn check_value(ty: &AttrType, value: &Value) -> Option<AttrProblem> {
match ty {
AttrType::Enum(allowed) => match value.as_str() {
Some(s) if allowed.contains(&s) => None,
Some(s) => Some(AttrProblem::NotInEnum {
allowed: allowed.to_vec(),
actual: s.to_string(),
}),
None => Some(AttrProblem::WrongType { expected: "string" }),
},
AttrType::IntRange(lo, hi) => match value.as_i64() {
Some(n) if n >= *lo && n <= *hi => None,
Some(n) => Some(AttrProblem::OutOfRange {
lo: *lo,
hi: *hi,
actual: n,
}),
None => Some(AttrProblem::WrongType {
expected: "integer",
}),
},
AttrType::NumRange(lo, hi) => match value.as_f64() {
Some(n) if n >= *lo && n <= *hi => None,
Some(n) => Some(AttrProblem::OutOfRangeF {
lo: *lo,
hi: *hi,
actual: n,
}),
None => Some(AttrProblem::WrongType { expected: "number" }),
},
AttrType::CondNumRange { when_false, .. } => {
check_value(&AttrType::NumRange(when_false.0, when_false.1), value)
}
AttrType::Bool => match value.as_bool() {
Some(_) => None,
None => Some(AttrProblem::WrongType { expected: "bool" }),
},
AttrType::String => match value.as_str() {
Some(_) => None,
None => Some(AttrProblem::WrongType { expected: "string" }),
},
AttrType::Url => match value.as_str() {
Some(s) => match url::Url::parse(s) {
Ok(_) => None,
Err(_) => Some(AttrProblem::BadFormat {
reason: "not a valid URL",
}),
},
None => Some(AttrProblem::WrongType { expected: "string" }),
},
AttrType::Object => match value {
Value::Object(_) => None,
_ => Some(AttrProblem::WrongType { expected: "object" }),
},
AttrType::Free => None,
}
}
#[cfg(test)]
#[allow(clippy::unwrap_used, clippy::expect_used)]
mod tests {
use super::*;
use serde_json::json;
fn run(node_type: &str, attrs: Value) -> Vec<AdfSchemaViolation> {
let mut out = Vec::new();
validate_attrs(node_type, Some(&attrs), &[], &mut out);
out
}
fn run_no_attrs(node_type: &str) -> Vec<AdfSchemaViolation> {
let mut out = Vec::new();
validate_attrs(node_type, None, &[], &mut out);
out
}
#[test]
fn panel_panel_type_known_value_validates() {
for value in ENUM_PANEL_TYPE {
assert!(
run("panel", json!({ "panelType": value })).is_empty(),
"panelType '{value}' should validate"
);
}
}
#[test]
fn panel_panel_type_unknown_value_flagged() {
let v = run("panel", json!({ "panelType": "purple" }));
assert_eq!(v.len(), 1);
match &v[0] {
AdfSchemaViolation::InvalidAttr {
node_type,
attr_name,
problem,
..
} => {
assert_eq!(node_type, "panel");
assert_eq!(attr_name, "panelType");
assert!(matches!(problem, AttrProblem::NotInEnum { .. }));
}
other => panic!("expected InvalidAttr, got {other:?}"),
}
}
#[test]
fn panel_missing_panel_type_flagged() {
let v = run("panel", json!({}));
assert_eq!(v.len(), 1);
match &v[0] {
AdfSchemaViolation::MissingAttr {
node_type,
attr_name,
..
} => {
assert_eq!(node_type, "panel");
assert_eq!(attr_name, "panelType");
}
other => panic!("expected MissingAttr, got {other:?}"),
}
}
#[test]
fn panel_missing_attrs_object_flagged() {
let v = run_no_attrs("panel");
assert_eq!(v.len(), 1);
assert!(matches!(v[0], AdfSchemaViolation::MissingAttr { .. }));
}
#[test]
fn heading_level_in_range_validates() {
for level in 1_i64..=6 {
assert!(run("heading", json!({ "level": level })).is_empty());
}
}
#[test]
fn heading_level_out_of_range_flagged() {
let v = run("heading", json!({ "level": 7 }));
assert_eq!(v.len(), 1);
match &v[0] {
AdfSchemaViolation::InvalidAttr {
attr_name, problem, ..
} => {
assert_eq!(attr_name, "level");
assert!(matches!(
problem,
AttrProblem::OutOfRange {
lo: 1,
hi: 6,
actual: 7
}
));
}
other => panic!("expected InvalidAttr, got {other:?}"),
}
}
#[test]
fn heading_level_wrong_type_flagged() {
let v = run("heading", json!({ "level": "two" }));
assert_eq!(v.len(), 1);
match &v[0] {
AdfSchemaViolation::InvalidAttr { problem, .. } => {
assert!(matches!(
problem,
AttrProblem::WrongType {
expected: "integer"
}
));
}
other => panic!("expected InvalidAttr, got {other:?}"),
}
}
#[test]
fn heading_missing_level_flagged_as_missing() {
let v = run("heading", json!({}));
assert_eq!(v.len(), 1);
assert!(
matches!(&v[0], AdfSchemaViolation::MissingAttr { attr_name, .. } if attr_name == "level")
);
}
#[test]
fn task_item_known_state_validates() {
for state in ENUM_TASK_STATE {
assert!(run("taskItem", json!({ "localId": "abc", "state": state })).is_empty());
}
}
#[test]
fn task_item_unknown_state_flagged() {
let v = run(
"taskItem",
json!({ "localId": "abc", "state": "INPROGRESS" }),
);
assert_eq!(v.len(), 1);
match &v[0] {
AdfSchemaViolation::InvalidAttr { attr_name, .. } => {
assert_eq!(attr_name, "state");
}
other => panic!("expected InvalidAttr, got {other:?}"),
}
}
#[test]
fn media_single_layout_known_validates() {
assert!(run("mediaSingle", json!({ "layout": "center" })).is_empty());
assert!(run("mediaSingle", json!({ "layout": "wide" })).is_empty());
}
#[test]
fn media_single_layout_misspelled_flagged() {
let v = run("mediaSingle", json!({ "layout": "centre" }));
assert_eq!(v.len(), 1);
assert!(matches!(
&v[0],
AdfSchemaViolation::InvalidAttr { attr_name, .. } if attr_name == "layout"
));
}
#[test]
fn media_single_pixel_width_above_100_validates() {
assert!(run(
"mediaSingle",
json!({ "layout": "center", "width": 900, "widthType": "pixel" })
)
.is_empty());
}
#[test]
fn media_single_percentage_width_above_100_flagged() {
for attrs in [
json!({ "layout": "center", "width": 900 }),
json!({ "layout": "center", "width": 900, "widthType": "percentage" }),
] {
let v = run("mediaSingle", attrs.clone());
assert_eq!(v.len(), 1, "expected one violation for {attrs}");
assert!(matches!(
&v[0],
AdfSchemaViolation::InvalidAttr { attr_name, problem, .. }
if attr_name == "width"
&& matches!(problem, AttrProblem::OutOfRangeF { .. })
));
}
}
#[test]
fn check_value_cond_num_range_falls_back_to_strict_branch() {
let ty = AttrType::CondNumRange {
sibling: "widthType",
equals: "pixel",
when_true: (0.0, f64::MAX),
when_false: (0.0, 100.0),
};
assert!(matches!(
check_value(&ty, &json!(900)),
Some(AttrProblem::OutOfRangeF { .. })
));
assert!(check_value(&ty, &json!(50)).is_none());
}
#[test]
fn media_single_percentage_width_in_range_validates() {
assert!(run(
"mediaSingle",
json!({ "layout": "center", "width": 75, "widthType": "percentage" })
)
.is_empty());
assert!(run("mediaSingle", json!({ "layout": "center", "width": 75 })).is_empty());
}
#[test]
fn media_type_required() {
let v = run("media", json!({}));
assert_eq!(v.len(), 1);
assert!(matches!(
&v[0],
AdfSchemaViolation::MissingAttr { attr_name, .. } if attr_name == "type"
));
}
#[test]
fn embed_card_url_format() {
assert!(run("embedCard", json!({ "url": "https://example.com" })).is_empty());
let v = run("embedCard", json!({ "url": "not a url" }));
assert_eq!(v.len(), 1);
match &v[0] {
AdfSchemaViolation::InvalidAttr { problem, .. } => {
assert!(matches!(problem, AttrProblem::BadFormat { .. }));
}
other => panic!("expected InvalidAttr, got {other:?}"),
}
}
#[test]
fn layout_column_width_in_range() {
assert!(run("layoutColumn", json!({ "width": 33.3 })).is_empty());
let v = run("layoutColumn", json!({ "width": 150 }));
assert_eq!(v.len(), 1);
assert!(matches!(
&v[0],
AdfSchemaViolation::InvalidAttr {
problem: AttrProblem::OutOfRangeF { .. },
..
}
));
}
#[test]
fn ordered_list_order_optional() {
assert!(run("orderedList", json!({})).is_empty());
assert!(run("orderedList", json!({ "order": 5 })).is_empty());
let v = run("orderedList", json!({ "order": -1 }));
assert_eq!(v.len(), 1);
}
#[test]
fn unknown_node_type_is_permissive() {
assert!(run("madeUpNode", json!({ "anyField": "anyValue" })).is_empty());
}
#[test]
fn unknown_field_under_known_node_is_permissive() {
assert!(run("panel", json!({ "panelType": "info", "futureField": "ok" })).is_empty());
}
#[test]
fn null_attribute_treated_as_absent() {
assert!(run(
"status",
json!({ "text": "hi", "color": "blue", "localId": null })
)
.is_empty());
let v = run("status", json!({ "text": "hi", "color": null }));
assert!(matches!(
&v[0],
AdfSchemaViolation::MissingAttr { attr_name, .. } if attr_name == "color"
));
}
#[test]
fn attrs_array_treated_as_invalid_object() {
let mut out = Vec::new();
validate_attrs("panel", Some(&json!([1, 2, 3])), &[], &mut out);
assert_eq!(out.len(), 1);
assert!(matches!(
&out[0],
AdfSchemaViolation::MissingAttr { attr_name, .. } if attr_name == "panelType"
));
}
#[test]
fn attr_problem_display_messages() {
let p = AttrProblem::NotInEnum {
allowed: vec!["info", "note"],
actual: "purple".to_string(),
};
let s = p.to_string();
assert!(s.contains("'purple'"), "got: {s}");
assert!(s.contains("'info'"), "got: {s}");
let p = AttrProblem::OutOfRange {
lo: 1,
hi: 6,
actual: 7,
};
assert!(p.to_string().contains("[1, 6]"));
let p = AttrProblem::BadFormat {
reason: "not a valid URL",
};
assert_eq!(p.to_string(), "not a valid URL");
let p = AttrProblem::WrongType {
expected: "integer",
};
assert!(p.to_string().contains("integer"));
}
#[test]
fn attr_problem_out_of_range_f_display() {
let p = AttrProblem::OutOfRangeF {
lo: 0.0,
hi: 100.0,
actual: 200.0,
};
let s = p.to_string();
assert!(s.contains("200"), "got: {s}");
assert!(s.contains("[0, 100]"), "got: {s}");
}
#[test]
fn check_value_enum_wrong_type_for_non_string() {
let ty = AttrType::Enum(&["a", "b"]);
let p = check_value(&ty, &json!(123)).expect("should reject");
assert!(matches!(p, AttrProblem::WrongType { expected: "string" }));
}
#[test]
fn check_value_int_range_wrong_type_for_non_integer() {
let ty = AttrType::IntRange(0, 10);
let p = check_value(&ty, &json!("abc")).expect("should reject");
assert!(matches!(
p,
AttrProblem::WrongType {
expected: "integer"
}
));
}
#[test]
fn check_value_num_range_wrong_type_for_non_number() {
let ty = AttrType::NumRange(0.0, 100.0);
let p = check_value(&ty, &json!("abc")).expect("should reject");
assert!(matches!(p, AttrProblem::WrongType { expected: "number" }));
}
#[test]
fn check_value_bool_arms() {
let ty = AttrType::Bool;
assert!(check_value(&ty, &json!(true)).is_none());
let p = check_value(&ty, &json!("yes")).expect("should reject");
assert!(matches!(p, AttrProblem::WrongType { expected: "bool" }));
}
#[test]
fn check_value_string_arms() {
let ty = AttrType::String;
assert!(check_value(&ty, &json!("hi")).is_none());
let p = check_value(&ty, &json!(42)).expect("should reject");
assert!(matches!(p, AttrProblem::WrongType { expected: "string" }));
}
#[test]
fn check_value_url_wrong_type_for_non_string() {
let ty = AttrType::Url;
let p = check_value(&ty, &json!(42)).expect("should reject");
assert!(matches!(p, AttrProblem::WrongType { expected: "string" }));
}
#[test]
fn check_value_object_arms() {
let ty = AttrType::Object;
assert!(check_value(&ty, &json!({"k": "v"})).is_none());
let p = check_value(&ty, &json!([1, 2])).expect("should reject");
assert!(matches!(p, AttrProblem::WrongType { expected: "object" }));
}
#[test]
fn check_value_free_accepts_anything() {
let ty = AttrType::Free;
assert!(check_value(&ty, &json!(null)).is_none());
assert!(check_value(&ty, &json!(42)).is_none());
assert!(check_value(&ty, &json!("x")).is_none());
assert!(check_value(&ty, &json!({"k": "v"})).is_none());
assert!(check_value(&ty, &json!([1, 2, 3])).is_none());
}
}