use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
#[serde(deny_unknown_fields)]
pub struct PluginDashboardSection {
pub layout: InstanceLayout,
pub auth_check: AuthCheck,
}
impl PluginDashboardSection {
pub fn validate(&self) -> Result<(), String> {
self.layout.validate()?;
self.auth_check.validate()?;
Ok(())
}
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(tag = "kind", rename_all = "snake_case", deny_unknown_fields)]
pub enum InstanceLayout {
Single,
WorkspaceWalk {
subdir: String,
},
}
impl Default for InstanceLayout {
fn default() -> Self {
Self::Single
}
}
impl InstanceLayout {
pub fn validate(&self) -> Result<(), String> {
match self {
Self::Single => Ok(()),
Self::WorkspaceWalk { subdir } => {
if subdir.is_empty() {
Err("WorkspaceWalk requires non-empty subdir".into())
} else if subdir.contains('/') {
Err(format!(
"WorkspaceWalk subdir must be a single segment; got `{subdir}`"
))
} else {
Ok(())
}
}
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(tag = "kind", rename_all = "snake_case", deny_unknown_fields)]
pub enum AuthCheck {
FilePresence {
path: String,
},
SessionDirFiles {
candidates: Vec<String>,
},
}
impl Default for AuthCheck {
fn default() -> Self {
Self::FilePresence {
path: String::new(),
}
}
}
impl AuthCheck {
pub fn validate(&self) -> Result<(), String> {
match self {
Self::FilePresence { path } => {
if path.is_empty() {
Err("FilePresence requires non-empty path".into())
} else if path.starts_with('/') {
Err(format!(
"FilePresence path must be relative to secrets_dir; got `{path}`"
))
} else {
Ok(())
}
}
Self::SessionDirFiles { candidates } => {
if candidates.is_empty() {
Err("SessionDirFiles requires at least one candidate filename".into())
} else {
Ok(())
}
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn validate_accepts_telegram_shape() {
let s = PluginDashboardSection {
layout: InstanceLayout::Single,
auth_check: AuthCheck::FilePresence {
path: "telegram_bot_token.txt".into(),
},
};
assert!(s.validate().is_ok());
}
#[test]
fn validate_accepts_whatsapp_shape() {
let s = PluginDashboardSection {
layout: InstanceLayout::WorkspaceWalk {
subdir: "whatsapp".into(),
},
auth_check: AuthCheck::SessionDirFiles {
candidates: vec![
"session.db".into(),
"state.db".into(),
"device.json".into(),
"registration.json".into(),
],
},
};
assert!(s.validate().is_ok());
}
#[test]
fn validate_rejects_empty_subdir() {
let s = PluginDashboardSection {
layout: InstanceLayout::WorkspaceWalk { subdir: "".into() },
auth_check: AuthCheck::FilePresence {
path: "foo.txt".into(),
},
};
assert!(s.validate().is_err());
}
#[test]
fn validate_rejects_subdir_with_slash() {
let s = PluginDashboardSection {
layout: InstanceLayout::WorkspaceWalk {
subdir: "foo/bar".into(),
},
auth_check: AuthCheck::FilePresence {
path: "foo.txt".into(),
},
};
assert!(s.validate().is_err());
}
#[test]
fn validate_rejects_absolute_file_path() {
let s = PluginDashboardSection {
layout: InstanceLayout::Single,
auth_check: AuthCheck::FilePresence {
path: "/etc/passwd".into(),
},
};
assert!(s.validate().is_err());
}
#[test]
fn validate_rejects_empty_candidates() {
let s = PluginDashboardSection {
layout: InstanceLayout::Single,
auth_check: AuthCheck::SessionDirFiles { candidates: vec![] },
};
assert!(s.validate().is_err());
}
#[test]
fn deserializes_telegram_toml() {
let toml = r#"
[layout]
kind = "single"
[auth_check]
kind = "file_presence"
path = "telegram_bot_token.txt"
"#;
let s: PluginDashboardSection = toml::from_str(toml).expect("parse");
assert!(matches!(s.layout, InstanceLayout::Single));
match s.auth_check {
AuthCheck::FilePresence { path } => {
assert_eq!(path, "telegram_bot_token.txt");
}
_ => panic!("expected FilePresence"),
}
}
#[test]
fn deserializes_whatsapp_toml() {
let toml = r#"
[layout]
kind = "workspace_walk"
subdir = "whatsapp"
[auth_check]
kind = "session_dir_files"
candidates = ["session.db", "state.db"]
"#;
let s: PluginDashboardSection = toml::from_str(toml).expect("parse");
match s.layout {
InstanceLayout::WorkspaceWalk { subdir } => {
assert_eq!(subdir, "whatsapp");
}
_ => panic!("expected WorkspaceWalk"),
}
}
}