Skip to main content

mimick/
config.rs

1//! Handles persistent configuration loading and provides desktop keyring access helpers.
2//!
3//! Configuration lives in a JSON file under the XDG config directory.
4//! Watch-path entries support both simple paths and extended per-folder
5//! rules (sync method, extension filters, size limits). The API key is
6//! stored in the desktop keyring via `oo7` and never written to disk.
7
8use serde::{Deserialize, Serialize};
9use std::fs;
10use std::path::Path;
11use std::path::PathBuf;
12
13/// Defines per-folder filters and guardrails applied before a file is queued for upload.
14#[derive(Serialize, Deserialize, Debug, Clone, Default, PartialEq, Eq)]
15pub struct FolderRules {
16    /// True if hidden files/folders should be ignored during sync.
17    #[serde(default)]
18    pub ignore_hidden: bool,
19    /// Maximum file size allowed for upload, in Megabytes.
20    #[serde(default)]
21    pub max_file_size_mb: Option<u64>,
22    /// List of file extensions permitted for sync.
23    #[serde(default)]
24    pub allowed_extensions: Vec<String>,
25    /// Selected synchronization direction method.
26    #[serde(default)]
27    pub sync_method: FolderSyncMethod,
28    /// Override startup catch-up mode, or None to use global setting.
29    #[serde(default)]
30    pub startup_catchup_mode: Option<StartupCatchupMode>,
31    /// Delete local file when its corresponding remote asset is removed.
32    #[serde(default)]
33    pub delete_folder_to_album: bool,
34    /// Delete remote asset when its corresponding local file is removed.
35    #[serde(default)]
36    pub delete_album_to_folder: bool,
37    /// Per-folder override for XMP sidecar upload. `None` inherits the global
38    /// `upload_xmp_sidecars` setting; `Some(true/false)` overrides it.
39    #[serde(default)]
40    pub include_xmp_sidecar: Option<bool>,
41}
42
43impl FolderRules {
44    /// Return the list of allowed extensions trimmed and lowercased.
45    pub fn normalized_extensions(&self) -> Vec<String> {
46        self.allowed_extensions
47            .iter()
48            .map(|ext| ext.trim().trim_start_matches('.').to_ascii_lowercase())
49            .filter(|ext| !ext.is_empty())
50            .collect()
51    }
52
53    /// Check if a specific file path meets all active folder validation rules.
54    pub fn matches(&self, path: &Path) -> bool {
55        if self.ignore_hidden
56            && path.components().any(|component| {
57                component
58                    .as_os_str()
59                    .to_str()
60                    .map(|part| part.starts_with('.') && part.len() > 1)
61                    .unwrap_or(false)
62            })
63        {
64            return false;
65        }
66
67        if let Some(limit_mb) = self.max_file_size_mb
68            && let Ok(metadata) = std::fs::metadata(path)
69            && metadata.len() > limit_mb.saturating_mul(1024 * 1024)
70        {
71            return false;
72        }
73
74        let normalized = self.normalized_extensions();
75        if !normalized.is_empty() {
76            let ext = path
77                .extension()
78                .and_then(std::ffi::OsStr::to_str)
79                .map(str::to_ascii_lowercase);
80            if ext
81                .as_deref()
82                .is_none_or(|ext| !normalized.iter().any(|allowed| allowed == ext))
83            {
84                return false;
85            }
86        }
87
88        true
89    }
90
91    /// Resolve whether XMP sidecar upload is enabled for this folder.
92    ///
93    /// Returns the per-folder override when set, otherwise falls back to the
94    /// caller-supplied global default.
95    pub fn xmp_sidecar_enabled(&self, global_default: bool) -> bool {
96        self.include_xmp_sidecar.unwrap_or(global_default)
97    }
98}
99
100#[derive(Serialize, Deserialize, Debug, Clone, Default, PartialEq, Eq)]
101pub enum FolderSyncMethod {
102    /// Upload folder-only assets and download album-only assets.
103    Full,
104    /// Only upload assets found in the folder.
105    #[default]
106    UploadOnly,
107    /// Only download assets found in the album.
108    DownloadOnly,
109}
110
111/// A watch path entry stored in config.
112///
113/// Older configs may contain plain strings, while newer entries can also store per-folder
114/// album targeting metadata.
115#[derive(Serialize, Deserialize, Debug, Clone)]
116#[serde(untagged)]
117pub enum WatchPathEntry {
118    /// Legacy, simple watched directory path without specific rules or targets.
119    Simple(String),
120    /// Configured watched directory path containing custom sync targets and filters.
121    WithConfig {
122        /// Absolute directory path on the local filesystem.
123        path: String,
124        /// Target Immich album unique identifier.
125        #[serde(default)]
126        album_id: Option<String>,
127        /// Target Immich album name.
128        #[serde(default)]
129        album_name: Option<String>,
130        /// Specific validation and sync rules applied to this path.
131        #[serde(default)]
132        rules: FolderRules,
133    },
134}
135
136impl WatchPathEntry {
137    /// Retrieve the base path of the watched directory.
138    pub fn path(&self) -> &str {
139        match self {
140            WatchPathEntry::Simple(p) => p,
141            WatchPathEntry::WithConfig { path, .. } => path,
142        }
143    }
144    /// Retrieve the target album name, if any.
145    pub fn album_name(&self) -> Option<&str> {
146        match self {
147            WatchPathEntry::Simple(_) => None,
148            WatchPathEntry::WithConfig { album_name, .. } => album_name.as_deref(),
149        }
150    }
151
152    /// Retrieve the folder rules or a default set for simple paths.
153    pub fn rules(&self) -> FolderRules {
154        match self {
155            WatchPathEntry::Simple(_) => FolderRules::default(),
156            WatchPathEntry::WithConfig { rules, .. } => rules.clone(),
157        }
158    }
159
160    /// Retrieve the sync direction configured for this directory.
161    pub fn sync_method(&self) -> FolderSyncMethod {
162        self.rules().sync_method
163    }
164
165    /// Retrieve the catchup strategy override or fallback to global configuration.
166    pub fn startup_catchup_mode(&self, fallback: &StartupCatchupMode) -> StartupCatchupMode {
167        self.rules()
168            .startup_catchup_mode
169            .unwrap_or_else(|| fallback.clone())
170    }
171}
172
173/// Find the most specific configured watch entry that contains `path`.
174///
175/// Matching is path-aware rather than string-prefix-based so sibling paths like
176/// `/home/user/Pictures` and `/home/user/Pictures-backup` are treated correctly.
177pub fn best_matching_watch_entry<'a>(
178    path: &Path,
179    entries: &'a [WatchPathEntry],
180) -> Option<&'a WatchPathEntry> {
181    entries
182        .iter()
183        .filter(|entry| path.starts_with(Path::new(entry.path())))
184        .max_by_key(|entry| entry.path().len())
185}
186
187/// Find a configured watch path entry by its target album name.
188pub fn watch_entry_for_album<'a>(
189    album_name: &str,
190    entries: &'a [WatchPathEntry],
191) -> Option<&'a WatchPathEntry> {
192    entries
193        .iter()
194        .find(|entry| entry.album_name() == Some(album_name))
195}
196
197/// Catch-up strategy applied to existing files when the application boots.
198#[derive(Serialize, Deserialize, Debug, Clone, Default, PartialEq, Eq)]
199pub enum StartupCatchupMode {
200    /// Perform full, deep comparison of all files on the local filesystem.
201    #[default]
202    Full,
203    /// Fast sync covering only files modified or added in the last 24 hours.
204    RecentOnly,
205    /// Sync only new files added to folders while daemon was offline.
206    NewFilesOnly,
207}
208
209/// Configuration schema driving application behavior and connection settings.
210#[derive(Serialize, Deserialize, Debug, Clone)]
211pub struct ConfigData {
212    /// Active local Immich server connection address.
213    #[serde(default)]
214    pub internal_url: String,
215    /// Active remote or external connection address.
216    #[serde(default)]
217    pub external_url: String,
218    /// True if internal URL connection is enabled.
219    #[serde(default = "default_true")]
220    pub internal_url_enabled: bool,
221    /// True if external URL connection is enabled.
222    #[serde(default = "default_true")]
223    pub external_url_enabled: bool,
224    /// List of watched local directory entries.
225    #[serde(default)]
226    pub watch_paths: Vec<WatchPathEntry>,
227    /// True if application should launch automatically at user login.
228    #[serde(default)]
229    pub run_on_startup: bool,
230    /// Pause transfers when metered connections are detected.
231    #[serde(default)]
232    pub pause_on_metered_network: bool,
233    /// Pause transfers when system runs on battery.
234    #[serde(default)]
235    pub pause_on_battery_power: bool,
236    /// Whether automatic background monitoring/upload discovery is enabled.
237    #[serde(default)]
238    pub background_sync_enabled: bool,
239    /// Whether desktop notifications (sync summary, connectivity lost, etc.) are shown.
240    #[serde(default = "default_true")]
241    pub notifications_enabled: bool,
242    /// Default catch-up scanning strategy when background scan starts.
243    #[serde(default)]
244    pub startup_catchup_mode: StartupCatchupMode,
245    /// Number of parallel upload workers (1–10). Defaults to 3.
246    #[serde(default = "default_upload_concurrency")]
247    pub upload_concurrency: u8,
248    /// Quiet-hours window start (local clock hour, 0-23). `None` means disabled.
249    #[serde(default)]
250    pub quiet_hours_start: Option<u8>,
251    /// Quiet-hours window end (local clock hour, 0-23, exclusive).
252    #[serde(default)]
253    pub quiet_hours_end: Option<u8>,
254    /// Whether the built-in library viewer is the primary window.
255    #[serde(default)]
256    pub library_view_enabled: bool,
257    /// Target folder for asset downloads from the library viewer.
258    #[serde(default)]
259    pub download_target_path: Option<String>,
260    /// When true, lightbox loads original full-resolution image instead of preview.
261    #[serde(default)]
262    pub library_preview_full_resolution: bool,
263    /// Grid thumbnail quality: "auto", "thumbnail", "preview". Defaults to auto.
264    #[serde(default = "default_grid_quality")]
265    pub library_grid_quality: String,
266    /// Total on-disk cache cap in megabytes across all subcaches
267    /// (thumbnails, raw_decode, exif, video, preview, open-in).
268    /// Pruning runs once at startup. Defaults to 2000 MB.
269    #[serde(default = "default_cache_disk_cap_mb")]
270    pub cache_disk_cap_mb: u32,
271    /// When true, decoded RAW textures are cached to disk for faster re-opens.
272    /// Disable to save storage; each cached file is a full-resolution PNG.
273    #[serde(default)]
274    pub raw_decode_cache_enabled: bool,
275    /// When true, RAW files are fully demosaiced from sensor data (slow but
276    /// highest quality). When false the embedded camera JPEG preview is
277    /// extracted instead (near-instant).
278    #[serde(default)]
279    pub raw_full_decode: bool,
280    /// Show people with no assigned name in the Explore view.
281    #[serde(default = "default_true")]
282    pub show_unnamed_faces: bool,
283    /// Include hidden people in the Explore view.
284    #[serde(default)]
285    pub show_hidden_faces: bool,
286    /// Attach XMP sidecar files alongside media during upload. Per-folder
287    /// rules can override this global default.
288    #[serde(default = "default_true")]
289    pub upload_xmp_sidecars: bool,
290    /// Border width between grid tiles in pixels (0.0 = edge-to-edge, max 10.0).
291    #[serde(default)]
292    pub grid_border_width: f32,
293    /// Border color as a CSS hex string (e.g. "#ffffff"). Defaults to white.
294    #[serde(default = "default_grid_border_color")]
295    pub grid_border_color: String,
296}
297
298impl Default for ConfigData {
299    fn default() -> Self {
300        Self {
301            internal_url: String::new(),
302            external_url: String::new(),
303            internal_url_enabled: true,
304            external_url_enabled: true,
305            watch_paths: Vec::new(),
306            run_on_startup: false,
307            pause_on_metered_network: false,
308            pause_on_battery_power: false,
309            background_sync_enabled: false,
310            notifications_enabled: true,
311            startup_catchup_mode: StartupCatchupMode::default(),
312            upload_concurrency: default_upload_concurrency(),
313            quiet_hours_start: None,
314            quiet_hours_end: None,
315            library_view_enabled: false,
316            download_target_path: None,
317            library_preview_full_resolution: false,
318            library_grid_quality: default_grid_quality(),
319            cache_disk_cap_mb: default_cache_disk_cap_mb(),
320            raw_decode_cache_enabled: false,
321            raw_full_decode: false,
322            show_unnamed_faces: true,
323            show_hidden_faces: false,
324            upload_xmp_sidecars: true,
325            grid_border_width: 0.0,
326            grid_border_color: default_grid_border_color(),
327        }
328    }
329}
330
331/// Helper to default boolean fields to true during serialization.
332fn default_true() -> bool {
333    true
334}
335
336fn default_grid_quality() -> String {
337    "thumbnail".to_string()
338}
339
340/// Helper to default parallel upload worker threads to 3.
341fn default_upload_concurrency() -> u8 {
342    3
343}
344
345/// Default total on-disk cache cap in MB.
346fn default_cache_disk_cap_mb() -> u32 {
347    2000
348}
349
350fn default_grid_border_color() -> String {
351    "#ffffff".to_string()
352}
353
354/// Persistent config container wrapping loaded schema and file source info.
355#[derive(Clone)]
356pub struct Config {
357    /// Active live configuration switches.
358    pub data: ConfigData,
359    /// Path to config file source.
360    pub config_file: PathBuf,
361}
362
363impl Config {
364    /// Load the config from the standard Mimick config path, creating a default file if missing.
365    pub fn new() -> Self {
366        let config_dir = crate::profile::config_dir()
367            .unwrap_or_else(|| PathBuf::from("~/.config").join(crate::profile::dir_segment()));
368
369        let config_file = config_dir.join("config.json");
370
371        let mut config = Config {
372            data: ConfigData::default(),
373            config_file,
374        };
375
376        config.load();
377        config
378    }
379
380    /// Load or parse configuration state from standard path source.
381    pub fn load(&mut self) -> bool {
382        if self.config_file.exists() {
383            if let Ok(content) = fs::read_to_string(&self.config_file) {
384                if let Ok(data) = serde_json::from_str(&content) {
385                    self.data = data;
386                    log::info!("Config loaded from: {}", self.config_file.display());
387                    return true;
388                }
389                log::warn!("Config parse failed: {}", self.config_file.display());
390            }
391        } else {
392            log::info!(
393                "No config found, creating default at: {}",
394                self.config_file.display()
395            );
396            self.save();
397        }
398        false
399    }
400
401    /// Atomically write current configuration values back to disk.
402    pub fn save(&self) -> bool {
403        if let Ok(content) = serde_json::to_string_pretty(&self.data) {
404            match crate::util::atomic_write(&self.config_file, content.as_bytes()) {
405                Ok(()) => {
406                    log::info!("Config saved to: {}", self.config_file.display());
407                    true
408                }
409                Err(err) => {
410                    log::error!(
411                        "Failed to write config {}: {}",
412                        self.config_file.display(),
413                        err
414                    );
415                    false
416                }
417            }
418        } else {
419            false
420        }
421    }
422
423    /// Look up the API key from the desktop keyring via oo7.
424    ///
425    /// Automatically selects the correct backend:
426    /// - Flatpak sandbox: encrypted file via the Secret portal
427    /// - Native: D-Bus Secret Service (GNOME Keyring, KWallet)
428    ///
429    /// When the portal is unavailable (common on Hyprland, Sway, XFCE),
430    /// falls back to the D-Bus Secret Service before giving up.
431    pub fn get_api_key(&self) -> Option<String> {
432        let result = tokio::task::block_in_place(|| {
433            tokio::runtime::Handle::current().block_on(async {
434                let keyring = keyring_with_dbus_fallback().await?;
435                let account = crate::profile::keyring_account();
436                let attributes: Vec<(&str, &str)> =
437                    vec![("service", "mimick"), ("account", account.as_str())];
438                let items = keyring.search_items(&attributes).await.map_err(Box::new)?;
439                if let Some(item) = items.first() {
440                    let secret = item.secret().await.map_err(Box::new)?;
441                    let key = String::from_utf8_lossy(&secret).trim().to_string();
442                    if !key.is_empty() {
443                        return Ok::<Option<String>, Box<oo7::Error>>(Some(key));
444                    }
445                }
446                Ok(None)
447            })
448        });
449
450        match result {
451            Ok(key) => {
452                if key.is_some() {
453                    log::debug!("API key retrieved via oo7 keyring.");
454                } else {
455                    log::warn!(
456                        "No API key found in keyring. \
457                         User must configure it in Settings."
458                    );
459                }
460                key
461            }
462            Err(e) => {
463                log::warn!(
464                    "Keyring lookup failed ({}): {:?}",
465                    keyring_error_hint(&e),
466                    e
467                );
468                None
469            }
470        }
471    }
472
473    /// Store the API key in the desktop keyring via oo7.
474    ///
475    /// Returns `Ok(())` on success. On failure, returns an error string
476    /// with user-facing guidance tailored to the specific keyring backend
477    /// that failed (portal misconfiguration, missing D-Bus service, etc.).
478    pub fn set_api_key(&self, key: &str) -> Result<(), String> {
479        let secret = key.to_string();
480        let result = tokio::task::block_in_place(|| {
481            tokio::runtime::Handle::current().block_on(async {
482                let keyring = keyring_with_dbus_fallback().await?;
483                let account = crate::profile::keyring_account();
484                let attributes: Vec<(&str, &str)> =
485                    vec![("service", "mimick"), ("account", account.as_str())];
486                let label = match crate::profile::name() {
487                    Some(profile) => format!("Mimick API Key ({})", profile),
488                    None => "Mimick API Key".to_string(),
489                };
490                keyring
491                    .create_item(&label, &attributes, secret.as_bytes(), true)
492                    .await
493                    .map_err(Box::new)?;
494                Ok::<(), Box<oo7::Error>>(())
495            })
496        });
497
498        match result {
499            Ok(()) => {
500                log::info!("API key saved via oo7 keyring.");
501                Ok(())
502            }
503            Err(e) => {
504                log::error!("Failed to save API key via oo7 keyring: {:?}", e);
505                Err(format!(
506                    "{}\n\nTechnical detail: {}",
507                    keyring_error_message(&e),
508                    e
509                ))
510            }
511        }
512    }
513
514    /// Return all configured watch paths as plain strings for the live monitor.
515    pub fn watch_path_strings(&self) -> Vec<String> {
516        self.data
517            .watch_paths
518            .iter()
519            .map(|e| e.path().to_string())
520            .collect()
521    }
522}
523
524/// Try `oo7::Keyring::new()`, which auto-selects portal (Flatpak) or
525/// D-Bus (native). If the portal backend fails -- common on Hyprland,
526/// Sway, XFCE, and other non-GNOME/KDE desktops -- explicitly attempt
527/// the D-Bus Secret Service as a fallback before returning an error.
528async fn keyring_with_dbus_fallback() -> Result<oo7::Keyring, Box<oo7::Error>> {
529    match oo7::Keyring::new().await {
530        Ok(keyring) => Ok(keyring),
531        Err(oo7::Error::File(ref file_err)) => {
532            // Portal errors surface when xdg-desktop-portal doesn't
533            // expose org.freedesktop.portal.Secret -- try D-Bus.
534            log::info!(
535                "oo7 portal backend unavailable ({}), trying D-Bus Secret Service fallback",
536                file_err
537            );
538            let service = oo7::dbus::Service::new()
539                .await
540                .map_err(|e| Box::new(oo7::Error::from(e)))?;
541            let collection = service
542                .default_collection()
543                .await
544                .map_err(|e| Box::new(oo7::Error::from(e)))?;
545            Ok(oo7::Keyring::DBus(collection))
546        }
547        Err(e) => Err(Box::new(e)),
548    }
549}
550
551/// Return a user-facing error message for a keyring failure, tailored
552/// to the specific backend that failed.
553fn keyring_error_message(e: &oo7::Error) -> &'static str {
554    match e {
555        oo7::Error::File(_) => {
556            "Your desktop's Secret portal is not configured.\n\
557             This typically happens on Hyprland, Sway, XFCE, and other \
558             non-GNOME/KDE desktops.\n\n\
559             To fix this, ensure gnome-keyring (or kwallet) is installed \
560             and add an entry for org.freedesktop.impl.portal.Secret in \
561             your portal configuration.\n\n\
562             See: https://github.com/nicx17/mimick/wiki/Keyring-Setup"
563        }
564        oo7::Error::DBus(_) => {
565            "The D-Bus Secret Service is not available.\n\
566             Ensure gnome-keyring-daemon or KWallet is installed \
567             and running.\n\n\
568             See: https://github.com/nicx17/mimick/wiki/Keyring-Setup"
569        }
570    }
571}
572
573/// Short diagnostic label for log messages.
574fn keyring_error_hint(e: &oo7::Error) -> &'static str {
575    match e {
576        oo7::Error::File(_) => "portal backend error",
577        oo7::Error::DBus(_) => "D-Bus Secret Service error",
578    }
579}
580
581#[cfg(test)]
582mod tests {
583    use super::*;
584    use std::path::Path;
585
586    #[test]
587    fn test_watch_path_entry_parsing_simple() {
588        let json = r#""/home/user/Pictures""#;
589        let entry: WatchPathEntry = serde_json::from_str(json).unwrap();
590
591        assert_eq!(entry.path(), "/home/user/Pictures");
592        assert!(matches!(entry, WatchPathEntry::Simple(_)));
593    }
594
595    #[test]
596    fn test_watch_path_entry_parsing_with_config() {
597        let json = r#"{
598            "path": "/home/user/Pictures",
599            "album_id": "abc-123",
600            "album_name": "My Album"
601        }"#;
602        let entry: WatchPathEntry = serde_json::from_str(json).unwrap();
603
604        assert_eq!(entry.path(), "/home/user/Pictures");
605        let WatchPathEntry::WithConfig { album_id, .. } = &entry else {
606            panic!("expected configured watch entry");
607        };
608        assert_eq!(album_id.as_deref(), Some("abc-123"));
609        assert_eq!(entry.album_name().unwrap(), "My Album");
610    }
611
612    #[test]
613    fn test_config_data_defaults() {
614        let data = ConfigData::default();
615        assert!(data.internal_url_enabled);
616        assert!(data.external_url_enabled);
617        assert!(!data.background_sync_enabled);
618    }
619
620    #[test]
621    fn test_config_data_background_sync_defaults_false_when_missing() {
622        let data: ConfigData = serde_json::from_str("{}").unwrap();
623        assert!(!data.background_sync_enabled);
624    }
625
626    #[test]
627    fn test_legacy_folder_rules_default_to_upload_only_and_global_startup_fallback() {
628        let entry: WatchPathEntry = serde_json::from_str(
629            r#"{
630                "path": "/home/nick/Pictures",
631                "rules": {
632                    "ignore_hidden": true
633                }
634            }"#,
635        )
636        .unwrap();
637
638        let rules = entry.rules();
639        assert_eq!(rules.sync_method, FolderSyncMethod::UploadOnly);
640        assert_eq!(
641            entry.startup_catchup_mode(&StartupCatchupMode::RecentOnly),
642            StartupCatchupMode::RecentOnly
643        );
644        assert!(!rules.delete_folder_to_album);
645        assert!(!rules.delete_album_to_folder);
646    }
647
648    #[test]
649    fn test_watch_path_strings_helper() {
650        let mut data = ConfigData::default();
651        data.watch_paths.push(WatchPathEntry::Simple("/a".into()));
652        data.watch_paths.push(WatchPathEntry::WithConfig {
653            path: "/b".into(),
654            album_id: None,
655            album_name: None,
656            rules: FolderRules::default(),
657        });
658
659        let config = Config {
660            data,
661            config_file: PathBuf::from("dummy.json"),
662        };
663
664        let strings = config.watch_path_strings();
665        assert_eq!(strings, vec!["/a".to_string(), "/b".to_string()]);
666    }
667
668    #[test]
669    fn test_folder_rules_match_extension_and_size() {
670        let dir = tempfile::tempdir().unwrap();
671        let path = dir.path().join("photo.jpg");
672        fs::write(&path, vec![0u8; 1024]).unwrap();
673
674        let rules = FolderRules {
675            ignore_hidden: false,
676            max_file_size_mb: Some(1),
677            allowed_extensions: vec!["jpg".into(), "png".into()],
678            ..FolderRules::default()
679        };
680
681        assert!(rules.matches(&path));
682
683        let restricted = FolderRules {
684            ignore_hidden: false,
685            max_file_size_mb: Some(0),
686            allowed_extensions: vec!["png".into()],
687            ..FolderRules::default()
688        };
689
690        assert!(!restricted.matches(&path));
691    }
692
693    #[test]
694    fn test_folder_rules_ignore_hidden_path_components() {
695        let dir = tempfile::tempdir().unwrap();
696        let hidden_dir = dir.path().join(".hidden");
697        fs::create_dir_all(&hidden_dir).unwrap();
698        let hidden_file = hidden_dir.join("photo.jpg");
699        fs::write(&hidden_file, vec![0u8; 16]).unwrap();
700
701        let rules = FolderRules {
702            ignore_hidden: true,
703            ..FolderRules::default()
704        };
705
706        assert!(!rules.matches(&hidden_file));
707    }
708
709    #[test]
710    fn test_normalized_extensions_trims_and_lowercases() {
711        let rules = FolderRules {
712            allowed_extensions: vec![" JPG ".into(), ".PNG".into(), "".into()],
713            ..FolderRules::default()
714        };
715
716        assert_eq!(rules.normalized_extensions(), vec!["jpg", "png"]);
717    }
718
719    #[test]
720    fn test_best_matching_watch_entry_prefers_most_specific_path() {
721        let entries = vec![
722            WatchPathEntry::Simple("/home/user/Pictures".into()),
723            WatchPathEntry::WithConfig {
724                path: "/home/user/Pictures/Trips".into(),
725                album_id: Some("album-1".into()),
726                album_name: Some("Trips".into()),
727                rules: FolderRules::default(),
728            },
729        ];
730
731        let matched = best_matching_watch_entry(
732            Path::new("/home/user/Pictures/Trips/day1/photo.jpg"),
733            &entries,
734        )
735        .unwrap();
736        assert_eq!(matched.path(), "/home/user/Pictures/Trips");
737        assert_eq!(matched.album_name(), Some("Trips"));
738    }
739
740    #[test]
741    fn test_best_matching_watch_entry_does_not_match_string_prefix_siblings() {
742        let entries = vec![WatchPathEntry::Simple("/home/user/Pictures".into())];
743
744        assert!(
745            best_matching_watch_entry(Path::new("/home/user/Pictures-backup/photo.jpg"), &entries)
746                .is_none()
747        );
748    }
749
750    #[test]
751    fn face_visibility_defaults_favour_discoverable_named_only() {
752        let data = ConfigData::default();
753        assert!(
754            data.show_unnamed_faces,
755            "unnamed people should be visible by default so users see them"
756        );
757        assert!(
758            !data.show_hidden_faces,
759            "hidden people stay hidden by default"
760        );
761    }
762
763    #[test]
764    fn face_visibility_flags_round_trip_through_json() {
765        let data = ConfigData {
766            show_unnamed_faces: false,
767            show_hidden_faces: true,
768            ..ConfigData::default()
769        };
770        let json = serde_json::to_string(&data).expect("serialize");
771        let restored: ConfigData = serde_json::from_str(&json).expect("deserialize");
772        assert!(!restored.show_unnamed_faces);
773        assert!(restored.show_hidden_faces);
774    }
775
776    #[test]
777    fn face_visibility_flags_default_when_absent_in_json() {
778        // Older config files written before the flags existed must still load.
779        let json = serde_json::to_string(&serde_json::json!({})).unwrap();
780        let restored: ConfigData = serde_json::from_str(&json).expect("deserialize legacy config");
781        assert!(restored.show_unnamed_faces);
782        assert!(!restored.show_hidden_faces);
783    }
784}