use std::path::Path;
use anyhow::{Context, Result};
use iroh::{EndpointId, RelayUrl, SecretKey};
use serde::{Deserialize, Serialize};
#[derive(Debug, Serialize, Deserialize)]
pub struct Config {
pub secret_key: SecretKey,
#[serde(default = "Config::default_daemon_port")]
pub daemon_port: u16,
#[serde(default)]
pub relay_url: Option<RelayUrl>,
}
impl Config {
fn default_daemon_port() -> u16 {
60001
}
pub fn public_id(&self) -> EndpointId {
self.secret_key.public()
}
pub fn load_or_create(data_dir: &Path) -> Result<Self> {
let path = data_dir.join("config.json");
if path.exists() {
let raw = std::fs::read_to_string(&path)
.with_context(|| format!("reading {}", path.display()))?;
serde_json::from_str(&raw).with_context(|| format!("parsing {}", path.display()))
} else {
let cfg = Config {
secret_key: SecretKey::generate(),
daemon_port: Self::default_daemon_port(),
relay_url: None,
};
let raw = serde_json::to_string_pretty(&cfg)?;
std::fs::write(&path, raw).with_context(|| format!("writing {}", path.display()))?;
Ok(cfg)
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::TempDir;
fn tmpdir() -> TempDir {
tempfile::tempdir().expect("tempdir")
}
#[test]
fn creates_config_file_on_first_call() {
let dir = tmpdir();
assert!(!dir.path().join("config.json").exists());
Config::load_or_create(dir.path()).unwrap();
assert!(dir.path().join("config.json").exists());
}
#[test]
fn returns_same_key_on_repeated_calls() {
let dir = tmpdir();
let first = Config::load_or_create(dir.path()).unwrap();
let second = Config::load_or_create(dir.path()).unwrap();
assert_eq!(first.secret_key.to_bytes(), second.secret_key.to_bytes());
}
#[test]
fn daemon_port_defaults_when_field_absent_in_existing_config() {
let dir = tmpdir();
let key = iroh::SecretKey::generate();
let legacy = serde_json::json!({ "secret_key": key });
std::fs::write(dir.path().join("config.json"), legacy.to_string()).unwrap();
let cfg = Config::load_or_create(dir.path()).unwrap();
assert_eq!(cfg.daemon_port, 60001);
}
#[test]
fn returns_error_on_invalid_config_file() {
let dir = tmpdir();
std::fs::write(dir.path().join("config.json"), b"not valid json").unwrap();
let err = Config::load_or_create(dir.path()).unwrap_err();
assert!(err.to_string().contains("parsing"));
}
#[test]
fn relay_url_absent_defaults_to_none() {
let dir = tmpdir();
let key = iroh::SecretKey::generate();
let legacy = serde_json::json!({ "secret_key": key });
std::fs::write(dir.path().join("config.json"), legacy.to_string()).unwrap();
let cfg = Config::load_or_create(dir.path()).unwrap();
assert!(cfg.relay_url.is_none());
}
#[test]
fn relay_url_invalid_value_returns_parse_error() {
let dir = tmpdir();
let key = iroh::SecretKey::generate();
let bad = serde_json::json!({ "secret_key": key, "relay_url": "not a url" });
std::fs::write(dir.path().join("config.json"), bad.to_string()).unwrap();
let err = Config::load_or_create(dir.path()).unwrap_err();
assert!(err.to_string().contains("parsing"));
}
#[test]
fn relay_url_valid_value_parses_correctly() {
let dir = tmpdir();
let key = iroh::SecretKey::generate();
let cfg_json =
serde_json::json!({ "secret_key": key, "relay_url": "https://relay.example.com" });
std::fs::write(dir.path().join("config.json"), cfg_json.to_string()).unwrap();
let cfg = Config::load_or_create(dir.path()).unwrap();
assert_eq!(
cfg.relay_url.unwrap().to_string(),
"https://relay.example.com/"
);
}
#[test]
fn public_id_matches_secret_key() {
let dir = tmpdir();
let cfg = Config::load_or_create(dir.path()).unwrap();
assert_eq!(cfg.public_id(), cfg.secret_key.public());
}
#[test]
fn config_with_unknown_fields_is_accepted() {
let dir = tmpdir();
let key = iroh::SecretKey::generate();
let json = serde_json::json!({
"secret_key": key,
"unknown_future_field": "some_value",
});
std::fs::write(dir.path().join("config.json"), json.to_string()).unwrap();
assert!(Config::load_or_create(dir.path()).is_ok());
}
}