use std::collections::HashMap;
use std::path::{Path, PathBuf};
use figment::providers::{Env, Format, Serialized, Toml};
use figment::Figment;
use serde::{Deserialize, Serialize};
use crate::error::{Result, RoamError};
#[derive(Debug, Deserialize, Serialize, Clone)]
pub struct AppConfig {
pub graph: GraphConfig,
#[serde(default)]
pub ui: UiConfig,
#[serde(default)]
pub keybindings: KeybindingsConfig,
}
#[derive(Debug, Deserialize, Serialize, Clone)]
pub struct GraphConfig {
pub name: String,
#[serde(default)]
pub api_token: String,
}
#[derive(Debug, Deserialize, Serialize, Clone)]
pub struct UiConfig {
#[serde(default = "default_theme")]
pub theme: String,
#[serde(default = "default_sidebar")]
pub sidebar_default: bool,
#[serde(default = "default_sidebar_width")]
pub sidebar_width_percent: u8,
}
impl Default for UiConfig {
fn default() -> Self {
Self {
theme: default_theme(),
sidebar_default: default_sidebar(),
sidebar_width_percent: default_sidebar_width(),
}
}
}
#[derive(Debug, Deserialize, Serialize, Clone)]
pub struct KeybindingsConfig {
#[serde(default = "default_preset")]
pub preset: String,
#[serde(default)]
pub bindings: HashMap<String, String>,
}
impl Default for KeybindingsConfig {
fn default() -> Self {
Self {
preset: default_preset(),
bindings: HashMap::new(),
}
}
}
fn default_theme() -> String {
"dark".into()
}
fn default_sidebar() -> bool {
true
}
fn default_sidebar_width() -> u8 {
35
}
fn default_preset() -> String {
"vim".into()
}
impl AppConfig {
pub fn load_from_path(config_path: &Path) -> Result<Self> {
let config: AppConfig = Figment::new()
.merge(Serialized::defaults(AppConfig::defaults()))
.merge(Toml::file(config_path))
.merge(Env::prefixed("ROAM_").split("_").lowercase(false))
.extract()
.map_err(|e| RoamError::Config(e.to_string()))?;
config.validate()?;
Ok(config)
}
pub fn validate(&self) -> Result<()> {
if self.graph.name.is_empty() {
return Err(RoamError::Config("graph.name is required".into()));
}
if self.graph.api_token.is_empty() {
return Err(RoamError::Config(
"graph.api_token is required (set in config or ROAM_API_TOKEN env var)".into(),
));
}
Ok(())
}
pub fn config_dir() -> Option<PathBuf> {
std::env::var("XDG_CONFIG_HOME")
.ok()
.map(|xdg| PathBuf::from(xdg).join("roam-tui"))
.or_else(|| {
directories::BaseDirs::new()
.map(|dirs| dirs.home_dir().join(".config").join("roam-tui"))
})
}
pub fn write_default(path: &Path) -> Result<()> {
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent)?;
}
let content = r#"[graph]
name = "your-graph-name"
api_token = "" # or set ROAM_API_TOKEN env var
[ui]
theme = "dark"
sidebar_default = true
sidebar_width_percent = 35
[keybindings]
preset = "vim" # vim | emacs | vscode
# Override specific keys:
# [keybindings.bindings]
# quit = "Ctrl+q"
# search = "Ctrl+f"
"#;
std::fs::write(path, content)?;
Ok(())
}
fn defaults() -> Self {
Self {
graph: GraphConfig {
name: String::new(),
api_token: String::new(),
},
ui: UiConfig::default(),
keybindings: KeybindingsConfig::default(),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::env;
use tempfile::TempDir;
fn write_config(dir: &Path, content: &str) -> PathBuf {
let path = dir.join("config.toml");
std::fs::write(&path, content).unwrap();
path
}
#[test]
fn loads_valid_config_from_toml() {
let tmp = TempDir::new().unwrap();
let path = write_config(
tmp.path(),
r#"
[graph]
name = "test-graph"
api_token = "token-123"
[ui]
theme = "light"
[keybindings]
preset = "emacs"
"#,
);
let config = AppConfig::load_from_path(&path).unwrap();
assert_eq!(config.graph.name, "test-graph");
assert_eq!(config.graph.api_token, "token-123");
assert_eq!(config.ui.theme, "light");
assert_eq!(config.keybindings.preset, "emacs");
}
#[test]
fn defaults_apply_for_missing_optional_fields() {
let tmp = TempDir::new().unwrap();
let path = write_config(
tmp.path(),
r#"
[graph]
name = "test-graph"
api_token = "token-123"
"#,
);
let config = AppConfig::load_from_path(&path).unwrap();
assert_eq!(config.ui.theme, "dark");
assert!(config.ui.sidebar_default);
assert_eq!(config.ui.sidebar_width_percent, 35);
assert_eq!(config.keybindings.preset, "vim");
}
#[test]
fn validate_fails_without_graph_name() {
let tmp = TempDir::new().unwrap();
let path = write_config(
tmp.path(),
r#"
[graph]
name = ""
api_token = "token-123"
"#,
);
let err = AppConfig::load_from_path(&path);
assert!(err.is_err());
let msg = err.unwrap_err().to_string();
assert!(msg.contains("graph.name"));
}
#[test]
fn validate_fails_without_api_token() {
let tmp = TempDir::new().unwrap();
let path = write_config(
tmp.path(),
r#"
[graph]
name = "test-graph"
api_token = ""
"#,
);
let err = AppConfig::load_from_path(&path);
assert!(err.is_err());
let msg = err.unwrap_err().to_string();
assert!(msg.contains("api_token"));
}
#[test]
fn env_var_overrides_token() {
let tmp = TempDir::new().unwrap();
let path = write_config(
tmp.path(),
r#"
[graph]
name = "test-graph"
api_token = "file-token"
"#,
);
env::set_var("ROAM_GRAPH_API__TOKEN", "env-token");
let config = AppConfig::load_from_path(&path).unwrap();
env::remove_var("ROAM_GRAPH_API__TOKEN");
assert!(!config.graph.api_token.is_empty());
}
#[test]
fn write_default_creates_config_file() {
let tmp = TempDir::new().unwrap();
let path = tmp.path().join("subdir").join("config.toml");
AppConfig::write_default(&path).unwrap();
assert!(path.exists());
let content = std::fs::read_to_string(&path).unwrap();
assert!(content.contains("your-graph-name"));
assert!(content.contains("vim"));
}
#[test]
fn keybinding_overrides_parsed() {
let tmp = TempDir::new().unwrap();
let path = write_config(
tmp.path(),
r#"
[graph]
name = "test-graph"
api_token = "token-123"
[keybindings]
preset = "vim"
[keybindings.bindings]
quit = "Ctrl+q"
search = "Ctrl+f"
"#,
);
let config = AppConfig::load_from_path(&path).unwrap();
assert_eq!(config.keybindings.bindings.get("quit").unwrap(), "Ctrl+q");
assert_eq!(config.keybindings.bindings.get("search").unwrap(), "Ctrl+f");
}
#[test]
fn config_dir_returns_some() {
let dir = AppConfig::config_dir();
assert!(dir.is_some());
}
}