Skip to main content

studio_worker/
config.rs

1//! Persistent config in `~/.config/minis-studio-worker/config.toml` (Linux/macOS)
2//! or `%APPDATA%\minis-studio-worker\config.toml` (Windows).
3//!
4//! Every load/save emits a structured tracing breadcrumb so operators
5//! can tell from `journalctl` which file the worker actually consulted
6//! (and whether the file existed, was freshly bootstrapped with
7//! defaults, or failed to read/parse).  The events deliberately omit
8//! the secret fields
9//! (`auth_token`, `registration_secret`) so logs can be shipped
10//! off-box without leaking credentials.  See `tests/config_tracing.rs`
11//! for the regression contract.
12//!
13//! What lives here vs. what's stripped from the user-editable surface:
14//!
15//! * **Operator-facing**: `api_base_url`, `vram_threshold_gb`,
16//!   `start_minimised`, `auto_update_*`, `models_root`.
17//!   These are exposed in the tray UI's Config tab (through the daemon's
18//!   `PUT /daemon/config`).
19//! * **Internal state, persisted but not user-editable**: `worker_id`,
20//!   `auth_token`, `install_id`, `registration_request_id`,
21//!   `registration_secret`.  The auto-register flow owns them; the UI
22//!   hides them entirely.
23//! * **Engine selection**: removed.  The runtime always builds a
24//!   `MultiEngine` containing every backend compiled into this binary
25//!   and routes each job to the right one.
26
27use anyhow::{anyhow, Context, Result};
28use directories::{ProjectDirs, UserDirs};
29use parking_lot::Mutex;
30use serde::{Deserialize, Serialize};
31use std::path::{Path, PathBuf};
32
33/// Tracing target for config persistence events.  Stable so operators
34/// can filter with `RUST_LOG=studio_worker::config=debug`.
35const TRACE_TARGET: &str = "studio_worker::config";
36
37#[derive(Debug, Clone, Serialize, Deserialize)]
38pub struct Config {
39    /// Base URL of the studio API (e.g. `https://studio.minis.gg/`).
40    pub api_base_url: String,
41    /// Worker id, written on operator approval.  Cleared by
42    /// `studio-worker register --reset`.  Internal — not surfaced as
43    /// a user-editable widget.
44    #[serde(default, skip_serializing_if = "Option::is_none")]
45    pub worker_id: Option<String>,
46    /// Per-worker token issued at registration.  Internal — never
47    /// surfaced in the UI and redacted from log events.
48    #[serde(default, skip_serializing_if = "Option::is_none")]
49    pub auth_token: Option<String>,
50    /// VRAM threshold the worker reports as its max claim size, in GB.
51    pub vram_threshold_gb: f32,
52    /// Start the desktop UI minimised (taskbar only — not hidden, so
53    /// the window stays reachable even when no tray host exists).
54    /// Default `true`: a worker auto-started at login must not pop a
55    /// window over the operator's session.
56    #[serde(default = "default_start_minimised")]
57    pub start_minimised: bool,
58    /// Periodically check the release feed and auto-install newer
59    /// versions when no job is running.
60    #[serde(default = "default_auto_update_enabled")]
61    pub auto_update_enabled: bool,
62    /// How often (seconds) to check the release feed.
63    #[serde(default = "default_auto_update_interval")]
64    pub auto_update_interval_secs: u64,
65    /// GitHub Releases feed for this binary.
66    #[serde(default = "default_auto_update_feed")]
67    pub auto_update_feed: String,
68    /// Whether to upgrade to pre-release versions.
69    #[serde(default)]
70    pub auto_update_prerelease: bool,
71    /// Root directory for downloaded model files (per-engine
72    /// subdirectories: `llm/`, `stt/`, `tts/`, `image/`, `video/`).
73    /// Defaults to `~/models` (resolved at load time).
74    #[serde(default = "default_models_root_persisted")]
75    pub models_root: PathBuf,
76    /// Maximum number of WebSocket reconnect attempts before the
77    /// worker gives up and exits non-zero (relying on the service
78    /// manager to restart it).  `0` = infinite, which is the default:
79    /// the turnkey install runs with no service manager, so a worker
80    /// that exits on a transient outage would never come back.  Pin a
81    /// finite value only for a fail-fast setup behind systemd.
82    #[serde(default, skip_serializing_if = "Option::is_none")]
83    pub ws_reconnect_attempts: Option<u32>,
84    /// Preferred TCP port for the always-on local API
85    /// (`127.0.0.1`).  `None` uses the built-in default; the
86    /// `STUDIO_WORKER_LOCAL_API_PORT` env var overrides both.
87    #[serde(default, skip_serializing_if = "Option::is_none")]
88    pub local_api_port: Option<u16>,
89    /// Port of the LAN streaming speech listener (`0.0.0.0`).  `None` uses
90    /// the built-in default; `STUDIO_WORKER_STREAM_PORT` overrides both.
91    #[serde(default, skip_serializing_if = "Option::is_none")]
92    pub stream_port: Option<u16>,
93    /// Bearer token local API clients must present.  Generated once
94    /// on first launch and persisted.  Internal — never surfaced in
95    /// the UI and redacted from log events; local clients discover it
96    /// via the owner-only `local-api.json` file next to this config.
97    #[serde(default, skip_serializing_if = "Option::is_none")]
98    pub local_api_token: Option<String>,
99    /// Per-install UUID written once on first launch.  Stable across
100    /// worker restarts so the studio can dedup pending requests.
101    /// Internal state, populated by the auto-register flow.
102    #[serde(default, skip_serializing_if = "Option::is_none")]
103    pub install_id: Option<String>,
104    /// `requestId` returned by `POST /workers/register-request`.
105    /// Cleared on approval / rejection.  Internal.
106    #[serde(default, skip_serializing_if = "Option::is_none")]
107    pub registration_request_id: Option<String>,
108    /// Bearer secret presented when polling the request status.
109    /// Cleared on approval / rejection.  Internal.
110    #[serde(default, skip_serializing_if = "Option::is_none")]
111    pub registration_secret: Option<String>,
112}
113
114fn default_auto_update_enabled() -> bool {
115    true
116}
117fn default_start_minimised() -> bool {
118    true
119}
120fn default_auto_update_interval() -> u64 {
121    1800
122}
123fn default_auto_update_feed() -> String {
124    "https://api.github.com/repos/webbertakken/studio-worker/releases".into()
125}
126
127/// Resolve `~/models` for the user running the worker.  Falls back to
128/// `$TMPDIR/studio-worker-models` on the (extremely unusual) machines
129/// where `directories` can't find a home directory.
130pub fn default_models_root() -> PathBuf {
131    models_root_from(home_dir())
132}
133
134/// The running user's home directory, if `directories` can resolve it.
135/// Returns `None` on the home-less boxes the fallbacks below guard
136/// against (containers, `DynamicUser=` systemd units, minimal images).
137fn home_dir() -> Option<PathBuf> {
138    UserDirs::new().map(|d| d.home_dir().to_path_buf())
139}
140
141/// Resolve the default models root given the running user's home dir.
142/// Pure (the home dir is injected) so both the normal path and the
143/// home-less fallback are unit-testable without touching the host.
144fn models_root_from(home: Option<PathBuf>) -> PathBuf {
145    match home {
146        Some(home) => home.join("models"),
147        None => std::env::temp_dir().join("studio-worker-models"),
148    }
149}
150
151fn default_models_root_persisted() -> PathBuf {
152    default_models_root()
153}
154
155/// Resolve a leading `~` to the running user's home dir.  Stops the
156/// worker from creating a literal `~` directory on disk when the
157/// config carries an unexpanded path (most commonly: a hand-edited
158/// `models_root = "~/models"`).
159fn expand_home(path: PathBuf) -> PathBuf {
160    expand_home_with(path, home_dir())
161}
162
163/// Pure core of [`expand_home`]: the home dir is injected so the
164/// home-less branches (where the path stays unexpanded) are testable
165/// without depending on the host having a real home directory.
166fn expand_home_with(path: PathBuf, home: Option<PathBuf>) -> PathBuf {
167    let s = path.to_string_lossy();
168    if s == "~" {
169        return home.unwrap_or(path);
170    }
171    if let Some(rest) = s.strip_prefix("~/") {
172        if let Some(home) = home {
173            return home.join(rest);
174        }
175    }
176    path
177}
178
179impl Default for Config {
180    fn default() -> Self {
181        Self {
182            api_base_url: "https://studio.minis.gg/".into(),
183            worker_id: None,
184            auth_token: None,
185            vram_threshold_gb: 12.0,
186            start_minimised: default_start_minimised(),
187            auto_update_enabled: default_auto_update_enabled(),
188            auto_update_interval_secs: default_auto_update_interval(),
189            auto_update_feed: default_auto_update_feed(),
190            auto_update_prerelease: false,
191            models_root: default_models_root(),
192            ws_reconnect_attempts: None,
193            local_api_port: None,
194            stream_port: None,
195            local_api_token: None,
196            install_id: None,
197            registration_request_id: None,
198            registration_secret: None,
199        }
200    }
201}
202
203fn default_config_path() -> Result<PathBuf> {
204    let dirs = ProjectDirs::from("gg", "minis", "minis-studio-worker")
205        .ok_or_else(|| anyhow!("cannot resolve config directory"))?;
206    Ok(dirs.config_dir().join("config.toml"))
207}
208
209/// Path to the local model catalog (`models.json`), next to the
210/// **active** config file.  Deriving from the config path (rather
211/// than the ProjectDirs singleton) keeps `--config` overrides — and
212/// the test suites that use temp configs — fully isolated from the
213/// real per-user state.
214pub fn catalog_path_for(config_path: &Path) -> Option<PathBuf> {
215    config_path.parent().map(|dir| dir.join("models.json"))
216}
217
218/// Path to the model residency file (`residency.json`), next to the
219/// active config file, for the same isolation reason as [`catalog_path_for`].
220pub fn residency_path_for(config_path: &Path) -> Option<PathBuf> {
221    config_path.parent().map(|dir| dir.join("residency.json"))
222}
223
224/// Path to the local API discovery file (`local-api.json`), next to
225/// the active config file.  Written on every successful bind so local
226/// clients can find the URL + bearer token without parsing logs;
227/// owner-only because it carries the token.  Sibling-of-config for
228/// the same isolation reason as [`catalog_path_for`].
229pub fn local_api_discovery_path_for(config_path: &Path) -> Option<PathBuf> {
230    config_path.parent().map(|dir| dir.join("local-api.json"))
231}
232
233pub fn resolve_path(override_path: Option<&str>) -> Result<PathBuf> {
234    if let Some(p) = override_path {
235        Ok(PathBuf::from(p))
236    } else {
237        default_config_path()
238    }
239}
240
241/// Lower a freshly-bootstrapped VRAM threshold to the card's detected
242/// capacity.  Pure over the probe so it's testable without a GPU.
243/// Only *lowers* (never raises) and only when the probe found real
244/// VRAM (`> 0`): the default 12 GB on an 8 GB card would otherwise make
245/// the worker advertise a budget it can't fit and OOM on the first
246/// job.  A failed probe (0) leaves the default — the threshold is then
247/// the only capacity signal we have.
248pub fn clamp_initial_threshold(default_threshold: f32, detected_vram: f32) -> f32 {
249    if detected_vram > 0.0 && default_threshold > detected_vram {
250        detected_vram
251    } else {
252        default_threshold
253    }
254}
255
256/// Read the config at `path` without ever writing it: defaults when it is
257/// missing or unreadable.  For the tray UI, which reads a few window
258/// preferences before the daemon answers but never owns the file.
259pub fn peek(path: &Path) -> Config {
260    std::fs::read_to_string(path)
261        .ok()
262        .and_then(|text| toml::from_str(&text).ok())
263        .unwrap_or_default()
264}
265
266pub fn load(override_path: Option<&str>) -> Result<(Config, PathBuf)> {
267    let path = resolve_path(override_path)?;
268    if !path.exists() {
269        let mut cfg = Config::default();
270        // First launch only: clamp the initial threshold to the GPU we
271        // can see, so a small card doesn't over-advertise out of the
272        // box.  The probe is cached (OnceLock) and returns 0 quickly on
273        // hosts with no NVIDIA tooling, where the default stands.
274        let detected = crate::sys::detect_vram_gb().unwrap_or(0.0);
275        cfg.vram_threshold_gb = clamp_initial_threshold(cfg.vram_threshold_gb, detected);
276        save(&cfg, &path)?;
277        tracing::info!(
278            target: TRACE_TARGET,
279            op = "load",
280            source = "default_created",
281            config_path = %path.display(),
282            api_base_url = %cfg.api_base_url,
283            vram_threshold_gb = cfg.vram_threshold_gb,
284            models_root = %cfg.models_root.display(),
285            "config file missing — bootstrapped defaults"
286        );
287        return Ok((cfg, path));
288    }
289    let text = match std::fs::read_to_string(&path) {
290        Ok(text) => text,
291        Err(e) => {
292            // Mirror save()'s failure breadcrumb: an unreadable config
293            // is never silent.  The io error names the path/cause only
294            // (never file content), so it is safe to log verbatim.
295            tracing::warn!(
296                target: TRACE_TARGET,
297                op = "load",
298                config_path = %path.display(),
299                error = %e,
300                "failed to read config file"
301            );
302            return Err(e).with_context(|| format!("reading {}", path.display()));
303        }
304    };
305    let mut cfg: Config = match toml::from_str(&text) {
306        Ok(cfg) => cfg,
307        Err(e) => {
308            // Deliberately omit the parser detail: toml renders the
309            // offending source span, which can echo a secret value
310            // (e.g. an unterminated `auth_token = "...`).  The path +
311            // category keep the failure operator-visible without
312            // risking a credential leak in journalctl / Sentry.
313            tracing::warn!(
314                target: TRACE_TARGET,
315                op = "load",
316                config_path = %path.display(),
317                "config file is not valid TOML"
318            );
319            return Err(e).context("parsing config.toml");
320        }
321    };
322    cfg.models_root = expand_home(std::mem::take(&mut cfg.models_root));
323    tracing::debug!(
324        target: TRACE_TARGET,
325        op = "load",
326        source = "existing_file",
327        config_path = %path.display(),
328        api_base_url = %cfg.api_base_url,
329        vram_threshold_gb = cfg.vram_threshold_gb,
330        models_root = %cfg.models_root.display(),
331        worker_id = cfg.worker_id.as_deref().unwrap_or("(unregistered)"),
332        has_auth_token = cfg.auth_token.is_some(),
333        "loaded config from disk"
334    );
335    Ok((cfg, path))
336}
337
338pub fn save(cfg: &Config, path: &Path) -> Result<()> {
339    match write_config(cfg, path) {
340        Ok(bytes) => {
341            tracing::debug!(
342                target: TRACE_TARGET,
343                op = "save",
344                config_path = %path.display(),
345                vram_threshold_gb = cfg.vram_threshold_gb,
346                    models_root = %cfg.models_root.display(),
347                bytes = bytes,
348                "persisted config to disk"
349            );
350            Ok(())
351        }
352        Err(e) => {
353            // Log at the source so a failed persist is never silent,
354            // regardless of whether the caller logs the returned Err
355            // (the UI Save button discards it, the auto-register flow
356            // logs it with extra context).  `error` carries an
357            // IO / serialisation message + the path only — never the
358            // config's secret fields — so this stays log-shippable.
359            tracing::warn!(
360                target: TRACE_TARGET,
361                op = "save",
362                config_path = %path.display(),
363                error = %e,
364                "failed to persist config to disk"
365            );
366            Err(e)
367        }
368    }
369}
370
371/// Side-effecting half of [`save`]: serialise + write, returning the
372/// byte count on success.  Split out so `save` can log a structured
373/// event on both the success and failure branch without duplicating
374/// the happy path.
375fn write_config(cfg: &Config, path: &Path) -> Result<usize> {
376    if let Some(parent) = path.parent() {
377        std::fs::create_dir_all(parent)
378            .with_context(|| format!("creating {}", parent.display()))?;
379    }
380    let text = toml::to_string_pretty(cfg).with_context(|| "serialising config")?;
381    let bytes = text.len();
382    write_atomic(path, text.as_bytes())?;
383    Ok(bytes)
384}
385
386/// Persist `bytes` to `path` atomically and owner-only.  The config
387/// carries the worker's identity and registration secrets
388/// (`auth_token`, `registration_secret`), so a plain `fs::write` is
389/// unsafe on two counts:
390///
391/// * **Durability**: an interrupted write (crash, power loss, full
392///   disk) truncates `path` to a half-written, unparseable file,
393///   wiping the worker's registration and forcing a fresh operator
394///   approval.  We stream into a temp file in the *same directory* (so
395///   the final step is a same-filesystem rename, which is atomic) and
396///   rename it over the target.  A failure leaves the previous config
397///   intact and drops the temp file.
398/// * **Confidentiality**: `fs::write` honours the umask and typically
399///   lands `0644`, exposing the secrets to every other local user.
400///   `tempfile` creates the temp file `0600` on Unix and `persist`
401///   keeps that mode through the rename.
402pub(crate) fn write_atomic(path: &Path, bytes: &[u8]) -> Result<()> {
403    use std::io::Write as _;
404    let dir = match path.parent() {
405        Some(p) if !p.as_os_str().is_empty() => p,
406        _ => Path::new("."),
407    };
408    let mut tmp = tempfile::NamedTempFile::new_in(dir)
409        .with_context(|| format!("creating temp file in {}", dir.display()))?;
410    tmp.write_all(bytes)
411        .with_context(|| "writing temp config")?;
412    tmp.as_file()
413        .sync_all()
414        .with_context(|| "flushing temp config to disk")?;
415    tmp.persist(path)
416        .map_err(|e| anyhow!("atomically replacing {}: {}", path.display(), e.error))?;
417    Ok(())
418}
419
420/// Names of the operator-editable fields that differ between `a` and `b`,
421/// in declaration order.  Backs the tray UI's dirty-check and the daemon's
422/// config-update breadcrumb, so both agree on what the operator can change.
423pub fn changed_fields(a: &Config, b: &Config) -> Vec<&'static str> {
424    let mut fields = Vec::new();
425    if a.api_base_url != b.api_base_url {
426        fields.push("api_base_url");
427    }
428    if (a.vram_threshold_gb - b.vram_threshold_gb).abs() >= f32::EPSILON {
429        fields.push("vram_threshold_gb");
430    }
431    if a.start_minimised != b.start_minimised {
432        fields.push("start_minimised");
433    }
434    if a.auto_update_enabled != b.auto_update_enabled {
435        fields.push("auto_update_enabled");
436    }
437    if a.auto_update_interval_secs != b.auto_update_interval_secs {
438        fields.push("auto_update_interval_secs");
439    }
440    if a.auto_update_feed != b.auto_update_feed {
441        fields.push("auto_update_feed");
442    }
443    if a.auto_update_prerelease != b.auto_update_prerelease {
444        fields.push("auto_update_prerelease");
445    }
446    if a.models_root != b.models_root {
447        fields.push("models_root");
448    }
449    fields
450}
451
452/// Wrap a Config in a mutex for use across the runtime.
453pub type SharedConfig = std::sync::Arc<Mutex<Config>>;
454
455pub fn shared(cfg: Config) -> SharedConfig {
456    std::sync::Arc::new(Mutex::new(cfg))
457}
458
459#[cfg(test)]
460mod tests {
461    use super::*;
462    use tempfile::tempdir;
463
464    #[test]
465    fn start_minimised_defaults_true_for_configs_predating_the_field() {
466        // Operators upgrading from a config.toml written before the
467        // field existed must get the minimised-by-default behaviour.
468        let cfg: Config = toml::from_str(
469            r#"
470            api_base_url = "https://studio.minis.gg/"
471            vram_threshold_gb = 12.0
472            "#,
473        )
474        .unwrap();
475        assert!(cfg.start_minimised);
476    }
477
478    #[test]
479    fn a_config_that_still_sets_auto_start_loads_and_drops_it_on_save() {
480        let dir = tempdir().unwrap();
481        let path = dir.path().join("config.toml");
482        std::fs::write(
483            &path,
484            "api_base_url = \"https://studio.minis.gg/\"\nvram_threshold_gb = 8.0\nauto_start = false\n",
485        )
486        .unwrap();
487        let (cfg, _) = load(Some(&path.to_string_lossy())).unwrap();
488        save(&cfg, &path).unwrap();
489        let text = std::fs::read_to_string(&path).unwrap();
490        assert!(!text.contains("auto_start"), "{text}");
491    }
492
493    #[test]
494    fn peek_reads_without_writing_and_falls_back_to_defaults() {
495        let dir = tempdir().unwrap();
496        let path = dir.path().join("config.toml");
497        assert!(peek(&path).start_minimised);
498        assert!(!path.exists(), "peek never creates the file");
499        std::fs::write(
500            &path,
501            "api_base_url = \"https://x/\"\nvram_threshold_gb = 1.0\nstart_minimised = false\n",
502        )
503        .unwrap();
504        assert!(!peek(&path).start_minimised);
505        std::fs::write(&path, "not toml [").unwrap();
506        assert!(peek(&path).start_minimised);
507    }
508
509    #[test]
510    fn changed_fields_names_only_the_differing_editable_fields() {
511        let base = Config::default();
512        let mut edited = base.clone();
513        edited.vram_threshold_gb = base.vram_threshold_gb + 8.0;
514        edited.models_root = PathBuf::from("/tmp/other-models");
515        edited.worker_id = Some("not-editable".into());
516        assert_eq!(
517            changed_fields(&base, &edited),
518            vec!["vram_threshold_gb", "models_root"]
519        );
520        assert!(changed_fields(&base, &base).is_empty());
521    }
522
523    #[test]
524    fn default_values_are_sensible() {
525        let cfg = Config::default();
526        assert_eq!(cfg.api_base_url, "https://studio.minis.gg/");
527        assert!(
528            cfg.start_minimised,
529            "the UI must start minimised by default"
530        );
531        assert!(cfg.auto_update_enabled);
532        assert_eq!(cfg.auto_update_interval_secs, 1800);
533        assert!(!cfg.auto_update_prerelease);
534        assert!(cfg.auto_update_feed.contains("webbertakken/studio-worker"));
535        assert_eq!(cfg.vram_threshold_gb, 12.0);
536        assert!(cfg.worker_id.is_none());
537        assert!(cfg.auth_token.is_none());
538        // Models root defaults to ~/models (or a temp fallback on
539        // headless boxes without UserDirs).
540        let m = cfg.models_root.to_string_lossy().to_string();
541        assert!(m.ends_with("models") || m.contains("studio-worker-models"));
542    }
543
544    #[test]
545    fn clamp_initial_threshold_lowers_to_a_small_card_only() {
546        // 12 GB default on an 8 GB card → clamped to 8.
547        assert_eq!(clamp_initial_threshold(12.0, 8.0), 8.0);
548        // A big card leaves the conservative default alone.
549        assert_eq!(clamp_initial_threshold(12.0, 24.0), 12.0);
550        // Exact match: no change (boundary).
551        assert_eq!(clamp_initial_threshold(12.0, 12.0), 12.0);
552        // Failed probe (0): the default stands — it's our only signal.
553        assert_eq!(clamp_initial_threshold(12.0, 0.0), 12.0);
554    }
555
556    #[test]
557    fn resolve_path_uses_override_when_provided() {
558        let path = resolve_path(Some("/tmp/test-config.toml")).unwrap();
559        assert_eq!(path, PathBuf::from("/tmp/test-config.toml"));
560    }
561
562    #[test]
563    fn resolve_path_defaults_when_no_override() {
564        let path = resolve_path(None).unwrap();
565        let s = path.to_string_lossy();
566        assert!(
567            s.contains("minis-studio-worker") || s.contains("minis.gg.minis-studio-worker"),
568            "unexpected default path: {s}"
569        );
570        assert!(s.ends_with("config.toml"));
571    }
572
573    #[test]
574    fn load_creates_default_when_file_missing() {
575        let dir = tempdir().unwrap();
576        let path = dir.path().join("sub").join("config.toml");
577        let path_str = path.to_string_lossy().to_string();
578        let (cfg, returned_path) = load(Some(&path_str)).unwrap();
579        assert_eq!(returned_path, path);
580        assert_eq!(cfg.api_base_url, "https://studio.minis.gg/");
581        // File should have been written.
582        assert!(path.exists());
583    }
584
585    #[test]
586    fn round_trip_via_save_and_load_preserves_fields() {
587        let dir = tempdir().unwrap();
588        let path = dir.path().join("config.toml");
589        let cfg = Config {
590            worker_id: Some("w-123".into()),
591            auth_token: Some("tok-xyz".into()),
592            vram_threshold_gb: 24.0,
593            auto_update_prerelease: true,
594            models_root: PathBuf::from("/tmp/test-models"),
595            ..Config::default()
596        };
597        save(&cfg, &path).unwrap();
598
599        let path_str = path.to_string_lossy().to_string();
600        let (loaded, _) = load(Some(&path_str)).unwrap();
601        assert_eq!(loaded.api_base_url, cfg.api_base_url);
602        assert_eq!(loaded.worker_id, cfg.worker_id);
603        assert_eq!(loaded.auth_token, cfg.auth_token);
604        assert_eq!(loaded.vram_threshold_gb, cfg.vram_threshold_gb);
605        assert_eq!(loaded.auto_update_prerelease, cfg.auto_update_prerelease);
606        assert_eq!(loaded.models_root, cfg.models_root);
607    }
608
609    #[test]
610    fn local_api_fields_round_trip_and_default_to_none() {
611        // Configs predating the local API auth fields must load with
612        // both unset (token generated later, port falling back to the
613        // built-in default).
614        let cfg: Config = toml::from_str(
615            r#"
616            api_base_url = "https://studio.minis.gg/"
617            vram_threshold_gb = 12.0
618            "#,
619        )
620        .unwrap();
621        assert!(cfg.local_api_token.is_none());
622        assert!(cfg.local_api_port.is_none());
623
624        // And once set, both persist across save/load.
625        let dir = tempdir().unwrap();
626        let path = dir.path().join("config.toml");
627        let cfg = Config {
628            local_api_token: Some("tok-local-abc".into()),
629            local_api_port: Some(4123),
630            ..Config::default()
631        };
632        save(&cfg, &path).unwrap();
633        let (loaded, _) = load(Some(&path.to_string_lossy())).unwrap();
634        assert_eq!(loaded.local_api_token.as_deref(), Some("tok-local-abc"));
635        assert_eq!(loaded.local_api_port, Some(4123));
636    }
637
638    #[test]
639    fn load_and_save_tracing_never_leaks_the_local_api_token() {
640        // The config tracing breadcrumbs deliberately name individual
641        // fields; the local API token must never be one of them, or a
642        // shipped journal would hand every reader GPU access.
643        let dir = tempdir().unwrap();
644        let path = dir.path().join("config.toml");
645        let cfg = Config {
646            local_api_token: Some("super-secret-local-token".into()),
647            ..Config::default()
648        };
649        let out = crate::test_support::capture({
650            let path = path.clone();
651            move || {
652                save(&cfg, &path).unwrap();
653                let _ = load(Some(&path.to_string_lossy())).unwrap();
654            }
655        });
656        assert!(
657            !out.contains("super-secret-local-token"),
658            "config tracing must not carry the local API token: {out}"
659        );
660    }
661
662    #[test]
663    fn catalog_and_discovery_paths_are_siblings_of_the_active_config() {
664        // Deriving from the config path (not a ProjectDirs singleton)
665        // is what keeps `--config` runs and test suites from touching
666        // the real user's `models.json` / `local-api.json`.
667        let cfg = Path::new("/tmp/custom-dir/config.toml");
668        assert_eq!(
669            catalog_path_for(cfg),
670            Some(PathBuf::from("/tmp/custom-dir/models.json"))
671        );
672        assert_eq!(
673            local_api_discovery_path_for(cfg),
674            Some(PathBuf::from("/tmp/custom-dir/local-api.json"))
675        );
676        assert_eq!(
677            residency_path_for(cfg),
678            Some(PathBuf::from("/tmp/custom-dir/residency.json"))
679        );
680        // A parentless path yields None rather than a panic.
681        assert_eq!(catalog_path_for(Path::new("/")), None);
682    }
683
684    #[test]
685    fn shared_wraps_in_arc_mutex() {
686        let cfg = Config::default();
687        let shared = shared(cfg.clone());
688        let guard = shared.lock();
689        assert_eq!(guard.api_base_url, cfg.api_base_url);
690    }
691
692    #[test]
693    fn load_returns_error_on_malformed_toml() {
694        let dir = tempdir().unwrap();
695        let path = dir.path().join("config.toml");
696        std::fs::write(&path, "this :: is = not = toml = :").unwrap();
697        let path_str = path.to_string_lossy().to_string();
698        let err = load(Some(&path_str)).unwrap_err();
699        assert!(err.to_string().contains("parsing config.toml"));
700    }
701
702    #[test]
703    fn load_strips_legacy_engine_fields_silently() {
704        // Older configs had `engine`, `engines`, `auto_enabled`, `label`.
705        // serde::Deserialize on the new struct should ignore them (they
706        // aren't in the schema any more); the worker keeps running.
707        let dir = tempdir().unwrap();
708        let path = dir.path().join("config.toml");
709        let legacy = r#"
710            api_base_url = "https://example.invalid"
711            vram_threshold_gb = 8.0
712            engine = "multi"
713            engines = ["llama", "synthetic"]
714            auto_enabled = false
715            label = "alice's rig"
716        "#;
717        std::fs::write(&path, legacy).unwrap();
718        let (cfg, _) = load(Some(&path.to_string_lossy())).unwrap();
719        assert_eq!(cfg.api_base_url, "https://example.invalid");
720        assert_eq!(cfg.vram_threshold_gb, 8.0);
721    }
722
723    #[test]
724    fn load_expands_leading_tilde_in_models_root() {
725        // Users who hand-edit `config.toml` often write `~/models`;
726        // the worker must expand it, not create a literal `~` dir.
727        let dir = tempdir().unwrap();
728        let path = dir.path().join("config.toml");
729        let raw = r#"
730            api_base_url = "https://x.invalid"
731            vram_threshold_gb = 4.0
732            auto_update_enabled = false
733            auto_update_interval_secs = 1
734            auto_update_feed = "https://x.invalid"
735            auto_update_prerelease = false
736            models_root = "~/models-test"
737        "#;
738        std::fs::write(&path, raw).unwrap();
739        let (cfg, _) = load(Some(&path.to_string_lossy())).unwrap();
740        assert!(
741            cfg.models_root.is_absolute(),
742            "~/ should expand to an absolute path, got {}",
743            cfg.models_root.display()
744        );
745        assert!(cfg.models_root.ends_with("models-test"));
746    }
747
748    #[test]
749    fn expand_home_leaves_absolute_paths_alone() {
750        let p = PathBuf::from("/tmp/anywhere");
751        assert_eq!(expand_home(p.clone()), p);
752    }
753
754    #[test]
755    fn expand_home_handles_bare_tilde() {
756        let expanded = expand_home(PathBuf::from("~"));
757        assert!(
758            expanded.is_absolute() || expanded == Path::new("~"),
759            "bare ~ expands to home (or stays put on weird boxes), got {}",
760            expanded.display()
761        );
762    }
763
764    // The injected-home seams below pin the home-less fallback paths
765    // (containers, `DynamicUser=` systemd units, minimal images where
766    // `UserDirs::new()` returns `None`) without depending on the host's
767    // real home directory.
768
769    #[test]
770    fn models_root_from_uses_home_when_available() {
771        let home = PathBuf::from("/home/someuser");
772        assert_eq!(models_root_from(Some(home.clone())), home.join("models"));
773    }
774
775    #[test]
776    fn models_root_from_falls_back_to_tmp_without_home() {
777        assert_eq!(
778            models_root_from(None),
779            std::env::temp_dir().join("studio-worker-models")
780        );
781    }
782
783    #[test]
784    fn expand_home_with_bare_tilde_uses_injected_home() {
785        let home = PathBuf::from("/home/x");
786        assert_eq!(
787            expand_home_with(PathBuf::from("~"), Some(home.clone())),
788            home
789        );
790    }
791
792    #[test]
793    fn expand_home_with_bare_tilde_without_home_stays_put() {
794        assert_eq!(
795            expand_home_with(PathBuf::from("~"), None),
796            PathBuf::from("~")
797        );
798    }
799
800    #[test]
801    fn expand_home_with_prefix_joins_injected_home() {
802        let home = PathBuf::from("/home/x");
803        assert_eq!(
804            expand_home_with(PathBuf::from("~/models"), Some(home.clone())),
805            home.join("models")
806        );
807    }
808
809    #[test]
810    fn expand_home_with_prefix_without_home_stays_unexpanded() {
811        let p = PathBuf::from("~/models");
812        assert_eq!(expand_home_with(p.clone(), None), p);
813    }
814
815    #[test]
816    fn expand_home_with_leaves_absolute_paths_alone() {
817        let p = PathBuf::from("/tmp/anywhere");
818        assert_eq!(
819            expand_home_with(p.clone(), Some(PathBuf::from("/home/x"))),
820            p
821        );
822    }
823
824    #[cfg(unix)]
825    #[test]
826    fn save_writes_config_owner_only_because_it_holds_secrets() {
827        // config.toml persists `auth_token` + `registration_secret`.
828        // A plain `fs::write` honours the umask and typically lands
829        // `0644`, exposing those credentials to every other local
830        // user.  The atomic temp-file write must leave the file
831        // owner-only (`0600`).
832        use std::os::unix::fs::PermissionsExt;
833        let dir = tempdir().unwrap();
834        let path = dir.path().join("config.toml");
835        let cfg = Config {
836            auth_token: Some("super-secret-token".into()),
837            registration_secret: Some("reg-secret".into()),
838            ..Config::default()
839        };
840        save(&cfg, &path).unwrap();
841        let mode = std::fs::metadata(&path).unwrap().permissions().mode();
842        assert_eq!(
843            mode & 0o077,
844            0,
845            "secrets-bearing config must not be group/world-accessible; got mode {mode:o}"
846        );
847    }
848
849    #[test]
850    fn save_atomically_replaces_existing_config_without_temp_litter() {
851        // A second save must fully replace the file (no stale fields
852        // from a longer previous version) and leave no temp-file
853        // siblings behind from the write-then-rename dance.
854        let dir = tempdir().unwrap();
855        let path = dir.path().join("config.toml");
856
857        let big = Config {
858            api_base_url: "https://a-very-long-host-name.example.invalid/studio/".into(),
859            worker_id: Some("worker-with-a-longish-id-000000".into()),
860            ..Config::default()
861        };
862        save(&big, &path).unwrap();
863
864        let small = Config {
865            api_base_url: "https://x/".into(),
866            ..Config::default()
867        };
868        save(&small, &path).unwrap();
869
870        let (loaded, _) = load(Some(&path.to_string_lossy())).unwrap();
871        assert_eq!(loaded.api_base_url, "https://x/");
872        assert!(
873            loaded.worker_id.is_none(),
874            "a replacing save must not leave the previous worker_id behind"
875        );
876
877        let names: Vec<String> = std::fs::read_dir(dir.path())
878            .unwrap()
879            .map(|e| e.unwrap().file_name().to_string_lossy().to_string())
880            .collect();
881        assert_eq!(
882            names,
883            vec!["config.toml".to_string()],
884            "atomic save must leave only the target file, found: {names:?}"
885        );
886    }
887}