offline_intelligence/model_management/
storage.rs1use anyhow::{Context, Result};
9use serde::{Deserialize, Serialize};
10use std::path::PathBuf;
11use tracing::{debug, info};
12
13fn sanitize_model_id(model_id: &str) -> String {
17 let mut sanitized = model_id
18 .replace(':', "_")
19 .replace('/', "_")
20 .replace('\\', "_")
21 .replace('<', "_")
22 .replace('>', "_")
23 .replace('"', "_")
24 .replace('|', "_")
25 .replace('?', "_")
26 .replace('*', "_");
27
28 sanitized = sanitized.trim_end_matches('.').trim_end().to_string();
30
31 let upper_sanitized = sanitized.to_uppercase();
33 if is_windows_reserved_name(&upper_sanitized) {
34 sanitized.push_str("_model");
35 }
36
37 if sanitized.len() > 200 {
39 use std::collections::hash_map::DefaultHasher;
41 use std::hash::{Hash, Hasher};
42
43 let mut hasher = DefaultHasher::new();
44 sanitized.hash(&mut hasher);
45 let hash = format!("{:x}", hasher.finish());
46
47 let prefix_len = std::cmp::min(150, sanitized.len());
49 sanitized = format!("{}_{}", &sanitized[..prefix_len], hash);
50 }
51
52 sanitized
53}
54
55fn is_windows_reserved_name(name: &str) -> bool {
57 matches!(name,
58 "CON" | "PRN" | "AUX" | "NUL" |
59 "COM1" | "COM2" | "COM3" | "COM4" | "COM5" | "COM6" | "COM7" | "COM8" | "COM9" |
60 "LPT1" | "LPT2" | "LPT3" | "LPT4" | "LPT5" | "LPT6" | "LPT7" | "LPT8" | "LPT9"
61 )
62}
63
64#[derive(Debug, Clone)]
66pub struct StorageLocation {
67 pub app_data_dir: PathBuf,
69 pub models_dir: PathBuf,
71 pub registry_dir: PathBuf,
73}
74
75pub struct ModelStorage {
77 pub location: StorageLocation,
78}
79
80impl ModelStorage {
81 pub fn new() -> Result<Self> {
83 let location = Self::get_platform_storage_location()?;
84
85 Self::ensure_directories(&location)?;
87
88 info!(
89 "Model storage initialized at: {}",
90 location.models_dir.display()
91 );
92
93 Ok(Self { location })
94 }
95
96 fn get_platform_storage_location() -> Result<StorageLocation> {
98 let app_data_dir = if cfg!(target_os = "windows") {
99 dirs::data_dir()
101 .context("Failed to get APPDATA directory")?
102 .join("Aud.io")
103 } else if cfg!(target_os = "macos") {
104 dirs::data_dir()
106 .context("Failed to get Library directory")?
107 .join("Aud.io")
108 } else {
109 dirs::data_dir()
111 .context("Failed to get .local/share directory")?
112 .join("aud.io")
113 };
114
115 let models_dir = app_data_dir.join("models");
116 let registry_dir = app_data_dir.join("registry");
117
118 Ok(StorageLocation {
119 app_data_dir,
120 models_dir,
121 registry_dir,
122 })
123 }
124
125 fn ensure_directories(location: &StorageLocation) -> Result<()> {
127 std::fs::create_dir_all(&location.app_data_dir)
128 .context("Failed to create app data directory")?;
129 std::fs::create_dir_all(&location.models_dir)
130 .context("Failed to create models directory")?;
131 std::fs::create_dir_all(&location.registry_dir)
132 .context("Failed to create registry directory")?;
133
134 debug!("Created storage directories successfully");
135 Ok(())
136 }
137
138 pub fn model_path(&self, model_id: &str, filename: &str) -> PathBuf {
140 self.location
141 .models_dir
142 .join(sanitize_model_id(model_id))
143 .join(filename)
144 }
145
146 pub fn metadata_path(&self, model_id: &str) -> PathBuf {
148 self.location
149 .registry_dir
150 .join(format!("{}.json", sanitize_model_id(model_id)))
151 }
152
153 pub fn list_models(&self) -> Result<Vec<String>> {
155 let mut models = Vec::new();
156
157 if self.location.models_dir.exists() {
158 for entry in std::fs::read_dir(&self.location.models_dir)? {
159 let entry = entry?;
160 if entry.file_type()?.is_dir() {
161 if let Some(model_name) = entry.file_name().to_str() {
162 models.push(model_name.to_string());
163 }
164 }
165 }
166 }
167
168 Ok(models)
169 }
170
171 pub fn model_exists(&self, model_id: &str) -> bool {
173 self.location
174 .models_dir
175 .join(sanitize_model_id(model_id))
176 .exists()
177 }
178
179 pub fn get_storage_usage(&self) -> Result<u64> {
181 if !self.location.models_dir.exists() {
182 return Ok(0);
183 }
184
185 let mut total_size = 0u64;
186
187 for entry in walkdir::WalkDir::new(&self.location.models_dir) {
188 let entry = entry?;
189 if entry.file_type().is_file() {
190 total_size += entry.metadata()?.len();
191 }
192 }
193
194 Ok(total_size)
195 }
196
197 pub fn remove_model(&self, model_id: &str) -> Result<()> {
199 let model_path = self.location.models_dir.join(sanitize_model_id(model_id));
200 let metadata_path = self.metadata_path(model_id);
201
202 if model_path.exists() {
203 std::fs::remove_dir_all(model_path)?;
204 }
205
206 if metadata_path.exists() {
207 std::fs::remove_file(metadata_path)?;
208 }
209
210 info!("Removed model: {}", model_id);
211 Ok(())
212 }
213
214 pub fn create_model_directory(&self, model_id: &str) -> Result<PathBuf> {
216 let model_dir = self.location.models_dir.join(sanitize_model_id(model_id));
217 std::fs::create_dir_all(&model_dir)?;
218 Ok(model_dir)
219 }
220
221 pub fn get_available_space(&self) -> Result<u64> {
223 let target_path = if self.location.models_dir.exists() {
224 self.location.models_dir.clone()
225 } else {
226 self.location.app_data_dir.clone()
227 };
228 let space = fs2::available_space(&target_path)?;
229 Ok(space)
230 }
231}
232
233#[derive(Debug, Clone, Serialize, Deserialize)]
235pub struct ModelMetadata {
236 pub id: String,
237 pub name: String,
238 pub description: Option<String>,
239 pub author: Option<String>,
240 pub size_bytes: u64,
241 pub format: String,
242 pub download_source: String,
243 pub download_date: chrono::DateTime<chrono::Utc>,
244 pub last_used: Option<chrono::DateTime<chrono::Utc>>,
245 pub tags: Vec<String>,
246 pub hardware_requirements: HardwareRequirements,
247 pub compatibility_notes: Option<String>,
248 #[serde(default)]
250 pub runtime_binaries: std::collections::HashMap<String, std::path::PathBuf>,
251}
252
253#[derive(Debug, Clone, Serialize, Deserialize)]
255pub struct HardwareRequirements {
256 pub min_ram_gb: f32,
257 pub min_vram_gb: Option<f32>,
258 pub recommended_ram_gb: f32,
259 pub recommended_vram_gb: Option<f32>,
260 pub cpu_cores: Option<u32>,
261 pub gpu_required: bool,
262}
263
264impl Default for HardwareRequirements {
265 fn default() -> Self {
266 Self {
267 min_ram_gb: 4.0,
268 min_vram_gb: None,
269 recommended_ram_gb: 8.0,
270 recommended_vram_gb: None,
271 cpu_cores: None,
272 gpu_required: false,
273 }
274 }
275}
276
277#[cfg(test)]
278mod tests {
279 use super::*;
280 use tempfile::TempDir;
281
282 #[test]
283 fn test_storage_creation() -> Result<()> {
284 let temp_dir = TempDir::new()?;
286 let test_base_dir = temp_dir.path().join("test_aud_io");
287
288 let app_data_dir = test_base_dir.join("app_data");
290 let models_dir = app_data_dir.join("models");
291 let registry_dir = app_data_dir.join("registry");
292
293 std::fs::create_dir_all(&app_data_dir)?;
294 std::fs::create_dir_all(&models_dir)?;
295 std::fs::create_dir_all(®istry_dir)?;
296
297 let storage = ModelStorage {
299 location: StorageLocation {
300 app_data_dir,
301 models_dir,
302 registry_dir,
303 },
304 };
305
306 assert!(storage.location.app_data_dir.exists());
308 assert!(storage.location.models_dir.exists());
309 assert!(storage.location.registry_dir.exists());
310
311 Ok(())
312 }
313
314 #[test]
315 fn test_model_path_generation() -> Result<()> {
316 let temp_dir = TempDir::new()?;
317 let storage = ModelStorage {
318 location: StorageLocation {
319 app_data_dir: temp_dir.path().to_path_buf(),
320 models_dir: temp_dir.path().join("models"),
321 registry_dir: temp_dir.path().join("registry"),
322 },
323 };
324
325 let path = storage.model_path("test-model", "model.gguf");
326 assert_eq!(
327 path,
328 temp_dir
329 .path()
330 .join("models")
331 .join("test-model")
332 .join("model.gguf")
333 );
334
335 Ok(())
336 }
337}