From 079c3d787d460ff9249f8f3a6c6178bdbfc4a615 Mon Sep 17 00:00:00 2001 From: Alexander Holte-Davidsen Date: Wed, 24 Jun 2026 13:10:13 +0200 Subject: [PATCH] feat: default config to ~/.config/taskbook/taskbook.json (#59) Resolve the client config from the XDG location `~/.config/taskbook/taskbook.json` (honoring `$XDG_CONFIG_HOME`), falling back to the legacy `~/.taskbook.json` when it exists and the XDG file does not. New installs use the XDG path; existing installs keep working untouched. `~/.config` is used on every platform (not the OS-native config dir) to match the conventional Linux location requested in the issue. - create_dir_all the parent on first write / save - migrate-sync hint now prints the resolved config path - docs updated (README, docs/, CLAUDE.md) Co-Authored-By: Claude Opus 4.8 (1M context) --- CLAUDE.md | 11 ++- README.md | 2 +- crates/taskbook-client/src/commands.rs | 6 +- crates/taskbook-client/src/config.rs | 121 +++++++++++++++++++++++-- docs/README.md | 2 +- docs/configuration.md | 4 +- docs/sync.md | 2 +- 7 files changed, 133 insertions(+), 15 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index f9e9f6f..be6746b 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -61,7 +61,7 @@ crates/ │ ├── api_client.rs # HTTP client for server communication │ ├── auth.rs # Register, login, logout, status commands │ ├── credentials.rs # Encryption key and token management -│ ├── config.rs # ~/.taskbook.json configuration (with sync section) +│ ├── config.rs # config file (XDG path, with sync section) │ ├── directory.rs # Taskbook directory resolution │ ├── render.rs # Terminal output with colored formatting │ ├── editor.rs # External editor support for notes @@ -110,9 +110,13 @@ crates/ 5. **Directory Resolution Priority**: - `--taskbook-dir` CLI flag (highest) - `TASKBOOK_DIR` environment variable - - `~/.taskbook.json` config file + - config file (lowest) - Default `~/.taskbook/` (lowest) + The config file is resolved as `~/.config/taskbook/taskbook.json` + (honoring `$XDG_CONFIG_HOME`), falling back to the legacy + `~/.taskbook.json` when it exists and the XDG file does not. + 6. **Storage Structure (Local)**: ``` ~/.taskbook/ @@ -167,7 +171,8 @@ tb --mcp # Run as MCP stdio server `tb --mcp` runs a Model Context Protocol server over stdio (newline-delimited JSON-RPC 2.0, no extra dependencies). It uses the configured storage backend: the remote server storage (with client-side encryption) when `sync.enabled = -true` in `~/.taskbook.json`, local files otherwise. +true` in the config file (`~/.config/taskbook/taskbook.json`), local files +otherwise. Register it in Claude Code: diff --git a/README.md b/README.md index f6b6dc8..d9b00e4 100644 --- a/README.md +++ b/README.md @@ -126,7 +126,7 @@ See [Server Setup](docs/server.md) for running your own server. ## Configuration -Configuration is stored in `~/.taskbook.json`: +Configuration is stored in `~/.config/taskbook/taskbook.json` (honoring `$XDG_CONFIG_HOME`; a legacy `~/.taskbook.json` is still used if present): ```json { diff --git a/crates/taskbook-client/src/commands.rs b/crates/taskbook-client/src/commands.rs index f64a1fe..255aca2 100644 --- a/crates/taskbook-client/src/commands.rs +++ b/crates/taskbook-client/src/commands.rs @@ -192,7 +192,11 @@ pub fn migrate(taskbook_dir: Option) -> Result<()> { ); println!( "{}", - "To enable sync, set sync.enabled = true in ~/.taskbook.json".dimmed() + format!( + "To enable sync, set sync.enabled = true in {}", + crate::config::Config::config_file_path().display() + ) + .dimmed() ); Ok(()) diff --git a/crates/taskbook-client/src/config.rs b/crates/taskbook-client/src/config.rs index d60c8ae..4eeb276 100644 --- a/crates/taskbook-client/src/config.rs +++ b/crates/taskbook-client/src/config.rs @@ -1,6 +1,7 @@ use serde::{Deserialize, Serialize}; +use std::ffi::OsStr; use std::fs; -use std::path::PathBuf; +use std::path::{Path, PathBuf}; use crate::error::Result; use crate::tui::ViewMode; @@ -266,17 +267,57 @@ impl Default for Config { } impl Config { - /// Get the config file path (~/.taskbook.json) - fn config_file_path() -> PathBuf { - dirs::home_dir() - .expect("Could not find home directory") - .join(".taskbook.json") + /// Resolve the config file path. + /// + /// Prefers the XDG location `~/.config/taskbook/taskbook.json` (honoring + /// `$XDG_CONFIG_HOME`). Falls back to the legacy `~/.taskbook.json` when it + /// exists and the XDG file does not, so existing installs keep working. + /// New installs use the XDG location. + pub fn config_file_path() -> PathBuf { + let home = dirs::home_dir().expect("Could not find home directory"); + let xdg = Self::xdg_config_path(&home, std::env::var_os("XDG_CONFIG_HOME").as_deref()); + let legacy = Self::legacy_config_path(&home); + Self::select_config_path(xdg, legacy) } - /// Ensure the config file exists, creating it with defaults if not + /// XDG config path: `$XDG_CONFIG_HOME/taskbook/taskbook.json`, or + /// `~/.config/taskbook/taskbook.json` when the env var is unset/empty. + /// + /// `~/.config` is used on every platform (not the OS-native config dir) to + /// match the conventional Linux location users expect. + fn xdg_config_path(home: &Path, xdg_env: Option<&OsStr>) -> PathBuf { + let base = match xdg_env.filter(|v| !v.is_empty()) { + Some(v) => PathBuf::from(v), + None => home.join(".config"), + }; + base.join("taskbook").join("taskbook.json") + } + + /// Legacy pre-XDG config path: `~/.taskbook.json`. + fn legacy_config_path(home: &Path) -> PathBuf { + home.join(".taskbook.json") + } + + /// Choose between the XDG and legacy paths: prefer XDG, fall back to an + /// existing legacy file, otherwise default to XDG for new installs. + fn select_config_path(xdg: PathBuf, legacy: PathBuf) -> PathBuf { + if xdg.exists() { + xdg + } else if legacy.exists() { + legacy + } else { + xdg + } + } + + /// Ensure the config file (and any parent directory) exists, creating it + /// with defaults if not. fn ensure_config_file() -> Result<()> { let config_path = Self::config_file_path(); if !config_path.exists() { + if let Some(parent) = config_path.parent() { + fs::create_dir_all(parent)?; + } let default_config = Config::default(); let data = serde_json::to_string_pretty(&default_config)?; fs::write(&config_path, data)?; @@ -337,6 +378,9 @@ impl Config { /// Save the configuration to file pub fn save(&self) -> Result<()> { let config_path = Self::config_file_path(); + if let Some(parent) = config_path.parent() { + fs::create_dir_all(parent)?; + } let data = serde_json::to_string_pretty(self)?; fs::write(&config_path, data)?; Ok(()) @@ -388,4 +432,67 @@ mod tests { let config: Config = serde_json::from_str(json).unwrap(); assert_eq!(config.default_view, ViewMode::Board); } + + #[test] + fn xdg_path_defaults_to_dot_config() { + let home = Path::new("/home/alice"); + assert_eq!( + Config::xdg_config_path(home, None), + PathBuf::from("/home/alice/.config/taskbook/taskbook.json") + ); + } + + #[test] + fn xdg_path_honors_env_var() { + let home = Path::new("/home/alice"); + assert_eq!( + Config::xdg_config_path(home, Some(OsStr::new("/custom/xdg"))), + PathBuf::from("/custom/xdg/taskbook/taskbook.json") + ); + } + + #[test] + fn xdg_path_ignores_empty_env_var() { + let home = Path::new("/home/alice"); + assert_eq!( + Config::xdg_config_path(home, Some(OsStr::new(""))), + PathBuf::from("/home/alice/.config/taskbook/taskbook.json") + ); + } + + #[test] + fn legacy_path_is_dot_taskbook_json() { + let home = Path::new("/home/alice"); + assert_eq!( + Config::legacy_config_path(home), + PathBuf::from("/home/alice/.taskbook.json") + ); + } + + #[test] + fn select_config_path_resolution() { + let dir = std::env::temp_dir().join(format!("tb_cfg_select_{}", std::process::id())); + fs::create_dir_all(&dir).unwrap(); + let xdg = dir.join("xdg.json"); + let legacy = dir.join("legacy.json"); + fs::write(&xdg, "{}").unwrap(); + fs::write(&legacy, "{}").unwrap(); + let missing_xdg = dir.join("missing_xdg.json"); + let missing_legacy = dir.join("missing_legacy.json"); + + // XDG present wins, even when legacy also exists. + assert_eq!(Config::select_config_path(xdg.clone(), legacy.clone()), xdg); + // XDG absent but legacy present -> legacy (existing installs keep working). + assert_eq!( + Config::select_config_path(missing_xdg.clone(), legacy.clone()), + legacy + ); + // Neither present -> XDG location for new installs. + assert_eq!( + Config::select_config_path(missing_xdg.clone(), missing_legacy), + missing_xdg + ); + + fs::remove_dir_all(&dir).unwrap(); + } } diff --git a/docs/README.md b/docs/README.md index 8db9063..ea11408 100644 --- a/docs/README.md +++ b/docs/README.md @@ -59,7 +59,7 @@ tb --begin 2 └── archive/ └── archive.json # Archived items -~/.taskbook.json # Configuration file +~/.config/taskbook/taskbook.json # Configuration file (legacy: ~/.taskbook.json) ~/.taskbook/credentials.json # Server credentials (when using sync) ``` diff --git a/docs/configuration.md b/docs/configuration.md index a89c2a8..119fdc5 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -1,6 +1,8 @@ # Configuration -Client configuration is stored in `~/.taskbook.json`. The file is created automatically with default values on first run. +Client configuration is stored in `~/.config/taskbook/taskbook.json` (honoring `$XDG_CONFIG_HOME`). The file is created automatically with default values on first run. + +For backward compatibility, if a legacy `~/.taskbook.json` exists and the XDG file does not, the legacy file is used. To migrate, move `~/.taskbook.json` to `~/.config/taskbook/taskbook.json`. ## Configuration File diff --git a/docs/sync.md b/docs/sync.md index 1930bd4..7d525e7 100644 --- a/docs/sync.md +++ b/docs/sync.md @@ -233,7 +233,7 @@ This is by design - the server cannot decrypt your data without your key. ## Disabling Sync Temporarily -To work locally without syncing, edit `~/.taskbook.json`: +To work locally without syncing, edit `~/.config/taskbook/taskbook.json` (or the legacy `~/.taskbook.json` if you still use it): ```json {