use anyhow::Result;
use config::{Config, ConfigError, Environment, File};
use serde::Deserialize;
use std::path::PathBuf;
#[cfg(test)]
struct EnvGuard {
vars: Vec<(String, Option<String>)>,
}
#[cfg(test)]
impl EnvGuard {
fn new(keys: &[&str]) -> Self {
let vars = keys
.iter()
.map(|key| (key.to_string(), std::env::var(key).ok()))
.collect();
Self { vars }
}
}
#[cfg(test)]
impl Drop for EnvGuard {
fn drop(&mut self) {
for (key, old_value) in &self.vars {
match old_value {
Some(v) => std::env::set_var(key, v),
None => std::env::remove_var(key),
}
}
}
}
#[derive(Debug, Deserialize, Clone)]
#[allow(dead_code)] pub struct NodeTokenConfig {
pub server_url: String,
pub registration_token: String,
pub client_instance_id: String,
pub display_name: String,
#[serde(default = "default_ollama_url")]
pub ollama_url: String,
#[serde(default = "default_heartbeat_interval")]
pub heartbeat_interval_secs: u64,
#[serde(default = "default_excluded_poll_check_interval")]
pub excluded_poll_check_interval_secs: u64,
#[serde(default = "default_data_dir")]
pub data_dir: Option<String>,
}
fn default_ollama_url() -> String {
"http://localhost:11434".to_string()
}
fn default_heartbeat_interval() -> u64 {
30
}
fn default_excluded_poll_check_interval() -> u64 {
30
}
fn default_data_dir() -> Option<String> {
dirs::data_local_dir().map(|d| d.join("node-token").to_string_lossy().to_string())
}
impl NodeTokenConfig {
#[allow(dead_code)] pub fn data_dir_path(&self) -> PathBuf {
self.data_dir
.as_ref()
.map(PathBuf::from)
.unwrap_or_else(|| {
dirs::data_local_dir()
.unwrap_or_else(|| PathBuf::from("."))
.join("node-token")
})
}
}
pub fn load_config() -> Result<NodeTokenConfig> {
let config_path =
std::env::var("NODE_TOKEN_CONFIG").unwrap_or_else(|_| "config.toml".to_string());
let builder = Config::builder()
.set_default("server_url", "http://localhost:3000")?
.set_default("registration_token", "")?
.set_default("client_instance_id", "")?
.set_default("display_name", "My KeyComputeC Node")?
.set_default("ollama_url", "http://localhost:11434")?
.set_default("heartbeat_interval_secs", 30)?
.set_default("excluded_poll_check_interval_secs", 30)?
.add_source(File::with_name(&config_path).required(false))
.add_source(
Environment::with_prefix("NODE_TOKEN")
.separator("__")
.try_parsing(true),
);
let config = builder.build()?;
let node_config: NodeTokenConfig = config
.try_deserialize()
.map_err(|e: ConfigError| anyhow::anyhow!("Failed to deserialize config: {}", e))?;
if node_config.server_url.is_empty() {
anyhow::bail!("server_url is required");
}
if node_config.registration_token.is_empty() {
anyhow::bail!("registration_token is required");
}
if node_config.client_instance_id.is_empty() {
anyhow::bail!("client_instance_id is required");
}
if node_config.display_name.is_empty() {
anyhow::bail!("display_name is required");
}
Ok(node_config)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_default_config() {
let config = NodeTokenConfig {
server_url: "http://localhost:3000".to_string(),
registration_token: "test-token".to_string(),
client_instance_id: "test-instance".to_string(),
display_name: "Test Node".to_string(),
ollama_url: default_ollama_url(),
heartbeat_interval_secs: default_heartbeat_interval(),
excluded_poll_check_interval_secs: default_excluded_poll_check_interval(),
data_dir: None,
};
assert_eq!(config.ollama_url, "http://localhost:11434");
assert_eq!(config.heartbeat_interval_secs, 30);
assert_eq!(config.excluded_poll_check_interval_secs, 30);
}
#[test]
fn test_data_dir_path() {
let config = NodeTokenConfig {
server_url: "http://localhost:3000".to_string(),
registration_token: "test-token".to_string(),
client_instance_id: "test-instance".to_string(),
display_name: "Test Node".to_string(),
ollama_url: "http://localhost:11434".to_string(),
heartbeat_interval_secs: 30,
excluded_poll_check_interval_secs: 30,
data_dir: Some("/tmp/test-node-token".to_string()),
};
assert_eq!(
config.data_dir_path(),
PathBuf::from("/tmp/test-node-token")
);
}
#[test]
fn test_config_from_toml_file() {
let temp_dir = tempfile::tempdir().unwrap();
let config_path = temp_dir.path().join("config.toml");
let config_content = r#"
server_url = "http://example.com:3000"
registration_token = "my-secret-token"
client_instance_id = "my-pc-001"
display_name = "My PC Node"
ollama_url = "http://localhost:11434"
heartbeat_interval_secs = 60
excluded_poll_check_interval_secs = 60
"#;
std::fs::write(&config_path, config_content).unwrap();
let _guard = EnvGuard::new(&["NODE_TOKEN_CONFIG"]);
std::env::set_var("NODE_TOKEN_CONFIG", config_path.to_str().unwrap());
std::env::remove_var("NODE_TOKEN__SERVER_URL");
std::env::remove_var("NODE_TOKEN__REGISTRATION_TOKEN");
std::env::remove_var("NODE_TOKEN__CLIENT_INSTANCE_ID");
std::env::remove_var("NODE_TOKEN__DISPLAY_NAME");
let config = load_config().unwrap();
assert_eq!(config.server_url, "http://example.com:3000");
assert_eq!(config.registration_token, "my-secret-token");
assert_eq!(config.client_instance_id, "my-pc-001");
assert_eq!(config.display_name, "My PC Node");
assert_eq!(config.ollama_url, "http://localhost:11434");
assert_eq!(config.heartbeat_interval_secs, 60);
assert_eq!(config.excluded_poll_check_interval_secs, 60);
}
#[test]
fn test_config_with_custom_values() {
let config = NodeTokenConfig {
server_url: "https://keycompute.example.com".to_string(),
registration_token: "custom-token-123".to_string(),
client_instance_id: "custom-instance-456".to_string(),
display_name: "Custom Node".to_string(),
ollama_url: "http://192.168.1.100:11434".to_string(),
heartbeat_interval_secs: 45,
excluded_poll_check_interval_secs: 45,
data_dir: Some("/custom/data/dir".to_string()),
};
assert_eq!(config.server_url, "https://keycompute.example.com");
assert_eq!(config.registration_token, "custom-token-123");
assert_eq!(config.client_instance_id, "custom-instance-456");
assert_eq!(config.display_name, "Custom Node");
assert_eq!(config.ollama_url, "http://192.168.1.100:11434");
assert_eq!(config.heartbeat_interval_secs, 45);
assert_eq!(config.excluded_poll_check_interval_secs, 45);
assert_eq!(config.data_dir, Some("/custom/data/dir".to_string()));
}
#[test]
fn test_data_dir_path_with_none() {
let config = NodeTokenConfig {
server_url: "http://localhost:3000".to_string(),
registration_token: "test-token".to_string(),
client_instance_id: "test-instance".to_string(),
display_name: "Test Node".to_string(),
ollama_url: "http://localhost:11434".to_string(),
heartbeat_interval_secs: 30,
excluded_poll_check_interval_secs: 30,
data_dir: None,
};
let path = config.data_dir_path();
assert!(path.ends_with("node-token"));
}
#[test]
fn test_config_validation_empty_server_url() {
let config = NodeTokenConfig {
server_url: "".to_string(), registration_token: "test-token".to_string(),
client_instance_id: "test-instance".to_string(),
display_name: "Test Node".to_string(),
ollama_url: "http://localhost:11434".to_string(),
heartbeat_interval_secs: 30,
excluded_poll_check_interval_secs: 30,
data_dir: None,
};
assert!(config.server_url.is_empty());
}
#[test]
fn test_config_validation_empty_registration_token() {
let config = NodeTokenConfig {
server_url: "http://localhost:3000".to_string(),
registration_token: "".to_string(), client_instance_id: "test-instance".to_string(),
display_name: "Test Node".to_string(),
ollama_url: "http://localhost:11434".to_string(),
heartbeat_interval_secs: 30,
excluded_poll_check_interval_secs: 30,
data_dir: None,
};
assert!(config.registration_token.is_empty());
}
#[test]
fn test_config_validation_empty_client_instance_id() {
let config = NodeTokenConfig {
server_url: "http://localhost:3000".to_string(),
registration_token: "test-token".to_string(),
client_instance_id: "".to_string(), display_name: "Test Node".to_string(),
ollama_url: "http://localhost:11434".to_string(),
heartbeat_interval_secs: 30,
excluded_poll_check_interval_secs: 30,
data_dir: None,
};
assert!(config.client_instance_id.is_empty());
}
#[test]
fn test_config_validation_empty_display_name() {
let config = NodeTokenConfig {
server_url: "http://localhost:3000".to_string(),
registration_token: "test-token".to_string(),
client_instance_id: "test-instance".to_string(),
display_name: "".to_string(), ollama_url: "http://localhost:11434".to_string(),
heartbeat_interval_secs: 30,
excluded_poll_check_interval_secs: 30,
data_dir: None,
};
assert!(config.display_name.is_empty());
}
#[test]
fn test_config_with_unicode_display_name() {
let config = NodeTokenConfig {
server_url: "http://localhost:3000".to_string(),
registration_token: "test-token".to_string(),
client_instance_id: "test-instance".to_string(),
display_name: "🚀 My Node 节点".to_string(),
ollama_url: "http://localhost:11434".to_string(),
heartbeat_interval_secs: 30,
excluded_poll_check_interval_secs: 30,
data_dir: None,
};
assert_eq!(config.display_name, "🚀 My Node 节点");
}
#[test]
fn test_config_with_special_characters() {
let config = NodeTokenConfig {
server_url: "http://localhost:3000".to_string(),
registration_token: "test-token-with-special-chars!@#$%".to_string(),
client_instance_id: "test-instance_123.456".to_string(),
display_name: "Node \"Test\" (Production)".to_string(),
ollama_url: "http://localhost:11434".to_string(),
heartbeat_interval_secs: 30,
excluded_poll_check_interval_secs: 30,
data_dir: None,
};
assert_eq!(
config.registration_token,
"test-token-with-special-chars!@#$%"
);
assert_eq!(config.display_name, "Node \"Test\" (Production)");
}
#[test]
fn test_config_custom_data_dir() {
let config = NodeTokenConfig {
server_url: "http://localhost:3000".to_string(),
registration_token: "test-token".to_string(),
client_instance_id: "test-instance".to_string(),
display_name: "Test Node".to_string(),
ollama_url: "http://localhost:11434".to_string(),
heartbeat_interval_secs: 30,
excluded_poll_check_interval_secs: 30,
data_dir: Some("/custom/path/to/data".to_string()),
};
assert_eq!(config.data_dir, Some("/custom/path/to/data".to_string()));
assert_eq!(
config.data_dir_path(),
PathBuf::from("/custom/path/to/data")
);
}
}