use std::collections::BTreeMap;
use std::collections::BTreeSet;
use serde::{Deserialize, Serialize};
pub const CURRENT_ADMIN_UI_SCHEMA_VERSION: u32 = 1;
pub const ADMIN_RPC_PREFIX: &str = "nexo/admin/";
pub const VISIBLE_WHEN_MAX_LEN: usize = crate::visible_when::MAX_LEN;
pub const CORE_SLOTS: &[&str] = &[
"core.sidebar.root",
"core.sidebar.channels",
"core.sidebar.integrations",
"core.sidebar.settings",
"core.agent_detail.tabs",
"core.command_palette.actions",
];
pub const LUCIDE_SUBSET: &[&str] = &[
"settings",
"mail",
"calendar",
"key",
"lock",
"shield",
"globe",
"link",
"database",
"cloud",
"bell",
"user",
"users",
"message-circle",
"message-square",
"send",
"phone",
"bot",
"cpu",
"server",
"plug",
"puzzle",
"sliders",
"wrench",
"tool",
"activity",
"bar-chart",
"pie-chart",
"list",
"grid",
"folder",
"file",
"search",
"filter",
"refresh-cw",
"download",
"upload",
"check",
"x",
"alert-triangle",
"info",
"eye",
"eye-off",
"play",
"pause",
"trash",
"plus",
"edit",
"external-link",
"zap",
"terminal",
"webhook",
"map-pin",
"clock",
"tag",
"star",
"home",
"layout",
];
#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
#[serde(deny_unknown_fields)]
pub struct PluginAdminUiSection {
#[serde(default = "default_schema_version")]
pub schema_version: u32,
#[serde(default)]
pub mode: AdminUiMode,
#[serde(default)]
pub describe: bool,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub contributions: Vec<Contribution>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub screens: Vec<Screen>,
}
fn default_schema_version() -> u32 {
CURRENT_ADMIN_UI_SCHEMA_VERSION
}
#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum AdminUiMode {
#[default]
Declarative,
Embedded,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(untagged)]
pub enum I18nLabel {
Plain(String),
Localized(BTreeMap<String, String>),
}
impl I18nLabel {
pub fn is_empty(&self) -> bool {
match self {
I18nLabel::Plain(s) => s.trim().is_empty(),
I18nLabel::Localized(m) => m.is_empty() || m.values().all(|v| v.trim().is_empty()),
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(deny_unknown_fields)]
pub struct Contribution {
pub id: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub slot: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub parent: Option<String>,
pub label: I18nLabel,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub icon: Option<String>,
#[serde(default)]
pub order: u32,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub screen: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub visible_when: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(deny_unknown_fields)]
pub struct Screen {
pub id: String,
pub title: I18nLabel,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub load: Option<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub fields: Vec<Field>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub actions: Vec<Action>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub refresh: Option<RefreshSpec>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(deny_unknown_fields)]
pub struct Field {
pub key: String,
#[serde(rename = "type")]
pub field_type: FieldType,
pub label: I18nLabel,
#[serde(default)]
pub required: bool,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub help: Option<I18nLabel>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub placeholder: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub visible_when: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub options: Option<Vec<SelectOption>>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub options_source: Option<OptionsSource>,
}
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum FieldType {
Text,
Number,
Secret,
Toggle,
Select,
Multiselect,
List,
Link,
Textarea,
Json,
}
impl FieldType {
fn needs_options(&self) -> bool {
matches!(self, FieldType::Select | FieldType::Multiselect)
}
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(deny_unknown_fields)]
pub struct SelectOption {
pub value: String,
pub label: I18nLabel,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(tag = "kind", rename_all = "snake_case", deny_unknown_fields)]
pub enum OptionsSource {
Static,
Rpc { method: String },
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(deny_unknown_fields)]
pub struct Action {
pub id: String,
pub label: I18nLabel,
pub method: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub confirm: Option<I18nLabel>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub prompt_fields: Vec<Field>,
#[serde(default)]
pub on_success: OnSuccess,
}
#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum OnSuccess {
#[default]
Toast,
InlineJson,
Table,
Redirect,
Refresh,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(deny_unknown_fields)]
pub struct RefreshSpec {
pub method: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub interval_seconds: Option<u64>,
}
impl PluginAdminUiSection {
pub fn validate(&self, plugin_id: &str) -> Vec<String> {
let mut e = Vec::new();
if self.schema_version != CURRENT_ADMIN_UI_SCHEMA_VERSION {
e.push(format!(
"schema_version {} unsupported; v1 accepts only {}",
self.schema_version, CURRENT_ADMIN_UI_SCHEMA_VERSION
));
}
if self.mode == AdminUiMode::Embedded {
e.push(
"mode \"embedded\" (Mode B) is deferred to v2; use mode \"declarative\". \
See FOLLOWUPS.md Phase 99 Mode B."
.to_string(),
);
}
let mut screen_ids: BTreeSet<&str> = BTreeSet::new();
for s in &self.screens {
if !is_kebab_id(&s.id) {
e.push(format!(
"screen id `{}` must be kebab-case (a-z, 0-9, -; 1-41 chars)",
s.id
));
}
if !screen_ids.insert(s.id.as_str()) {
e.push(format!("duplicate screen id `{}`", s.id));
}
s.validate(self.describe, &mut e);
}
let all_contrib_ids: BTreeSet<&str> =
self.contributions.iter().map(|c| c.id.as_str()).collect();
let parent_of: BTreeMap<&str, Option<&str>> = self
.contributions
.iter()
.map(|c| (c.id.as_str(), c.parent.as_deref()))
.collect();
let mut seen_ids: BTreeSet<&str> = BTreeSet::new();
for c in &self.contributions {
if !is_kebab_id(&c.id) {
e.push(format!(
"contribution id `{}` must be kebab-case (a-z, 0-9, -; 1-41 chars)",
c.id
));
}
if !seen_ids.insert(c.id.as_str()) {
e.push(format!("duplicate contribution id `{}`", c.id));
}
if c.label.is_empty() {
e.push(format!("contribution `{}` label is empty", c.id));
}
match (&c.parent, &c.slot) {
(Some(parent), _) => {
if parent.as_str() == c.id {
e.push(format!("contribution `{}` cannot be its own parent", c.id));
} else if !all_contrib_ids.contains(parent.as_str()) {
e.push(format!(
"contribution `{}` parent `{}` does not exist",
c.id, parent
));
} else if let Some(at) = detect_parent_cycle(c.id.as_str(), &parent_of) {
e.push(format!(
"contribution `{}` parent chain cycles / nests too deep at `{}`",
c.id, at
));
}
}
(None, Some(slot)) => {
if !slot_is_valid(slot, plugin_id) {
e.push(format!(
"contribution `{}` slot `{}` unknown; use a core slot {:?} or `plugin.{}.<seg>`",
c.id, slot, CORE_SLOTS, plugin_id
));
}
}
(None, None) => {
e.push(format!(
"contribution `{}` needs a `slot` (top-level menu) or a `parent` (submenu)",
c.id
));
}
}
if let Some(icon) = &c.icon {
if !LUCIDE_SUBSET.contains(&icon.as_str()) {
e.push(format!(
"contribution `{}` icon `{}` not in the lucide subset",
c.id, icon
));
}
}
if let Some(screen) = &c.screen {
if !screen_ids.contains(screen.as_str()) {
e.push(format!(
"contribution `{}` references unknown screen `{}`",
c.id, screen
));
}
}
check_visible_when(
c.visible_when.as_deref(),
&format!("contribution `{}`", c.id),
&mut e,
);
}
e
}
}
impl Screen {
fn validate(&self, describe: bool, e: &mut Vec<String>) {
if self.title.is_empty() {
e.push(format!("screen `{}` title is empty", self.id));
}
if let Some(load) = &self.load {
require_admin_prefix(load, &format!("screen `{}` load", self.id), e);
}
if !describe && self.fields.is_empty() {
e.push(format!(
"screen `{}` declares no fields but `describe = false`; \
add fields or set `describe = true`",
self.id
));
}
let mut field_keys: BTreeSet<&str> = BTreeSet::new();
for f in &self.fields {
if !field_keys.insert(f.key.as_str()) {
e.push(format!(
"screen `{}` duplicate field key `{}`",
self.id, f.key
));
}
f.validate(&format!("screen `{}` field `{}`", self.id, f.key), e);
}
let mut action_ids: BTreeSet<&str> = BTreeSet::new();
for a in &self.actions {
if !is_kebab_id(&a.id) {
e.push(format!(
"screen `{}` action id `{}` must be kebab-case",
self.id, a.id
));
}
if !action_ids.insert(a.id.as_str()) {
e.push(format!(
"screen `{}` duplicate action id `{}`",
self.id, a.id
));
}
a.validate(&format!("screen `{}` action `{}`", self.id, a.id), e);
}
if let Some(r) = &self.refresh {
require_admin_prefix(&r.method, &format!("screen `{}` refresh", self.id), e);
}
}
}
impl Field {
fn validate(&self, ctx: &str, e: &mut Vec<String>) {
if !is_config_key(&self.key) {
e.push(format!(
"{ctx}: key `{}` invalid (non-empty, ≤64, [A-Za-z0-9_.-])",
self.key
));
}
if self.label.is_empty() {
e.push(format!("{ctx}: label is empty"));
}
if self.field_type.needs_options()
&& self.options.is_none()
&& self.options_source.is_none()
{
e.push(format!(
"{ctx}: select/multiselect needs `options` or `options_source`"
));
}
if let Some(OptionsSource::Rpc { method }) = &self.options_source {
require_admin_prefix(method, &format!("{ctx} options_source"), e);
}
check_visible_when(self.visible_when.as_deref(), ctx, e);
}
}
impl Action {
fn validate(&self, ctx: &str, e: &mut Vec<String>) {
if self.label.is_empty() {
e.push(format!("{ctx}: label is empty"));
}
require_admin_prefix(&self.method, ctx, e);
for f in &self.prompt_fields {
f.validate(&format!("{ctx} prompt_field `{}`", f.key), e);
}
}
}
fn slot_is_valid(slot: &str, plugin_id: &str) -> bool {
if CORE_SLOTS.contains(&slot) {
return true;
}
let prefix = format!("plugin.{plugin_id}.");
if let Some(tail) = slot.strip_prefix(&prefix) {
return is_kebab_id(tail);
}
false
}
fn detect_parent_cycle(start: &str, parent_of: &BTreeMap<&str, Option<&str>>) -> Option<String> {
const MAX_DEPTH: usize = 5;
let mut visited: BTreeSet<&str> = BTreeSet::new();
let mut cur = start;
for _ in 0..=MAX_DEPTH {
if !visited.insert(cur) {
return Some(cur.to_string());
}
match parent_of.get(cur).copied().flatten() {
Some(p) => cur = p,
None => return None,
}
}
Some(cur.to_string())
}
fn require_admin_prefix(method: &str, ctx: &str, e: &mut Vec<String>) {
if !method.starts_with(ADMIN_RPC_PREFIX) {
e.push(format!(
"{ctx}: method `{method}` must start with `{ADMIN_RPC_PREFIX}`"
));
}
}
fn check_visible_when(expr: Option<&str>, ctx: &str, e: &mut Vec<String>) {
if let Some(expr) = expr {
if let Err(err) = crate::visible_when::parse(expr) {
e.push(format!("{ctx}: visible_when invalid: {err}"));
}
}
}
fn is_kebab_id(s: &str) -> bool {
let len = s.len();
if !(1..=41).contains(&len) {
return false;
}
let mut chars = s.chars();
let Some(first) = chars.next() else {
return false;
};
if !first.is_ascii_lowercase() {
return false;
}
chars.all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-')
}
fn is_config_key(s: &str) -> bool {
let len = s.len();
if !(1..=64).contains(&len) {
return false;
}
let mut chars = s.chars();
let Some(first) = chars.next() else {
return false;
};
if !first.is_ascii_alphanumeric() {
return false;
}
s.chars()
.all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '.' || c == '-')
}
#[cfg(test)]
mod tests {
use super::*;
const PID: &str = "google";
fn parse(toml_body: &str) -> PluginAdminUiSection {
toml::from_str(toml_body).expect("parse admin_ui toml")
}
fn minimal_static() -> PluginAdminUiSection {
parse(
r#"
schema_version = 1
mode = "declarative"
describe = false
[[contributions]]
id = "google-settings"
slot = "core.sidebar.integrations"
label = "Google"
icon = "mail"
order = 1000
screen = "smtp"
[[screens]]
id = "smtp"
title = "SMTP"
[[screens.fields]]
key = "host"
type = "text"
label = "SMTP Host"
required = true
"#,
)
}
#[test]
fn minimal_static_is_valid() {
assert!(minimal_static().validate(PID).is_empty());
}
#[test]
fn describe_true_without_fields_is_valid() {
let s = parse(
r#"
describe = true
[[contributions]]
id = "g"
slot = "plugin.google.root"
label = "G"
screen = "main"
[[screens]]
id = "main"
title = "Main"
"#,
);
assert!(s.validate(PID).is_empty(), "{:?}", s.validate(PID));
}
#[test]
fn embedded_mode_parses_but_rejected_with_v2_hint() {
let s = parse(
r#"
mode = "embedded"
describe = true
[[screens]]
id = "x"
title = "X"
"#,
);
let errs = s.validate(PID);
assert!(errs
.iter()
.any(|m| m.contains("embedded") && m.contains("v2")));
}
#[test]
fn unsupported_schema_version_rejected() {
let s = parse("schema_version = 2\ndescribe = true\n");
assert!(s.validate(PID).iter().any(|m| m.contains("schema_version")));
}
#[test]
fn icon_outside_lucide_subset_rejected() {
let mut s = minimal_static();
s.contributions[0].icon = Some("not-a-real-icon".into());
assert!(s.validate(PID).iter().any(|m| m.contains("lucide")));
}
#[test]
fn valid_lucide_icon_accepted() {
let mut s = minimal_static();
s.contributions[0].icon = Some("calendar".into());
assert!(s.validate(PID).is_empty());
}
#[test]
fn unknown_slot_rejected() {
let mut s = minimal_static();
s.contributions[0].slot = Some("core.sidebar.bogus".into());
assert!(s.validate(PID).iter().any(|m| m.contains("slot")));
}
#[test]
fn core_slot_accepted() {
let mut s = minimal_static();
s.contributions[0].slot = Some("core.command_palette.actions".into());
assert!(s.validate(PID).is_empty());
}
#[test]
fn plugin_namespaced_slot_accepted() {
let mut s = minimal_static();
s.contributions[0].slot = Some("plugin.google.root".into());
assert!(s.validate(PID).is_empty());
}
#[test]
fn plugin_slot_for_wrong_id_rejected() {
let mut s = minimal_static();
s.contributions[0].slot = Some("plugin.telegram.root".into());
assert!(s.validate(PID).iter().any(|m| m.contains("slot")));
}
#[test]
fn contribution_referencing_unknown_screen_rejected() {
let mut s = minimal_static();
s.contributions[0].screen = Some("ghost".into());
assert!(s.validate(PID).iter().any(|m| m.contains("unknown screen")));
}
#[test]
fn action_method_without_prefix_rejected() {
let s = parse(
r#"
describe = true
[[screens]]
id = "x"
title = "X"
[[screens.actions]]
id = "send"
label = "Send"
method = "whatsapp/send"
"#,
);
assert!(s
.validate(PID)
.iter()
.any(|m| m.contains("must start with")));
}
#[test]
fn action_method_with_prefix_accepted() {
let s = parse(
r#"
describe = true
[[screens]]
id = "x"
title = "X"
[[screens.actions]]
id = "send"
label = "Send"
method = "nexo/admin/google/smtp_test"
"#,
);
assert!(s.validate(PID).is_empty());
}
#[test]
fn options_source_rpc_without_prefix_rejected() {
let s = parse(
r#"
describe = false
[[screens]]
id = "x"
title = "X"
[[screens.fields]]
key = "cal"
type = "select"
label = "Calendar"
[screens.fields.options_source]
kind = "rpc"
method = "google/calendars"
"#,
);
assert!(s.validate(PID).iter().any(|m| m.contains("options_source")));
}
#[test]
fn load_without_prefix_rejected() {
let s = parse(
r#"
describe = false
[[screens]]
id = "x"
title = "X"
load = "google/load"
[[screens.fields]]
key = "host"
type = "text"
label = "Host"
"#,
);
assert!(s.validate(PID).iter().any(|m| m.contains("load")));
}
#[test]
fn describe_false_empty_fields_rejected() {
let s = parse(
r#"
describe = false
[[screens]]
id = "x"
title = "X"
"#,
);
assert!(s.validate(PID).iter().any(|m| m.contains("no fields")));
}
#[test]
fn duplicate_contribution_ids_rejected() {
let s = parse(
r#"
describe = true
[[contributions]]
id = "dup"
slot = "core.sidebar.root"
label = "A"
screen = "x"
[[contributions]]
id = "dup"
slot = "core.command_palette.actions"
label = "B"
screen = "x"
[[screens]]
id = "x"
title = "X"
"#,
);
assert!(s
.validate(PID)
.iter()
.any(|m| m.contains("duplicate contribution")));
}
#[test]
fn duplicate_screen_ids_rejected() {
let s = parse(
r#"
describe = true
[[screens]]
id = "x"
title = "X"
[[screens]]
id = "x"
title = "Y"
"#,
);
assert!(s
.validate(PID)
.iter()
.any(|m| m.contains("duplicate screen")));
}
#[test]
fn duplicate_field_keys_rejected() {
let s = parse(
r#"
describe = false
[[screens]]
id = "x"
title = "X"
[[screens.fields]]
key = "host"
type = "text"
label = "Host"
[[screens.fields]]
key = "host"
type = "text"
label = "Host 2"
"#,
);
assert!(s
.validate(PID)
.iter()
.any(|m| m.contains("duplicate field key")));
}
#[test]
fn duplicate_action_ids_rejected() {
let s = parse(
r#"
describe = true
[[screens]]
id = "x"
title = "X"
[[screens.actions]]
id = "go"
label = "Go"
method = "nexo/admin/x/a"
[[screens.actions]]
id = "go"
label = "Go2"
method = "nexo/admin/x/b"
"#,
);
assert!(s
.validate(PID)
.iter()
.any(|m| m.contains("duplicate action")));
}
#[test]
fn select_without_options_rejected() {
let s = parse(
r#"
describe = false
[[screens]]
id = "x"
title = "X"
[[screens.fields]]
key = "region"
type = "select"
label = "Region"
"#,
);
assert!(s
.validate(PID)
.iter()
.any(|m| m.contains("needs `options`")));
}
#[test]
fn select_with_static_options_accepted() {
let s = parse(
r#"
describe = false
[[screens]]
id = "x"
title = "X"
[[screens.fields]]
key = "region"
type = "select"
label = "Region"
[[screens.fields.options]]
value = "bogota"
label = "Bogotá"
"#,
);
assert!(s.validate(PID).is_empty(), "{:?}", s.validate(PID));
}
#[test]
fn select_with_rpc_options_source_accepted() {
let s = parse(
r#"
describe = false
[[screens]]
id = "x"
title = "X"
[[screens.fields]]
key = "cal"
type = "select"
label = "Calendar"
[screens.fields.options_source]
kind = "rpc"
method = "nexo/admin/google/calendars"
"#,
);
assert!(s.validate(PID).is_empty(), "{:?}", s.validate(PID));
}
#[test]
fn visible_when_too_long_rejected() {
let mut s = minimal_static();
s.contributions[0].visible_when = Some("a".repeat(VISIBLE_WHEN_MAX_LEN + 1));
assert!(s.validate(PID).iter().any(|m| m.contains("visible_when")));
}
#[test]
fn visible_when_within_limit_accepted() {
let mut s = minimal_static();
s.contributions[0].visible_when = Some("plugin.enabled && plugin.healthy".into());
assert!(s.validate(PID).is_empty());
}
#[test]
fn i18n_label_plain_parses() {
let s = minimal_static();
assert_eq!(s.contributions[0].label, I18nLabel::Plain("Google".into()));
}
#[test]
fn i18n_label_localized_parses() {
let s = parse(
r#"
describe = true
[[contributions]]
id = "g"
slot = "core.sidebar.root"
label = { en = "Google", es = "Google" }
screen = "x"
[[screens]]
id = "x"
title = "X"
"#,
);
match &s.contributions[0].label {
I18nLabel::Localized(m) => {
assert_eq!(m.get("en").map(String::as_str), Some("Google"));
assert_eq!(m.get("es").map(String::as_str), Some("Google"));
}
other => panic!("expected localized, got {other:?}"),
}
}
#[test]
fn submenu_parent_accepted() {
let s = parse(
r#"
describe = true
[[contributions]]
id = "google"
slot = "core.sidebar.integrations"
label = "Google"
[[contributions]]
id = "smtp"
parent = "google"
label = "SMTP"
screen = "smtp"
[[contributions]]
id = "oauth"
parent = "google"
label = "OAuth"
screen = "oauth"
[[screens]]
id = "smtp"
title = "SMTP"
[[screens]]
id = "oauth"
title = "OAuth"
"#,
);
assert!(s.validate(PID).is_empty(), "{:?}", s.validate(PID));
}
#[test]
fn contribution_without_slot_or_parent_rejected() {
let mut s = minimal_static();
s.contributions[0].slot = None;
s.contributions[0].parent = None;
assert!(s
.validate(PID)
.iter()
.any(|m| m.contains("needs a `slot`") && m.contains("parent")));
}
#[test]
fn submenu_parent_nonexistent_rejected() {
let mut s = minimal_static();
s.contributions[0].slot = None;
s.contributions[0].parent = Some("ghost-menu".into());
assert!(s
.validate(PID)
.iter()
.any(|m| m.contains("parent") && m.contains("does not exist")));
}
#[test]
fn submenu_parent_cycle_rejected() {
let s = parse(
r#"
describe = true
[[contributions]]
id = "a"
parent = "b"
label = "A"
[[contributions]]
id = "b"
parent = "a"
label = "B"
"#,
);
assert!(s.validate(PID).iter().any(|m| m.contains("cycle")));
}
#[test]
fn contribution_id_not_kebab_rejected() {
let mut s = minimal_static();
s.contributions[0].id = "Bad_Id".into();
assert!(s.validate(PID).iter().any(|m| m.contains("kebab")));
}
#[test]
fn empty_field_key_rejected() {
let mut s = minimal_static();
s.screens[0].fields[0].key = String::new();
assert!(s.validate(PID).iter().any(|m| m.contains("key")));
}
#[test]
fn full_section_serde_round_trip() {
let s = minimal_static();
let toml = toml::to_string(&s).expect("serialize");
let back: PluginAdminUiSection = toml::from_str(&toml).expect("deserialize");
assert_eq!(s, back);
}
#[test]
fn on_success_defaults_to_toast() {
let s = parse(
r#"
describe = true
[[screens]]
id = "x"
title = "X"
[[screens.actions]]
id = "go"
label = "Go"
method = "nexo/admin/x/a"
"#,
);
assert_eq!(s.screens[0].actions[0].on_success, OnSuccess::Toast);
}
#[test]
fn all_field_types_deserialize() {
for t in [
"text",
"number",
"secret",
"toggle",
"select",
"multiselect",
"list",
"link",
"textarea",
"json",
] {
let body = format!(
"describe = true\n[[screens]]\nid=\"x\"\ntitle=\"X\"\n\
[[screens.fields]]\nkey=\"k\"\ntype=\"{t}\"\nlabel=\"L\"\n\
[[screens.fields.options]]\nvalue=\"v\"\nlabel=\"Lv\"\n"
);
let s = parse(&body);
let _ = s.validate(PID);
}
}
#[test]
fn multiple_errors_collected_one_pass() {
let s = parse(
r#"
schema_version = 9
mode = "embedded"
describe = false
[[contributions]]
id = "Bad"
slot = "core.sidebar.bogus"
label = "X"
screen = "ghost"
[[screens]]
id = "empty"
title = "E"
"#,
);
let errs = s.validate(PID);
assert!(errs.len() >= 5, "expected ≥5 errors, got {errs:?}");
}
}