Skip to main content

zettel_core/
config.rs

1// crates/zettel-core/src/config.rs - Configuration System
2//
3// This module provides the complete configuration schema and loading mechanism
4// for the zettel CLI tool. It handles the hierarchy of configuration sources
5// and provides a clean interface for all configurable behavior.
6//
7// CONFIGURATION HIERARCHY (highest to lowest priority):
8// 1. Command-line arguments (--vault, etc.)
9// 2. Environment variables (ZETTEL_VAULT, ZETTEL_EDITOR, etc.)
10// 3. Vault-specific config file (.zettel/config.toml)
11// 4. Global config file (~/.config/zettel/config.toml)
12// 5. Built-in defaults
13//
14// DESIGN PRINCIPLES:
15// - Comprehensive: Cover all behavior that could reasonably vary between users
16// - Hierarchical: Allow both global and vault-specific overrides
17// - Extensible: Easy to add new settings without breaking existing configs
18// - Validated: Catch configuration errors early with helpful messages
19// - Self-documenting: Generated config files include explanatory comments
20
21use serde::{Deserialize, Serialize};
22use std::path::Path;
23use thiserror::Error;
24
25/// Errors that can occur during configuration loading and validation
26#[derive(Error, Debug)]
27pub enum ConfigError {
28    #[error("Configuration file not found: {0}")]
29    FileNotFound(String),
30
31    #[error("Invalid TOML syntax in {file}: {error}")]
32    ParseError { file: String, error: String },
33
34    #[error("Invalid configuration value: {0}")]
35    ValidationError(String),
36
37    #[error("I/O error reading config: {0}")]
38    IoError(#[from] std::io::Error),
39}
40
41/// Result type for configuration operations
42pub type ConfigResult<T> = Result<T, ConfigError>;
43
44/// Complete configuration schema for the zettel system
45///
46/// This encompasses all user-configurable behavior, organized into logical sections.
47/// Each section corresponds to a major area of functionality.
48///
49/// SERIALIZATION NOTES:
50/// - Uses serde for TOML serialization/deserialization
51/// - Provides defaults for all fields to handle partial config files
52/// - Field names match TOML keys for clear mapping
53///
54/// RUST PATTERNS:
55/// - All fields are owned (String, not &str) for easier manipulation
56/// - Uses Option<T> for truly optional settings
57/// - Provides Default trait for sensible fallbacks
58#[derive(Debug, Clone, Serialize, Deserialize)]
59pub struct ZettelConfig {
60    /// Vault-specific settings
61    #[serde(default)]
62    pub vault: VaultConfig,
63
64    /// ID parsing and generation rules
65    #[serde(default)]
66    pub id: IdConfig,
67
68    /// Note creation and content settings
69    #[serde(default)]
70    pub note: NoteConfig,
71
72    /// Template system configuration
73    #[serde(default)]
74    pub template: TemplateConfig,
75
76    /// Linking behavior settings
77    #[serde(default)]
78    pub linking: LinkingConfig,
79
80    /// Editor integration settings
81    #[serde(default)]
82    pub editor: EditorConfig,
83
84    /// Output formatting options
85    #[serde(default)]
86    pub output: OutputConfig,
87
88    /// Performance and caching settings
89    #[serde(default)]
90    pub performance: PerformanceConfig,
91}
92
93/// Vault-level configuration
94///
95/// Settings that control how the vault operates as a whole, including
96/// file organization and backup behavior.
97#[derive(Debug, Clone, Serialize, Deserialize)]
98pub struct VaultConfig {
99    /// Default vault path if not specified via CLI or environment
100    pub default_path: Option<String>,
101
102    /// Whether to automatically rebuild search index on file changes
103    #[serde(default = "default_true")]
104    pub auto_index: bool,
105
106    /// Whether to create backup files before destructive operations
107    #[serde(default = "default_false")]
108    pub backup_on_change: bool,
109
110    /// Directories to exclude from zettel operations (relative to vault root)
111    #[serde(default)]
112    pub exclude_dirs: Vec<String>,
113
114    /// File patterns to exclude from zettel operations
115    #[serde(default)]
116    pub exclude_patterns: Vec<String>,
117}
118
119/// ID parsing and generation configuration
120///
121/// Controls how IDs are recognized in filenames and how new IDs are generated.
122/// This is the core of the zettelkasten system's organizational structure.
123#[derive(Debug, Clone, Serialize, Deserialize)]
124pub struct IdConfig {
125    /// ID matching rule: "strict", "separator", or "fuzzy"
126    ///
127    /// - strict: Filename must be exactly the ID (e.g., "1a2.md")
128    /// - separator: ID followed by separator then title (e.g., "1a2 - My Note.md")
129    /// - fuzzy: ID at start, anything after first non-alphanumeric (e.g., "1a2_note.md")
130    #[serde(default = "default_match_rule")]
131    pub match_rule: String,
132
133    /// Separator between ID and title in filenames
134    ///
135    /// Only used when match_rule is "separator" or when creating files with titles.
136    /// Can include whitespace for prettier filenames.
137    #[serde(default = "default_separator")]
138    pub separator: String,
139
140    /// Whether to allow Unicode characters in IDs
141    ///
142    /// When false, IDs are restricted to ASCII alphanumeric characters.
143    /// When true, allows international characters but may cause filesystem issues.
144    #[serde(default = "default_false")]
145    pub allow_unicode: bool,
146
147    /// Maximum depth for ID hierarchy
148    ///
149    /// Prevents runaway nesting that could cause performance issues.
150    /// Set to 0 for unlimited depth (not recommended).
151    #[serde(default = "default_max_depth")]
152    pub max_depth: u32,
153}
154
155/// Note creation and file naming configuration
156///
157/// Controls how new notes are created, named, and initially populated with content.
158#[derive(Debug, Clone, Serialize, Deserialize)]
159pub struct NoteConfig {
160    /// Whether to include note title in the filename
161    ///
162    /// When true: "1a2 - My Note Title.md"
163    /// When false: "1a2.md"
164    #[serde(default = "default_false")]
165    pub add_title: bool,
166
167    /// Whether to add note title as an alias in frontmatter
168    ///
169    /// Enables title-based search even with ID-only filenames.
170    #[serde(default = "default_false")]
171    pub add_alias: bool,
172
173    /// File extension for new notes
174    #[serde(default = "default_extension")]
175    pub extension: String,
176
177    /// Default directory for new notes (relative to vault root)
178    ///
179    /// If empty, notes are created in the same directory as the current file.
180    #[serde(default)]
181    pub default_directory: String,
182
183    /// Whether to create notes in date-based subdirectories
184    #[serde(default = "default_false")]
185    pub use_date_directories: bool,
186
187    /// Date format for directory names (when use_date_directories is true)
188    #[serde(default = "default_date_format")]
189    pub date_format: String,
190}
191
192/// Template system configuration
193///
194/// Controls whether and how custom templates are used for note creation.
195/// Templates allow advanced users to customize note structure beyond basic formatting.
196#[derive(Debug, Clone, Serialize, Deserialize)]
197pub struct TemplateConfig {
198    /// Whether to use custom templates instead of built-in formatting
199    #[serde(default = "default_false")]
200    pub enabled: bool,
201
202    /// Path to template file (relative to vault root)
203    ///
204    /// Template supports {{title}} and {{link}} placeholders.
205    #[serde(default)]
206    pub file: String,
207
208    /// Directory containing multiple template files
209    ///
210    /// If specified, users can choose from multiple templates.
211    #[serde(default)]
212    pub directory: String,
213
214    /// Default template name when directory is used
215    #[serde(default = "default_template_name")]
216    pub default_template: String,
217
218    /// Whether template validation requires {{title}} placeholder
219    #[serde(default = "default_true")]
220    pub require_title: bool,
221
222    /// Whether template validation requires {{link}} placeholder
223    #[serde(default = "default_true")]
224    pub require_link: bool,
225}
226
227/// Bidirectional linking configuration
228///
229/// Controls the core zettelkasten feature of automatic linking between
230/// parent and child notes when the hierarchy is modified.
231#[derive(Debug, Clone, Serialize, Deserialize)]
232pub struct LinkingConfig {
233    /// Whether to insert link to child in parent when creating children
234    #[serde(default = "default_true")]
235    pub insert_in_parent: bool,
236
237    /// Whether to insert link to parent in child when creating children
238    #[serde(default = "default_true")]
239    pub insert_in_child: bool,
240
241    /// Whether to use title as display text in generated links
242    ///
243    /// When true: [[1a2|My Note Title]] (prettier but can break)
244    /// When false: [[1a2]] (always works)
245    #[serde(default = "default_false")]
246    pub use_title_alias: bool,
247
248    /// Link format template
249    ///
250    /// Supports placeholders: {id}, {title}, {filename}
251    /// Default: "[[{filename}]]" or "[[{filename}|{title}]]" based on use_title_alias
252    #[serde(default)]
253    pub format: Option<String>,
254
255    /// Where to insert child links in parent notes
256    ///
257    /// Options: "end" (at end of file), "after_title" (after # heading), "section" (in ## Links section)
258    #[serde(default = "default_link_insertion_point")]
259    pub insertion_point: String,
260
261    /// Whether to create a dedicated links section when inserting
262    #[serde(default = "default_false")]
263    pub create_links_section: bool,
264}
265/// Editor integration configuration
266///
267/// Controls how the CLI integrates with text editors for note editing and creation.
268#[derive(Debug, Clone, Serialize, Deserialize)]
269pub struct EditorConfig {
270    /// Editor command to use (overrides ZETTEL_EDITOR and EDITOR env vars)
271    #[serde(default)]
272    pub command: Option<String>,
273
274    /// Arguments to pass to editor
275    ///
276    /// Supports placeholders: {file}, {line}, {col}
277    /// Example: ["+{line}:{col}", "{file}"] for vim-style cursor positioning
278    #[serde(default)]
279    pub args: Vec<String>,
280
281    /// Whether to wait for editor to exit before continuing
282    #[serde(default = "default_true")]
283    pub wait: bool,
284
285    /// Working directory for editor (relative to vault root)
286    #[serde(default)]
287    pub working_directory: Option<String>,
288}
289
290/// Output formatting configuration
291///
292/// Controls how command output is formatted for both human and machine consumption.
293#[derive(Debug, Clone, Serialize, Deserialize)]
294pub struct OutputConfig {
295    /// Default output format: "human", "json", "csv", "xml"
296    #[serde(default = "default_output_format")]
297    pub default_format: String,
298
299    /// Color output: "auto", "always", "never"
300    #[serde(default = "default_color")]
301    pub color: String,
302
303    /// Whether to use a pager for long output
304    #[serde(default = "default_pager")]
305    pub pager: String,
306
307    /// Date format for human-readable output
308    #[serde(default = "default_date_format")]
309    pub date_format: String,
310
311    /// Whether to show relative dates ("2 days ago") vs absolute dates
312    #[serde(default = "default_true")]
313    pub relative_dates: bool,
314}
315
316/// Performance and caching configuration
317///
318/// Controls optimizations for large vaults and resource usage.
319#[derive(Debug, Clone, Serialize, Deserialize)]
320pub struct PerformanceConfig {
321    /// Whether to enable file system caching
322    #[serde(default = "default_true")]
323    pub cache_enabled: bool,
324
325    /// Maximum cache age in seconds
326    #[serde(default = "default_cache_max_age")]
327    pub cache_max_age: u64,
328
329    /// Maximum cache size in MB
330    #[serde(default = "default_cache_max_size")]
331    pub cache_max_size: u64,
332
333    /// Whether to use parallel processing for file operations
334    #[serde(default = "default_true")]
335    pub parallel_processing: bool,
336
337    /// Maximum number of threads for parallel operations
338    #[serde(default)]
339    pub max_threads: Option<usize>,
340}
341
342/// Configuration loading and management
343///
344/// Handles the complex logic of loading configuration from multiple sources
345/// and merging them according to the priority hierarchy.
346pub struct ConfigManager;
347
348impl ConfigManager {
349    /// Load complete configuration from all sources
350    ///
351    /// This implements the configuration hierarchy by loading from multiple
352    /// sources and merging them in priority order.
353    ///
354    /// LOADING STRATEGY:
355    /// 1. Start with built-in defaults
356    /// 2. Override with global config file (if exists)
357    /// 3. Override with vault-specific config (if exists)
358    /// 4. Override with environment variables
359    /// 5. Override with command-line arguments (handled by clap)
360    ///
361    /// ERROR HANDLING:
362    /// - Missing config files are not errors (use defaults)
363    /// - Invalid TOML syntax is an error with helpful context
364    /// - Validation errors include suggestions for fixes
365    pub fn load_config(vault_path: Option<&Path>) -> ConfigResult<ZettelConfig> {
366        // Start with sensible defaults
367        let mut config = ZettelConfig::default();
368
369        // Try to load global config file
370        if let Some(global_config) = Self::try_load_global_config()? {
371            config = Self::merge_configs(config, global_config);
372        }
373
374        // Try to load vault-specific config file
375        if let Some(vault_path) = vault_path {
376            if let Some(vault_config) = Self::try_load_vault_config(vault_path)? {
377                config = Self::merge_configs(config, vault_config);
378            }
379        }
380
381        // Apply environment variable overrides
382        Self::apply_env_overrides(&mut config);
383
384        // Validate the final configuration
385        Self::validate_config(&config)?;
386
387        Ok(config)
388    }
389
390    /// Generate a default configuration file with comments
391    ///
392    /// Creates a well-documented config file that users can customize.
393    /// Includes explanations of each setting and examples of common configurations.
394    pub fn generate_default_config() -> String {
395        // This would generate a comprehensive TOML file with comments
396        // explaining each section and setting. For brevity, showing structure:
397        r#"# Zettel Configuration File
398#
399# This file controls how the zettel CLI tool behaves.
400# Lines starting with # are comments and are ignored.
401
402[vault]
403# Default vault path if not specified via --vault or ZETTEL_VAULT
404# default_path = "~/notes"
405
406# Automatically rebuild search index when files change
407auto_index = true
408
409# Create backup files before destructive operations
410backup_on_change = false
411
412[id]
413# ID matching rule: "strict", "separator", or "fuzzy"
414match_rule = "fuzzy"
415
416# Separator between ID and title in filenames
417separator = " - "
418
419# Allow Unicode characters in IDs (may cause filesystem issues)
420allow_unicode = false
421
422[note]
423# Include note title in filename
424add_title = false
425
426# Add note title as frontmatter alias
427add_alias = false
428
429# File extension for new notes
430extension = "md"
431
432[template]
433# Use custom template files
434enabled = false
435
436# Path to template file (relative to vault root)
437# file = "templates/note.md"
438
439[linking]
440# Insert link to child in parent when creating children
441insert_in_parent = true
442
443# Insert link to parent in child when creating children
444insert_in_child = true
445
446# Use title as display text in links
447use_title_alias = false
448
449[editor]
450# Editor command (overrides ZETTEL_EDITOR and EDITOR env vars)
451# command = "helix"
452
453# Arguments to pass to editor (supports {file}, {line}, {col} placeholders)
454# args = ["+{line}:{col}"]
455
456[output]
457# Default output format: "human", "json", "csv"
458default_format = "human"
459
460# Color output: "auto", "always", "never"
461color = "auto"
462
463# Use pager for long output: "auto", "always", "never"
464pager = "auto"
465
466[performance]
467# Enable file system caching
468cache_enabled = true
469
470# Maximum cache age in seconds
471cache_max_age = 3600
472
473# Use parallel processing for file operations
474parallel_processing = true
475"#
476        .to_string()
477    }
478
479    /// Try to load global configuration file
480    ///
481    /// Looks for config in standard locations following XDG Base Directory spec:
482    /// - Linux: ~/.config/zettel/config.toml
483    /// - macOS: ~/Library/Application Support/zettel/config.toml
484    /// - Windows: %APPDATA%\zettel\config.toml
485    fn try_load_global_config() -> ConfigResult<Option<ZettelConfig>> {
486        // Implementation would use dirs crate to find config directory
487        // and attempt to load config.toml from there
488        Ok(None) // Placeholder - returns None if no global config found
489    }
490
491    /// Try to load vault-specific configuration file
492    ///
493    /// Looks for .zettel/config.toml in the vault directory.
494    /// This allows per-vault customization of behavior.
495    fn try_load_vault_config(vault_path: &Path) -> ConfigResult<Option<ZettelConfig>> {
496        let config_path = vault_path.join(".zettel").join("config.toml");
497
498        if !config_path.exists() {
499            return Ok(None);
500        }
501
502        let config_content =
503            std::fs::read_to_string(&config_path).map_err(|e| ConfigError::IoError(e))?;
504
505        let config: ZettelConfig =
506            toml::from_str(&config_content).map_err(|e| ConfigError::ParseError {
507                file: config_path.display().to_string(),
508                error: e.to_string(),
509            })?;
510
511        Ok(Some(config))
512    }
513
514    /// Merge two configurations, with the second taking priority
515    ///
516    /// This implements the override behavior where later configs
517    /// take precedence over earlier ones.
518    fn merge_configs(_base: ZettelConfig, override_config: ZettelConfig) -> ZettelConfig {
519        // Implementation would merge each field, with override_config taking precedence
520        // For now, just return override_config as placeholder
521        override_config
522    }
523
524    /// Apply environment variable overrides
525    ///
526    /// Certain settings can be overridden by environment variables:
527    /// - ZETTEL_VAULT -> vault.default_path
528    /// - ZETTEL_EDITOR -> editor.command
529    /// - ZETTEL_MATCH_RULE -> id.match_rule
530    /// - etc.
531    fn apply_env_overrides(config: &mut ZettelConfig) {
532        use std::env;
533
534        if let Ok(vault) = env::var("ZETTEL_VAULT") {
535            config.vault.default_path = Some(vault);
536        }
537
538        if let Ok(editor) = env::var("ZETTEL_EDITOR") {
539            config.editor.command = Some(editor);
540        }
541
542        if let Ok(match_rule) = env::var("ZETTEL_MATCH_RULE") {
543            config.id.match_rule = match_rule;
544        }
545
546        // Add more environment variable mappings as needed
547    }
548
549    /// Validate the final configuration for consistency and correctness
550    ///
551    /// Catches configuration errors that would cause runtime failures
552    /// and provides helpful error messages with suggestions for fixes.
553    fn validate_config(config: &ZettelConfig) -> ConfigResult<()> {
554        // Validate match rule
555        match config.id.match_rule.as_str() {
556            "strict" | "separator" | "fuzzy" => {}
557            _ => {
558                return Err(ConfigError::ValidationError(format!(
559                    "Invalid match_rule '{}'. Must be one of: strict, separator, fuzzy",
560                    config.id.match_rule
561                )));
562            }
563        }
564
565        // Validate separator is not empty when required
566        if config.id.match_rule == "separator" && config.id.separator.is_empty() {
567            return Err(ConfigError::ValidationError(
568                "Separator cannot be empty when match_rule is 'separator'".to_string(),
569            ));
570        }
571
572        // Validate template configuration
573        if config.template.enabled {
574            if config.template.file.is_empty() && config.template.directory.is_empty() {
575                return Err(ConfigError::ValidationError(
576                    "Template file or directory must be specified when templates are enabled"
577                        .to_string(),
578                ));
579            }
580        }
581
582        // Validate output format
583        match config.output.default_format.as_str() {
584            "human" | "json" | "csv" | "xml" => {}
585            _ => {
586                return Err(ConfigError::ValidationError(format!(
587                    "Invalid output format '{}'. Must be one of: human, json, csv, xml",
588                    config.output.default_format
589                )));
590            }
591        }
592
593        // Add more validation rules as needed
594
595        Ok(())
596    }
597}
598
599/// Default value implementations for serde
600///
601/// These functions provide the default values used when config fields
602/// are missing from TOML files. They're separate functions so they can
603/// be used both for serde defaults and for documentation.
604
605fn default_link_insertion_point() -> String {
606    "end".to_string()
607}
608
609fn default_true() -> bool {
610    true
611}
612fn default_false() -> bool {
613    false
614}
615
616fn default_match_rule() -> String {
617    "fuzzy".to_string()
618}
619fn default_separator() -> String {
620    " - ".to_string()
621}
622fn default_extension() -> String {
623    "md".to_string()
624}
625fn default_template_name() -> String {
626    "default".to_string()
627}
628fn default_date_format() -> String {
629    "%Y-%m-%d".to_string()
630}
631fn default_output_format() -> String {
632    "human".to_string()
633}
634fn default_color() -> String {
635    "auto".to_string()
636}
637fn default_pager() -> String {
638    "auto".to_string()
639}
640
641fn default_max_depth() -> u32 {
642    10
643}
644fn default_cache_max_age() -> u64 {
645    3600
646}
647fn default_cache_max_size() -> u64 {
648    100
649}
650
651/// Provide sensible defaults for the entire configuration
652impl Default for ZettelConfig {
653    fn default() -> Self {
654        Self {
655            vault: VaultConfig::default(),
656            id: IdConfig::default(),
657            note: NoteConfig::default(),
658            template: TemplateConfig::default(),
659            linking: LinkingConfig::default(),
660            editor: EditorConfig::default(),
661            output: OutputConfig::default(),
662            performance: PerformanceConfig::default(),
663        }
664    }
665}
666
667// Default implementations for each config section
668impl Default for VaultConfig {
669    fn default() -> Self {
670        Self {
671            default_path: None,
672            auto_index: true,
673            backup_on_change: false,
674            exclude_dirs: vec![
675                "_layouts".to_string(),
676                "templates".to_string(),
677                "scripts".to_string(),
678            ],
679            exclude_patterns: vec![],
680        }
681    }
682}
683
684impl Default for IdConfig {
685    fn default() -> Self {
686        Self {
687            match_rule: default_match_rule(),
688            separator: default_separator(),
689            allow_unicode: false,
690            max_depth: default_max_depth(),
691        }
692    }
693}
694
695impl Default for NoteConfig {
696    fn default() -> Self {
697        Self {
698            add_title: false,
699            add_alias: false,
700            extension: default_extension(),
701            default_directory: String::new(),
702            use_date_directories: false,
703            date_format: default_date_format(),
704        }
705    }
706}
707
708impl Default for TemplateConfig {
709    fn default() -> Self {
710        Self {
711            enabled: false,
712            file: String::new(),
713            directory: String::new(),
714            default_template: default_template_name(),
715            require_title: true,
716            require_link: true,
717        }
718    }
719}
720
721impl Default for LinkingConfig {
722    fn default() -> Self {
723        Self {
724            insert_in_parent: true,
725            insert_in_child: true,
726            use_title_alias: false,
727            format: None,
728            insertion_point: default_link_insertion_point(),
729            create_links_section: false,
730        }
731    }
732}
733
734impl Default for EditorConfig {
735    fn default() -> Self {
736        Self {
737            command: None,
738            args: vec![],
739            wait: true,
740            working_directory: None,
741        }
742    }
743}
744
745impl Default for OutputConfig {
746    fn default() -> Self {
747        Self {
748            default_format: default_output_format(),
749            color: default_color(),
750            pager: default_pager(),
751            date_format: default_date_format(),
752            relative_dates: true,
753        }
754    }
755}
756
757impl Default for PerformanceConfig {
758    fn default() -> Self {
759        Self {
760            cache_enabled: true,
761            cache_max_age: default_cache_max_age(),
762            cache_max_size: default_cache_max_size(),
763            parallel_processing: true,
764            max_threads: None,
765        }
766    }
767}
768
769#[cfg(test)]
770mod tests {
771    use super::*;
772
773    #[test]
774    fn test_default_config_is_valid() {
775        let config = ZettelConfig::default();
776        assert!(ConfigManager::validate_config(&config).is_ok());
777    }
778
779    #[test]
780    fn test_config_serialization() {
781        let config = ZettelConfig::default();
782        let toml = toml::to_string_pretty(&config).unwrap();
783        let parsed: ZettelConfig = toml::from_str(&toml).unwrap();
784        // Compare some key fields to ensure round-trip works
785        assert_eq!(config.id.match_rule, parsed.id.match_rule);
786        assert_eq!(
787            config.linking.insert_in_parent,
788            parsed.linking.insert_in_parent
789        );
790    }
791
792    #[test]
793    fn test_invalid_match_rule_validation() {
794        let mut config = ZettelConfig::default();
795        config.id.match_rule = "invalid".to_string();
796        assert!(ConfigManager::validate_config(&config).is_err());
797    }
798
799    #[test]
800    fn test_empty_separator_with_separator_rule() {
801        let mut config = ZettelConfig::default();
802        config.id.match_rule = "separator".to_string();
803        config.id.separator = "".to_string();
804        assert!(ConfigManager::validate_config(&config).is_err());
805    }
806}