use std::num::NonZeroU32;
use std::path::PathBuf;
use std::sync::OnceLock;
use serde::Deserialize;
static CONFIG: OnceLock<WebfindConfig> = OnceLock::new();
pub fn init() -> &'static WebfindConfig {
CONFIG.get_or_init(|| load().unwrap_or_default())
}
pub fn data_dir() -> PathBuf {
resolve_data_dir(init())
}
#[derive(Debug, Default, Deserialize, Clone)]
pub struct WebfindConfig {
pub data_dir: Option<PathBuf>,
pub graph_store: Option<String>,
pub turso: Option<TursoConfig>,
pub rate_limit: Option<u32>,
pub body_limit: Option<usize>,
pub cors_origins: Option<String>,
pub storage: Option<StorageConfig>,
}
#[derive(Debug, Default, Deserialize, Clone)]
pub struct StorageConfig {
pub max_bytes: Option<u64>,
pub max_full_content_pages: Option<u64>,
}
pub fn resolve_storage_max_bytes(config: &WebfindConfig) -> Option<u64> {
std::env::var("WEBFIND_STORAGE_MAX_BYTES")
.ok()
.and_then(|s| parse_bytes(&s))
.or_else(|| config.storage.as_ref().and_then(|s| s.max_bytes))
}
pub fn resolve_max_full_content_pages(config: &WebfindConfig) -> Option<u64> {
std::env::var("WEBFIND_STORAGE_MAX_FULL_CONTENT")
.ok()
.and_then(|s| s.trim().parse().ok())
.or_else(|| {
config
.storage
.as_ref()
.and_then(|s| s.max_full_content_pages)
})
}
fn parse_bytes(s: &str) -> Option<u64> {
let s = s.trim().to_ascii_lowercase();
let (num, mult) = if let Some(n) = s.strip_suffix("gb") {
(n, 1024u64.pow(3))
} else if let Some(n) = s.strip_suffix("mb") {
(n, 1024u64.pow(2))
} else if let Some(n) = s.strip_suffix("kb") {
(n, 1024)
} else if let Some(n) = s.strip_suffix("b") {
(n, 1)
} else {
(s.as_str(), 1)
};
num.trim()
.parse::<f64>()
.ok()
.map(|v| (v * mult as f64) as u64)
}
#[derive(Debug, Default, Deserialize, Clone)]
pub struct TursoConfig {
pub path: Option<String>,
pub encryption_key: Option<String>,
}
pub fn load() -> anyhow::Result<WebfindConfig> {
let path = std::env::var("WEBFIND_CONFIG")
.map(PathBuf::from)
.unwrap_or_else(|_| PathBuf::from("webfind.toml"));
if !path.exists() {
return Ok(WebfindConfig::default());
}
let contents = std::fs::read_to_string(&path)
.map_err(|e| anyhow::anyhow!("failed to read config {}: {}", path.display(), e))?;
let config: WebfindConfig = toml::from_str(&contents)
.map_err(|e| anyhow::anyhow!("failed to parse config {}: {}", path.display(), e))?;
Ok(config)
}
fn resolve(cli: Option<&str>, env_key: &str, config: Option<&str>, default: &str) -> String {
cli.map(std::string::ToString::to_string)
.or_else(|| std::env::var(env_key).ok())
.or_else(|| config.map(std::string::ToString::to_string))
.unwrap_or_else(|| default.to_string())
}
pub fn resolve_turso(config: &WebfindConfig, cli_path: Option<&str>) -> String {
let resolved = resolve(
cli_path,
"WEBFIND_TURSO_PATH",
config.turso.as_ref().and_then(|t| t.path.as_deref()),
"",
);
if !resolved.is_empty() {
return resolved;
}
data_dir()
.join("webfind.db")
.to_string_lossy()
.into_owned()
}
use crate::cli::GraphStoreArg;
pub fn resolve_graph_store(config: &WebfindConfig, cli: Option<GraphStoreArg>) -> GraphStoreArg {
cli.or_else(|| {
std::env::var("WEBFIND_GRAPH_STORE")
.ok()
.and_then(|s| s.parse().ok())
})
.or_else(|| {
config
.graph_store
.as_ref()
.and_then(|s| s.parse().ok())
})
.unwrap_or(GraphStoreArg::Turso)
}
pub fn resolve_data_dir(config: &WebfindConfig) -> PathBuf {
config
.data_dir
.clone()
.or_else(|| std::env::var("WEBFIND_DATA_DIR").map(PathBuf::from).ok())
.or_else(default_home_data_dir)
.unwrap_or_else(|| PathBuf::from("."))
}
fn default_home_data_dir() -> Option<PathBuf> {
std::env::var_os("HOME")
.or_else(|| std::env::var_os("USERPROFILE"))
.map(|home| PathBuf::from(home).join(".webfind"))
}
pub fn resolve_rate_limit(config: &WebfindConfig, cli: Option<u32>) -> Option<NonZeroU32> {
cli.or(config.rate_limit)
.or_else(|| {
std::env::var("WEBFIND_RATE_LIMIT")
.ok()
.and_then(|s| s.parse().ok())
})
.and_then(NonZeroU32::new)
}
pub fn resolve_body_limit(config: &WebfindConfig) -> usize {
std::env::var("WEBFIND_BODY_LIMIT")
.ok()
.and_then(|s| s.parse().ok())
.or(config.body_limit)
.unwrap_or(1024 * 1024)
}
pub fn resolve_cors_origins(config: &WebfindConfig) -> Vec<String> {
std::env::var("WEBFIND_CORS_ORIGINS")
.ok()
.or_else(|| config.cors_origins.clone())
.map(|s| {
s.split(',')
.map(|o| o.trim().to_string())
.filter(|o| !o.is_empty())
.collect()
})
.unwrap_or_default()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_parse_config() {
let text = r#"
data_dir = "/var/webfind"
graph_store = "turso"
[turso]
path = "/var/webfind/webfind.db"
"#;
let cfg: WebfindConfig = toml::from_str(text).unwrap();
assert_eq!(cfg.data_dir, Some(PathBuf::from("/var/webfind")));
assert_eq!(cfg.graph_store, Some("turso".to_string()));
let t = cfg.turso.unwrap();
assert_eq!(t.path, Some("/var/webfind/webfind.db".to_string()));
}
#[test]
fn test_resolve_turso_priority_cli_over_config() {
let cfg = WebfindConfig {
turso: Some(TursoConfig {
path: Some("/config/db".to_string()),
..Default::default()
}),
..Default::default()
};
assert_eq!(resolve_turso(&cfg, Some("/cli/db")), "/cli/db");
assert_eq!(resolve_turso(&cfg, None), "/config/db");
}
#[test]
fn test_resolve_graph_store_defaults() {
let cfg = WebfindConfig::default();
assert_eq!(resolve_graph_store(&cfg, None), GraphStoreArg::Turso);
assert_eq!(
resolve_graph_store(&cfg, Some(GraphStoreArg::Memory)),
GraphStoreArg::Memory
);
}
#[test]
fn test_resolve_data_dir_from_config() {
let cfg = WebfindConfig {
data_dir: Some(PathBuf::from("/tmp/webfind")),
..Default::default()
};
assert_eq!(resolve_data_dir(&cfg), PathBuf::from("/tmp/webfind"));
}
#[test]
fn test_parse_bytes() {
assert_eq!(parse_bytes("500MB"), Some(500 * 1024 * 1024));
assert_eq!(parse_bytes("2GB"), Some(2 * 1024 * 1024 * 1024));
assert_eq!(parse_bytes("1500KB"), Some(1500 * 1024));
assert_eq!(parse_bytes("1048576"), Some(1_048_576));
assert_eq!(parse_bytes("512B"), Some(512));
assert_eq!(parse_bytes("garbage"), None);
}
#[test]
fn test_resolve_storage_from_config() {
let cfg = WebfindConfig {
storage: Some(StorageConfig {
max_bytes: Some(500 * 1024 * 1024),
max_full_content_pages: Some(1000),
}),
..Default::default()
};
assert_eq!(resolve_storage_max_bytes(&cfg), Some(500 * 1024 * 1024));
assert_eq!(resolve_max_full_content_pages(&cfg), Some(1000));
}
}