1use 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 std::env::var("USER")
36 .or_else(|_| std::env::var("USERNAME")) .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 "*.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 "*.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 "*.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 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 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 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 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 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 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 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 static ENV_MUTEX: Mutex<()> = Mutex::new(());
302
303 #[test]
304 fn test_default_artist() {
305 let artist = default_artist();
306 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 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 config.set_value("default_artist", "TestArtist").unwrap();
369 assert_eq!(config.default_artist, "TestArtist");
370
371 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 let result = config.set_value("normalize_project_names", "invalid");
382 assert!(result.is_err());
383
384 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 let mut config = Config::new();
401 config.default_artist = "TestArtist".to_string();
402 config.save().unwrap();
403
404 let config_path = Config::config_path().unwrap();
406 assert!(config_path.exists());
407
408 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 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 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 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}