use serde::{Deserialize, Serialize};
use std::path::PathBuf;
pub const BUILTIN_CCN_NAME: &str = "official";
pub const BUILTIN_CCN_URL: &str = "https://api.aleph.im";
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CcnEntry {
pub name: String,
pub url: String,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct ConfigManifest {
pub default_ccn: Option<String>,
#[serde(default)]
pub ccns: Vec<CcnEntry>,
}
#[derive(Debug, thiserror::Error)]
pub enum ConfigError {
#[error("ccn '{0}' already exists")]
AlreadyExists(String),
#[error("ccn '{0}' not found")]
NotFound(String),
#[error(
"invalid name '{0}': names must be non-empty and contain only alphanumeric characters, hyphens, and underscores"
)]
InvalidName(String),
#[error("invalid URL '{0}': {1}")]
InvalidUrl(String, String),
#[error(transparent)]
Io(#[from] std::io::Error),
#[error("failed to parse config: {0}")]
Parse(String),
}
pub struct ConfigStore {
manifest_path: PathBuf,
}
impl ConfigStore {
#[cfg(test)]
pub fn with_manifest_path(manifest_path: PathBuf) -> Self {
Self { manifest_path }
}
pub fn open() -> Result<Self, ConfigError> {
let proj = directories::ProjectDirs::from("", "", "aleph").ok_or_else(|| {
ConfigError::Io(std::io::Error::new(
std::io::ErrorKind::NotFound,
"could not determine home directory",
))
})?;
let config_dir = proj.config_dir();
std::fs::create_dir_all(config_dir)?;
let store = Self {
manifest_path: config_dir.join("config.toml"),
};
store.ensure_builtin()?;
Ok(store)
}
fn ensure_builtin(&self) -> Result<(), ConfigError> {
let mut manifest = self.load_manifest()?;
if manifest.ccns.iter().any(|c| c.name == BUILTIN_CCN_NAME) {
return Ok(());
}
manifest.ccns.push(CcnEntry {
name: BUILTIN_CCN_NAME.to_string(),
url: BUILTIN_CCN_URL.to_string(),
});
if manifest.default_ccn.is_none() {
manifest.default_ccn = Some(BUILTIN_CCN_NAME.to_string());
}
self.save_manifest(&manifest)
}
pub fn load_manifest(&self) -> Result<ConfigManifest, ConfigError> {
match std::fs::read_to_string(&self.manifest_path) {
Ok(contents) => {
toml::from_str(&contents).map_err(|e| ConfigError::Parse(e.to_string()))
}
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(ConfigManifest::default()),
Err(e) => Err(ConfigError::Io(e)),
}
}
fn save_manifest(&self, manifest: &ConfigManifest) -> Result<(), ConfigError> {
let content =
toml::to_string_pretty(manifest).map_err(|e| ConfigError::Parse(e.to_string()))?;
if let Some(parent) = self.manifest_path.parent() {
std::fs::create_dir_all(parent)?;
}
std::fs::write(&self.manifest_path, content)?;
Ok(())
}
fn validate_name(name: &str) -> Result<(), ConfigError> {
if name.is_empty()
|| !name
.chars()
.all(|c| c.is_alphanumeric() || c == '-' || c == '_')
{
return Err(ConfigError::InvalidName(name.to_string()));
}
Ok(())
}
fn validate_url(raw: &str) -> Result<(), ConfigError> {
let parsed = url::Url::parse(raw)
.map_err(|e| ConfigError::InvalidUrl(raw.to_string(), e.to_string()))?;
match parsed.scheme() {
"http" | "https" => Ok(()),
other => Err(ConfigError::InvalidUrl(
raw.to_string(),
format!("scheme must be http or https, got '{other}'"),
)),
}
}
pub fn add_ccn(&self, name: &str, url: &str) -> Result<(), ConfigError> {
Self::validate_name(name)?;
Self::validate_url(url)?;
let mut manifest = self.load_manifest()?;
if manifest.ccns.iter().any(|c| c.name == name) {
return Err(ConfigError::AlreadyExists(name.to_string()));
}
manifest.ccns.push(CcnEntry {
name: name.to_string(),
url: url.to_string(),
});
if manifest.default_ccn.is_none() {
manifest.default_ccn = Some(name.to_string());
}
self.save_manifest(&manifest)
}
pub fn get_ccn(&self, name: &str) -> Result<CcnEntry, ConfigError> {
let manifest = self.load_manifest()?;
manifest
.ccns
.iter()
.find(|c| c.name == name)
.cloned()
.ok_or_else(|| ConfigError::NotFound(name.to_string()))
}
pub fn default_ccn_name(&self) -> Result<Option<String>, ConfigError> {
Ok(self.load_manifest()?.default_ccn)
}
pub fn set_default_ccn(&self, name: &str) -> Result<(), ConfigError> {
let mut manifest = self.load_manifest()?;
if !manifest.ccns.iter().any(|c| c.name == name) {
return Err(ConfigError::NotFound(name.to_string()));
}
manifest.default_ccn = Some(name.to_string());
self.save_manifest(&manifest)
}
pub fn remove_ccn(&self, name: &str) -> Result<(), ConfigError> {
let mut manifest = self.load_manifest()?;
let len_before = manifest.ccns.len();
manifest.ccns.retain(|c| c.name != name);
if manifest.ccns.len() == len_before {
return Err(ConfigError::NotFound(name.to_string()));
}
if manifest.default_ccn.as_deref() == Some(name) {
manifest.default_ccn = None;
}
self.save_manifest(&manifest)
}
}
#[cfg(test)]
mod tests {
use super::*;
fn temp_store() -> (tempfile::TempDir, ConfigStore) {
let dir = tempfile::tempdir().unwrap();
let manifest_path = dir.path().join("config.toml");
let store = ConfigStore::with_manifest_path(manifest_path);
(dir, store)
}
#[test]
fn load_empty_manifest_returns_default() {
let (_dir, store) = temp_store();
let manifest = store.load_manifest().unwrap();
assert!(manifest.default_ccn.is_none());
assert!(manifest.ccns.is_empty());
}
#[test]
fn roundtrip_manifest_serde() {
let manifest = ConfigManifest {
default_ccn: Some("api3".to_string()),
ccns: vec![
CcnEntry {
name: "api3".to_string(),
url: "https://api3.aleph.im".to_string(),
},
CcnEntry {
name: "local".to_string(),
url: "http://localhost:4024".to_string(),
},
],
};
let serialized = toml::to_string_pretty(&manifest).unwrap();
let deserialized: ConfigManifest = toml::from_str(&serialized).unwrap();
assert_eq!(deserialized.default_ccn.as_deref(), Some("api3"));
assert_eq!(deserialized.ccns.len(), 2);
assert_eq!(deserialized.ccns[0].url, "https://api3.aleph.im");
assert_eq!(deserialized.ccns[1].name, "local");
}
#[test]
fn validate_name_rejects_empty() {
assert!(ConfigStore::validate_name("").is_err());
}
#[test]
fn validate_name_rejects_spaces() {
assert!(ConfigStore::validate_name("my node").is_err());
}
#[test]
fn validate_name_rejects_special_chars() {
assert!(ConfigStore::validate_name("my@node").is_err());
}
#[test]
fn validate_name_accepts_valid() {
assert!(ConfigStore::validate_name("my-node_01").is_ok());
}
#[test]
fn validate_url_rejects_ftp() {
assert!(ConfigStore::validate_url("ftp://example.com").is_err());
}
#[test]
fn validate_url_rejects_garbage() {
assert!(ConfigStore::validate_url("not a url").is_err());
}
#[test]
fn validate_url_accepts_https() {
assert!(ConfigStore::validate_url("https://api3.aleph.im").is_ok());
}
#[test]
fn validate_url_accepts_http() {
assert!(ConfigStore::validate_url("http://localhost:4024").is_ok());
}
#[test]
fn add_and_get_ccn() {
let (_dir, store) = temp_store();
store.add_ccn("api3", "https://api3.aleph.im").unwrap();
let entry = store.get_ccn("api3").unwrap();
assert_eq!(entry.name, "api3");
assert_eq!(entry.url, "https://api3.aleph.im");
}
#[test]
fn add_ccn_sets_first_as_default() {
let (_dir, store) = temp_store();
store.add_ccn("api3", "https://api3.aleph.im").unwrap();
assert_eq!(store.default_ccn_name().unwrap().as_deref(), Some("api3"));
}
#[test]
fn add_ccn_does_not_override_existing_default() {
let (_dir, store) = temp_store();
store.add_ccn("api3", "https://api3.aleph.im").unwrap();
store.add_ccn("local", "http://localhost:4024").unwrap();
assert_eq!(store.default_ccn_name().unwrap().as_deref(), Some("api3"));
}
#[test]
fn add_duplicate_errors() {
let (_dir, store) = temp_store();
store.add_ccn("api3", "https://api3.aleph.im").unwrap();
let err = store.add_ccn("api3", "https://other.aleph.im").unwrap_err();
assert!(matches!(err, ConfigError::AlreadyExists(_)));
}
#[test]
fn add_ccn_invalid_name_errors() {
let (_dir, store) = temp_store();
let err = store
.add_ccn("bad name!", "https://api3.aleph.im")
.unwrap_err();
assert!(matches!(err, ConfigError::InvalidName(_)));
}
#[test]
fn add_ccn_invalid_url_errors() {
let (_dir, store) = temp_store();
let err = store.add_ccn("api3", "not a url").unwrap_err();
assert!(matches!(err, ConfigError::InvalidUrl(_, _)));
}
#[test]
fn get_nonexistent_ccn_errors() {
let (_dir, store) = temp_store();
let err = store.get_ccn("nope").unwrap_err();
assert!(matches!(err, ConfigError::NotFound(_)));
}
#[test]
fn set_default_ccn() {
let (_dir, store) = temp_store();
store.add_ccn("api3", "https://api3.aleph.im").unwrap();
store.add_ccn("local", "http://localhost:4024").unwrap();
store.set_default_ccn("local").unwrap();
assert_eq!(store.default_ccn_name().unwrap().as_deref(), Some("local"));
}
#[test]
fn set_default_ccn_nonexistent_errors() {
let (_dir, store) = temp_store();
let err = store.set_default_ccn("nope").unwrap_err();
assert!(matches!(err, ConfigError::NotFound(_)));
}
#[test]
fn remove_ccn() {
let (_dir, store) = temp_store();
store.add_ccn("api3", "https://api3.aleph.im").unwrap();
store.add_ccn("local", "http://localhost:4024").unwrap();
store.remove_ccn("local").unwrap();
assert!(store.get_ccn("local").is_err());
assert!(store.get_ccn("api3").is_ok());
}
#[test]
fn remove_default_ccn_clears_default() {
let (_dir, store) = temp_store();
store.add_ccn("api3", "https://api3.aleph.im").unwrap();
store.add_ccn("local", "http://localhost:4024").unwrap();
store.remove_ccn("api3").unwrap();
assert_eq!(store.default_ccn_name().unwrap(), None);
}
#[test]
fn remove_nonexistent_ccn_errors() {
let (_dir, store) = temp_store();
let err = store.remove_ccn("nope").unwrap_err();
assert!(matches!(err, ConfigError::NotFound(_)));
}
#[test]
fn save_and_load_manifest() {
let (_dir, store) = temp_store();
let manifest = ConfigManifest {
default_ccn: Some("api3".to_string()),
ccns: vec![CcnEntry {
name: "api3".to_string(),
url: "https://api3.aleph.im".to_string(),
}],
};
store.save_manifest(&manifest).unwrap();
let loaded = store.load_manifest().unwrap();
assert_eq!(loaded.default_ccn.as_deref(), Some("api3"));
assert_eq!(loaded.ccns.len(), 1);
}
}