1use serde::{Deserialize, Serialize};
22use std::path::Path;
23use thiserror::Error;
24
25#[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
41pub type ConfigResult<T> = Result<T, ConfigError>;
43
44#[derive(Debug, Clone, Serialize, Deserialize)]
59pub struct ZettelConfig {
60 #[serde(default)]
62 pub vault: VaultConfig,
63
64 #[serde(default)]
66 pub id: IdConfig,
67
68 #[serde(default)]
70 pub note: NoteConfig,
71
72 #[serde(default)]
74 pub template: TemplateConfig,
75
76 #[serde(default)]
78 pub linking: LinkingConfig,
79
80 #[serde(default)]
82 pub editor: EditorConfig,
83
84 #[serde(default)]
86 pub output: OutputConfig,
87
88 #[serde(default)]
90 pub performance: PerformanceConfig,
91}
92
93#[derive(Debug, Clone, Serialize, Deserialize)]
98pub struct VaultConfig {
99 pub default_path: Option<String>,
101
102 #[serde(default = "default_true")]
104 pub auto_index: bool,
105
106 #[serde(default = "default_false")]
108 pub backup_on_change: bool,
109
110 #[serde(default)]
112 pub exclude_dirs: Vec<String>,
113
114 #[serde(default)]
116 pub exclude_patterns: Vec<String>,
117}
118
119#[derive(Debug, Clone, Serialize, Deserialize)]
124pub struct IdConfig {
125 #[serde(default = "default_match_rule")]
131 pub match_rule: String,
132
133 #[serde(default = "default_separator")]
138 pub separator: String,
139
140 #[serde(default = "default_false")]
145 pub allow_unicode: bool,
146
147 #[serde(default = "default_max_depth")]
152 pub max_depth: u32,
153}
154
155#[derive(Debug, Clone, Serialize, Deserialize)]
159pub struct NoteConfig {
160 #[serde(default = "default_false")]
165 pub add_title: bool,
166
167 #[serde(default = "default_false")]
171 pub add_alias: bool,
172
173 #[serde(default = "default_extension")]
175 pub extension: String,
176
177 #[serde(default)]
181 pub default_directory: String,
182
183 #[serde(default = "default_false")]
185 pub use_date_directories: bool,
186
187 #[serde(default = "default_date_format")]
189 pub date_format: String,
190}
191
192#[derive(Debug, Clone, Serialize, Deserialize)]
197pub struct TemplateConfig {
198 #[serde(default = "default_false")]
200 pub enabled: bool,
201
202 #[serde(default)]
206 pub file: String,
207
208 #[serde(default)]
212 pub directory: String,
213
214 #[serde(default = "default_template_name")]
216 pub default_template: String,
217
218 #[serde(default = "default_true")]
220 pub require_title: bool,
221
222 #[serde(default = "default_true")]
224 pub require_link: bool,
225}
226
227#[derive(Debug, Clone, Serialize, Deserialize)]
232pub struct LinkingConfig {
233 #[serde(default = "default_true")]
235 pub insert_in_parent: bool,
236
237 #[serde(default = "default_true")]
239 pub insert_in_child: bool,
240
241 #[serde(default = "default_false")]
246 pub use_title_alias: bool,
247
248 #[serde(default)]
253 pub format: Option<String>,
254
255 #[serde(default = "default_link_insertion_point")]
259 pub insertion_point: String,
260
261 #[serde(default = "default_false")]
263 pub create_links_section: bool,
264}
265#[derive(Debug, Clone, Serialize, Deserialize)]
269pub struct EditorConfig {
270 #[serde(default)]
272 pub command: Option<String>,
273
274 #[serde(default)]
279 pub args: Vec<String>,
280
281 #[serde(default = "default_true")]
283 pub wait: bool,
284
285 #[serde(default)]
287 pub working_directory: Option<String>,
288}
289
290#[derive(Debug, Clone, Serialize, Deserialize)]
294pub struct OutputConfig {
295 #[serde(default = "default_output_format")]
297 pub default_format: String,
298
299 #[serde(default = "default_color")]
301 pub color: String,
302
303 #[serde(default = "default_pager")]
305 pub pager: String,
306
307 #[serde(default = "default_date_format")]
309 pub date_format: String,
310
311 #[serde(default = "default_true")]
313 pub relative_dates: bool,
314}
315
316#[derive(Debug, Clone, Serialize, Deserialize)]
320pub struct PerformanceConfig {
321 #[serde(default = "default_true")]
323 pub cache_enabled: bool,
324
325 #[serde(default = "default_cache_max_age")]
327 pub cache_max_age: u64,
328
329 #[serde(default = "default_cache_max_size")]
331 pub cache_max_size: u64,
332
333 #[serde(default = "default_true")]
335 pub parallel_processing: bool,
336
337 #[serde(default)]
339 pub max_threads: Option<usize>,
340}
341
342pub struct ConfigManager;
347
348impl ConfigManager {
349 pub fn load_config(vault_path: Option<&Path>) -> ConfigResult<ZettelConfig> {
366 let mut config = ZettelConfig::default();
368
369 if let Some(global_config) = Self::try_load_global_config()? {
371 config = Self::merge_configs(config, global_config);
372 }
373
374 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 Self::apply_env_overrides(&mut config);
383
384 Self::validate_config(&config)?;
386
387 Ok(config)
388 }
389
390 pub fn generate_default_config() -> String {
395 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 fn try_load_global_config() -> ConfigResult<Option<ZettelConfig>> {
486 Ok(None) }
490
491 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 fn merge_configs(_base: ZettelConfig, override_config: ZettelConfig) -> ZettelConfig {
519 override_config
522 }
523
524 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 }
548
549 fn validate_config(config: &ZettelConfig) -> ConfigResult<()> {
554 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 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 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 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 Ok(())
596 }
597}
598
599fn 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
651impl 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
667impl 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 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}