Skip to main content

cbtop/profile_persistence/
mod.rs

1//! Profile Persistence and Rotation (PMAT-028)
2//!
3//! Configuration profile management with save/load/switch/export capabilities
4//! for different workload scenarios.
5//!
6//! # Features
7//!
8//! - Named profiles for different workloads (ml_training, inference, stress_test)
9//! - Profile save/load/list/export operations
10//! - CLI overlay merging (CLI > profile > default)
11//! - TOML-based serialization
12//!
13//! # Falsification Criteria (F1201-F1210)
14//!
15//! See `tests/profile_persistence_f1201.rs` for falsification tests.
16
17mod config;
18mod overlay;
19
20pub use config::{BackendConfig, ProfileConfig, WorkloadConfig};
21pub use overlay::{templates, ProfileOverlay};
22
23use config::validate_profile_name;
24use std::collections::HashMap;
25use std::path::{Path, PathBuf};
26
27/// Profile persistence error
28#[derive(Debug, Clone, PartialEq)]
29pub enum ProfileError {
30    /// Profile not found
31    NotFound(String),
32    /// Invalid profile name (contains invalid characters)
33    InvalidName(String),
34    /// IO error during read/write
35    IoError(String),
36    /// TOML parse error
37    ParseError(String),
38    /// Profile directory creation failed
39    DirectoryError(String),
40}
41
42impl std::fmt::Display for ProfileError {
43    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
44        match self {
45            Self::NotFound(name) => write!(f, "Profile not found: {}", name),
46            Self::InvalidName(msg) => write!(f, "Invalid profile name: {}", msg),
47            Self::IoError(msg) => write!(f, "IO error: {}", msg),
48            Self::ParseError(msg) => write!(f, "Parse error: {}", msg),
49            Self::DirectoryError(msg) => write!(f, "Directory error: {}", msg),
50        }
51    }
52}
53
54impl std::error::Error for ProfileError {}
55
56/// Result type for profile operations
57pub type ProfileResult<T> = Result<T, ProfileError>;
58
59/// Profile manager for save/load/list operations
60#[derive(Debug, Clone)]
61pub struct ProfileManager {
62    /// Profile directory
63    profile_dir: PathBuf,
64    /// Cached profiles
65    cache: HashMap<String, ProfileConfig>,
66    /// Default profile name
67    default_profile: Option<String>,
68}
69
70impl ProfileManager {
71    /// Create a new profile manager with the given directory
72    pub fn new(profile_dir: PathBuf) -> Self {
73        Self {
74            profile_dir,
75            cache: HashMap::new(),
76            default_profile: None,
77        }
78    }
79
80    /// Create with default profile directory (~/.config/cbtop/profiles)
81    pub fn with_default_dir() -> Self {
82        let profile_dir = dirs::config_dir()
83            .unwrap_or_else(|| PathBuf::from("."))
84            .join("cbtop")
85            .join("profiles");
86        Self::new(profile_dir)
87    }
88
89    /// Get profile directory
90    pub fn profile_dir(&self) -> &Path {
91        &self.profile_dir
92    }
93
94    /// Set default profile name
95    pub fn set_default(&mut self, name: &str) {
96        self.default_profile = Some(name.to_string());
97    }
98
99    /// Get default profile name
100    pub fn default_profile(&self) -> Option<&str> {
101        self.default_profile.as_deref()
102    }
103
104    /// Ensure profile directory exists
105    pub fn ensure_directory(&self) -> ProfileResult<()> {
106        if !self.profile_dir.exists() {
107            std::fs::create_dir_all(&self.profile_dir)
108                .map_err(|e| ProfileError::DirectoryError(e.to_string()))?;
109        }
110        Ok(())
111    }
112
113    /// Save a profile to disk
114    pub fn save_profile(&mut self, profile: &ProfileConfig) -> ProfileResult<PathBuf> {
115        self.ensure_directory()?;
116
117        let filename = format!("{}.toml", profile.name);
118        let path = self.profile_dir.join(&filename);
119
120        let toml_content = profile.to_toml()?;
121        std::fs::write(&path, toml_content).map_err(|e| ProfileError::IoError(e.to_string()))?;
122
123        // Update cache
124        self.cache.insert(profile.name.clone(), profile.clone());
125
126        Ok(path)
127    }
128
129    /// Load a profile by name
130    pub fn load_profile(&mut self, name: &str) -> ProfileResult<ProfileConfig> {
131        validate_profile_name(name)?;
132
133        // Check cache first
134        if let Some(profile) = self.cache.get(name) {
135            return Ok(profile.clone());
136        }
137
138        let filename = format!("{}.toml", name);
139        let path = self.profile_dir.join(&filename);
140
141        if !path.exists() {
142            return Err(ProfileError::NotFound(name.to_string()));
143        }
144
145        let content =
146            std::fs::read_to_string(&path).map_err(|e| ProfileError::IoError(e.to_string()))?;
147
148        let profile = ProfileConfig::from_toml(&content)?;
149
150        // Update cache
151        self.cache.insert(name.to_string(), profile.clone());
152
153        Ok(profile)
154    }
155
156    /// Load default profile or return default config
157    pub fn load_default(&mut self) -> ProfileConfig {
158        if let Some(name) = &self.default_profile.clone() {
159            if let Ok(profile) = self.load_profile(name) {
160                return profile;
161            }
162        }
163        ProfileConfig::default()
164    }
165
166    /// List all available profiles
167    pub fn list_profiles(&self) -> ProfileResult<Vec<String>> {
168        if !self.profile_dir.exists() {
169            return Ok(vec![]);
170        }
171
172        let entries = std::fs::read_dir(&self.profile_dir)
173            .map_err(|e| ProfileError::IoError(e.to_string()))?;
174
175        let mut profiles = Vec::new();
176        for entry in entries.flatten() {
177            let path = entry.path();
178            if path.extension().map_or(false, |ext| ext == "toml") {
179                if let Some(stem) = path.file_stem() {
180                    if let Some(name) = stem.to_str() {
181                        profiles.push(name.to_string());
182                    }
183                }
184            }
185        }
186
187        profiles.sort();
188        Ok(profiles)
189    }
190
191    /// Delete a profile by name
192    pub fn delete_profile(&mut self, name: &str) -> ProfileResult<()> {
193        validate_profile_name(name)?;
194
195        let filename = format!("{}.toml", name);
196        let path = self.profile_dir.join(&filename);
197
198        if !path.exists() {
199            return Err(ProfileError::NotFound(name.to_string()));
200        }
201
202        std::fs::remove_file(&path).map_err(|e| ProfileError::IoError(e.to_string()))?;
203
204        // Remove from cache
205        self.cache.remove(name);
206
207        Ok(())
208    }
209
210    /// Export a profile to a specific path
211    pub fn export_profile(&self, name: &str, export_path: &Path) -> ProfileResult<()> {
212        let profile = if let Some(cached) = self.cache.get(name) {
213            cached.clone()
214        } else {
215            let filename = format!("{}.toml", name);
216            let path = self.profile_dir.join(&filename);
217
218            if !path.exists() {
219                return Err(ProfileError::NotFound(name.to_string()));
220            }
221
222            let content =
223                std::fs::read_to_string(&path).map_err(|e| ProfileError::IoError(e.to_string()))?;
224
225            ProfileConfig::from_toml(&content)?
226        };
227
228        let toml_content = profile.to_toml()?;
229        std::fs::write(export_path, toml_content).map_err(|e| ProfileError::IoError(e.to_string()))
230    }
231
232    /// Import a profile from a specific path
233    pub fn import_profile(&mut self, import_path: &Path) -> ProfileResult<ProfileConfig> {
234        if !import_path.exists() {
235            return Err(ProfileError::NotFound(import_path.display().to_string()));
236        }
237
238        let content = std::fs::read_to_string(import_path)
239            .map_err(|e| ProfileError::IoError(e.to_string()))?;
240
241        let profile = ProfileConfig::from_toml(&content)?;
242
243        // Save to local profile directory
244        self.save_profile(&profile)?;
245
246        Ok(profile)
247    }
248
249    /// Check if a profile exists
250    pub fn profile_exists(&self, name: &str) -> bool {
251        if self.cache.contains_key(name) {
252            return true;
253        }
254
255        let filename = format!("{}.toml", name);
256        let path = self.profile_dir.join(&filename);
257        path.exists()
258    }
259
260    /// Get profile count
261    pub fn profile_count(&self) -> ProfileResult<usize> {
262        self.list_profiles().map(|p| p.len())
263    }
264
265    /// Clear cache
266    pub fn clear_cache(&mut self) {
267        self.cache.clear();
268    }
269}
270
271#[cfg(test)]
272mod tests;