Skip to main content

voirs_cli/config/
profiles.rs

1//! Configuration profile management.
2//!
3//! Provides support for multiple configuration profiles, allowing users to
4//! quickly switch between different settings for different use cases.
5
6use crate::config::{CliConfig, ConfigManager};
7use crate::error::{CliError, Result};
8use serde::{Deserialize, Serialize};
9use std::collections::HashMap;
10use std::fs;
11use std::path::{Path, PathBuf};
12
13/// Profile metadata
14#[derive(Debug, Clone, Serialize, Deserialize)]
15pub struct ProfileInfo {
16    /// Profile name
17    pub name: String,
18    /// Profile description
19    pub description: Option<String>,
20    /// Creation timestamp
21    pub created_at: chrono::DateTime<chrono::Utc>,
22    /// Last modified timestamp
23    pub modified_at: chrono::DateTime<chrono::Utc>,
24    /// Tags for categorization
25    pub tags: Vec<String>,
26    /// Whether this is a system/built-in profile
27    pub system: bool,
28}
29
30/// Profile configuration container
31#[derive(Debug, Clone, Serialize, Deserialize)]
32pub struct ConfigProfile {
33    /// Profile metadata
34    pub info: ProfileInfo,
35    /// The actual configuration
36    pub config: CliConfig,
37}
38
39/// Profile manager for handling multiple configurations
40pub struct ProfileManager {
41    profiles_dir: PathBuf,
42    current_profile: Option<String>,
43    profiles_cache: HashMap<String, ConfigProfile>,
44}
45
46impl ProfileManager {
47    /// Create a new profile manager
48    pub fn new() -> Result<Self> {
49        let profiles_dir = Self::get_profiles_directory()
50            .ok_or_else(|| CliError::config("Cannot determine profiles directory"))?;
51
52        // Create profiles directory if it doesn't exist
53        fs::create_dir_all(&profiles_dir).map_err(|e| {
54            CliError::file_operation("create directory", &profiles_dir.display().to_string(), e)
55        })?;
56
57        let mut manager = Self {
58            profiles_dir,
59            current_profile: None,
60            profiles_cache: HashMap::new(),
61        };
62
63        // Load current profile selection
64        manager.load_current_profile()?;
65
66        // Initialize with default profiles if none exist
67        if manager.list_profiles()?.is_empty() {
68            manager.create_default_profiles()?;
69        }
70
71        Ok(manager)
72    }
73
74    /// Create a new profile
75    pub fn create_profile(
76        &mut self,
77        name: &str,
78        description: Option<String>,
79        config: CliConfig,
80    ) -> Result<()> {
81        if self.profile_exists(name) {
82            return Err(CliError::config(format!(
83                "Profile '{}' already exists",
84                name
85            )));
86        }
87
88        if !Self::is_valid_profile_name(name) {
89            return Err(CliError::config(
90                "Profile name must contain only alphanumeric characters, hyphens, and underscores",
91            ));
92        }
93
94        let profile = ConfigProfile {
95            info: ProfileInfo {
96                name: name.to_string(),
97                description,
98                created_at: chrono::Utc::now(),
99                modified_at: chrono::Utc::now(),
100                tags: Vec::new(),
101                system: false,
102            },
103            config,
104        };
105
106        self.save_profile(&profile)?;
107        self.profiles_cache.insert(name.to_string(), profile);
108
109        Ok(())
110    }
111
112    /// Update an existing profile
113    pub fn update_profile(&mut self, name: &str, config: CliConfig) -> Result<()> {
114        let mut profile = self.load_profile(name)?;
115        profile.config = config;
116        profile.info.modified_at = chrono::Utc::now();
117
118        self.save_profile(&profile)?;
119        self.profiles_cache.insert(name.to_string(), profile);
120
121        Ok(())
122    }
123
124    /// Delete a profile
125    pub fn delete_profile(&mut self, name: &str) -> Result<()> {
126        if !self.profile_exists(name) {
127            return Err(CliError::config(format!(
128                "Profile '{}' does not exist",
129                name
130            )));
131        }
132
133        // Check if it's a system profile
134        if let Ok(profile) = self.load_profile(name) {
135            if profile.info.system {
136                return Err(CliError::config(format!(
137                    "Cannot delete system profile '{}'",
138                    name
139                )));
140            }
141        }
142
143        // Don't allow deleting the current profile without switching first
144        if self.current_profile.as_ref() == Some(&name.to_string()) {
145            return Err(CliError::config(
146                "Cannot delete the currently active profile. Switch to another profile first.",
147            ));
148        }
149
150        let profile_path = self.get_profile_path(name);
151        fs::remove_file(&profile_path).map_err(|e| {
152            CliError::file_operation("delete", &profile_path.display().to_string(), e)
153        })?;
154
155        self.profiles_cache.remove(name);
156
157        Ok(())
158    }
159
160    /// Switch to a different profile
161    pub fn switch_profile(&mut self, name: &str) -> Result<()> {
162        if !self.profile_exists(name) {
163            return Err(CliError::config(format!(
164                "Profile '{}' does not exist",
165                name
166            )));
167        }
168
169        self.current_profile = Some(name.to_string());
170        self.save_current_profile()?;
171
172        Ok(())
173    }
174
175    /// Get the current profile
176    pub fn get_current_profile(&self) -> Result<Option<ConfigProfile>> {
177        if let Some(ref name) = self.current_profile {
178            Ok(Some(self.load_profile(name)?))
179        } else {
180            Ok(None)
181        }
182    }
183
184    /// Get current profile name
185    pub fn get_current_profile_name(&self) -> Option<&str> {
186        self.current_profile.as_deref()
187    }
188
189    /// Load a specific profile
190    pub fn load_profile(&self, name: &str) -> Result<ConfigProfile> {
191        if let Some(profile) = self.profiles_cache.get(name) {
192            return Ok(profile.clone());
193        }
194
195        let profile_path = self.get_profile_path(name);
196        if !profile_path.exists() {
197            return Err(CliError::config(format!(
198                "Profile '{}' does not exist",
199                name
200            )));
201        }
202
203        let content = fs::read_to_string(&profile_path).map_err(|e| {
204            CliError::file_operation("read", &profile_path.display().to_string(), e)
205        })?;
206
207        let profile: ConfigProfile = toml::from_str(&content).map_err(|e| {
208            CliError::config(format!("Invalid profile format for '{}': {}", name, e))
209        })?;
210
211        Ok(profile)
212    }
213
214    /// List all available profiles
215    pub fn list_profiles(&self) -> Result<Vec<ProfileInfo>> {
216        let mut profiles = Vec::new();
217
218        let entries = fs::read_dir(&self.profiles_dir).map_err(|e| {
219            CliError::file_operation(
220                "read directory",
221                &self.profiles_dir.display().to_string(),
222                e,
223            )
224        })?;
225
226        for entry in entries {
227            let entry =
228                entry.map_err(|e| CliError::file_operation("read directory entry", "", e))?;
229            let path = entry.path();
230
231            if path.extension().and_then(|s| s.to_str()) == Some("toml") {
232                if let Some(name) = path.file_stem().and_then(|s| s.to_str()) {
233                    if let Ok(profile) = self.load_profile(name) {
234                        profiles.push(profile.info);
235                    }
236                }
237            }
238        }
239
240        // Sort by name
241        profiles.sort_by(|a, b| a.name.cmp(&b.name));
242
243        Ok(profiles)
244    }
245
246    /// Copy an existing profile with a new name
247    pub fn copy_profile(
248        &mut self,
249        source: &str,
250        target: &str,
251        description: Option<String>,
252    ) -> Result<()> {
253        if !self.profile_exists(source) {
254            return Err(CliError::config(format!(
255                "Source profile '{}' does not exist",
256                source
257            )));
258        }
259
260        if self.profile_exists(target) {
261            return Err(CliError::config(format!(
262                "Target profile '{}' already exists",
263                target
264            )));
265        }
266
267        if !Self::is_valid_profile_name(target) {
268            return Err(CliError::config(
269                "Profile name must contain only alphanumeric characters, hyphens, and underscores",
270            ));
271        }
272
273        let source_profile = self.load_profile(source)?;
274        let mut new_profile = source_profile.clone();
275        new_profile.info.name = target.to_string();
276        new_profile.info.description = description;
277        new_profile.info.created_at = chrono::Utc::now();
278        new_profile.info.modified_at = chrono::Utc::now();
279        new_profile.info.system = false;
280
281        self.save_profile(&new_profile)?;
282        self.profiles_cache.insert(target.to_string(), new_profile);
283
284        Ok(())
285    }
286
287    /// Export a profile to a file
288    pub fn export_profile(&self, name: &str, export_path: &Path) -> Result<()> {
289        let profile = self.load_profile(name)?;
290
291        let content = toml::to_string_pretty(&profile)
292            .map_err(|e| CliError::config(format!("Failed to serialize profile: {}", e)))?;
293
294        fs::write(export_path, content).map_err(|e| {
295            CliError::file_operation("write", &export_path.display().to_string(), e)
296        })?;
297
298        Ok(())
299    }
300
301    /// Import a profile from a file
302    pub fn import_profile(&mut self, import_path: &Path, name: Option<&str>) -> Result<()> {
303        let content = fs::read_to_string(import_path)
304            .map_err(|e| CliError::file_operation("read", &import_path.display().to_string(), e))?;
305
306        let mut profile: ConfigProfile = toml::from_str(&content)
307            .map_err(|e| CliError::config(format!("Invalid profile format: {}", e)))?;
308
309        // Use provided name or the one in the file
310        let final_name = name
311            .map(|s| s.to_string())
312            .unwrap_or_else(|| profile.info.name.clone());
313
314        if self.profile_exists(&final_name) {
315            return Err(CliError::config(format!(
316                "Profile '{}' already exists",
317                final_name
318            )));
319        }
320
321        profile.info.name = final_name.clone();
322        profile.info.created_at = chrono::Utc::now();
323        profile.info.modified_at = chrono::Utc::now();
324        profile.info.system = false;
325
326        self.save_profile(&profile)?;
327        self.profiles_cache.insert(final_name.clone(), profile);
328
329        Ok(())
330    }
331
332    /// Add tags to a profile
333    pub fn add_tags(&mut self, name: &str, tags: Vec<String>) -> Result<()> {
334        let mut profile = self.load_profile(name)?;
335        for tag in tags {
336            if !profile.info.tags.contains(&tag) {
337                profile.info.tags.push(tag);
338            }
339        }
340        profile.info.modified_at = chrono::Utc::now();
341
342        self.save_profile(&profile)?;
343        self.profiles_cache.insert(name.to_string(), profile);
344
345        Ok(())
346    }
347
348    /// Remove tags from a profile
349    pub fn remove_tags(&mut self, name: &str, tags: Vec<String>) -> Result<()> {
350        let mut profile = self.load_profile(name)?;
351        profile.info.tags.retain(|tag| !tags.contains(tag));
352        profile.info.modified_at = chrono::Utc::now();
353
354        self.save_profile(&profile)?;
355        self.profiles_cache.insert(name.to_string(), profile);
356
357        Ok(())
358    }
359
360    /// Search profiles by tags
361    pub fn find_profiles_by_tags(&self, tags: &[String]) -> Result<Vec<ProfileInfo>> {
362        let all_profiles = self.list_profiles()?;
363        let matching_profiles = all_profiles
364            .into_iter()
365            .filter(|profile| tags.iter().any(|tag| profile.tags.contains(tag)))
366            .collect();
367
368        Ok(matching_profiles)
369    }
370
371    // Private helper methods
372
373    fn profile_exists(&self, name: &str) -> bool {
374        self.get_profile_path(name).exists()
375    }
376
377    fn get_profile_path(&self, name: &str) -> PathBuf {
378        self.profiles_dir.join(format!("{}.toml", name))
379    }
380
381    fn save_profile(&self, profile: &ConfigProfile) -> Result<()> {
382        let profile_path = self.get_profile_path(&profile.info.name);
383
384        let content = toml::to_string_pretty(profile)
385            .map_err(|e| CliError::config(format!("Failed to serialize profile: {}", e)))?;
386
387        fs::write(&profile_path, content).map_err(|e| {
388            CliError::file_operation("write", &profile_path.display().to_string(), e)
389        })?;
390
391        Ok(())
392    }
393
394    fn load_current_profile(&mut self) -> Result<()> {
395        let current_file = self.profiles_dir.join("current");
396        if current_file.exists() {
397            let content = fs::read_to_string(&current_file).map_err(|e| {
398                CliError::file_operation("read", &current_file.display().to_string(), e)
399            })?;
400            let name = content.trim();
401            if self.profile_exists(name) {
402                self.current_profile = Some(name.to_string());
403            }
404        }
405        Ok(())
406    }
407
408    fn save_current_profile(&self) -> Result<()> {
409        let current_file = self.profiles_dir.join("current");
410        if let Some(ref current) = self.current_profile {
411            fs::write(&current_file, current).map_err(|e| {
412                CliError::file_operation("write", &current_file.display().to_string(), e)
413            })?;
414        } else {
415            // Remove current file if no profile is selected
416            if current_file.exists() {
417                fs::remove_file(&current_file).map_err(|e| {
418                    CliError::file_operation("delete", &current_file.display().to_string(), e)
419                })?;
420            }
421        }
422        Ok(())
423    }
424
425    fn create_default_profiles(&mut self) -> Result<()> {
426        // Default profile
427        let default_profile = ConfigProfile {
428            info: ProfileInfo {
429                name: "default".to_string(),
430                description: Some("Default VoiRS configuration".to_string()),
431                created_at: chrono::Utc::now(),
432                modified_at: chrono::Utc::now(),
433                tags: vec!["system".to_string()],
434                system: true,
435            },
436            config: CliConfig::default(),
437        };
438        self.save_profile(&default_profile)?;
439
440        // High-quality profile
441        let mut hq_config = CliConfig::default();
442        hq_config.cli.default_quality = "ultra".to_string();
443        hq_config.cli.default_output_format = "flac".to_string();
444        let hq_profile = ConfigProfile {
445            info: ProfileInfo {
446                name: "high-quality".to_string(),
447                description: Some("High-quality synthesis with FLAC output".to_string()),
448                created_at: chrono::Utc::now(),
449                modified_at: chrono::Utc::now(),
450                tags: vec!["system".to_string(), "quality".to_string()],
451                system: true,
452            },
453            config: hq_config,
454        };
455        self.save_profile(&hq_profile)?;
456
457        // Fast profile
458        let mut fast_config = CliConfig::default();
459        fast_config.cli.default_quality = "low".to_string();
460        fast_config.cli.show_progress = false;
461        let fast_profile = ConfigProfile {
462            info: ProfileInfo {
463                name: "fast".to_string(),
464                description: Some("Fast synthesis for quick testing".to_string()),
465                created_at: chrono::Utc::now(),
466                modified_at: chrono::Utc::now(),
467                tags: vec!["system".to_string(), "speed".to_string()],
468                system: true,
469            },
470            config: fast_config,
471        };
472        self.save_profile(&fast_profile)?;
473
474        // Set default as current if no current profile is set
475        if self.current_profile.is_none() {
476            self.current_profile = Some("default".to_string());
477            self.save_current_profile()?;
478        }
479
480        Ok(())
481    }
482
483    fn is_valid_profile_name(name: &str) -> bool {
484        !name.is_empty()
485            && name.len() <= 50
486            && name
487                .chars()
488                .all(|c| c.is_alphanumeric() || c == '-' || c == '_')
489    }
490
491    fn get_profiles_directory() -> Option<PathBuf> {
492        if let Ok(xdg_config) = std::env::var("XDG_CONFIG_HOME") {
493            Some(PathBuf::from(xdg_config).join("voirs").join("profiles"))
494        } else if let Ok(home) = std::env::var("HOME") {
495            Some(
496                PathBuf::from(home)
497                    .join(".config")
498                    .join("voirs")
499                    .join("profiles"),
500            )
501        } else if let Ok(appdata) = std::env::var("APPDATA") {
502            Some(PathBuf::from(appdata).join("voirs").join("profiles"))
503        } else {
504            None
505        }
506    }
507}
508
509#[cfg(test)]
510mod tests {
511    use super::*;
512    use tempfile::tempdir;
513
514    #[test]
515    fn test_profile_name_validation() {
516        assert!(ProfileManager::is_valid_profile_name("default"));
517        assert!(ProfileManager::is_valid_profile_name("test-profile"));
518        assert!(ProfileManager::is_valid_profile_name("test_profile"));
519        assert!(ProfileManager::is_valid_profile_name("profile123"));
520
521        assert!(!ProfileManager::is_valid_profile_name(""));
522        assert!(!ProfileManager::is_valid_profile_name(
523            "profile with spaces"
524        ));
525        assert!(!ProfileManager::is_valid_profile_name("profile.dot"));
526        assert!(!ProfileManager::is_valid_profile_name("profile/slash"));
527    }
528
529    #[test]
530    fn test_profile_info_creation() {
531        let info = ProfileInfo {
532            name: "test".to_string(),
533            description: Some("Test profile".to_string()),
534            created_at: chrono::Utc::now(),
535            modified_at: chrono::Utc::now(),
536            tags: vec!["test".to_string()],
537            system: false,
538        };
539
540        assert_eq!(info.name, "test");
541        assert_eq!(info.description, Some("Test profile".to_string()));
542        assert!(!info.system);
543        assert_eq!(info.tags.len(), 1);
544    }
545}