use std::collections::BTreeMap;
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
#[serde(deny_unknown_fields)]
pub struct PairingSection {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub kind: Option<PairingKind>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub label: Option<String>,
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
pub instructions: BTreeMap<String, String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub fields: Vec<PairingFieldDescriptor>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub rpc_namespace: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub instance_field: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub adapter: Option<PairingAdapterSection>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub trigger: Option<PairingTriggerSection>,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
#[serde(deny_unknown_fields)]
pub struct PairingTriggerSection {
pub start_method: String,
pub cancel_method: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub timeout_seconds: Option<u64>,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
#[serde(deny_unknown_fields)]
pub struct PairingAdapterSection {
pub channel_id: String,
pub broker_topic_prefix: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub format_challenge_text_kind: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub normalize_cache_ttl_seconds: Option<u64>,
}
impl PairingSection {
pub fn is_unset(&self) -> bool {
self.kind.is_none()
&& self.label.is_none()
&& self.instructions.is_empty()
&& self.fields.is_empty()
&& self.rpc_namespace.is_none()
&& self.instance_field.is_none()
&& self.adapter.is_none()
&& self.trigger.is_none()
}
}
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum PairingKind {
Qr,
Form,
Info,
Custom,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(deny_unknown_fields)]
pub struct PairingFieldDescriptor {
pub name: String,
pub label: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub help: Option<String>,
#[serde(default)]
pub sensitive: bool,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub placeholder: Option<String>,
#[serde(default)]
pub required: bool,
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn default_section_is_unset() {
let s = PairingSection::default();
assert!(s.is_unset());
}
#[test]
fn qr_kind_round_trips() {
let toml_src = r#"
kind = "qr"
label = "WhatsApp"
[instructions]
es = "Abrí WhatsApp y escaneá."
en = "Open WhatsApp and scan."
"#;
let parsed: PairingSection = toml::from_str(toml_src).unwrap();
assert_eq!(parsed.kind, Some(PairingKind::Qr));
assert_eq!(parsed.label.as_deref(), Some("WhatsApp"));
assert_eq!(parsed.instructions.len(), 2);
assert!(parsed.fields.is_empty());
}
#[test]
fn form_kind_with_fields_round_trips() {
let toml_src = r#"
kind = "form"
label = "Telegram"
[[fields]]
name = "instance"
label = "Bot username"
placeholder = "mi_bot"
required = true
[[fields]]
name = "token"
label = "Bot token"
sensitive = true
required = true
"#;
let parsed: PairingSection = toml::from_str(toml_src).unwrap();
assert_eq!(parsed.kind, Some(PairingKind::Form));
assert_eq!(parsed.fields.len(), 2);
assert_eq!(parsed.fields[0].name, "instance");
assert!(parsed.fields[0].required);
assert!(!parsed.fields[0].sensitive);
assert!(parsed.fields[1].sensitive);
}
#[test]
fn info_kind_without_fields_round_trips() {
let toml_src = r#"
kind = "info"
[instructions]
en = "Configure plugin via YAML and restart."
"#;
let parsed: PairingSection = toml::from_str(toml_src).unwrap();
assert_eq!(parsed.kind, Some(PairingKind::Info));
assert!(parsed.fields.is_empty());
}
#[test]
fn custom_kind_with_namespace_round_trips() {
let toml_src = r#"
kind = "custom"
rpc_namespace = "myauth"
"#;
let parsed: PairingSection = toml::from_str(toml_src).unwrap();
assert_eq!(parsed.kind, Some(PairingKind::Custom));
assert_eq!(parsed.rpc_namespace.as_deref(), Some("myauth"));
}
#[test]
fn unknown_kind_errors_with_clear_message() {
let toml_src = r#"kind = "bluetooth""#;
let err = toml::from_str::<PairingSection>(toml_src).unwrap_err();
assert!(
err.to_string().contains("unknown variant"),
"expected 'unknown variant' in error, got: {err}"
);
}
#[test]
fn deny_unknown_fields_rejects_typos() {
let toml_src = r#"
kind = "qr"
laybel = "WhatsApp"
"#;
let err = toml::from_str::<PairingSection>(toml_src).unwrap_err();
assert!(
err.to_string().contains("unknown field"),
"expected 'unknown field' in error, got: {err}"
);
}
#[test]
fn skip_serializing_if_unset_emits_empty_toml() {
let s = PairingSection::default();
let out = toml::to_string(&s).unwrap();
assert!(out.trim().is_empty(), "expected empty TOML, got: {out:?}");
}
#[test]
fn trigger_section_round_trips() {
let toml_src = r#"
kind = "qr"
[trigger]
start_method = "nexo/admin/whatsapp/pairing/start"
cancel_method = "nexo/admin/whatsapp/pairing/cancel"
timeout_seconds = 120
"#;
let parsed: PairingSection = toml::from_str(toml_src).unwrap();
let trigger = parsed.trigger.expect("trigger present");
assert_eq!(trigger.start_method, "nexo/admin/whatsapp/pairing/start");
assert_eq!(trigger.cancel_method, "nexo/admin/whatsapp/pairing/cancel");
assert_eq!(trigger.timeout_seconds, Some(120));
}
#[test]
fn trigger_section_timeout_optional() {
let toml_src = r#"
kind = "qr"
[trigger]
start_method = "nexo/admin/foo/pair/start"
cancel_method = "nexo/admin/foo/pair/cancel"
"#;
let parsed: PairingSection = toml::from_str(toml_src).unwrap();
let trigger = parsed.trigger.expect("trigger present");
assert!(trigger.timeout_seconds.is_none());
}
#[test]
fn trigger_section_deny_unknown_fields() {
let toml_src = r#"
[trigger]
start_method = "a"
cancel_method = "b"
woops = true
"#;
let err = toml::from_str::<PairingSection>(toml_src).unwrap_err();
assert!(err.to_string().contains("unknown field"));
}
#[test]
fn pairing_section_unset_includes_trigger() {
let s = PairingSection {
trigger: Some(PairingTriggerSection {
start_method: "x".into(),
cancel_method: "y".into(),
timeout_seconds: None,
}),
..PairingSection::default()
};
assert!(!s.is_unset());
}
}