use serde::{Deserialize, Serialize};
use std::path::Path;
use thiserror::Error;
#[derive(Error, Debug)]
pub enum ConfigError {
#[error("Configuration file not found: {0}")]
FileNotFound(String),
#[error("Invalid TOML syntax in {file}: {error}")]
ParseError { file: String, error: String },
#[error("Invalid configuration value: {0}")]
ValidationError(String),
#[error("I/O error reading config: {0}")]
IoError(#[from] std::io::Error),
}
pub type ConfigResult<T> = Result<T, ConfigError>;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ZettelConfig {
#[serde(default)]
pub vault: VaultConfig,
#[serde(default)]
pub id: IdConfig,
#[serde(default)]
pub note: NoteConfig,
#[serde(default)]
pub template: TemplateConfig,
#[serde(default)]
pub linking: LinkingConfig,
#[serde(default)]
pub editor: EditorConfig,
#[serde(default)]
pub output: OutputConfig,
#[serde(default)]
pub performance: PerformanceConfig,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct VaultConfig {
pub default_path: Option<String>,
#[serde(default = "default_true")]
pub auto_index: bool,
#[serde(default = "default_false")]
pub backup_on_change: bool,
#[serde(default)]
pub exclude_dirs: Vec<String>,
#[serde(default)]
pub exclude_patterns: Vec<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct IdConfig {
#[serde(default = "default_match_rule")]
pub match_rule: String,
#[serde(default = "default_separator")]
pub separator: String,
#[serde(default = "default_false")]
pub allow_unicode: bool,
#[serde(default = "default_max_depth")]
pub max_depth: u32,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct NoteConfig {
#[serde(default = "default_false")]
pub add_title: bool,
#[serde(default = "default_false")]
pub add_alias: bool,
#[serde(default = "default_extension")]
pub extension: String,
#[serde(default)]
pub default_directory: String,
#[serde(default = "default_false")]
pub use_date_directories: bool,
#[serde(default = "default_date_format")]
pub date_format: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct TemplateConfig {
#[serde(default = "default_false")]
pub enabled: bool,
#[serde(default)]
pub file: String,
#[serde(default)]
pub directory: String,
#[serde(default = "default_template_name")]
pub default_template: String,
#[serde(default = "default_true")]
pub require_title: bool,
#[serde(default = "default_true")]
pub require_link: bool,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct LinkingConfig {
#[serde(default = "default_true")]
pub insert_in_parent: bool,
#[serde(default = "default_true")]
pub insert_in_child: bool,
#[serde(default = "default_false")]
pub use_title_alias: bool,
#[serde(default)]
pub format: Option<String>,
#[serde(default = "default_link_insertion_point")]
pub insertion_point: String,
#[serde(default = "default_false")]
pub create_links_section: bool,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct EditorConfig {
#[serde(default)]
pub command: Option<String>,
#[serde(default)]
pub args: Vec<String>,
#[serde(default = "default_true")]
pub wait: bool,
#[serde(default)]
pub working_directory: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct OutputConfig {
#[serde(default = "default_output_format")]
pub default_format: String,
#[serde(default = "default_color")]
pub color: String,
#[serde(default = "default_pager")]
pub pager: String,
#[serde(default = "default_date_format")]
pub date_format: String,
#[serde(default = "default_true")]
pub relative_dates: bool,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PerformanceConfig {
#[serde(default = "default_true")]
pub cache_enabled: bool,
#[serde(default = "default_cache_max_age")]
pub cache_max_age: u64,
#[serde(default = "default_cache_max_size")]
pub cache_max_size: u64,
#[serde(default = "default_true")]
pub parallel_processing: bool,
#[serde(default)]
pub max_threads: Option<usize>,
}
pub struct ConfigManager;
impl ConfigManager {
pub fn load_config(vault_path: Option<&Path>) -> ConfigResult<ZettelConfig> {
let mut config = ZettelConfig::default();
if let Some(global_config) = Self::try_load_global_config()? {
config = Self::merge_configs(config, global_config);
}
if let Some(vault_path) = vault_path {
if let Some(vault_config) = Self::try_load_vault_config(vault_path)? {
config = Self::merge_configs(config, vault_config);
}
}
Self::apply_env_overrides(&mut config);
Self::validate_config(&config)?;
Ok(config)
}
pub fn generate_default_config() -> String {
r#"# Zettel Configuration File
#
# This file controls how the zettel CLI tool behaves.
# Lines starting with # are comments and are ignored.
[vault]
# Default vault path if not specified via --vault or ZETTEL_VAULT
# default_path = "~/notes"
# Automatically rebuild search index when files change
auto_index = true
# Create backup files before destructive operations
backup_on_change = false
[id]
# ID matching rule: "strict", "separator", or "fuzzy"
match_rule = "fuzzy"
# Separator between ID and title in filenames
separator = " - "
# Allow Unicode characters in IDs (may cause filesystem issues)
allow_unicode = false
[note]
# Include note title in filename
add_title = false
# Add note title as frontmatter alias
add_alias = false
# File extension for new notes
extension = "md"
[template]
# Use custom template files
enabled = false
# Path to template file (relative to vault root)
# file = "templates/note.md"
[linking]
# Insert link to child in parent when creating children
insert_in_parent = true
# Insert link to parent in child when creating children
insert_in_child = true
# Use title as display text in links
use_title_alias = false
[editor]
# Editor command (overrides ZETTEL_EDITOR and EDITOR env vars)
# command = "helix"
# Arguments to pass to editor (supports {file}, {line}, {col} placeholders)
# args = ["+{line}:{col}"]
[output]
# Default output format: "human", "json", "csv"
default_format = "human"
# Color output: "auto", "always", "never"
color = "auto"
# Use pager for long output: "auto", "always", "never"
pager = "auto"
[performance]
# Enable file system caching
cache_enabled = true
# Maximum cache age in seconds
cache_max_age = 3600
# Use parallel processing for file operations
parallel_processing = true
"#
.to_string()
}
fn try_load_global_config() -> ConfigResult<Option<ZettelConfig>> {
Ok(None) }
fn try_load_vault_config(vault_path: &Path) -> ConfigResult<Option<ZettelConfig>> {
let config_path = vault_path.join(".zettel").join("config.toml");
if !config_path.exists() {
return Ok(None);
}
let config_content =
std::fs::read_to_string(&config_path).map_err(|e| ConfigError::IoError(e))?;
let config: ZettelConfig =
toml::from_str(&config_content).map_err(|e| ConfigError::ParseError {
file: config_path.display().to_string(),
error: e.to_string(),
})?;
Ok(Some(config))
}
fn merge_configs(_base: ZettelConfig, override_config: ZettelConfig) -> ZettelConfig {
override_config
}
fn apply_env_overrides(config: &mut ZettelConfig) {
use std::env;
if let Ok(vault) = env::var("ZETTEL_VAULT") {
config.vault.default_path = Some(vault);
}
if let Ok(editor) = env::var("ZETTEL_EDITOR") {
config.editor.command = Some(editor);
}
if let Ok(match_rule) = env::var("ZETTEL_MATCH_RULE") {
config.id.match_rule = match_rule;
}
}
fn validate_config(config: &ZettelConfig) -> ConfigResult<()> {
match config.id.match_rule.as_str() {
"strict" | "separator" | "fuzzy" => {}
_ => {
return Err(ConfigError::ValidationError(format!(
"Invalid match_rule '{}'. Must be one of: strict, separator, fuzzy",
config.id.match_rule
)));
}
}
if config.id.match_rule == "separator" && config.id.separator.is_empty() {
return Err(ConfigError::ValidationError(
"Separator cannot be empty when match_rule is 'separator'".to_string(),
));
}
if config.template.enabled {
if config.template.file.is_empty() && config.template.directory.is_empty() {
return Err(ConfigError::ValidationError(
"Template file or directory must be specified when templates are enabled"
.to_string(),
));
}
}
match config.output.default_format.as_str() {
"human" | "json" | "csv" | "xml" => {}
_ => {
return Err(ConfigError::ValidationError(format!(
"Invalid output format '{}'. Must be one of: human, json, csv, xml",
config.output.default_format
)));
}
}
Ok(())
}
}
fn default_link_insertion_point() -> String {
"end".to_string()
}
fn default_true() -> bool {
true
}
fn default_false() -> bool {
false
}
fn default_match_rule() -> String {
"fuzzy".to_string()
}
fn default_separator() -> String {
" - ".to_string()
}
fn default_extension() -> String {
"md".to_string()
}
fn default_template_name() -> String {
"default".to_string()
}
fn default_date_format() -> String {
"%Y-%m-%d".to_string()
}
fn default_output_format() -> String {
"human".to_string()
}
fn default_color() -> String {
"auto".to_string()
}
fn default_pager() -> String {
"auto".to_string()
}
fn default_max_depth() -> u32 {
10
}
fn default_cache_max_age() -> u64 {
3600
}
fn default_cache_max_size() -> u64 {
100
}
impl Default for ZettelConfig {
fn default() -> Self {
Self {
vault: VaultConfig::default(),
id: IdConfig::default(),
note: NoteConfig::default(),
template: TemplateConfig::default(),
linking: LinkingConfig::default(),
editor: EditorConfig::default(),
output: OutputConfig::default(),
performance: PerformanceConfig::default(),
}
}
}
impl Default for VaultConfig {
fn default() -> Self {
Self {
default_path: None,
auto_index: true,
backup_on_change: false,
exclude_dirs: vec![
"_layouts".to_string(),
"templates".to_string(),
"scripts".to_string(),
],
exclude_patterns: vec![],
}
}
}
impl Default for IdConfig {
fn default() -> Self {
Self {
match_rule: default_match_rule(),
separator: default_separator(),
allow_unicode: false,
max_depth: default_max_depth(),
}
}
}
impl Default for NoteConfig {
fn default() -> Self {
Self {
add_title: false,
add_alias: false,
extension: default_extension(),
default_directory: String::new(),
use_date_directories: false,
date_format: default_date_format(),
}
}
}
impl Default for TemplateConfig {
fn default() -> Self {
Self {
enabled: false,
file: String::new(),
directory: String::new(),
default_template: default_template_name(),
require_title: true,
require_link: true,
}
}
}
impl Default for LinkingConfig {
fn default() -> Self {
Self {
insert_in_parent: true,
insert_in_child: true,
use_title_alias: false,
format: None,
insertion_point: default_link_insertion_point(),
create_links_section: false,
}
}
}
impl Default for EditorConfig {
fn default() -> Self {
Self {
command: None,
args: vec![],
wait: true,
working_directory: None,
}
}
}
impl Default for OutputConfig {
fn default() -> Self {
Self {
default_format: default_output_format(),
color: default_color(),
pager: default_pager(),
date_format: default_date_format(),
relative_dates: true,
}
}
}
impl Default for PerformanceConfig {
fn default() -> Self {
Self {
cache_enabled: true,
cache_max_age: default_cache_max_age(),
cache_max_size: default_cache_max_size(),
parallel_processing: true,
max_threads: None,
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_default_config_is_valid() {
let config = ZettelConfig::default();
assert!(ConfigManager::validate_config(&config).is_ok());
}
#[test]
fn test_config_serialization() {
let config = ZettelConfig::default();
let toml = toml::to_string_pretty(&config).unwrap();
let parsed: ZettelConfig = toml::from_str(&toml).unwrap();
assert_eq!(config.id.match_rule, parsed.id.match_rule);
assert_eq!(
config.linking.insert_in_parent,
parsed.linking.insert_in_parent
);
}
#[test]
fn test_invalid_match_rule_validation() {
let mut config = ZettelConfig::default();
config.id.match_rule = "invalid".to_string();
assert!(ConfigManager::validate_config(&config).is_err());
}
#[test]
fn test_empty_separator_with_separator_rule() {
let mut config = ZettelConfig::default();
config.id.match_rule = "separator".to_string();
config.id.separator = "".to_string();
assert!(ConfigManager::validate_config(&config).is_err());
}
}