use serde::{Deserialize, Serialize};
use std::path::{Path, PathBuf};
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct Server {
pub name: String,
pub url: String,
pub pin: String,
}
impl Server {
pub fn new(name: impl Into<String>, url: impl Into<String>, pin: impl Into<String>) -> Self {
Self {
name: name.into(),
url: url.into().trim().trim_end_matches('/').to_string(),
pin: pin.into().trim().to_string(),
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct Profile {
pub name: String,
pub servers: Vec<Server>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct ConfigState {
pub config_dir: String,
pub main_config_path: String,
pub profiles: Vec<Profile>,
pub active_profile_name: String,
pub active_server: Server,
}
pub fn get_default_server() -> Server {
let url = std::env::var("NOTIFY_URL")
.or_else(|_| std::env::var("SERVER_URL"))
.unwrap_or_else(|_| "http://127.0.0.1:8080".to_string());
let pin = std::env::var("NOTIFY_PIN")
.or_else(|_| std::env::var("PIN"))
.or_else(|_| std::env::var("NOTIFY_API_KEY"))
.or_else(|_| std::env::var("API_KEY"))
.unwrap_or_else(|_| "123456".to_string());
Server::new("", url, pin)
}
pub fn get_config_dir() -> PathBuf {
if let Ok(dir) = std::env::var("NOTIFY_CONFIG_DIR") {
let p = PathBuf::from(dir.trim());
if !p.as_os_str().is_empty() {
return p;
}
}
dirs::home_dir()
.unwrap_or_else(|| PathBuf::from("."))
.join(".config")
.join("notify")
}
pub fn normalize_server(val: &serde_json::Value) -> Option<Server> {
let obj = val.as_object()?;
let url = obj
.get("url")
.or_else(|| obj.get("server"))
.or_else(|| obj.get("host"))
.or_else(|| obj.get("endpoint"))
.or_else(|| obj.get("notify_url"))
.and_then(|v| v.as_str())
.unwrap_or("")
.trim()
.trim_end_matches('/')
.to_string();
if url.is_empty() {
return None;
}
let pin = obj
.get("pin")
.or_else(|| obj.get("token"))
.or_else(|| obj.get("apiKey"))
.or_else(|| obj.get("api_key"))
.or_else(|| obj.get("password"))
.and_then(|v| v.as_str())
.unwrap_or("")
.trim()
.to_string();
let name = obj
.get("name")
.and_then(|v| v.as_str())
.unwrap_or("")
.trim()
.to_string();
Some(Server { name, url, pin })
}
pub fn parse_profile(filename_or_name: &str, content: &str) -> Profile {
let clean_name = filename_or_name
.trim_end_matches(".json")
.trim()
.to_string();
let parsed: serde_json::Value = match serde_json::from_str(content) {
Ok(v) => v,
Err(_) => {
return Profile {
name: clean_name,
servers: Vec::new(),
};
}
};
let mut profile_name = clean_name;
let mut servers = Vec::new();
if let Some(arr) = parsed.as_array() {
for item in arr {
if let Some(srv) = normalize_server(item) {
servers.push(srv);
}
}
} else if let Some(obj) = parsed.as_object() {
if let Some(n) = obj.get("name").and_then(|v| v.as_str()) {
let t = n.trim();
if !t.is_empty() {
profile_name = t.to_string();
}
}
if let Some(servers_arr) = obj.get("servers").and_then(|v| v.as_array()) {
for item in servers_arr {
if let Some(srv) = normalize_server(item) {
servers.push(srv);
}
}
} else if let Some(srv) = normalize_server(&parsed) {
servers.push(srv);
}
}
Profile {
name: profile_name,
servers,
}
}
pub fn load_config_from_dir(dir: &Path) -> ConfigState {
let main_path = dir.join("notify.json");
let mut profiles = Vec::new();
let mut active_name = String::new();
// 1. Read notify.json first
if main_path.exists()
&& let Ok(content) = std::fs::read_to_string(&main_path)
{
if let Ok(val) = serde_json::from_str::<serde_json::Value>(&content)
&& let Some(act) = val.get("active").and_then(|v| v.as_str())
{
active_name = act.trim().to_string();
}
let main_profile = parse_profile("notify", &content);
if !main_profile.servers.is_empty() {
profiles.push(main_profile);
}
}
// 2. Scan other *.json profile files in directory
if let Ok(entries) = std::fs::read_dir(dir) {
let mut extra_files: Vec<PathBuf> = entries
.filter_map(|e| e.ok())
.map(|e| e.path())
.filter(|p| {
p.is_file()
&& p.extension().and_then(|s| s.to_str()) == Some("json")
&& p.file_name() != Some(std::ffi::OsStr::new("notify.json"))
})
.collect();
extra_files.sort();
for path in extra_files {
if let Some(file_name) = path.file_name().and_then(|s| s.to_str())
&& let Ok(content) = std::fs::read_to_string(&path)
{
let prof = parse_profile(file_name, &content);
if !prof.servers.is_empty() && !profiles.iter().any(|p| p.name == prof.name) {
profiles.push(prof);
}
}
}
}
// 3. Determine active profile and active server
if active_name.is_empty() && !profiles.is_empty() {
active_name = profiles[0].name.clone();
}
let active_profile = profiles.iter().find(|p| p.name == active_name);
let active_server = active_profile
.and_then(|p| p.servers.first())
.cloned()
.unwrap_or_else(get_default_server);
ConfigState {
config_dir: dir.to_string_lossy().to_string(),
main_config_path: main_path.to_string_lossy().to_string(),
profiles,
active_profile_name: active_name,
active_server,
}
}
pub fn load_config() -> ConfigState {
load_config_from_dir(&get_config_dir())
}
pub fn set_active_profile_in_dir(dir: &Path, name: &str) -> Result<ConfigState, String> {
let _ = std::fs::create_dir_all(dir);
let main_path = dir.join("notify.json");
let mut current_json = if main_path.exists() {
let content = std::fs::read_to_string(&main_path).map_err(|e| e.to_string())?;
serde_json::from_str::<serde_json::Value>(&content).unwrap_or(serde_json::json!({}))
} else {
serde_json::json!({
"name": "notify",
"url": get_default_server().url,
"pin": get_default_server().pin,
})
};
if let Some(obj) = current_json.as_object_mut() {
obj.insert(
"active".to_string(),
serde_json::Value::String(name.to_string()),
);
}
let serialized = serde_json::to_string_pretty(¤t_json).map_err(|e| e.to_string())?;
std::fs::write(&main_path, serialized + "\n").map_err(|e| e.to_string())?;
Ok(load_config_from_dir(dir))
}
pub fn set_active_profile(name: &str) -> Result<ConfigState, String> {
set_active_profile_in_dir(&get_config_dir(), name)
}
pub fn save_profile_in_dir(
dir: &Path,
name: &str,
url: &str,
pin: &str,
make_active: bool,
) -> Result<ConfigState, String> {
std::fs::create_dir_all(dir).map_err(|e| e.to_string())?;
let trimmed_name = name.trim();
let profile_name = if trimmed_name.is_empty() {
"notify"
} else {
trimmed_name
};
let target_file = if profile_name == "notify" {
dir.join("notify.json")
} else {
dir.join(format!("{profile_name}.json"))
};
let normalized_url = url.trim().trim_end_matches('/').to_string();
let normalized_pin = pin.trim().to_string();
let json_val = serde_json::json!({
"name": profile_name,
"url": normalized_url,
"pin": normalized_pin,
"api_key": normalized_pin,
"active": if make_active || profile_name == "notify" { profile_name } else { "" }
});
let content = serde_json::to_string_pretty(&json_val).map_err(|e| e.to_string())? + "\n";
std::fs::write(&target_file, content).map_err(|e| e.to_string())?;
if make_active && profile_name != "notify" {
let _ = set_active_profile_in_dir(dir, profile_name);
}
Ok(load_config_from_dir(dir))
}
pub fn save_profile(
name: &str,
url: &str,
pin: &str,
make_active: bool,
) -> Result<ConfigState, String> {
save_profile_in_dir(&get_config_dir(), name, url, pin, make_active)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_normalize_server_aliases() {
let json1 = serde_json::json!({
"name": "home",
"server": "https://notify.example.com/",
"apiKey": "my_key"
});
let s1 = normalize_server(&json1).unwrap();
assert_eq!(s1.name, "home");
assert_eq!(s1.url, "https://notify.example.com");
assert_eq!(s1.pin, "my_key");
let json2 = serde_json::json!({
"endpoint": "http://192.168.1.10:8080",
"token": "tok123"
});
let s2 = normalize_server(&json2).unwrap();
assert_eq!(s2.url, "http://192.168.1.10:8080");
assert_eq!(s2.pin, "tok123");
}
#[test]
fn test_parse_profile_single_object() {
let raw = r#"{
"name": "work",
"url": "http://work.internal:8080",
"pin": "secret"
}"#;
let prof = parse_profile("work", raw);
assert_eq!(prof.name, "work");
assert_eq!(prof.servers.len(), 1);
assert_eq!(prof.servers[0].url, "http://work.internal:8080");
assert_eq!(prof.servers[0].pin, "secret");
}
#[test]
fn test_parse_profile_servers_array() {
let raw = r#"{
"name": "cluster",
"servers": [
{ "name": "node1", "url": "http://node1:8080", "pin": "p1" },
{ "name": "node2", "url": "http://node2:8080", "pin": "p2" }
]
}"#;
let prof = parse_profile("cluster", raw);
assert_eq!(prof.name, "cluster");
assert_eq!(prof.servers.len(), 2);
assert_eq!(prof.servers[0].name, "node1");
assert_eq!(prof.servers[1].name, "node2");
}
#[test]
fn test_load_and_switch_profile_in_dir() {
let now = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_nanos();
let temp_dir = std::env::temp_dir().join(format!("notify_cfg_test_{now}"));
let _ = std::fs::create_dir_all(&temp_dir);
// Initially empty
let cfg0 = load_config_from_dir(&temp_dir);
assert!(cfg0.profiles.is_empty());
assert_eq!(cfg0.active_server.pin, get_default_server().pin);
// Save "notify" profile
let cfg1 =
save_profile_in_dir(&temp_dir, "notify", "http://10.0.0.1:8080", "1111", true).unwrap();
assert_eq!(cfg1.profiles.len(), 1);
assert_eq!(cfg1.active_profile_name, "notify");
assert_eq!(cfg1.active_server.url, "http://10.0.0.1:8080");
assert_eq!(cfg1.active_server.pin, "1111");
// Save another profile "office"
let cfg2 = save_profile_in_dir(
&temp_dir,
"office",
"https://office.example.com",
"2222",
false,
)
.unwrap();
assert_eq!(cfg2.profiles.len(), 2);
assert_eq!(cfg2.active_profile_name, "notify");
// Switch to "office"
let cfg3 = set_active_profile_in_dir(&temp_dir, "office").unwrap();
assert_eq!(cfg3.active_profile_name, "office");
assert_eq!(cfg3.active_server.url, "https://office.example.com");
assert_eq!(cfg3.active_server.pin, "2222");
let _ = std::fs::remove_dir_all(&temp_dir);
}
}