Skip to main content

dicom_viewer/
config.rs

1//! Portable-first config and path resolution.
2//!
3//! Settings live in `config.toml` next to the executable. If the exe
4//! directory is read-only (e.g. running from a CD), we fall back to a
5//! per-user data directory and remember that decision for the session.
6
7use anyhow::{Context, Result};
8use serde::{Deserialize, Serialize};
9use std::fs;
10use std::path::{Path, PathBuf};
11
12const CONFIG_FILE: &str = "config.toml";
13
14/// Resolved filesystem locations for the running viewer.
15///
16/// Construct with [`Self::resolve`]. In *portable mode* the data and log
17/// directories live next to the executable; otherwise they fall back to a
18/// platform user-data directory (via the [`directories`] crate):
19///
20/// | OS | Fallback `data_dir` |
21/// |---|---|
22/// | Windows | `%APPDATA%\dicom-viewer\` |
23/// | macOS | `~/Library/Application Support/dicom-viewer/` |
24/// | Linux | `~/.local/share/dicom-viewer/` |
25#[derive(Debug, Clone)]
26pub struct Paths {
27    /// Directory containing the running executable.
28    pub exe_dir: PathBuf,
29    /// Where `config.toml`, `annotations.json`, and `logs/` are stored.
30    pub data_dir: PathBuf,
31    /// `data_dir/logs/`. Created on resolve.
32    pub logs_dir: PathBuf,
33    portable: bool,
34}
35
36impl Paths {
37    /// Resolve paths for the current process.
38    ///
39    /// Tries to write a probe file into the exe directory; success means
40    /// portable mode (the disc/USB is writable), failure means we fall
41    /// back to the per-user data directory. Either way the `logs/`
42    /// subdirectory is created if missing.
43    ///
44    /// # Errors
45    /// Returns an error when [`std::env::current_exe`] fails or when the
46    /// platform user-data directory can't be resolved (only possible on
47    /// extremely unusual configurations).
48    pub fn resolve() -> Result<Self> {
49        let exe = std::env::current_exe().context("locating current exe")?;
50        let exe_dir = exe
51            .parent()
52            .context("exe has no parent directory")?
53            .to_path_buf();
54
55        let (data_dir, portable) = if dir_is_writable(&exe_dir) {
56            (exe_dir.clone(), true)
57        } else {
58            let proj = directories::ProjectDirs::from("dev", "AIMedic", "dicom-viewer")
59                .context("could not resolve user data directory")?;
60            (proj.data_dir().to_path_buf(), false)
61        };
62
63        let logs_dir = data_dir.join("logs");
64        fs::create_dir_all(&logs_dir).ok();
65
66        Ok(Self {
67            exe_dir,
68            data_dir,
69            logs_dir,
70            portable,
71        })
72    }
73
74    /// `true` if the data directory lives next to the executable
75    /// (writable exe dir), `false` if the user-data fallback is in use.
76    pub fn is_portable(&self) -> bool {
77        self.portable
78    }
79
80    /// Full path to `config.toml` (relative to [`Self::data_dir`]).
81    pub fn config_path(&self) -> PathBuf {
82        self.data_dir.join(CONFIG_FILE)
83    }
84}
85
86fn dir_is_writable(dir: &Path) -> bool {
87    let probe = dir.join(".dicom-viewer-write-probe");
88    match fs::write(&probe, b"") {
89        Ok(()) => {
90            let _ = fs::remove_file(&probe);
91            true
92        }
93        Err(_) => false,
94    }
95}
96
97/// On-disk user settings. Serialised to `config.toml` next to the
98/// executable (portable) or in the per-user data directory.
99///
100/// All fields default cleanly, so a missing or partial `config.toml` is
101/// not an error — see the `#[serde(default)]` on each subsection.
102#[derive(Debug, Clone, Default, Serialize, Deserialize)]
103#[serde(default)]
104pub struct Config {
105    /// Remembered initial window size.
106    pub window: WindowConfig,
107    /// Theme + visible-panel state.
108    pub ui: UiConfig,
109    /// Whether the user has acknowledged the "Not for diagnostic use"
110    /// disclaimer. Once `true`, the disclaimer never reappears on this
111    /// machine.
112    pub disclaimer_acknowledged: bool,
113}
114
115/// Initial viewer window dimensions in logical pixels.
116#[derive(Debug, Clone, Serialize, Deserialize)]
117#[serde(default)]
118pub struct WindowConfig {
119    /// Width in logical pixels.
120    pub width: f32,
121    /// Height in logical pixels.
122    pub height: f32,
123}
124
125/// Persisted UI preferences (theme + panel visibility).
126#[derive(Debug, Clone, Serialize, Deserialize)]
127#[serde(default)]
128pub struct UiConfig {
129    /// Currently the theme is dark-only; this field is reserved.
130    pub dark_mode: bool,
131    /// Show the right-hand metadata panel on startup.
132    pub show_metadata_panel: bool,
133    /// Show the left-hand study browser on startup.
134    pub show_study_browser: bool,
135}
136
137impl Default for WindowConfig {
138    fn default() -> Self {
139        Self {
140            width: 1280.0,
141            height: 800.0,
142        }
143    }
144}
145
146impl Default for UiConfig {
147    fn default() -> Self {
148        Self {
149            dark_mode: true,
150            show_metadata_panel: false,
151            show_study_browser: true,
152        }
153    }
154}
155
156impl Config {
157    /// Read `config.toml` from `paths.config_path()`. Returns
158    /// [`Config::default`] when the file is absent (first run).
159    ///
160    /// # Errors
161    /// Returns an error only when the file exists but cannot be read or
162    /// parsed as TOML.
163    pub fn load(paths: &Paths) -> Result<Self> {
164        let path = paths.config_path();
165        if !path.exists() {
166            return Ok(Self::default());
167        }
168        // nosemgrep: path-traversal — `path` is composed from `paths.data_dir`,
169        // which is derived from `std::env::current_exe()` or the platform
170        // user-data dir (see `Paths::resolve`). There is no untrusted input
171        // along this code path; this is a desktop binary, not an HTTP handler.
172        let text =
173            fs::read_to_string(&path).with_context(|| format!("reading {}", path.display()))?;
174        let cfg: Self =
175            toml::from_str(&text).with_context(|| format!("parsing {}", path.display()))?;
176        Ok(cfg)
177    }
178
179    /// Write `self` as pretty TOML to `paths.config_path()`, creating the
180    /// parent directory if needed. Called on app exit.
181    ///
182    /// # Errors
183    /// Filesystem or serialisation failures.
184    pub fn save(&self, paths: &Paths) -> Result<()> {
185        let path = paths.config_path();
186        let text = toml::to_string_pretty(self).context("serializing config")?;
187        if let Some(parent) = path.parent() {
188            fs::create_dir_all(parent).ok();
189        }
190        fs::write(&path, text).with_context(|| format!("writing {}", path.display()))?;
191        Ok(())
192    }
193}