Skip to main content

zim_studio/
config.rs

1//! Application configuration management.
2//!
3//! This module handles the persistent configuration for zim-studio, including
4//! the root directory for projects, default folders to create, artist information,
5//! and various preferences. Configuration is stored in the user's config directory
6//! (typically ~/.config/zim/config.toml) and supports customization of project
7//! structure and behavior.
8
9use serde::{Deserialize, Serialize};
10use std::collections::HashMap;
11use std::error::Error;
12use std::fs;
13use std::path::PathBuf;
14
15#[derive(Debug, Serialize, Deserialize)]
16pub struct Config {
17    #[serde(default = "default_artist")]
18    pub default_artist: String,
19    #[serde(default = "default_folders")]
20    pub default_folders: Vec<String>,
21    #[serde(default = "default_gitignore")]
22    pub default_gitignore: Vec<String>,
23    #[serde(default = "default_include_readmes")]
24    pub include_readmes: bool,
25    #[serde(default = "default_normalize_project_names")]
26    pub normalize_project_names: bool,
27    #[serde(default = "default_daw_folders")]
28    pub daw_folders: Vec<String>,
29    #[serde(default = "default_tag_mappings")]
30    pub tag_mappings: HashMap<String, String>,
31}
32
33fn default_artist() -> String {
34    // Try to get username and capitalize first letter
35    std::env::var("USER")
36        .or_else(|_| std::env::var("USERNAME")) // Windows fallback
37        .ok()
38        .and_then(|name| {
39            if name.is_empty() {
40                None
41            } else {
42                let mut chars = name.chars();
43                chars.next().map(|first| {
44                    first.to_uppercase().collect::<String>() + &chars.as_str().to_lowercase()
45                })
46            }
47        })
48        .unwrap_or_default()
49}
50
51fn default_folders() -> Vec<String> {
52    vec![
53        "sources".to_string(),
54        "edits".to_string(),
55        "bounced".to_string(),
56        "mixes".to_string(),
57        "masters".to_string(),
58        "project".to_string(),
59    ]
60}
61
62fn default_gitignore() -> Vec<String> {
63    vec![
64        // Audio files
65        "*.wav".to_string(),
66        "*.flac".to_string(),
67        "*.aiff".to_string(),
68        "*.aif".to_string(),
69        "*.asd".to_string(),
70        "*.mp3".to_string(),
71        "*.m4a".to_string(),
72        // Visual media files
73        "*.jpg".to_string(),
74        "*.jpeg".to_string(),
75        "*.png".to_string(),
76        "*.gif".to_string(),
77        "*.mp4".to_string(),
78        "*.mov".to_string(),
79        "*.avi".to_string(),
80        "*.webm".to_string(),
81        "*.tiff".to_string(),
82        "*.bmp".to_string(),
83        "*.heic".to_string(),
84        "*.heif".to_string(),
85        // DAW temp files
86        "*.als~".to_string(),
87        "project/*/temp/".to_string(),
88    ]
89}
90
91fn default_include_readmes() -> bool {
92    true
93}
94
95fn default_normalize_project_names() -> bool {
96    true
97}
98
99fn default_daw_folders() -> Vec<String> {
100    vec![
101        "live".to_string(),
102        "reaper".to_string(),
103        "bitwig".to_string(),
104        "renoise".to_string(),
105    ]
106}
107
108fn default_tag_mappings() -> HashMap<String, String> {
109    let mut mappings = HashMap::new();
110    // Common audio interface/hardware patterns
111    mappings.insert("ES-9".to_string(), "eurorack".to_string());
112    mappings.insert("ES-8".to_string(), "eurorack".to_string());
113    mappings.insert("ES-3".to_string(), "eurorack".to_string());
114    mappings.insert("modular".to_string(), "eurorack".to_string());
115    // DAW patterns
116    mappings.insert("ableton".to_string(), "ableton-live".to_string());
117    mappings.insert("reaper".to_string(), "reaper".to_string());
118    mappings.insert("bitwig".to_string(), "bitwig".to_string());
119    mappings.insert("renoise".to_string(), "renoise".to_string());
120    // Common descriptors
121    mappings.insert("loop".to_string(), "loop".to_string());
122    mappings.insert("drum".to_string(), "drums".to_string());
123    mappings.insert("bass".to_string(), "bass".to_string());
124    mappings.insert("kick".to_string(), "drums".to_string());
125    mappings.insert("snare".to_string(), "drums".to_string());
126    mappings.insert("hihat".to_string(), "drums".to_string());
127    mappings.insert("synth".to_string(), "synth".to_string());
128    mappings.insert("vocal".to_string(), "vocals".to_string());
129    mappings.insert("vox".to_string(), "vocals".to_string());
130    mappings.insert("field-recording".to_string(), "field-recording".to_string());
131    mappings.insert("ambient".to_string(), "ambient".to_string());
132    mappings
133}
134
135fn default_zimignore_content() -> String {
136    r#"# ZIM Studio Default .zimignore
137#
138# This file defines patterns for files and directories that should be ignored
139# when running "zim update" to generate sidecar files. The syntax is similar
140# to .gitignore with support for glob patterns.
141
142# DAW Project Files and Directories
143project/live/
144project/reaper/
145project/bitwig/
146project/renoise/
147
148# DAW-specific temporary and backup files
149*.als-backup
150*.rpp-bak
151*.bwproject-backup
152*.xrns-backup
153
154# Common DAW auto-save and backup directories
155**/Backup/
156**/Auto Save/
157**/AutoSave/
158**/Ableton Project Info/
159
160# Cache and temporary directories
161**/.cache/
162**/temp/
163**/tmp/
164**/.tmp/
165
166# System files
167.DS_Store
168Thumbs.db
169desktop.ini
170
171# Log files
172*.log
173
174# Example: Keep important files even if they match patterns above
175# Use ! to negate patterns
176# !important.als
177# !project/live/important-session.als
178"#
179    .to_string()
180}
181
182impl Default for Config {
183    fn default() -> Self {
184        Self::new()
185    }
186}
187
188impl Config {
189    pub fn new() -> Self {
190        Self {
191            default_artist: default_artist(),
192            default_folders: default_folders(),
193            default_gitignore: default_gitignore(),
194            include_readmes: default_include_readmes(),
195            normalize_project_names: default_normalize_project_names(),
196            daw_folders: default_daw_folders(),
197            tag_mappings: default_tag_mappings(),
198        }
199    }
200
201    pub fn config_dir() -> Result<PathBuf, Box<dyn Error>> {
202        // Check for XDG_CONFIG_HOME first (useful for testing)
203        let config_dir = if let Ok(xdg_config) = std::env::var("XDG_CONFIG_HOME") {
204            PathBuf::from(xdg_config).join("zim")
205        } else {
206            dirs::config_dir()
207                .ok_or("Unable to find config directory")?
208                .join("zim")
209        };
210        Ok(config_dir)
211    }
212
213    pub fn config_path() -> Result<PathBuf, Box<dyn Error>> {
214        Ok(Self::config_dir()?.join("config.toml"))
215    }
216
217    pub fn default_zimignore_path() -> Result<PathBuf, Box<dyn Error>> {
218        Ok(Self::config_dir()?.join("default.zimignore"))
219    }
220
221    pub fn load() -> Result<Self, Box<dyn Error>> {
222        let config_path = Self::config_path()?;
223
224        if !config_path.exists() {
225            // Return default config instead of error
226            return Ok(Default::default());
227        }
228
229        let contents = fs::read_to_string(&config_path)?;
230        let config: Config = toml::from_str(&contents)?;
231        Ok(config)
232    }
233
234    pub fn save(&self) -> Result<(), Box<dyn Error>> {
235        let config_dir = Self::config_dir()?;
236
237        if !config_dir.exists() {
238            fs::create_dir_all(&config_dir)?;
239        }
240
241        let config_path = Self::config_path()?;
242        let toml_string = toml::to_string_pretty(self)?;
243        fs::write(&config_path, toml_string)?;
244
245        Ok(())
246    }
247
248    pub fn exists() -> Result<bool, Box<dyn Error>> {
249        Ok(Self::config_path()?.exists())
250    }
251
252    /// Create the default .zimignore template if it doesn't exist
253    pub fn ensure_default_zimignore() -> Result<(), Box<dyn Error>> {
254        let zimignore_path = Self::default_zimignore_path()?;
255
256        if !zimignore_path.exists() {
257            let config_dir = Self::config_dir()?;
258            if !config_dir.exists() {
259                fs::create_dir_all(&config_dir)?;
260            }
261
262            let default_content = default_zimignore_content();
263            fs::write(&zimignore_path, default_content)?;
264        }
265
266        Ok(())
267    }
268
269    /// Load the default .zimignore template content
270    pub fn load_default_zimignore() -> Result<String, Box<dyn Error>> {
271        let zimignore_path = Self::default_zimignore_path()?;
272
273        if zimignore_path.exists() {
274            Ok(fs::read_to_string(&zimignore_path)?)
275        } else {
276            Ok(default_zimignore_content())
277        }
278    }
279
280    pub fn set_value(&mut self, key: &str, value: &str) -> Result<(), Box<dyn Error>> {
281        match key {
282            "default_artist" => self.default_artist = value.to_string(),
283            "normalize_project_names" => {
284                self.normalize_project_names = value
285                    .parse::<bool>()
286                    .map_err(|_| "Value must be 'true' or 'false'")?;
287            }
288            _ => return Err(format!("Unknown configuration key: {key}").into()),
289        }
290        Ok(())
291    }
292}
293
294#[cfg(test)]
295mod tests {
296    use super::*;
297    use std::sync::Mutex;
298    use tempfile::TempDir;
299
300    // Use a mutex to ensure tests that modify environment variables don't run concurrently
301    static ENV_MUTEX: Mutex<()> = Mutex::new(());
302
303    #[test]
304    fn test_default_artist() {
305        let artist = default_artist();
306        // Should either be empty or start with uppercase
307        if !artist.is_empty() {
308            assert!(artist.chars().next().unwrap().is_uppercase());
309        }
310    }
311
312    #[test]
313    fn test_default_folders() {
314        let folders = default_folders();
315        assert!(folders.contains(&"sources".to_string()));
316        assert!(folders.contains(&"edits".to_string()));
317        assert!(folders.contains(&"bounced".to_string()));
318        assert!(folders.contains(&"mixes".to_string()));
319        assert!(folders.contains(&"masters".to_string()));
320        assert!(folders.contains(&"project".to_string()));
321        assert_eq!(folders.len(), 6);
322    }
323
324    #[test]
325    fn test_default_daw_folders() {
326        let folders = default_daw_folders();
327        assert!(folders.contains(&"live".to_string()));
328        assert!(folders.contains(&"reaper".to_string()));
329        assert!(folders.contains(&"bitwig".to_string()));
330        assert!(folders.contains(&"renoise".to_string()));
331    }
332
333    #[test]
334    fn test_default_gitignore() {
335        let gitignore = default_gitignore();
336        assert!(gitignore.contains(&"*.wav".to_string()));
337        assert!(gitignore.contains(&"*.aif".to_string()));
338        assert!(gitignore.contains(&"*.flac".to_string()));
339        assert!(gitignore.contains(&"*.mp3".to_string()));
340        assert!(gitignore.contains(&"*.jpg".to_string()));
341        // Check that it has many entries
342        assert!(gitignore.len() > 15);
343    }
344
345    #[test]
346    fn test_config_new() {
347        let config = Config::new();
348        assert_eq!(config.default_folders, default_folders());
349        assert_eq!(config.default_gitignore, default_gitignore());
350        assert!(config.include_readmes);
351        assert!(config.normalize_project_names);
352    }
353
354    #[test]
355    fn test_config_default() {
356        let config: Config = Default::default();
357        assert_eq!(config.default_folders, default_folders());
358        assert_eq!(config.default_gitignore, default_gitignore());
359        assert!(config.include_readmes);
360        assert!(config.normalize_project_names);
361    }
362
363    #[test]
364    fn test_set_value() {
365        let mut config = Config::new();
366
367        // Test default_artist
368        config.set_value("default_artist", "TestArtist").unwrap();
369        assert_eq!(config.default_artist, "TestArtist");
370
371        // Test normalize_project_names
372        config.set_value("normalize_project_names", "true").unwrap();
373        assert!(config.normalize_project_names);
374
375        config
376            .set_value("normalize_project_names", "false")
377            .unwrap();
378        assert!(!config.normalize_project_names);
379
380        // Test invalid boolean
381        let result = config.set_value("normalize_project_names", "invalid");
382        assert!(result.is_err());
383
384        // Test unknown key
385        let result = config.set_value("unknown_key", "value");
386        assert!(result.is_err());
387    }
388
389    #[test]
390    fn test_config_save_and_load() {
391        let _guard = ENV_MUTEX.lock().unwrap();
392
393        let temp_dir = TempDir::new().unwrap();
394        let original_xdg = std::env::var("XDG_CONFIG_HOME").ok();
395        unsafe {
396            std::env::set_var("XDG_CONFIG_HOME", temp_dir.path());
397        }
398
399        // Create a unique test config
400        let mut config = Config::new();
401        config.default_artist = "TestArtist".to_string();
402        config.save().unwrap();
403
404        // Verify the config file was created in the temp directory
405        let config_path = Config::config_path().unwrap();
406        assert!(config_path.exists());
407
408        // The path should be under temp_dir/zim/config.toml
409        let expected_dir = temp_dir.path().join("zim");
410        assert!(config_path.starts_with(&expected_dir));
411
412        let loaded = Config::load().unwrap();
413        assert_eq!(loaded.default_artist, "TestArtist");
414        assert_eq!(loaded.default_folders, default_folders());
415
416        // Clean up - restore original value if it existed
417        unsafe {
418            if let Some(original) = original_xdg {
419                std::env::set_var("XDG_CONFIG_HOME", original);
420            } else {
421                std::env::remove_var("XDG_CONFIG_HOME");
422            }
423        }
424    }
425
426    #[test]
427    fn test_config_exists() {
428        let _guard = ENV_MUTEX.lock().unwrap();
429
430        let temp_dir = TempDir::new().unwrap();
431        let original_xdg = std::env::var("XDG_CONFIG_HOME").ok();
432        unsafe {
433            std::env::set_var("XDG_CONFIG_HOME", temp_dir.path());
434        }
435
436        // Verify we're checking in the temp directory
437        let expected_path = temp_dir.path().join("zim").join("config.toml");
438        assert!(!expected_path.exists());
439        assert!(!Config::exists().unwrap());
440
441        let config = Config::new();
442        config.save().unwrap();
443
444        assert!(expected_path.exists());
445        assert!(Config::exists().unwrap());
446
447        // Clean up - restore original value if it existed
448        unsafe {
449            if let Some(original) = original_xdg {
450                std::env::set_var("XDG_CONFIG_HOME", original);
451            } else {
452                std::env::remove_var("XDG_CONFIG_HOME");
453            }
454        }
455    }
456}