Skip to main content

brows3r_lib/settings/
mod.rs

1//! Application settings: typed store with all v1 defaults.
2//!
3//! # Layout
4//!
5//! - `mod.rs`      — `Settings` struct, sub-structs, load/save, and `SettingsHandle`.
6//! - `defaults.rs` — `Default for Settings` with every v1 default from the proposal.
7//!
8//! # Persistence
9//!
10//! `${app_config_dir}/settings.json` is the canonical backing file.  On save the
11//! file is written atomically (temp file + rename) so a crash during write cannot
12//! corrupt the stored settings.
13//!
14//! # Forward-compatibility
15//!
16//! The `unknown` field uses `#[serde(flatten)]` over a `BTreeMap<String, Value>`.
17//! Any JSON key that does not map to a known field is round-tripped verbatim, so
18//! settings written by a future app version are preserved when the user downgrades.
19//!
20//! # OCP
21//!
22//! Typed sub-structs (`NotificationSettings`, `TransferConfirmations`, …) make
23//! adding a new sub-field a non-breaking change — serde simply deserialises new
24//! keys into `unknown` on older builds and propagates them back on save.
25
26use std::{
27    collections::BTreeMap,
28    path::{Path, PathBuf},
29    sync::Arc,
30};
31
32use serde::{Deserialize, Serialize};
33use serde_json::Value;
34use tokio::sync::Mutex;
35
36use crate::error::AppError;
37
38mod defaults;
39
40// ---------------------------------------------------------------------------
41// Sub-structs
42// ---------------------------------------------------------------------------
43
44/// Controls in-app and OS-level notification behaviour.
45#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
46#[serde(rename_all = "camelCase")]
47pub struct NotificationSettings {
48    /// Show notifications in the in-app notification area.
49    pub in_app: bool,
50    /// Trigger an OS notification when a background transfer completes.
51    pub os_enabled: bool,
52    /// Play a sound with OS notifications.
53    pub sound: bool,
54}
55
56/// Confirmation thresholds for potentially destructive or billable operations.
57#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
58#[serde(rename_all = "camelCase")]
59pub struct TransferConfirmations {
60    /// Ask before deleting objects.
61    pub delete: bool,
62    /// Ask before overwriting an existing object.
63    pub overwrite: bool,
64    /// Ask before uploading a file larger than this many MiB.
65    pub large_upload_mb: u64,
66}
67
68/// A single S3-compatible endpoint entry in the endpoint registry.
69#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
70#[serde(rename_all = "camelCase")]
71pub struct S3CompatibleEndpoint {
72    pub name: String,
73    pub endpoint_url: String,
74    pub default_region: String,
75    /// Optional compat flags template for this endpoint.
76    /// When `None` the app uses its built-in provider-detection heuristics.
77    pub compat_flags_template: Option<crate::profiles::compat_flags::CompatFlags>,
78}
79
80/// Auto-update channel and behaviour.
81#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
82#[serde(rename_all = "camelCase")]
83pub struct AutoUpdateSettings {
84    pub enabled: bool,
85    /// `"stable"`, `"beta"`, or `"nightly"`.
86    pub channel: String,
87}
88
89/// What the app does on startup.
90#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
91#[serde(rename_all = "camelCase")]
92pub struct StartupBehavior {
93    /// Re-open the last session (last open profile + last navigated path).
94    pub restore_session: bool,
95    /// Override the initial navigation target (`"s3://bucket/prefix"` or a
96    /// profile name).  `None` means "last location" when `restore_session` is
97    /// true, or the bucket list when false.
98    pub open_to: Option<String>,
99}
100
101/// HTTP proxy mode.
102///
103/// Matches the shape of `s3::client::ProxyConfig` so the two can be converted
104/// without a full S3 client dependency cycle.  Task 7's `ProxyConfig` and this
105/// enum are intentionally isomorphic — callers in `s3::client` convert via
106/// `From<ProxyMode>`.
107#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)]
108#[serde(tag = "mode", rename_all = "camelCase")]
109pub enum ProxyMode {
110    /// Inherit proxy from environment variables (`HTTP_PROXY`, `HTTPS_PROXY`,
111    /// `NO_PROXY`).  This is the v1 default.
112    #[default]
113    System,
114    /// Route all traffic through the given URL.
115    Explicit { url: String },
116    /// Disable proxy entirely, ignoring environment variables.
117    None,
118}
119
120// ---------------------------------------------------------------------------
121// Settings root struct
122// ---------------------------------------------------------------------------
123
124/// All application settings, versioned and forward-compatible.
125///
126/// Defaults are in `defaults.rs`; this struct must never hard-code a default
127/// inline — use `Settings::default()` as the canonical factory.
128#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
129#[serde(rename_all = "camelCase")]
130pub struct Settings {
131    /// Schema version; currently always `1`.
132    pub schema_version: u32,
133
134    // --- storage / transfer ---
135    /// Default download directory.  `None` means "resolve from
136    /// `tauri::api::path::download_dir()` at runtime".
137    pub download_dir: Option<PathBuf>,
138    /// Maximum number of concurrent S3 object transfers.
139    pub transfer_concurrency: u32,
140
141    // --- cache ---
142    /// Listing cache TTL in seconds.
143    pub cache_ttl_secs: u64,
144    /// Maximum in-memory cache size in MiB.
145    pub cache_size_cap_mb: u64,
146
147    // --- preview ---
148    /// Files larger than this (MiB) show a warning before preview.
149    pub preview_size_limit_mb: u64,
150
151    // --- view ---
152    /// Default view mode: `"Details"`, `"Icons"`, `"Gallery"`, `"Tree"`,
153    /// `"FlatKey"`, `"Column"`, or `"DualPane"`.
154    pub default_view_mode: String,
155
156    // --- notifications ---
157    pub notifications: NotificationSettings,
158
159    // --- cross-account fallback ---
160    /// Objects up to this size (MiB) auto-fall back on cross-account ops;
161    /// larger objects require confirmation.
162    pub fallback_threshold_mb: u64,
163
164    // --- transfer confirmations ---
165    pub transfer_confirmations: TransferConfirmations,
166
167    // --- S3-compatible endpoint registry ---
168    pub s3_compatible_endpoints: Vec<S3CompatibleEndpoint>,
169
170    // --- auto-update ---
171    pub auto_update: AutoUpdateSettings,
172
173    // --- diagnostics ---
174    /// Enable local log/exception collection.
175    /// Collection is always user-initiated; nothing is ever auto-uploaded.
176    pub diagnostics_enabled: bool,
177
178    // --- startup ---
179    pub startup_behavior: StartupBehavior,
180
181    // --- proxy ---
182    pub proxy: ProxyMode,
183
184    // --- appearance ---
185    /// `"light"`, `"dark"`, or `"system"`.
186    pub theme: String,
187
188    // --- keyboard shortcuts ---
189    /// Sparse map of user-overridden shortcuts.  Keys are action identifiers
190    /// (e.g. `"navigate.up"`); values are key-combo strings (e.g. `"Alt+Up"`).
191    /// The frontend baseline shortcut map is canonical and is NOT stored here;
192    /// only user overrides are persisted (per Decision D3).
193    pub keyboard_shortcuts: BTreeMap<String, String>,
194
195    // --- forward-compat ---
196    /// Absorbs any JSON keys not known to this schema version.  Round-tripped
197    /// verbatim on save so future-app settings survive a downgrade.
198    #[serde(flatten)]
199    pub unknown: BTreeMap<String, Value>,
200}
201
202// ---------------------------------------------------------------------------
203// Load / save
204// ---------------------------------------------------------------------------
205
206impl Settings {
207    /// Load settings from `path`.
208    ///
209    /// Returns `Settings::default()` when the file does not exist.
210    /// Returns an `AppError::Internal` if the file exists but cannot be read
211    /// or parsed.
212    pub async fn load(path: &Path) -> Result<Self, AppError> {
213        if !path.exists() {
214            return Ok(Self::default());
215        }
216        let raw = tokio::fs::read_to_string(path)
217            .await
218            .map_err(|e| AppError::Internal {
219                trace_id: format!("settings_load_read: {e}"),
220            })?;
221        serde_json::from_str(&raw).map_err(|e| AppError::Internal {
222            trace_id: format!("settings_load_parse: {e}"),
223        })
224    }
225
226    /// Synchronous variant of `load` for use in contexts where async is
227    /// unavailable (e.g. the Tauri `setup` callback, which is synchronous).
228    ///
229    /// Returns `Settings::default()` when the file does not exist.
230    pub fn load_sync(path: &Path) -> Self {
231        if !path.exists() {
232            return Self::default();
233        }
234        std::fs::read_to_string(path)
235            .ok()
236            .and_then(|raw| serde_json::from_str(&raw).ok())
237            .unwrap_or_default()
238    }
239
240    /// Persist settings to `path` atomically (write to `<path>.tmp`, then
241    /// rename), so a crash during write cannot corrupt the stored settings.
242    pub async fn save(&self, path: &Path) -> Result<(), AppError> {
243        let json = serde_json::to_string_pretty(self).map_err(|e| AppError::Internal {
244            trace_id: format!("settings_save_serialize: {e}"),
245        })?;
246
247        // Ensure the parent directory exists.
248        if let Some(parent) = path.parent() {
249            tokio::fs::create_dir_all(parent)
250                .await
251                .map_err(|e| AppError::Internal {
252                    trace_id: format!("settings_save_mkdir: {e}"),
253                })?;
254        }
255
256        let tmp_path = path.with_extension("json.tmp");
257        tokio::fs::write(&tmp_path, json.as_bytes())
258            .await
259            .map_err(|e| AppError::Internal {
260                trace_id: format!("settings_save_write: {e}"),
261            })?;
262        tokio::fs::rename(&tmp_path, path)
263            .await
264            .map_err(|e| AppError::Internal {
265                trace_id: format!("settings_save_rename: {e}"),
266            })?;
267        Ok(())
268    }
269}
270
271// ---------------------------------------------------------------------------
272// SettingsHandle — shared mutable state for Tauri managed state
273// ---------------------------------------------------------------------------
274
275/// Newtype around `Arc<Mutex<Settings>>` used as Tauri managed state.
276///
277/// Commands receive `tauri::State<SettingsHandle>` and lock the inner mutex
278/// for the duration of the read or write.
279#[derive(Clone)]
280pub struct SettingsHandle {
281    pub inner: Arc<Mutex<Settings>>,
282    /// Path to `settings.json`; stored so commands can call `save()`.
283    pub path: PathBuf,
284}
285
286impl SettingsHandle {
287    pub fn new(settings: Settings, path: PathBuf) -> Self {
288        Self {
289            inner: Arc::new(Mutex::new(settings)),
290            path,
291        }
292    }
293}
294
295// ---------------------------------------------------------------------------
296// From<ProxyMode> for s3::client::ProxyConfig
297// ---------------------------------------------------------------------------
298
299impl From<ProxyMode> for crate::s3::client::ProxyConfig {
300    fn from(mode: ProxyMode) -> Self {
301        match mode {
302            ProxyMode::System => crate::s3::client::ProxyConfig::System,
303            ProxyMode::Explicit { url } => crate::s3::client::ProxyConfig::Explicit(url),
304            ProxyMode::None => crate::s3::client::ProxyConfig::None,
305        }
306    }
307}
308
309// ---------------------------------------------------------------------------
310// Patch validation — also used by settings_cmd
311// ---------------------------------------------------------------------------
312
313/// Validate a JSON patch object before applying it to `Settings`.
314///
315/// Returns `AppError::Validation` if the patch violates any constraint.
316/// This is the single validation gate; `settings_update` calls it before
317/// merging and persisting.
318pub fn validate_patch(patch: &Value) -> Result<(), AppError> {
319    if let Some(tc) = patch.get("transferConcurrency") {
320        let v = tc.as_u64().unwrap_or(0);
321        if v == 0 {
322            return Err(AppError::Validation {
323                field: "transferConcurrency".to_string(),
324                hint: "must be at least 1".to_string(),
325            });
326        }
327    }
328    if let Some(ttl) = patch.get("cacheTtlSecs") {
329        if ttl.as_u64().is_none() {
330            return Err(AppError::Validation {
331                field: "cacheTtlSecs".to_string(),
332                hint: "must be a non-negative integer".to_string(),
333            });
334        }
335    }
336    if let Some(lim) = patch.get("previewSizeLimitMb") {
337        if lim.as_u64().is_none() {
338            return Err(AppError::Validation {
339                field: "previewSizeLimitMb".to_string(),
340                hint: "must be a non-negative integer".to_string(),
341            });
342        }
343    }
344    Ok(())
345}
346
347// ---------------------------------------------------------------------------
348// Tests
349// ---------------------------------------------------------------------------
350
351#[cfg(test)]
352mod tests {
353    use super::*;
354    use serde_json::json;
355
356    // (1) Every v1 default from proposal lines 190-206 is codified correctly.
357    #[test]
358    fn defaults_match_proposal() {
359        let s = Settings::default();
360
361        assert_eq!(s.schema_version, 1, "schema_version");
362        assert_eq!(s.download_dir, None, "download_dir");
363        assert_eq!(s.transfer_concurrency, 4, "transfer_concurrency");
364        assert_eq!(s.cache_ttl_secs, 30, "cache_ttl_secs");
365        assert_eq!(s.cache_size_cap_mb, 256, "cache_size_cap_mb");
366        assert_eq!(s.preview_size_limit_mb, 50, "preview_size_limit_mb");
367        assert_eq!(s.default_view_mode, "Details", "default_view_mode");
368
369        assert!(s.notifications.in_app, "notifications.in_app");
370        assert!(s.notifications.os_enabled, "notifications.os_enabled");
371        assert!(!s.notifications.sound, "notifications.sound");
372
373        assert_eq!(s.fallback_threshold_mb, 100, "fallback_threshold_mb");
374
375        assert!(
376            s.transfer_confirmations.delete,
377            "transfer_confirmations.delete"
378        );
379        assert!(
380            s.transfer_confirmations.overwrite,
381            "transfer_confirmations.overwrite"
382        );
383        assert_eq!(
384            s.transfer_confirmations.large_upload_mb, 500,
385            "transfer_confirmations.large_upload_mb"
386        );
387
388        assert!(
389            s.s3_compatible_endpoints.is_empty(),
390            "s3_compatible_endpoints"
391        );
392
393        assert!(s.auto_update.enabled, "auto_update.enabled");
394        assert_eq!(s.auto_update.channel, "stable", "auto_update.channel");
395
396        assert!(s.diagnostics_enabled, "diagnostics_enabled");
397
398        assert!(
399            s.startup_behavior.restore_session,
400            "startup_behavior.restore_session"
401        );
402        assert_eq!(s.startup_behavior.open_to, None, "startup_behavior.open_to");
403
404        assert_eq!(s.proxy, ProxyMode::System, "proxy");
405        assert_eq!(s.theme, "system", "theme");
406        assert!(s.keyboard_shortcuts.is_empty(), "keyboard_shortcuts");
407    }
408
409    // (2) Round-trip: serialize → parse → assert equality.
410    #[test]
411    fn round_trip_defaults() {
412        let original = Settings::default();
413        let json = serde_json::to_string(&original).expect("serialize");
414        let restored: Settings = serde_json::from_str(&json).expect("deserialize");
415        assert_eq!(
416            original, restored,
417            "round-trip must produce identical Settings"
418        );
419    }
420
421    // (3) Unknown keys are preserved on round-trip.
422    #[test]
423    fn unknown_keys_preserved() {
424        let input = json!({
425            "schemaVersion": 1,
426            "transferConcurrency": 4,
427            "cacheTtlSecs": 30,
428            "cacheSizeCapMb": 256,
429            "previewSizeLimitMb": 50,
430            "defaultViewMode": "Details",
431            "notifications": {
432                "inApp": true,
433                "osEnabled": true,
434                "sound": false
435            },
436            "fallbackThresholdMb": 100,
437            "transferConfirmations": {
438                "delete": true,
439                "overwrite": true,
440                "largeUploadMb": 500
441            },
442            "s3CompatibleEndpoints": [],
443            "autoUpdate": {
444                "enabled": true,
445                "channel": "stable"
446            },
447            "diagnosticsEnabled": true,
448            "startupBehavior": {
449                "restoreSession": true,
450                "openTo": null
451            },
452            "proxy": { "mode": "system" },
453            "theme": "system",
454            "keyboardShortcuts": {},
455            // future key unknown to this schema version
456            "futureFlag": true,
457            "experimentalBatch": { "size": 100 }
458        });
459
460        let settings: Settings =
461            serde_json::from_value(input).expect("deserialize with unknown keys");
462
463        // The unknown keys must survive serialization.
464        let out = serde_json::to_value(&settings).expect("serialize");
465
466        assert_eq!(
467            out["futureFlag"],
468            json!(true),
469            "futureFlag must be preserved"
470        );
471        assert_eq!(
472            out["experimentalBatch"]["size"],
473            json!(100),
474            "experimentalBatch must be preserved"
475        );
476    }
477
478    // (4) settings_update validation: transfer_concurrency = 0 is rejected.
479    // This tests the validation helper used by the command.
480    #[test]
481    fn validate_transfer_concurrency_zero() {
482        let patch = json!({ "transferConcurrency": 0 });
483        let result = validate_patch(&patch);
484        assert!(
485            result.is_err(),
486            "transferConcurrency=0 must produce a validation error"
487        );
488        if let Err(AppError::Validation { field, .. }) = result {
489            assert_eq!(field, "transferConcurrency");
490        } else {
491            panic!("expected AppError::Validation");
492        }
493    }
494
495    // (5) Valid patch passes validation.
496    #[test]
497    fn validate_patch_valid() {
498        let patch = json!({ "transferConcurrency": 8 });
499        assert!(validate_patch(&patch).is_ok());
500    }
501}