1use 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#[derive(Debug, Clone, Serialize, Deserialize)]
15pub struct ProfileInfo {
16 pub name: String,
18 pub description: Option<String>,
20 pub created_at: chrono::DateTime<chrono::Utc>,
22 pub modified_at: chrono::DateTime<chrono::Utc>,
24 pub tags: Vec<String>,
26 pub system: bool,
28}
29
30#[derive(Debug, Clone, Serialize, Deserialize)]
32pub struct ConfigProfile {
33 pub info: ProfileInfo,
35 pub config: CliConfig,
37}
38
39pub struct ProfileManager {
41 profiles_dir: PathBuf,
42 current_profile: Option<String>,
43 profiles_cache: HashMap<String, ConfigProfile>,
44}
45
46impl ProfileManager {
47 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 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 manager.load_current_profile()?;
65
66 if manager.list_profiles()?.is_empty() {
68 manager.create_default_profiles()?;
69 }
70
71 Ok(manager)
72 }
73
74 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 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 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 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 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 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 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 pub fn get_current_profile_name(&self) -> Option<&str> {
186 self.current_profile.as_deref()
187 }
188
189 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 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 profiles.sort_by(|a, b| a.name.cmp(&b.name));
242
243 Ok(profiles)
244 }
245
246 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 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 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 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 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 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 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 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(¤t_file).map_err(|e| {
398 CliError::file_operation("read", ¤t_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(¤t_file, current).map_err(|e| {
412 CliError::file_operation("write", ¤t_file.display().to_string(), e)
413 })?;
414 } else {
415 if current_file.exists() {
417 fs::remove_file(¤t_file).map_err(|e| {
418 CliError::file_operation("delete", ¤t_file.display().to_string(), e)
419 })?;
420 }
421 }
422 Ok(())
423 }
424
425 fn create_default_profiles(&mut self) -> Result<()> {
426 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 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 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 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}