use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::path::PathBuf;
use crate::error::{DiaryxError, Result};
use crate::fs::AsyncFileSystem;
#[cfg(not(target_arch = "wasm32"))]
use crate::fs::{FileSystem, RealFileSystem, SyncToAsyncFs};
use crate::link_parser::LinkFormat;
use crate::workspace_registry::{WorkspaceEntry, WorkspaceRegistry};
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Config {
#[serde(default = "default_config_title")]
pub title: String,
#[serde(default = "default_config_contents")]
pub contents: Vec<String>,
#[serde(alias = "base_dir")]
pub default_workspace: PathBuf,
#[serde(skip_serializing_if = "Option::is_none")]
pub editor: Option<String>,
#[serde(default, skip_serializing_if = "is_default_link_format")]
pub link_format: LinkFormat,
#[serde(default, skip_serializing_if = "GitConfig::is_default")]
pub git: GitConfig,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub workspaces: Vec<WorkspaceEntry>,
#[serde(default, skip_serializing_if = "HashMap::is_empty")]
pub workspace_bookmarks: HashMap<String, String>,
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
pub icloud_enabled: bool,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct GitConfig {
#[serde(default)]
pub auto_commit: bool,
#[serde(default = "default_auto_commit_interval")]
pub auto_commit_interval_minutes: u32,
}
fn default_auto_commit_interval() -> u32 {
30
}
impl Default for GitConfig {
fn default() -> Self {
Self {
auto_commit: false,
auto_commit_interval_minutes: default_auto_commit_interval(),
}
}
}
impl GitConfig {
fn is_default(&self) -> bool {
*self == Self::default()
}
}
fn is_default_link_format(format: &LinkFormat) -> bool {
*format == LinkFormat::default()
}
fn default_config_title() -> String {
"Diaryx Configuration".to_string()
}
fn default_config_contents() -> Vec<String> {
vec!["auth.md".to_string()]
}
impl Config {
pub fn base_dir(&self) -> &PathBuf {
&self.default_workspace
}
pub fn new(default_workspace: PathBuf) -> Self {
Self {
title: default_config_title(),
contents: default_config_contents(),
default_workspace,
editor: None,
link_format: LinkFormat::default(),
git: GitConfig::default(),
workspaces: Vec::new(),
workspace_bookmarks: HashMap::new(),
icloud_enabled: false,
}
}
pub fn with_options(
default_workspace: PathBuf,
editor: Option<String>,
_default_template: Option<String>,
) -> Self {
Self {
title: default_config_title(),
contents: default_config_contents(),
default_workspace,
editor,
link_format: LinkFormat::default(),
git: GitConfig::default(),
workspaces: Vec::new(),
workspace_bookmarks: HashMap::new(),
icloud_enabled: false,
}
}
pub fn workspace_bookmark(&self, path: &std::path::Path) -> Option<&str> {
self.workspace_bookmarks
.get(&path.to_string_lossy().into_owned())
.map(String::as_str)
}
pub fn set_workspace_bookmark(&mut self, path: PathBuf, bookmark: String) {
self.workspace_bookmarks
.insert(path.to_string_lossy().into_owned(), bookmark);
}
pub fn workspace_registry(&self) -> WorkspaceRegistry {
let mut reg = WorkspaceRegistry {
entries: self.workspaces.clone(),
default_id: None,
};
if let Some(entry) = reg.find_by_path(&self.default_workspace) {
reg.default_id = Some(entry.id.clone());
}
reg
}
pub fn apply_registry(&mut self, registry: &WorkspaceRegistry) {
self.workspaces = registry.entries.clone();
if let Some(entry) = registry.default_entry()
&& let Some(ref path) = entry.path
{
self.default_workspace = path.clone();
}
}
pub async fn load_from<FS: AsyncFileSystem>(fs: &FS, path: &std::path::Path) -> Result<Self> {
let contents = fs
.read_to_string(path)
.await
.map_err(|e| DiaryxError::FileRead {
path: path.to_path_buf(),
source: e,
})?;
let config: Config = crate::frontmatter::parse_typed(&contents)?;
Ok(config)
}
pub async fn save_to<FS: AsyncFileSystem>(
&self,
fs: &FS,
path: &std::path::Path,
) -> Result<()> {
if let Some(parent) = path.parent()
&& !parent.as_os_str().is_empty()
{
fs.create_dir_all(parent).await?;
}
let contents = crate::frontmatter::serialize_typed(self)?;
fs.write_file(path, &contents).await?;
Ok(())
}
pub async fn load_from_or_default<FS: AsyncFileSystem>(
fs: &FS,
path: &std::path::Path,
default_workspace: PathBuf,
) -> Self {
match Self::load_from(fs, path).await {
Ok(config) => config,
Err(_) => Self::new(default_workspace),
}
}
#[cfg(not(target_arch = "wasm32"))]
pub fn load_from_sync<FS: FileSystem>(fs: FS, path: &std::path::Path) -> Result<Self> {
futures_lite::future::block_on(Self::load_from(&SyncToAsyncFs::new(fs), path))
}
#[cfg(not(target_arch = "wasm32"))]
pub fn save_to_sync<FS: FileSystem>(&self, fs: FS, path: &std::path::Path) -> Result<()> {
futures_lite::future::block_on(self.save_to(&SyncToAsyncFs::new(fs), path))
}
#[cfg(not(target_arch = "wasm32"))]
pub fn load_from_or_default_sync<FS: FileSystem>(
fs: FS,
path: &std::path::Path,
default_workspace: PathBuf,
) -> Self {
futures_lite::future::block_on(Self::load_from_or_default(
&SyncToAsyncFs::new(fs),
path,
default_workspace,
))
}
}
#[cfg(not(target_arch = "wasm32"))]
impl Default for Config {
fn default() -> Self {
let default_base = dirs::home_dir()
.unwrap_or_else(|| PathBuf::from("."))
.join("diaryx");
Self {
title: default_config_title(),
contents: default_config_contents(),
default_workspace: default_base,
editor: None,
link_format: LinkFormat::default(),
git: GitConfig::default(),
workspaces: Vec::new(),
workspace_bookmarks: HashMap::new(),
icloud_enabled: false,
}
}
}
#[cfg(not(target_arch = "wasm32"))]
impl Config {
pub fn config_path() -> Option<PathBuf> {
dirs::config_dir().map(|dir| dir.join("diaryx").join("config.md"))
}
pub fn load() -> Result<Self> {
let Some(path) = Self::config_path() else {
return Ok(Config::default());
};
if !path.exists() {
return Ok(Config::default());
}
Self::load_from_sync(RealFileSystem, &path)
}
pub fn save(&self) -> Result<()> {
let path = Self::config_path().ok_or(DiaryxError::NoConfigDir)?;
self.save_to_sync(RealFileSystem, &path)
}
pub fn init(default_workspace: PathBuf) -> Result<Self> {
let config = Config::new(default_workspace);
config.save()?;
Ok(config)
}
}
#[cfg(target_arch = "wasm32")]
impl Default for Config {
fn default() -> Self {
Self {
title: default_config_title(),
contents: default_config_contents(),
default_workspace: PathBuf::from("/workspace"),
editor: None,
link_format: LinkFormat::default(),
git: GitConfig::default(),
workspaces: Vec::new(),
workspace_bookmarks: HashMap::new(),
icloud_enabled: false,
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn workspace_registry_from_empty_config() {
let config = Config::new(PathBuf::from("/home/user/journal"));
let reg = config.workspace_registry();
assert!(reg.entries.is_empty());
assert!(reg.default_id.is_none());
}
#[test]
fn workspace_registry_marks_default() {
let mut config = Config::new(PathBuf::from("/home/user/journal"));
config.workspaces.push(WorkspaceEntry {
id: "local-abc".into(),
name: "journal".into(),
path: Some(PathBuf::from("/home/user/journal")),
});
let reg = config.workspace_registry();
assert_eq!(reg.default_id.as_deref(), Some("local-abc"));
}
#[test]
fn apply_registry_updates_default_workspace() {
let mut config = Config::new(PathBuf::from("/old"));
let mut reg = WorkspaceRegistry::default();
let id = reg
.register("new-ws".into(), Some(PathBuf::from("/new")))
.id
.clone();
reg.set_default(&id);
config.apply_registry(®);
assert_eq!(config.default_workspace, PathBuf::from("/new"));
assert_eq!(config.workspaces.len(), 1);
}
#[test]
fn yaml_frontmatter_round_trip_with_workspaces() {
let mut config = Config::new(PathBuf::from("/ws"));
config.workspaces.push(WorkspaceEntry {
id: "local-123".into(),
name: "personal".into(),
path: Some(PathBuf::from("/ws")),
});
config.set_workspace_bookmark(PathBuf::from("/ws"), "bookmark-data".into());
let md_str = crate::frontmatter::serialize_typed(&config).unwrap();
assert!(md_str.starts_with("---\n"));
let parsed: Config = crate::frontmatter::parse_typed(&md_str).unwrap();
assert_eq!(parsed.workspaces.len(), 1);
assert_eq!(parsed.workspaces[0].id, "local-123");
assert_eq!(parsed.workspaces[0].name, "personal");
assert_eq!(
parsed.workspace_bookmark(PathBuf::from("/ws").as_path()),
Some("bookmark-data")
);
assert_eq!(parsed.title, "Diaryx Configuration");
assert_eq!(parsed.contents, vec!["auth.md"]);
}
#[test]
fn yaml_frontmatter_round_trip_without_workspaces() {
let config = Config::new(PathBuf::from("/ws"));
let md_str = crate::frontmatter::serialize_typed(&config).unwrap();
assert!(md_str.starts_with("---\n"));
let parsed: Config = crate::frontmatter::parse_typed(&md_str).unwrap();
assert!(parsed.workspaces.is_empty());
assert!(parsed.workspace_bookmarks.is_empty());
}
}