Skip to main content

journey/
config.rs

1use serde::{Deserialize, Serialize, Deserializer};
2use std::collections::HashMap;
3use std::path::PathBuf;
4use std::env;
5
6#[derive(Debug, Clone, Serialize, Deserialize)]
7pub struct TableHeaders {
8    pub time: String,
9    pub content: String,
10}
11
12#[derive(Debug, Clone, Serialize)]
13pub struct Config {
14    pub vaults: HashMap<String, VaultConfig>,
15    pub default_vault: Option<String>,
16}
17
18#[derive(Debug, Clone, Serialize, Deserialize)]
19pub struct VaultConfig {
20    pub name: String,
21    #[serde(deserialize_with = "deserialize_path_with_tilde_expansion")]
22    pub path: PathBuf,
23    pub locale: String,
24    pub phrases: HashMap<String, String>,
25    pub section_header: Option<String>,
26    pub section_header_work: Option<String>,
27    pub section_header_personal: Option<String>,
28    pub section_header_health: Option<String>,
29    pub section_header_meetings: Option<String>,
30    pub table_headers: Option<TableHeaders>,
31    pub date_format: Option<String>,
32    #[serde(deserialize_with = "deserialize_template_file_with_expansion")]
33    pub template_file: Option<String>,
34    pub file_path_format: Option<String>,
35    // List type configuration
36    pub list_type: Option<NoteFormat>,
37    // Legacy/compat fields expected by older tests (not used by runtime)
38    pub section_name: Option<String>,
39    pub weekly_format: Option<String>,
40    pub monthly_format: Option<String>,
41    pub quarterly_format: Option<String>,
42    pub yearly_format: Option<String>,
43    pub note_format: Option<NoteFormat>,
44}
45
46#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
47pub enum NoteFormat {
48    #[serde(rename = "bullet")]
49    Bullet,
50    #[serde(rename = "table")]
51    Table,
52}
53
54impl Default for NoteFormat {
55    fn default() -> Self {
56        NoteFormat::Bullet
57    }
58}
59
60/// Custom deserializer for PathBuf that expands tildes
61fn deserialize_path_with_tilde_expansion<'de, D>(deserializer: D) -> Result<PathBuf, D::Error>
62where
63    D: Deserializer<'de>,
64{
65    let path_str = String::deserialize(deserializer)?;
66    Ok(expand_tilde(&path_str))
67}
68
69/// Custom deserializer for template_file that expands tildes and Windows env vars
70fn deserialize_template_file_with_expansion<'de, D>(deserializer: D) -> Result<Option<String>, D::Error>
71where
72    D: Deserializer<'de>,
73{
74    let template_file: Option<String> = Option::deserialize(deserializer)?;
75    Ok(template_file.map(|path| expand_tilde(&path).to_string_lossy().to_string()))
76}
77
78impl Default for Config {
79    fn default() -> Self {
80        Self {
81            vaults: HashMap::new(),
82            default_vault: None,
83        }
84    }
85}
86
87impl<'de> Deserialize<'de> for Config {
88    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
89    where
90        D: Deserializer<'de>,
91    {
92        // First, deserialize using the derived implementation via an intermediate
93        #[derive(Deserialize)]
94        struct RawConfig {
95            vaults: HashMap<String, VaultConfig>,
96            default_vault: Option<String>,
97        }
98
99        let mut raw = RawConfig::deserialize(deserializer)?;
100
101        // Post-process to ensure tilde/env expansion on paths in case field-level deserializers were bypassed
102        for (_name, vault) in raw.vaults.iter_mut() {
103            // Expand path
104            if let Some(path_str) = vault.path.to_str() {
105                vault.path = expand_tilde(path_str);
106            }
107            // Expand template_file if present
108            if let Some(tpl) = vault.template_file.clone() {
109                let expanded = expand_tilde(&tpl).to_string_lossy().to_string();
110                vault.template_file = Some(expanded);
111            }
112        }
113
114        Ok(Config { vaults: raw.vaults, default_vault: raw.default_vault })
115    }
116}
117
118/// Expand tilde (~) and Windows environment variables (%VAR%) in a path string
119/// Cross-platform implementation that handles:
120/// - Unix/Linux/macOS: ~/path -> $HOME/path
121/// - Windows: ~/path -> %USERPROFILE%/path
122/// - Windows: %USERPROFILE%\path -> C:\Users\username\path
123/// - Windows: %APPDATA%\path -> C:\Users\username\AppData\Roaming\path
124fn expand_tilde(path: &str) -> PathBuf {
125    // First check for tilde expansion
126    if path.starts_with("~/") {
127        // Try to get home directory from environment variables
128        let home_dir = if cfg!(windows) {
129            // On Windows, try USERPROFILE first, then HOME
130            env::var("USERPROFILE")
131                .or_else(|_| env::var("HOME"))
132                .ok()
133        } else {
134            // On Unix-like systems, use HOME
135            env::var("HOME").ok()
136        };
137        
138        if let Some(home) = home_dir {
139            PathBuf::from(home).join(&path[2..])
140        } else {
141            // If no home directory found, return the path as-is
142            PathBuf::from(path)
143        }
144    } else if path == "~" {
145        // Just the tilde by itself
146        let home_dir = if cfg!(windows) {
147            env::var("USERPROFILE")
148                .or_else(|_| env::var("HOME"))
149                .ok()
150        } else {
151            env::var("HOME").ok()
152        };
153        
154        if let Some(home) = home_dir {
155            PathBuf::from(home)
156        } else {
157            PathBuf::from(path)
158        }
159    } else {
160        // Check for Windows-style environment variable expansion
161        expand_windows_env_vars(path)
162    }
163}
164
165/// Expand Windows-style environment variables (%VAR%) in a path string
166fn expand_windows_env_vars(path: &str) -> PathBuf {
167    if !cfg!(windows) {
168        // On non-Windows systems, return the path as-is
169        return PathBuf::from(path);
170    }
171    
172    let mut result = path.to_string();
173    let mut start = 0;
174    
175    // Find and replace %VAR% patterns
176    while let Some(var_start) = result[start..].find('%') {
177        let actual_start = start + var_start;
178        let var_start_pos = actual_start + 1; // Skip the %
179        
180        if let Some(var_end) = result[var_start_pos..].find('%') {
181            let var_end_pos = var_start_pos + var_end;
182            let var_name = &result[var_start_pos..var_end_pos];
183            
184            // Try to get the environment variable
185            if let Ok(var_value) = env::var(var_name) {
186                // Replace %VAR% with the actual value
187                result.replace_range(actual_start..=var_end_pos, &var_value);
188                start = actual_start + var_value.len();
189            } else {
190                // Variable not found, skip this occurrence
191                start = var_end_pos + 1;
192            }
193        } else {
194            // No closing %, break
195            break;
196        }
197    }
198    
199    PathBuf::from(result)
200}
201
202impl VaultConfig {
203    /// Create a new VaultConfig with tilde expansion applied to the path
204    pub fn new(name: String, path: String, locale: String) -> Self {
205        Self {
206            name,
207            path: expand_tilde(&path),
208            locale,
209            phrases: HashMap::new(),
210            section_header: None,
211            section_header_work: None,
212            section_header_personal: None,
213            section_header_health: None,
214            section_header_meetings: None,
215            table_headers: None,
216            date_format: None,
217            template_file: None,
218            file_path_format: None,
219            list_type: None,
220            section_name: None,
221            weekly_format: None,
222            monthly_format: None,
223            quarterly_format: None,
224            yearly_format: None,
225            note_format: None,
226        }
227    }
228
229    /// Get the section header for a category, falling back to default section_header
230    pub fn get_section_header(&self, category: Option<&str>) -> Option<&String> {
231        if let Some(cat) = category {
232            // Try category-specific section headers
233            match cat {
234                "work" => return self.section_header_work.as_ref(),
235                "personal" => return self.section_header_personal.as_ref(),
236                "health" => return self.section_header_health.as_ref(),
237                "meetings" => return self.section_header_meetings.as_ref(),
238                _ => {} // Unknown category, fall through to default
239            }
240        }
241        // Fall back to default section_header
242        self.section_header.as_ref().or(self.section_name.as_ref())
243    }
244
245    /// Create a test VaultConfig with minimal required fields
246    pub fn test_config(name: &str, path: &str) -> Self {
247        Self {
248            name: name.to_string(),
249            path: std::path::PathBuf::from(path),
250            locale: "en-US".to_string(),
251            phrases: HashMap::new(),
252            section_header: None,
253            section_header_work: None,
254            section_header_personal: None,
255            section_header_health: None,
256            section_header_meetings: None,
257            table_headers: None,
258            date_format: None,
259            template_file: None,
260            file_path_format: None,
261            list_type: None,
262            section_name: None,
263            weekly_format: None,
264            monthly_format: None,
265            quarterly_format: None,
266            yearly_format: None,
267            note_format: None,
268        }
269    }
270}
271
272impl Config {
273    pub fn new() -> Self {
274        Self::default()
275    }
276
277    pub fn add_vault(&mut self, vault: VaultConfig) {
278        self.vaults.insert(vault.name.clone(), vault);
279    }
280
281    pub fn get_vault(&self, name: &str) -> Option<&VaultConfig> {
282        self.vaults.get(name)
283    }
284
285    pub fn get_default_vault(&self) -> Option<&VaultConfig> {
286        if let Some(default_name) = &self.default_vault {
287            self.vaults.get(default_name)
288        } else {
289            // Fallback to first vault if no default is set
290            self.vaults.values().next()
291        }
292    }
293
294    pub fn set_default_vault(&mut self, vault_name: &str) -> Result<(), String> {
295        if self.vaults.contains_key(vault_name) {
296            self.default_vault = Some(vault_name.to_string());
297            Ok(())
298        } else {
299            Err(format!("Vault '{}' not found", vault_name))
300        }
301    }
302
303    pub fn clear_default_vault(&mut self) {
304        self.default_vault = None;
305    }
306
307    pub fn unset_default_vault(&mut self) {
308        self.default_vault = None;
309    }
310
311    pub fn remove_vault(&mut self, vault_name: &str) -> Result<(), String> {
312        if self.vaults.remove(vault_name).is_some() {
313            // If the removed vault was the default, clear the default
314            if let Some(default_name) = &self.default_vault {
315                if default_name == vault_name {
316                    self.default_vault = None;
317                }
318            }
319            Ok(())
320        } else {
321            Err(format!("Vault '{}' not found", vault_name))
322        }
323    }
324}
325