Files
mqttx/gpui-app/src/model.rs
T
JoeAllen 96865c0588 perf: 深度优化消息内存占用
- 消息缓冲 Vec → VecDeque:原来溢出时 drain(0..n) 每条消息 O(n)
  搬移,高频消息下产生大量拷贝;环形缓冲均摊 O(1),并按配置上限
  预分配避免反复扩容
- connection_id/topic/payload 改 Arc<str>:引擎侧主题驻留
  (10k 上限,超出整体重建),同主题数千条记录只存一份;
  消息列表每帧克隆最多 300 条用于渲染,payload 共享后零拷贝
- 单条 payload 历史保留上限 256KB(retained_payload,UTF-8
  边界安全截断),超大报文不再长期占据环形缓冲;消息行显示
  「已截断」徽章;发布记录改用实际发送字节(修 Base64/Hex
  历史内容与真实报文不一致的隐患)
- live 测试适配 Arc<str>;补截断边界单元测试(22 个 lib 测试 +
  2 个 live 测试通过)
2026-09-29 01:07:32 +08:00

921 lines
31 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! 应用数据模型:连接配置、订阅、消息、日志、预设、全局变量、设置。
//!
//! 本层不依赖 GPUI,可被持久化层与 MQTT 引擎共用。
use std::sync::Arc;
use serde::{Deserialize, Serialize};
// ─── 连接配置 ────────────────────────────────────────────────────────────────
/// MQTT 协议版本。
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case")]
#[derive(Default)]
pub enum ProtocolVersion {
/// MQTT 3.1.1
V311,
/// MQTT 5.0
#[default]
V5,
}
impl ProtocolVersion {
pub const ALL: [Self; 2] = [Self::V5, Self::V311];
pub fn label(self) -> &'static str {
match self {
Self::V311 => "MQTT 3.1.1",
Self::V5 => "MQTT 5.0",
}
}
pub fn is_v5(self) -> bool {
matches!(self, Self::V5)
}
}
/// 传输方式。
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
#[derive(Default)]
pub enum TransportKind {
/// 明文 TCP,默认 1883
#[default]
Tcp,
/// TLS over TCP,默认 8883
Tls,
/// WebSocket (ws://),默认 8083
Ws,
/// WebSocket over TLS (wss://),默认 8084
Wss,
}
impl TransportKind {
pub const ALL: [Self; 4] = [Self::Tcp, Self::Tls, Self::Ws, Self::Wss];
pub fn label(self) -> &'static str {
match self {
Self::Tcp => "TCP",
Self::Tls => "TLS",
Self::Ws => "WebSocket",
Self::Wss => "WSS",
}
}
pub fn default_port(self) -> u16 {
match self {
Self::Tcp => 1883,
Self::Tls => 8883,
Self::Ws => 8083,
Self::Wss => 8084,
}
}
pub fn is_tls(self) -> bool {
matches!(self, Self::Tls | Self::Wss)
}
pub fn is_websocket(self) -> bool {
matches!(self, Self::Ws | Self::Wss)
}
}
/// MQTT 5 遗嘱消息(3.1.1 下忽略 v5 专属属性)。
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct LastWill {
pub topic: String,
pub payload: String,
pub qos: u8,
pub retain: bool,
#[serde(default)]
pub content_type: Option<String>,
#[serde(default)]
pub response_topic: Option<String>,
}
/// SSL/TLS 自定义配置(CA、双向认证、跳过校验)。
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct SslConfig {
/// 自定义 CA 文件路径(PEM),作为系统根证书的补充
#[serde(default)]
pub ca_file: String,
/// 客户端证书(PEM),双向认证用
#[serde(default)]
pub client_cert_file: String,
/// 客户端私钥(PEM),与客户端证书配套
#[serde(default)]
pub client_key_file: String,
/// 跳过服务器证书校验(insecure),仅调试环境使用
#[serde(default)]
pub ignore_ca: bool,
}
impl SslConfig {
/// 是否完全未配置(走 rumqttc 默认 TLS 即可)。
pub fn is_default(&self) -> bool {
!self.ignore_ca
&& self.ca_file.is_empty()
&& self.client_cert_file.is_empty()
&& self.client_key_file.is_empty()
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ConnectionConfig {
pub id: String,
pub name: String,
/// 主机(不含端口、协议前缀)
pub host: String,
pub port: u16,
/// WebSocket 路径,如 /mqtt
#[serde(default)]
pub path: String,
#[serde(default)]
pub protocol: ProtocolVersion,
#[serde(default)]
pub transport: TransportKind,
pub client_id: String,
#[serde(default)]
pub username: String,
#[serde(default)]
pub password: String,
#[serde(default = "default_true")]
pub clean_start: bool,
#[serde(default = "default_keep_alive")]
pub keep_alive: u16,
// ── MQTT 5 专属 ──
#[serde(default)]
pub session_expiry_interval: u32,
#[serde(default)]
pub receive_maximum: Option<u16>,
#[serde(default)]
pub maximum_packet_size: Option<u32>,
#[serde(default)]
pub topic_alias_maximum: Option<u16>,
/// 连接成功后自动恢复已保存的订阅
#[serde(default = "default_true")]
pub auto_resubscribe: bool,
/// 断线自动重连
#[serde(default = "default_true")]
pub auto_reconnect: bool,
#[serde(default)]
pub last_will: Option<LastWill>,
/// 创建时间(unix 秒),用于排序
#[serde(default)]
pub created_at: i64,
/// 连接分组(对标 MQTTX folder)
#[serde(default)]
pub group: Option<String>,
/// 连接超时(秒),覆盖建连与 CONNACK 等待
#[serde(default = "default_connection_timeout")]
pub connection_timeout_secs: u16,
/// 最大重连次数,0 = 无限重连
#[serde(default)]
pub max_reconnect_times: u32,
/// SSL/TLS 自定义配置
#[serde(default)]
pub ssl: SslConfig,
}
fn default_true() -> bool {
true
}
fn default_keep_alive() -> u16 {
60
}
fn default_connection_timeout() -> u16 {
10
}
/// 生成随机 Client ID(新建连接与表单「重新生成」共用同一逻辑)。
pub fn generate_client_id() -> String {
format!("mqttx_{}", &uuid::Uuid::new_v4().simple().to_string()[..8])
}
impl ConnectionConfig {
pub fn new() -> Self {
let transport = TransportKind::default();
Self {
id: uuid::Uuid::new_v4().to_string(),
name: "新建连接".into(),
host: "broker.emqx.io".into(),
port: transport.default_port(),
path: "/mqtt".into(),
protocol: ProtocolVersion::V5,
transport,
client_id: generate_client_id(),
username: String::new(),
password: String::new(),
clean_start: true,
keep_alive: 60,
session_expiry_interval: 0,
receive_maximum: None,
maximum_packet_size: None,
topic_alias_maximum: None,
auto_resubscribe: true,
auto_reconnect: true,
last_will: None,
created_at: chrono::Local::now().timestamp(),
group: None,
connection_timeout_secs: default_connection_timeout(),
max_reconnect_times: 0,
ssl: SslConfig::default(),
}
}
/// broker.emqx.io 等公共服务器的快速预设。
pub fn with_public(name: &str, host: &str, port: u16, transport: TransportKind) -> Self {
let mut c = Self::new();
c.name = name.into();
c.host = host.into();
c.port = port;
c.transport = transport;
c
}
pub fn display_address(&self) -> String {
format!("{}:{}", self.host, self.port)
}
/// 与另一份配置相比,影响 broker 会话建立的关键参数是否变化。
/// 这些字段改动只有在重新建立连接后才会生效,用于提示用户重连。
pub fn session_params_changed(&self, other: &ConnectionConfig) -> bool {
self.host != other.host
|| self.port != other.port
|| self.transport != other.transport
|| self.protocol != other.protocol
|| self.client_id != other.client_id
|| self.username != other.username
|| self.password != other.password
|| self.clean_start != other.clean_start
}
}
impl Default for ConnectionConfig {
fn default() -> Self {
Self::new()
}
}
// ─── 订阅 ────────────────────────────────────────────────────────────────────
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Subscription {
pub id: String,
pub connection_id: String,
pub topic: String,
pub qos: u8,
/// 本地备注颜色(与 MQTTX 一致,按订阅给消息着色),0..=359 色相
#[serde(default)]
pub color: Option<f32>,
/// 订阅别名(对标 MQTTX topic alias),仅用于界面展示
#[serde(default)]
pub alias: Option<String>,
/// 是否启用该订阅;禁用后界面置灰、恢复订阅时跳过
#[serde(default = "default_true")]
pub enabled: bool,
/// MQTT 5 订阅标识符
#[serde(default)]
pub sub_identifier: Option<u32>,
/// MQTT 5 No Local (NL)
#[serde(default)]
pub no_local: bool,
/// MQTT 5 Retain As Published (RAP)
#[serde(default)]
pub retain_as_published: bool,
/// MQTT 5 Retain Handling (0/1/2)
#[serde(default)]
pub retain_handling: u8,
}
impl Subscription {
pub fn new(connection_id: impl Into<String>, topic: impl Into<String>, qos: u8) -> Self {
Self {
id: uuid::Uuid::new_v4().to_string(),
connection_id: connection_id.into(),
topic: topic.into(),
qos,
color: None,
alias: None,
enabled: true,
sub_identifier: None,
no_local: false,
retain_as_published: false,
retain_handling: 0,
}
}
}
/// MQTT 5 订阅选项(NL/RAP/Retain Handling/订阅标识符);v4 连接会忽略这些选项。
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct SubscribeOptions {
/// 订阅标识符,0 视为未设置
#[serde(default)]
pub sub_identifier: Option<u32>,
#[serde(default)]
pub no_local: bool,
#[serde(default)]
pub retain_as_published: bool,
/// 0=每次订阅都发送保留消息,1=仅新订阅发送,2=不发送
#[serde(default)]
pub retain_handling: u8,
}
impl From<&Subscription> for SubscribeOptions {
fn from(sub: &Subscription) -> Self {
Self {
sub_identifier: sub.sub_identifier,
no_local: sub.no_local,
retain_as_published: sub.retain_as_published,
retain_handling: sub.retain_handling,
}
}
}
// ─── 消息 ────────────────────────────────────────────────────────────────────
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum Direction {
Received,
Published,
}
impl Direction {
pub fn label(self) -> &'static str {
match self {
Self::Received => "接收",
Self::Published => "发布",
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
#[derive(Default)]
pub enum PayloadFormat {
#[default]
Plaintext,
Json,
Base64,
Hex,
}
impl PayloadFormat {
pub const ALL: [Self; 4] = [Self::Plaintext, Self::Json, Self::Base64, Self::Hex];
pub fn label(self) -> &'static str {
match self {
Self::Plaintext => "Plaintext",
Self::Json => "JSON",
Self::Base64 => "Base64",
Self::Hex => "Hex",
}
}
pub fn encode(self, text: &str) -> Result<Vec<u8>, String> {
match self {
Self::Plaintext | Self::Json => Ok(text.as_bytes().to_vec()),
Self::Base64 => {
use base64::Engine as _;
base64::engine::general_purpose::STANDARD
.decode(text.trim())
.map_err(|e| format!("Base64 解码失败: {e}"))
}
Self::Hex => {
let cleaned: String = text.chars().filter(|c| !c.is_whitespace()).collect();
if !cleaned.len().is_multiple_of(2) {
return Err("Hex 长度必须为偶数(每两个字符一个字节)".into());
}
(0..cleaned.len())
.step_by(2)
.map(|i| {
u8::from_str_radix(&cleaned[i..i + 2], 16)
.map_err(|e| format!("Hex 解码失败: {e}"))
})
.collect()
}
}
}
}
/// 一条收到/发出的消息记录。payload 以 UTF-8 文本保存(非文本按 lossy 显示)。
///
/// 内存优化:`connection_id`/`topic` 用 `Arc<str>`——同一连接内主题高度重复,
/// 驻留后数千条记录只保留一份字符串。
#[derive(Debug, Clone)]
pub struct MqttRecord {
pub seq: u64,
pub connection_id: Arc<str>,
pub direction: Direction,
pub topic: Arc<str>,
/// Arc 共享:消息列表渲染每帧克隆最多数百条记录,零拷贝
pub payload: Arc<str>,
pub qos: u8,
pub retain: bool,
/// unix 毫秒
pub timestamp: i64,
pub user_properties: Vec<(String, String)>,
pub content_type: Option<String>,
pub response_topic: Option<String>,
pub correlation_data: Option<String>,
pub message_expiry_interval: Option<u32>,
pub subscription_identifier: Option<u32>,
/// payload 超过 [`MAX_PAYLOAD_RETAIN`] 被截断
pub payload_truncated: bool,
}
/// 消息历史中单条 payload 的保留上限。超限截断,避免个别大报文
/// 长期占据历史缓冲(MQTTX 亦有类似限制)。
pub const MAX_PAYLOAD_RETAIN: usize = 256 * 1024;
/// 把原始 payload 字节转成用于历史记录的共享文本,超限截断。
/// 返回 (Arc<str>, 是否截断)。截断时回退到 UTF-8 字符边界。
pub fn retained_payload(raw: &[u8]) -> (Arc<str>, bool) {
if raw.len() <= MAX_PAYLOAD_RETAIN {
(Arc::from(String::from_utf8_lossy(raw).as_ref()), false)
} else {
let mut end = MAX_PAYLOAD_RETAIN;
while end > 0 && (raw[end] & 0xC0) == 0x80 {
end -= 1;
}
(
Arc::from(String::from_utf8_lossy(&raw[..end]).as_ref()),
true,
)
}
}
// ─── 发布参数 / 发布预设 ──────────────────────────────────────────────────────
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct PublishParams {
pub topic: String,
/// 用于界面展示与记录的文本负载。
pub payload: String,
#[serde(default)]
pub payload_format: PayloadFormat,
pub qos: u8,
pub retain: bool,
/// MQTT 5 用户属性
#[serde(default)]
pub user_properties: Vec<(String, String)>,
#[serde(default)]
pub content_type: Option<String>,
#[serde(default)]
pub message_expiry_interval: Option<u32>,
#[serde(default)]
pub response_topic: Option<String>,
#[serde(default)]
pub correlation_data: Option<String>,
/// 实际发送的字节。Base64/Hex 时为解码结果;不持久化、不参与预设。
/// None 时引擎回退为 `payload.as_bytes()`。
#[serde(skip)]
pub raw_bytes: Option<Vec<u8>>,
}
/// 发布预设:保存常用的发布参数,一键复用(对标 MQTTX Publish Scripts/Preset)。
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PublishPreset {
pub id: String,
pub name: String,
#[serde(default)]
pub connection_id: Option<String>,
#[serde(flatten)]
pub params: PublishParams,
}
impl PublishPreset {
pub fn new(name: impl Into<String>, params: PublishParams) -> Self {
Self {
id: uuid::Uuid::new_v4().to_string(),
name: name.into(),
connection_id: None,
params,
}
}
}
// ─── 全局变量 ────────────────────────────────────────────────────────────────
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct GlobalVariable {
pub key: String,
pub value: String,
}
/// 解析 `{{key}}` 模板:
/// - 用户定义的全局变量优先;
/// - 内置变量:`{{$ts}}`(unix 秒)、`{{$ts_ms}}`(毫秒)、`{{$uuid}}`;
/// - 找不到的占位符保持原样。
pub fn render_template(input: &str, vars: &[GlobalVariable]) -> String {
let mut out = String::with_capacity(input.len());
let bytes = input.as_bytes();
// 同一次渲染内时间基准保持一致,避免一条消息里多个时间变量取值不同。
let now_ms = chrono::Local::now().timestamp_millis();
let now_s = now_ms / 1000;
let mut i = 0;
while i < bytes.len() {
if i + 1 < bytes.len() && bytes[i] == b'{' && bytes[i + 1] == b'{'
&& let Some(rel_end) = input[i + 2..].find("}}") {
let key = &input[i + 2..i + 2 + rel_end];
let resolved = match key.trim() {
"$ts" => Some(now_s.to_string()),
"$ts_ms" => Some(now_ms.to_string()),
"$uuid" => Some(uuid::Uuid::new_v4().to_string()),
k => vars
.iter()
.find(|v| v.key == k)
.map(|v| v.value.clone()),
};
match resolved {
Some(v) => out.push_str(&v),
None => out.push_str(&input[i..i + 2 + rel_end + 2]),
}
i = i + 2 + rel_end + 2;
continue;
}
// 按字符推进,避免把多字节 UTF-8 拆开
let ch = input[i..].chars().next().unwrap();
out.push(ch);
i += ch.len_utf8();
}
out
}
/// 连接前渲染遗嘱消息中的 `{{变量}}`。
/// 只作用于本次连接的配置副本,落盘的连接配置保持模板原文,
/// 这样改全局变量后下次重连即生效,`$ts` 等也按连接时刻求值。
pub fn render_will_templates(cfg: &mut ConnectionConfig, vars: &[GlobalVariable]) {
let Some(will) = cfg.last_will.as_mut() else {
return;
};
will.topic = render_template(&will.topic, vars);
will.payload = render_template(&will.payload, vars);
if let Some(v) = will.content_type.take() {
will.content_type = Some(render_template(&v, vars));
}
if let Some(v) = will.response_topic.take() {
will.response_topic = Some(render_template(&v, vars));
}
}
// ─── 日志 ────────────────────────────────────────────────────────────────────
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum LogLevel {
Debug,
Info,
Warn,
Error,
}
impl LogLevel {
pub fn label(self) -> &'static str {
match self {
Self::Debug => "DEBUG",
Self::Info => "INFO",
Self::Warn => "WARN",
Self::Error => "ERROR",
}
}
pub fn parse(s: &str) -> Self {
match s {
"debug" => Self::Debug,
"warn" => Self::Warn,
"error" => Self::Error,
_ => Self::Info,
}
}
}
#[derive(Debug, Clone)]
pub struct LogEntry {
pub timestamp: i64,
pub connection_id: String,
pub level: LogLevel,
pub event: String,
pub message: String,
pub details: Option<String>,
}
// ─── 连接状态 ────────────────────────────────────────────────────────────────
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum ConnectionStatus {
#[default]
Disconnected,
Connecting,
Connected,
Error,
}
impl ConnectionStatus {
pub fn label(self) -> &'static str {
match self {
Self::Disconnected => "未连接",
Self::Connecting => "连接中",
Self::Connected => "已连接",
Self::Error => "错误",
}
}
}
// ─── 应用设置 ────────────────────────────────────────────────────────────────
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
#[serde(rename_all = "snake_case")]
pub enum ThemeModePref {
Light,
Dark,
#[default]
System,
}
impl ThemeModePref {
pub const ALL: [Self; 3] = [Self::System, Self::Light, Self::Dark];
pub fn label(self) -> &'static str {
match self {
Self::System => "跟随系统",
Self::Light => "浅色",
Self::Dark => "深色",
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AppSettings {
#[serde(default)]
pub theme: ThemeModePref,
/// 每条连接在内存中保留的最大消息条数
#[serde(default = "default_max_messages")]
pub max_messages: usize,
/// 时间戳是否显示毫秒
#[serde(default = "default_true")]
pub show_millis: bool,
/// 是否自动检查更新(占位,暂不实现更新)
#[serde(default = "default_true")]
pub auto_check_update: bool,
}
fn default_max_messages() -> usize {
2000
}
impl Default for AppSettings {
fn default() -> Self {
Self {
theme: ThemeModePref::default(),
max_messages: default_max_messages(),
show_millis: true,
auto_check_update: true,
}
}
}
// ─── 单元测试 ────────────────────────────────────────────────────────────────
#[cfg(test)]
mod tests {
use super::*;
// 修复回归:Hex 奇数长度必须报错,不能静默截断
#[test]
fn hex_odd_length_is_rejected() {
let err = PayloadFormat::Hex.encode("abc").unwrap_err();
assert!(err.contains("偶数"), "应提示偶数长度,实际: {err}");
assert!(PayloadFormat::Hex.encode("").is_ok());
assert_eq!(
PayloadFormat::Hex.encode("68 69").unwrap(),
vec![0x68, 0x69],
"空白应被忽略"
);
assert!(PayloadFormat::Hex.encode("zz").is_err());
}
#[test]
fn base64_encode_roundtrip() {
use base64::Engine as _;
let text = "hello 世界";
let encoded = base64::engine::general_purpose::STANDARD.encode(text);
assert_eq!(PayloadFormat::Base64.encode(&encoded).unwrap(), text.as_bytes());
assert!(PayloadFormat::Base64.encode("not base64!!!").is_err());
}
#[test]
fn plaintext_and_json_encode_as_utf8() {
assert_eq!(
PayloadFormat::Plaintext.encode("中文").unwrap(),
"中文".as_bytes()
);
assert_eq!(
PayloadFormat::Json.encode(r#"{"a":1}"#).unwrap(),
r#"{"a":1}"#.as_bytes()
);
}
// 修复回归:raw_bytes 是运行时字段,不得写入持久化 JSON
#[test]
fn raw_bytes_not_serialized() {
let p = PublishParams {
topic: "t".into(),
payload: "hi".into(),
raw_bytes: Some(vec![0x00, 0xff]),
..Default::default()
};
let json = serde_json::to_string(&p).unwrap();
assert!(!json.contains("raw_bytes"), "raw_bytes 不应被序列化: {json}");
let back: PublishParams = serde_json::from_str(&json).unwrap();
assert!(back.raw_bytes.is_none());
// 反序列化时输入中的 raw_bytes 字段应被忽略(skip 双向生效)
let mut with_junk = serde_json::to_string(&PublishParams::default()).unwrap();
with_junk.pop(); // 去掉结尾 '}'
with_junk.push_str(r#", "raw_bytes":[1,2]}"#);
let back: PublishParams = serde_json::from_str(&with_junk).unwrap();
assert!(back.raw_bytes.is_none(), "#[serde(skip)] 应忽略输入字段");
}
// 修复回归:同一条消息内 $ts 与 $ts_ms 基于同一时刻
#[test]
fn template_timestamps_share_one_timebase() {
let out = render_template("s={{$ts}} ms={{$ts_ms}}", &[]);
let (s_part, ms_part) = out.split_once(" ms=").expect("格式 s=.. ms=..");
let s: i64 = s_part
.strip_prefix("s=")
.expect("秒字段")
.parse()
.unwrap();
let ms: i64 = ms_part.parse().expect("毫秒字段");
assert_eq!(s, ms / 1000, "秒与毫秒应来自同一时间基准: {out}");
}
#[test]
fn template_unknown_placeholder_kept_and_custom_var_resolved() {
assert_eq!(render_template("{{$unknown}}", &[]), "{{$unknown}}");
let vars = vec![GlobalVariable {
key: "device".into(),
value: "sensor-1".into(),
}];
assert!(render_template("d={{device}}/{{$uuid}}", &vars).starts_with("d=sensor-1/"));
// 多字节字符不被拆坏
assert_eq!(render_template("中文{{$no}}", &[]), "中文{{$no}}");
}
// 连接前遗嘱模板注入:只改副本、空遗嘱不 panic、v5 属性一并渲染
#[test]
fn render_will_templates_injects_and_keeps_no_will_safe() {
let vars = vec![GlobalVariable {
key: "device".into(),
value: "sensor-9".into(),
}];
// 无遗嘱:直接返回
let mut plain = ConnectionConfig::new();
render_will_templates(&mut plain, &vars);
assert!(plain.last_will.is_none());
let mut cfg = ConnectionConfig::new();
cfg.last_will = Some(LastWill {
topic: "alarm/{{device}}".into(),
payload: "d={{device}}".into(),
qos: 1,
retain: false,
content_type: Some("text/{{device}}".into()),
response_topic: Some("ack/{{device}}".into()),
});
render_will_templates(&mut cfg, &vars);
let w = cfg.last_will.as_ref().unwrap();
assert_eq!(w.topic, "alarm/sensor-9");
assert_eq!(w.payload, "d=sensor-9");
assert_eq!(w.content_type.as_deref(), Some("text/sensor-9"));
assert_eq!(w.response_topic.as_deref(), Some("ack/sensor-9"));
// 原始模板串不因注入而需要回写:连接配置持久化的是模板原文,
// 这里只验证副本注入结果;未知占位符保留
let mut raw = ConnectionConfig::new();
raw.last_will = Some(LastWill {
topic: "t/{{unknown}}".into(),
..Default::default()
});
render_will_templates(&mut raw, &vars);
assert_eq!(raw.last_will.as_ref().unwrap().topic, "t/{{unknown}}");
}
// 预设持久化往返:raw_bytes 不落盘,参数完整保留
#[test]
fn publish_preset_roundtrip_skips_raw_bytes() {
let preset = PublishPreset {
id: "p1".into(),
name: "报警".into(),
connection_id: Some("c1".into()),
params: PublishParams {
topic: "alarm/{{device}}".into(),
payload: "6869".into(),
payload_format: PayloadFormat::Hex,
qos: 1,
retain: true,
content_type: Some("application/json".into()),
user_properties: vec![("k".into(), "v".into())],
raw_bytes: Some(vec![0x68, 0x69]),
..Default::default()
},
};
let json = serde_json::to_string(&preset).unwrap();
let back: PublishPreset = serde_json::from_str(&json).unwrap();
assert_eq!(back.name, "报警");
assert_eq!(back.params.topic, "alarm/{{device}}");
assert_eq!(back.params.payload_format, PayloadFormat::Hex);
assert_eq!(back.params.qos, 1);
assert!(back.params.retain);
assert_eq!(back.params.content_type.as_deref(), Some("application/json"));
assert_eq!(back.params.user_properties.len(), 1);
assert!(back.params.raw_bytes.is_none());
}
#[test]
fn payload_format_equality_and_default() {
assert_eq!(PayloadFormat::default(), PayloadFormat::Plaintext);
assert_ne!(PayloadFormat::Hex, PayloadFormat::Base64);
let json = serde_json::to_string(&PayloadFormat::Hex).unwrap();
assert_eq!(
serde_json::from_str::<PayloadFormat>(&json).unwrap(),
PayloadFormat::Hex
);
}
// 向后兼容:旧版 connections.json 不含 Phase 1 新字段,必须能反序列化且取默认值
#[test]
fn legacy_connection_json_fills_new_defaults() {
let legacy = r#"{
"id": "c1",
"name": "旧连接",
"host": "broker.emqx.io",
"port": 1883,
"client_id": "legacy_cid",
"keep_alive": 30
}"#;
let c: ConnectionConfig = serde_json::from_str(legacy).expect("旧版 JSON 应能反序列化");
assert_eq!(c.name, "旧连接");
assert_eq!(c.keep_alive, 30);
assert!(c.group.is_none(), "group 默认应为 None");
assert_eq!(c.connection_timeout_secs, 10, "连接超时默认 10 秒");
assert_eq!(c.max_reconnect_times, 0, "最大重连次数默认 0(无限)");
assert!(c.ssl.ca_file.is_empty());
assert!(c.ssl.client_cert_file.is_empty());
assert!(c.ssl.client_key_file.is_empty());
assert!(!c.ssl.ignore_ca, "ignore_ca 默认关闭");
}
// 向后兼容:旧版订阅 JSON 必须默认 enabled=true,其余 v5 选项为关闭/None
#[test]
fn legacy_subscription_json_defaults_enabled() {
let legacy = r#"{"id":"s1","connection_id":"c1","topic":"a/b","qos":1}"#;
let s: Subscription = serde_json::from_str(legacy).expect("旧版订阅应能反序列化");
assert!(s.enabled, "旧订阅默认启用");
assert!(s.alias.is_none());
assert!(s.sub_identifier.is_none());
assert!(!s.no_local);
assert!(!s.retain_as_published);
assert_eq!(s.retain_handling, 0);
// Subscription::new 同样产出默认启用的订阅
assert!(Subscription::new("c1", "t", 0).enabled);
}
// payload 截断:上限内不截断;超出时截断且不切坏 UTF-8 字符
#[test]
fn retained_payload_caps_and_preserves_utf8() {
let small = b"hello";
let (p, trunc) = retained_payload(small);
assert_eq!(&*p, "hello");
assert!(!trunc);
// 中文每字符 3 字节,让上限落在字符中间(256K 不是 3 的倍数)
let big: Vec<u8> = "中".repeat(MAX_PAYLOAD_RETAIN).into_bytes();
let (p, trunc) = retained_payload(&big);
assert!(trunc, "超过上限必须截断");
assert!(p.len() <= MAX_PAYLOAD_RETAIN);
assert_eq!(p.chars().last(), Some('中'), "不能切坏 UTF-8 字符");
assert!(
big.starts_with(p.as_bytes()),
"截断内容必须是原数据前缀"
);
// 恰好上限不截断
let exact = vec![b'a'; MAX_PAYLOAD_RETAIN];
let (_, trunc) = retained_payload(&exact);
assert!(!trunc);
}
}