1use std::path::PathBuf;
4
5use serde::{Deserialize, Serialize};
6
7#[derive(Debug, Clone, Serialize, Deserialize)]
9pub struct AppConfig {
10 pub theme: AppTheme,
11 pub thumbnail_size: u32,
12 pub face_detection_confidence: f32,
13 pub face_clustering_threshold: f32,
14 pub burst_time_window_seconds: i64,
15 pub trash_auto_delete_days: u32,
16 pub scan_hidden_folders: bool,
17 #[serde(default = "default_show_timeline_stacks")]
18 pub show_timeline_stacks: bool,
19 pub date_format: DateFormat,
20 pub remembered_drives: Vec<PathBuf>,
21 pub window_width: u32,
22 pub window_height: u32,
23 #[serde(default)]
26 pub window_maximized: bool,
27 pub sidebar_collapsed: bool,
28
29 #[serde(default = "default_map_cache_limit_mb")]
30 pub map_cache_limit_mb: u32,
31
32 #[serde(default = "default_weight_cooccurrence")]
35 pub weight_cooccurrence: f32,
36
37 #[serde(default = "default_weight_temporal")]
39 pub weight_temporal: f32,
40
41 #[serde(default = "default_memories_enabled")]
43 pub memories_enabled: bool,
44
45 #[serde(default)]
48 pub home_city_override: Option<String>,
49
50 #[serde(default)]
53 pub last_view: Option<String>,
54
55 #[serde(default)]
60 pub auto_update_check_enabled: bool,
61
62 #[serde(default)]
65 pub last_update_check_at_unix: Option<i64>,
66
67 #[serde(default = "default_show_first_run_update_prompt")]
70 pub show_first_run_update_prompt: bool,
71
72 #[serde(default)]
79 pub date_logic_version: u32,
80
81 #[serde(default)]
85 pub geonames_warning_seen: bool,
86
87 #[serde(default = "default_thumbnail_cache_gb")]
92 pub thumbnail_cache_gb: f64,
93
94 #[serde(default)]
96 pub face_gpu_bridge_url: Option<String>,
97
98 #[serde(default)]
100 pub face_gpu_bridge_enabled: bool,
101
102 #[serde(default)]
105 pub config_version: u32,
106
107 #[serde(default = "default_face_embedder_model")]
110 pub face_embedder_model: String,
111
112 #[serde(default = "default_assistant_enabled")]
115 pub assistant_enabled: bool,
116
117 #[serde(default)]
120 pub ai_features_enabled: bool,
121
122 #[serde(default = "default_assistant_provider")]
126 pub assistant_provider: String,
127
128 #[serde(default = "default_assistant_base_url")]
130 pub assistant_base_url: String,
131
132 #[serde(default = "default_assistant_model")]
134 pub assistant_model: String,
135
136 #[serde(default)]
138 pub assistant_api_key: Option<String>,
139}
140
141pub const CURRENT_DATE_LOGIC_VERSION: u32 = 2;
156
157fn default_show_first_run_update_prompt() -> bool {
158 true
159}
160
161fn default_weight_cooccurrence() -> f32 {
162 0.30
163}
164
165fn default_weight_temporal() -> f32 {
166 0.50
167}
168
169fn default_memories_enabled() -> bool {
170 true
171}
172
173fn default_map_cache_limit_mb() -> u32 {
174 500
175}
176
177fn default_thumbnail_cache_gb() -> f64 {
178 5.0
179}
180
181fn default_show_timeline_stacks() -> bool {
182 true
183}
184
185fn default_face_embedder_model() -> String {
186 "adaface_ir101_webface12m.onnx".to_string()
187}
188
189fn default_assistant_enabled() -> bool {
190 false
191}
192
193fn default_assistant_provider() -> String {
194 "openai_compatible".to_string()
195}
196
197fn default_assistant_base_url() -> String {
198 "https://openrouter.ai/api/v1".to_string()
199}
200
201fn default_assistant_model() -> String {
202 "deepseek/deepseek-v4-flash".to_string()
203}
204
205impl Default for AppConfig {
206 fn default() -> Self {
207 Self {
208 theme: AppTheme::Dark,
209 thumbnail_size: 500,
210 face_detection_confidence: 0.25,
211 face_clustering_threshold: 0.30,
212 burst_time_window_seconds: 3,
213 trash_auto_delete_days: 30,
214 scan_hidden_folders: false,
215 show_timeline_stacks: true,
216 date_format: DateFormat::Locale,
217 remembered_drives: Vec::new(),
218 window_width: 1600,
219 window_height: 1000,
220 window_maximized: false,
221 sidebar_collapsed: false,
222 map_cache_limit_mb: default_map_cache_limit_mb(),
223 weight_cooccurrence: default_weight_cooccurrence(),
224 weight_temporal: default_weight_temporal(),
225 memories_enabled: default_memories_enabled(),
226 home_city_override: None,
227 last_view: None,
228 auto_update_check_enabled: false,
229 last_update_check_at_unix: None,
230 show_first_run_update_prompt: true,
231 date_logic_version: 0,
232 geonames_warning_seen: false,
233 thumbnail_cache_gb: default_thumbnail_cache_gb(),
234 config_version: 4,
235 face_embedder_model: default_face_embedder_model(),
236 assistant_enabled: false,
237 ai_features_enabled: false,
238 assistant_provider: default_assistant_provider(),
239 assistant_base_url: default_assistant_base_url(),
240 assistant_model: default_assistant_model(),
241 assistant_api_key: None,
242 face_gpu_bridge_url: None,
243 face_gpu_bridge_enabled: false,
244 }
245 }
246}
247
248impl AppConfig {
249 pub fn load() -> Self {
251 let path = Self::config_path();
252 let read_path = if path.exists() {
255 path
256 } else {
257 let legacy = Self::legacy_config_path();
258 if legacy.exists() {
259 legacy
260 } else {
261 return Self::default();
262 }
263 };
264
265 match std::fs::read_to_string(&read_path) {
266 Ok(content) => match serde_json::from_str::<Self>(&content) {
267 Ok(mut cfg) => {
268 cfg.validate();
269 cfg.migrate_config_defaults();
270 cfg
271 }
272 Err(e) => {
273 tracing::warn!("Failed to parse config {}: {}", read_path.display(), e);
274 Self::default()
275 }
276 },
277 Err(e) => {
278 tracing::warn!("Failed to read config {}: {}", read_path.display(), e);
279 Self::default()
280 }
281 }
282 }
283
284 fn migrate_config_defaults(&mut self) {
287 if self.config_version < 1 {
288 if (self.face_clustering_threshold - 0.42).abs() < 0.001 {
291 self.face_clustering_threshold = 0.28;
292 }
293 self.config_version = 1;
294 }
295 if self.config_version < 2 {
296 if (self.face_clustering_threshold - 0.28).abs() < 0.001 {
298 self.face_clustering_threshold = 0.30;
299 }
300 self.config_version = 2;
301 }
302 if self.config_version < 3 {
303 self.face_embedder_model = default_face_embedder_model();
304 self.config_version = 3;
305 }
306 if self.config_version < 4 {
307 if self.assistant_provider == "local" {
308 self.assistant_provider = default_assistant_provider();
309 }
310 if self.assistant_base_url == "https://api.openai.com/v1" {
311 self.assistant_base_url = default_assistant_base_url();
312 }
313 if self.assistant_model == "gpt-4.1-mini" {
314 self.assistant_model = default_assistant_model();
315 }
316 self.config_version = 4;
317 }
318 }
319
320 pub fn validate(&mut self) {
322 if let Some(ref url) = self.face_gpu_bridge_url {
325 if !is_allowed_gpu_bridge_url(url) {
326 tracing::warn!(
327 "Invalid face_gpu_bridge_url '{}' — must use https:// or local http://. Disabling.",
328 url
329 );
330 self.face_gpu_bridge_url = None;
331 self.face_gpu_bridge_enabled = false;
332 }
333 }
334
335 if !self.face_detection_confidence.is_finite() {
336 self.face_detection_confidence = Self::default().face_detection_confidence;
337 }
338 if !self.face_clustering_threshold.is_finite() {
339 self.face_clustering_threshold = Self::default().face_clustering_threshold;
340 }
341 if !self.weight_cooccurrence.is_finite() {
342 self.weight_cooccurrence = default_weight_cooccurrence();
343 }
344 if !self.weight_temporal.is_finite() {
345 self.weight_temporal = default_weight_temporal();
346 }
347 if !self.thumbnail_cache_gb.is_finite() {
348 self.thumbnail_cache_gb = default_thumbnail_cache_gb();
349 }
350
351 self.face_detection_confidence = self.face_detection_confidence.clamp(0.1, 0.95);
352 self.face_clustering_threshold = self.face_clustering_threshold.clamp(0.1, 0.8);
353 self.thumbnail_size = self.thumbnail_size.clamp(100, 1000);
354 self.burst_time_window_seconds = self.burst_time_window_seconds.clamp(1, 30);
355 self.trash_auto_delete_days = self.trash_auto_delete_days.clamp(1, 365);
356 self.window_width = self.window_width.clamp(400, 7680);
357 self.window_height = self.window_height.clamp(300, 4320);
358 self.weight_cooccurrence = self.weight_cooccurrence.clamp(0.0, 2.0);
359 self.weight_temporal = self.weight_temporal.clamp(0.0, 2.0);
360 self.map_cache_limit_mb = self.map_cache_limit_mb.clamp(50, 10_000);
361 self.thumbnail_cache_gb = self.thumbnail_cache_gb.clamp(0.5, 100.0);
362 if self.face_embedder_model != default_face_embedder_model() {
363 self.face_embedder_model = default_face_embedder_model();
364 }
365 if !matches!(
366 self.assistant_provider.as_str(),
367 "local" | "openai_compatible"
368 ) {
369 self.assistant_provider = default_assistant_provider();
370 }
371 if !self.assistant_base_url.starts_with("https://")
372 && !self.assistant_base_url.starts_with("http://localhost")
373 && !self.assistant_base_url.starts_with("http://127.0.0.1")
374 {
375 self.assistant_base_url = default_assistant_base_url();
376 }
377 if self.assistant_model.trim().is_empty() {
378 self.assistant_model = default_assistant_model();
379 }
380 if self
381 .assistant_api_key
382 .as_deref()
383 .is_some_and(|s| s.trim().is_empty())
384 {
385 self.assistant_api_key = None;
386 }
387 }
388
389 pub fn save(&self) -> std::io::Result<()> {
391 let path = Self::config_path();
392 if let Some(parent) = path.parent() {
393 std::fs::create_dir_all(parent)?;
394 }
395 let content = serde_json::to_string_pretty(self)?;
396 std::fs::write(path, content)
397 }
398
399 pub fn config_path() -> PathBuf {
401 dirs::config_dir()
402 .unwrap_or_else(|| PathBuf::from("."))
403 .join("smriti")
404 .join("config.json")
405 }
406
407 fn legacy_config_path() -> PathBuf {
411 dirs::config_dir()
412 .unwrap_or_else(|| PathBuf::from("."))
413 .join("photovault")
414 .join("config.json")
415 }
416
417 pub fn remember_drive(&mut self, path: PathBuf) {
419 if !self.remembered_drives.contains(&path) {
420 self.remembered_drives.push(path);
421 }
422 }
423}
424
425pub fn is_allowed_gpu_bridge_url(url: &str) -> bool {
426 if url.starts_with("https://") {
427 return true;
428 }
429 let Some(rest) = url.strip_prefix("http://") else {
430 return false;
431 };
432 let host_port = rest.split('/').next().unwrap_or_default();
433 let host = host_port
434 .strip_prefix('[')
435 .and_then(|s| s.split(']').next())
436 .unwrap_or_else(|| host_port.split(':').next().unwrap_or_default());
437
438 matches!(host, "localhost" | "127.0.0.1" | "::1") || host.starts_with("127.")
439}
440
441#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
443#[serde(rename_all = "lowercase")]
444pub enum AppTheme {
445 Dark,
446 Light,
447 System,
448}
449
450#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
452#[serde(rename_all = "lowercase")]
453pub enum DateFormat {
454 Locale,
455 Iso,
456 Us,
457 Eu,
458}
459
460#[cfg(test)]
461mod tests {
462 use super::*;
463
464 #[test]
465 fn validate_forces_adaface_embedder() {
466 let mut cfg = AppConfig {
467 face_embedder_model: "legacy.onnx".to_string(),
468 ..Default::default()
469 };
470
471 cfg.validate();
472
473 assert_eq!(cfg.face_embedder_model, default_face_embedder_model());
474 }
475
476 #[test]
477 fn validate_replaces_non_finite_numeric_settings() {
478 let mut cfg = AppConfig {
479 face_detection_confidence: f32::NAN,
480 face_clustering_threshold: f32::INFINITY,
481 weight_cooccurrence: f32::NEG_INFINITY,
482 weight_temporal: f32::NAN,
483 thumbnail_cache_gb: f64::NAN,
484 ..Default::default()
485 };
486
487 cfg.validate();
488
489 assert_eq!(
490 cfg.face_detection_confidence,
491 AppConfig::default().face_detection_confidence
492 );
493 assert_eq!(
494 cfg.face_clustering_threshold,
495 AppConfig::default().face_clustering_threshold
496 );
497 assert_eq!(cfg.weight_cooccurrence, default_weight_cooccurrence());
498 assert_eq!(cfg.weight_temporal, default_weight_temporal());
499 assert_eq!(cfg.thumbnail_cache_gb, default_thumbnail_cache_gb());
500 }
501
502 #[test]
503 fn config_migration_forces_adaface_embedder() {
504 let mut cfg = AppConfig {
505 config_version: 2,
506 face_embedder_model: "legacy.onnx".to_string(),
507 ..Default::default()
508 };
509
510 cfg.migrate_config_defaults();
511
512 assert_eq!(cfg.config_version, 4);
513 assert_eq!(cfg.face_embedder_model, default_face_embedder_model());
514 }
515}