use crate::core::tags::default_tag_names;
use color_eyre::eyre::{Result, WrapErr};
use serde::{Deserialize, Serialize};
use std::path::{Path, PathBuf};
fn config_home() -> Option<PathBuf> {
std::env::var_os("XDG_CONFIG_HOME")
.map(PathBuf::from)
.filter(|p| !p.as_os_str().is_empty())
.or_else(dirs::config_dir)
}
fn env_list(name: &str) -> Option<Vec<String>> {
let value = std::env::var(name).ok()?;
let items: Vec<String> = value
.split(',')
.map(str::trim)
.filter(|s| !s.is_empty())
.map(String::from)
.collect();
if items.is_empty() { None } else { Some(items) }
}
fn env_bool(name: &str) -> Option<bool> {
let value = std::env::var(name).ok()?;
Some(!value.is_empty() && value != "0" && !value.eq_ignore_ascii_case("false"))
}
#[derive(Debug, Clone, Default)]
pub struct CliOptions {
pub tags: Option<Vec<String>>,
pub include: Option<Vec<String>>,
pub exclude: Option<Vec<String>>,
pub json: bool,
pub flat: bool,
pub no_color: bool,
pub ignore_case: bool,
pub no_require_colon: bool,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
#[serde(default)]
pub struct Config {
pub tags: Vec<String>,
pub include: Vec<String>,
pub exclude: Vec<String>,
pub json: bool,
pub flat: bool,
pub no_color: bool,
pub custom_pattern: Option<String>,
pub ignore_case: bool,
pub require_colon: bool,
}
impl Config {
pub fn new() -> Self {
Self {
tags: default_tag_names(),
include: Vec::new(),
exclude: Vec::new(),
json: false,
flat: false,
no_color: false,
custom_pattern: None,
ignore_case: false,
require_colon: true,
}
}
pub fn load(start_path: &Path) -> Result<Option<Self>> {
let local_configs = [
start_path.join(".todorc"),
start_path.join(".todorc.json"),
start_path.join(".todorc.toml"),
];
for config_path in &local_configs {
if config_path.exists() {
return Self::load_from_file(config_path).map(Some);
}
}
if let Some(parent) = start_path.parent()
&& parent != start_path
&& let Ok(Some(config)) = Self::load(parent)
{
return Ok(Some(config));
}
if let Some(config_dir) = config_home() {
let global_configs = [
config_dir.join("todo-tree").join("config.json"),
config_dir.join("todo-tree").join("config.toml"),
];
for config_path in &global_configs {
if config_path.exists() {
return Self::load_from_file(config_path).map(Some);
}
}
}
Ok(None)
}
pub fn load_from_file(path: &Path) -> Result<Self> {
let content = std::fs::read_to_string(path).wrap_err_with(|| {
format!(
"Failed to read config file: {}. Check that it exists and you have permission to read it.",
path.display()
)
})?;
let extension = path.extension().and_then(|e| e.to_str()).unwrap_or("");
let parse_result: Result<Self> = if extension == "toml" {
toml::from_str(&content).map_err(|e| color_eyre::eyre::eyre!(e))
} else {
serde_json::from_str(&content)
.map_err(|e| color_eyre::eyre::eyre!(e))
.or_else(|_| toml::from_str(&content).map_err(|e| color_eyre::eyre::eyre!(e)))
};
parse_result.wrap_err_with(|| {
format!(
"Failed to parse config: {}. Check that it's valid {} and that its keys match the documented .todorc options.",
path.display(),
if extension == "toml" { "TOML" } else { "JSON (or TOML)" }
)
})
}
pub fn merge_with_cli(&mut self, cli: CliOptions) {
if let Some(tags) = cli.tags
&& !tags.is_empty()
{
self.tags = tags;
}
if let Some(include) = cli.include
&& !include.is_empty()
{
self.include = include;
}
if let Some(exclude) = cli.exclude
&& !exclude.is_empty()
{
self.exclude.extend(exclude);
}
if cli.json {
self.json = true;
}
if cli.flat {
self.flat = true;
}
if cli.no_color {
self.no_color = true;
}
if cli.ignore_case {
self.ignore_case = true;
}
if cli.no_require_colon {
self.require_colon = false;
}
}
pub fn load_or_default(path: &Path, config_path: Option<&Path>) -> Result<Self> {
let mut config = if let Some(config_path) = config_path {
Self::load_from_file(config_path)?
} else {
match Self::load(path)? {
Some(config) => config,
None => Self::new(),
}
};
config.apply_env_overrides();
Ok(config)
}
fn apply_env_overrides(&mut self) {
if let Some(tags) = env_list("TODO_TREE_TAGS") {
self.tags = tags;
}
if let Some(include) = env_list("TODO_TREE_INCLUDE") {
self.include = include;
}
if let Some(exclude) = env_list("TODO_TREE_EXCLUDE") {
self.exclude.extend(exclude);
}
if let Some(value) = env_bool("TODO_TREE_JSON") {
self.json = value;
}
if let Some(value) = env_bool("TODO_TREE_FLAT") {
self.flat = value;
}
if let Some(value) = env_bool("TODO_TREE_NO_COLOR") {
self.no_color = value;
}
if let Some(value) = env_bool("TODO_TREE_IGNORE_CASE") {
self.ignore_case = value;
}
if let Some(value) = env_bool("TODO_TREE_REQUIRE_COLON") {
self.require_colon = value;
}
}
pub fn save_in_cwd(&self) -> Result<()> {
let current_dir = std::env::current_dir()?;
let config_files = [
current_dir.join(".todorc"),
current_dir.join(".todorc.json"),
current_dir.join(".todorc.toml"),
];
for path in &config_files {
if path.exists() {
return self.save(path);
}
}
let path = current_dir.join(".todorc.json");
self.save(&path)
}
pub fn save(&self, path: &Path) -> Result<()> {
let extension = path.extension().and_then(|e| e.to_str()).unwrap_or("");
let content = if extension == "toml" {
toml::to_string_pretty(self)?
} else {
serde_json::to_string_pretty(self)?
};
std::fs::write(path, content)
.wrap_err_with(|| format!("Failed to write config file: {}", path.display()))?;
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::fs;
use std::time::{SystemTime, UNIX_EPOCH};
static XDG_ENV_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
fn temp_path(name: &str) -> std::path::PathBuf {
let unique = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap()
.as_nanos();
std::env::temp_dir().join(format!("todo_tree_config_test_{name}_{unique}"))
}
#[test]
fn load_from_file_parses_json() {
let path = temp_path("json").with_extension("json");
fs::write(&path, r#"{"tags": ["TODO", "FIXME"], "ignore_case": true}"#).unwrap();
let config = Config::load_from_file(&path).unwrap();
let _ = fs::remove_file(&path);
assert_eq!(config.tags, vec!["TODO".to_string(), "FIXME".to_string()]);
assert!(config.ignore_case);
}
#[test]
fn config_home_prefers_xdg_config_home_when_set() {
let _lock = XDG_ENV_LOCK.lock().unwrap();
let dir = temp_path("xdg");
unsafe {
std::env::set_var("XDG_CONFIG_HOME", &dir);
}
let resolved = config_home();
unsafe {
std::env::remove_var("XDG_CONFIG_HOME");
}
assert_eq!(resolved, Some(dir));
}
#[test]
fn load_from_file_parses_toml() {
let path = temp_path("toml").with_extension("toml");
fs::write(&path, "tags = [\"TODO\", \"FIXME\"]\nignore_case = true\n").unwrap();
let config = Config::load_from_file(&path).unwrap();
let _ = fs::remove_file(&path);
assert_eq!(config.tags, vec!["TODO".to_string(), "FIXME".to_string()]);
assert!(config.ignore_case);
}
#[test]
fn save_then_load_round_trips_toml() {
let path = temp_path("roundtrip").with_extension("toml");
let mut config = Config::new();
config.tags = vec!["NOTE".to_string()];
config.save(&path).unwrap();
let loaded = Config::load_from_file(&path).unwrap();
let _ = fs::remove_file(&path);
assert_eq!(loaded.tags, vec!["NOTE".to_string()]);
}
#[test]
fn load_does_not_recognize_yaml_files() {
let dir = temp_path("yaml_dir");
fs::create_dir_all(&dir).unwrap();
fs::write(dir.join(".todorc.yaml"), "tags:\n - TODO\n").unwrap();
let result = Config::load(&dir).unwrap();
let _ = fs::remove_dir_all(&dir);
assert!(
result.is_none() || result.unwrap().tags != vec!["TODO".to_string()],
".todorc.yaml must no longer be picked up as a config file"
);
}
#[test]
fn config_home_falls_back_to_platform_dir_when_xdg_unset() {
let _lock = XDG_ENV_LOCK.lock().unwrap();
let previous = std::env::var_os("XDG_CONFIG_HOME");
unsafe {
std::env::remove_var("XDG_CONFIG_HOME");
}
let resolved = config_home();
unsafe {
match &previous {
Some(v) => std::env::set_var("XDG_CONFIG_HOME", v),
None => std::env::remove_var("XDG_CONFIG_HOME"),
}
}
assert_eq!(resolved, dirs::config_dir());
}
#[test]
fn config_home_falls_back_when_xdg_is_empty() {
let _lock = XDG_ENV_LOCK.lock().unwrap();
let previous = std::env::var_os("XDG_CONFIG_HOME");
unsafe {
std::env::set_var("XDG_CONFIG_HOME", "");
}
let resolved = config_home();
unsafe {
match &previous {
Some(v) => std::env::set_var("XDG_CONFIG_HOME", v),
None => std::env::remove_var("XDG_CONFIG_HOME"),
}
}
assert_eq!(resolved, dirs::config_dir());
}
#[test]
fn new_returns_strict_defaults() {
let config = Config::new();
assert_eq!(config.tags, default_tag_names());
assert!(config.include.is_empty());
assert!(config.exclude.is_empty());
assert!(!config.json);
assert!(!config.flat);
assert!(!config.no_color);
assert!(config.custom_pattern.is_none());
assert!(!config.ignore_case);
assert!(config.require_colon);
}
#[test]
fn load_returns_none_when_nothing_found_anywhere() {
let _lock = XDG_ENV_LOCK.lock().unwrap();
let empty_xdg = temp_path("empty_xdg");
fs::create_dir_all(&empty_xdg).unwrap();
let scan_dir = temp_path("no_config_anywhere");
fs::create_dir_all(&scan_dir).unwrap();
let previous = std::env::var_os("XDG_CONFIG_HOME");
unsafe {
std::env::set_var("XDG_CONFIG_HOME", &empty_xdg);
}
let result = Config::load(&scan_dir);
unsafe {
match &previous {
Some(v) => std::env::set_var("XDG_CONFIG_HOME", v),
None => std::env::remove_var("XDG_CONFIG_HOME"),
}
}
let _ = fs::remove_dir_all(&empty_xdg);
let _ = fs::remove_dir_all(&scan_dir);
assert!(result.unwrap().is_none());
}
#[test]
fn load_falls_back_to_global_config_dir() {
let _lock = XDG_ENV_LOCK.lock().unwrap();
let xdg_dir = temp_path("global_xdg");
let todo_tree_dir = xdg_dir.join("todo-tree");
fs::create_dir_all(&todo_tree_dir).unwrap();
fs::write(todo_tree_dir.join("config.json"), r#"{"tags": ["GLOBAL"]}"#).unwrap();
let scan_dir = temp_path("global_scan_target");
fs::create_dir_all(&scan_dir).unwrap();
let previous = std::env::var_os("XDG_CONFIG_HOME");
unsafe {
std::env::set_var("XDG_CONFIG_HOME", &xdg_dir);
}
let result = Config::load(&scan_dir);
unsafe {
match &previous {
Some(v) => std::env::set_var("XDG_CONFIG_HOME", v),
None => std::env::remove_var("XDG_CONFIG_HOME"),
}
}
let _ = fs::remove_dir_all(&xdg_dir);
let _ = fs::remove_dir_all(&scan_dir);
let config = result
.unwrap()
.expect("expected the global config to be found");
assert_eq!(config.tags, vec!["GLOBAL".to_string()]);
}
#[test]
fn load_finds_exact_todorc_filename() {
let dir = temp_path("exact_todorc");
fs::create_dir_all(&dir).unwrap();
fs::write(dir.join(".todorc"), r#"{"tags": ["NOTE"]}"#).unwrap();
let config = Config::load(&dir).unwrap().expect("expected a config");
let _ = fs::remove_dir_all(&dir);
assert_eq!(config.tags, vec!["NOTE".to_string()]);
}
#[test]
fn load_recurses_into_parent_directories() {
let dir = temp_path("parent_recursion");
let child = dir.join("child");
fs::create_dir_all(&child).unwrap();
fs::write(dir.join(".todorc.json"), r#"{"tags": ["PARENT"]}"#).unwrap();
let config = Config::load(&child).unwrap().expect("expected a config");
let _ = fs::remove_dir_all(&dir);
assert_eq!(config.tags, vec!["PARENT".to_string()]);
}
#[test]
fn load_from_file_errors_on_missing_file() {
let path = temp_path("missing").with_extension("json");
assert!(Config::load_from_file(&path).is_err());
}
#[test]
fn load_from_file_errors_on_unparseable_content() {
let path = temp_path("garbage").with_extension("toml");
fs::write(&path, "not: valid { toml or json").unwrap();
let result = Config::load_from_file(&path);
let _ = fs::remove_file(&path);
assert!(result.is_err());
}
#[test]
fn save_writes_json_for_non_toml_extension() {
let path = temp_path("save_json").with_extension("json");
let config = Config::new();
config.save(&path).unwrap();
let content = fs::read_to_string(&path).unwrap();
let _ = fs::remove_file(&path);
assert!(content.trim_start().starts_with('{'));
}
#[test]
fn merge_with_cli_applies_every_override() {
let mut config = Config::new();
config.exclude = vec!["existing/**".to_string()];
config.merge_with_cli(CliOptions {
tags: Some(vec!["CUSTOM".to_string()]),
include: Some(vec!["*.rs".to_string()]),
exclude: Some(vec!["extra/**".to_string()]),
json: true,
flat: true,
no_color: true,
ignore_case: true,
no_require_colon: true,
});
assert_eq!(config.tags, vec!["CUSTOM".to_string()]);
assert_eq!(config.include, vec!["*.rs".to_string()]);
assert_eq!(
config.exclude,
vec!["existing/**".to_string(), "extra/**".to_string()]
);
assert!(config.json);
assert!(config.flat);
assert!(config.no_color);
assert!(config.ignore_case);
assert!(!config.require_colon);
}
#[test]
fn merge_with_cli_is_a_no_op_with_default_options() {
let config_before = Config::new();
let mut config = Config::new();
config.merge_with_cli(CliOptions::default());
assert_eq!(config.tags, config_before.tags);
assert_eq!(config.include, config_before.include);
assert_eq!(config.exclude, config_before.exclude);
assert_eq!(config.json, config_before.json);
assert_eq!(config.flat, config_before.flat);
assert_eq!(config.no_color, config_before.no_color);
assert_eq!(config.ignore_case, config_before.ignore_case);
assert_eq!(config.require_colon, config_before.require_colon);
}
static TODO_TREE_ENV_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
fn clear_todo_tree_env() {
for var in [
"TODO_TREE_TAGS",
"TODO_TREE_INCLUDE",
"TODO_TREE_EXCLUDE",
"TODO_TREE_JSON",
"TODO_TREE_FLAT",
"TODO_TREE_NO_COLOR",
"TODO_TREE_IGNORE_CASE",
"TODO_TREE_REQUIRE_COLON",
] {
unsafe {
std::env::remove_var(var);
}
}
}
#[test]
fn apply_env_overrides_applies_every_recognized_var() {
let _lock = TODO_TREE_ENV_LOCK.lock().unwrap();
clear_todo_tree_env();
let mut config = Config::new();
config.exclude = vec!["existing/**".to_string()];
unsafe {
std::env::set_var("TODO_TREE_TAGS", "CUSTOM, OTHER");
std::env::set_var("TODO_TREE_INCLUDE", "*.rs");
std::env::set_var("TODO_TREE_EXCLUDE", "extra/**");
std::env::set_var("TODO_TREE_JSON", "true");
std::env::set_var("TODO_TREE_FLAT", "1");
std::env::set_var("TODO_TREE_NO_COLOR", "true");
std::env::set_var("TODO_TREE_IGNORE_CASE", "true");
std::env::set_var("TODO_TREE_REQUIRE_COLON", "false");
}
config.apply_env_overrides();
clear_todo_tree_env();
assert_eq!(config.tags, vec!["CUSTOM".to_string(), "OTHER".to_string()]);
assert_eq!(config.include, vec!["*.rs".to_string()]);
assert_eq!(
config.exclude,
vec!["existing/**".to_string(), "extra/**".to_string()]
);
assert!(config.json);
assert!(config.flat);
assert!(config.no_color);
assert!(config.ignore_case);
assert!(!config.require_colon);
}
#[test]
fn apply_env_overrides_is_a_no_op_when_unset() {
let _lock = TODO_TREE_ENV_LOCK.lock().unwrap();
clear_todo_tree_env();
let before = Config::new();
let mut config = Config::new();
config.apply_env_overrides();
assert_eq!(config.tags, before.tags);
assert_eq!(config.include, before.include);
assert_eq!(config.exclude, before.exclude);
assert_eq!(config.json, before.json);
assert_eq!(config.flat, before.flat);
assert_eq!(config.no_color, before.no_color);
assert_eq!(config.ignore_case, before.ignore_case);
assert_eq!(config.require_colon, before.require_colon);
}
#[test]
fn merge_with_cli_ignores_empty_tag_and_include_overrides() {
let mut config = Config::new();
let original_tags = config.tags.clone();
config.merge_with_cli(CliOptions {
tags: Some(vec![]),
include: Some(vec![]),
exclude: Some(vec![]),
..Default::default()
});
assert_eq!(config.tags, original_tags);
assert!(config.include.is_empty());
assert!(config.exclude.is_empty());
}
}