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//!   `auto_start`, `auto_update_*`, `models_root`.
17//!   These are exposed in the desktop UI's Config tab.
18//! * **Internal state, persisted but not user-editable**: `worker_id`,
19//!   `auth_token`, `install_id`, `registration_request_id`,
20//!   `registration_secret`.  The auto-register flow owns them; the UI
21//!   hides them entirely.
22//! * **Engine selection**: removed.  The runtime always builds a
23//!   `MultiEngine` containing every backend compiled into this binary
24//!   and routes each job to the right one.
25
26use anyhow::{anyhow, Context, Result};
27use directories::{ProjectDirs, UserDirs};
28use parking_lot::Mutex;
29use serde::{Deserialize, Serialize};
30use std::path::{Path, PathBuf};
31
32/// Tracing target for config persistence events.  Stable so operators
33/// can filter with `RUST_LOG=studio_worker::config=debug`.
34const TRACE_TARGET: &str = "studio_worker::config";
35
36#[derive(Debug, Clone, Serialize, Deserialize)]
37pub struct Config {
38    /// Base URL of the studio API (e.g. `https://studio.minis.gg/`).
39    pub api_base_url: String,
40    /// Worker id, written on operator approval.  Cleared by
41    /// `studio-worker register --reset`.  Internal — not surfaced as
42    /// a user-editable widget.
43    #[serde(default, skip_serializing_if = "Option::is_none")]
44    pub worker_id: Option<String>,
45    /// Per-worker token issued at registration.  Internal — never
46    /// surfaced in the UI and redacted from log events.
47    #[serde(default, skip_serializing_if = "Option::is_none")]
48    pub auth_token: Option<String>,
49    /// VRAM threshold the worker reports as its max claim size, in GB.
50    pub vram_threshold_gb: f32,
51    /// Whether to auto-launch the run loop at boot via the OS service.
52    pub auto_start: bool,
53    /// Start the desktop UI minimised (taskbar only — not hidden, so
54    /// the window stays reachable even when no tray host exists).
55    /// Default `true`: a worker auto-started at login must not pop a
56    /// window over the operator's session.
57    #[serde(default = "default_start_minimised")]
58    pub start_minimised: bool,
59    /// Periodically check the release feed and auto-install newer
60    /// versions when no job is running.
61    #[serde(default = "default_auto_update_enabled")]
62    pub auto_update_enabled: bool,
63    /// How often (seconds) to check the release feed.
64    #[serde(default = "default_auto_update_interval")]
65    pub auto_update_interval_secs: u64,
66    /// GitHub Releases feed for this binary.
67    #[serde(default = "default_auto_update_feed")]
68    pub auto_update_feed: String,
69    /// Whether to upgrade to pre-release versions.
70    #[serde(default)]
71    pub auto_update_prerelease: bool,
72    /// Root directory for downloaded model files (per-engine
73    /// subdirectories: `llm/`, `stt/`, `tts/`, `image/`, `video/`).
74    /// Defaults to `~/models` (resolved at load time).
75    #[serde(default = "default_models_root_persisted")]
76    pub models_root: PathBuf,
77    /// Maximum number of WebSocket reconnect attempts before the
78    /// worker gives up and exits non-zero (relying on the service
79    /// manager to restart it).  `0` = infinite.  Defaults to `5`.
80    #[serde(default, skip_serializing_if = "Option::is_none")]
81    pub ws_reconnect_attempts: Option<u32>,
82    /// Per-install UUID written once on first launch.  Stable across
83    /// worker restarts so the studio can dedup pending requests.
84    /// Internal state, populated by the auto-register flow.
85    #[serde(default, skip_serializing_if = "Option::is_none")]
86    pub install_id: Option<String>,
87    /// `requestId` returned by `POST /workers/register-request`.
88    /// Cleared on approval / rejection.  Internal.
89    #[serde(default, skip_serializing_if = "Option::is_none")]
90    pub registration_request_id: Option<String>,
91    /// Bearer secret presented when polling the request status.
92    /// Cleared on approval / rejection.  Internal.
93    #[serde(default, skip_serializing_if = "Option::is_none")]
94    pub registration_secret: Option<String>,
95}
96
97fn default_auto_update_enabled() -> bool {
98    true
99}
100fn default_start_minimised() -> bool {
101    true
102}
103fn default_auto_update_interval() -> u64 {
104    1800
105}
106fn default_auto_update_feed() -> String {
107    "https://api.github.com/repos/webbertakken/studio-worker/releases".into()
108}
109
110/// Resolve `~/models` for the user running the worker.  Falls back to
111/// `$TMPDIR/studio-worker-models` on the (extremely unusual) machines
112/// where `directories` can't find a home directory.
113pub fn default_models_root() -> PathBuf {
114    models_root_from(home_dir())
115}
116
117/// The running user's home directory, if `directories` can resolve it.
118/// Returns `None` on the home-less boxes the fallbacks below guard
119/// against (containers, `DynamicUser=` systemd units, minimal images).
120fn home_dir() -> Option<PathBuf> {
121    UserDirs::new().map(|d| d.home_dir().to_path_buf())
122}
123
124/// Resolve the default models root given the running user's home dir.
125/// Pure (the home dir is injected) so both the normal path and the
126/// home-less fallback are unit-testable without touching the host.
127fn models_root_from(home: Option<PathBuf>) -> PathBuf {
128    match home {
129        Some(home) => home.join("models"),
130        None => std::env::temp_dir().join("studio-worker-models"),
131    }
132}
133
134fn default_models_root_persisted() -> PathBuf {
135    default_models_root()
136}
137
138/// Resolve a leading `~` to the running user's home dir.  Stops the
139/// worker from creating a literal `~` directory on disk when the
140/// config carries an unexpanded path (most commonly: a hand-edited
141/// `models_root = "~/models"`).
142fn expand_home(path: PathBuf) -> PathBuf {
143    expand_home_with(path, home_dir())
144}
145
146/// Pure core of [`expand_home`]: the home dir is injected so the
147/// home-less branches (where the path stays unexpanded) are testable
148/// without depending on the host having a real home directory.
149fn expand_home_with(path: PathBuf, home: Option<PathBuf>) -> PathBuf {
150    let s = path.to_string_lossy();
151    if s == "~" {
152        return home.unwrap_or(path);
153    }
154    if let Some(rest) = s.strip_prefix("~/") {
155        if let Some(home) = home {
156            return home.join(rest);
157        }
158    }
159    path
160}
161
162impl Default for Config {
163    fn default() -> Self {
164        Self {
165            api_base_url: "https://studio.minis.gg/".into(),
166            worker_id: None,
167            auth_token: None,
168            vram_threshold_gb: 12.0,
169            auto_start: true,
170            start_minimised: default_start_minimised(),
171            auto_update_enabled: default_auto_update_enabled(),
172            auto_update_interval_secs: default_auto_update_interval(),
173            auto_update_feed: default_auto_update_feed(),
174            auto_update_prerelease: false,
175            models_root: default_models_root(),
176            ws_reconnect_attempts: None,
177            install_id: None,
178            registration_request_id: None,
179            registration_secret: None,
180        }
181    }
182}
183
184fn default_config_path() -> Result<PathBuf> {
185    let dirs = ProjectDirs::from("gg", "minis", "minis-studio-worker")
186        .ok_or_else(|| anyhow!("cannot resolve config directory"))?;
187    Ok(dirs.config_dir().join("config.toml"))
188}
189
190/// Path to the local model catalog (`models.json`), in the config dir next to
191/// `config.toml`. The local image API seeds this on first use.
192pub fn default_catalog_path() -> Result<PathBuf> {
193    let dirs = ProjectDirs::from("gg", "minis", "minis-studio-worker")
194        .ok_or_else(|| anyhow!("cannot resolve config directory"))?;
195    Ok(dirs.config_dir().join("models.json"))
196}
197
198pub fn resolve_path(override_path: Option<&str>) -> Result<PathBuf> {
199    if let Some(p) = override_path {
200        Ok(PathBuf::from(p))
201    } else {
202        default_config_path()
203    }
204}
205
206pub fn load(override_path: Option<&str>) -> Result<(Config, PathBuf)> {
207    let path = resolve_path(override_path)?;
208    if !path.exists() {
209        let cfg = Config::default();
210        save(&cfg, &path)?;
211        tracing::info!(
212            target: TRACE_TARGET,
213            op = "load",
214            source = "default_created",
215            config_path = %path.display(),
216            api_base_url = %cfg.api_base_url,
217            vram_threshold_gb = cfg.vram_threshold_gb,
218            auto_start = cfg.auto_start,
219            models_root = %cfg.models_root.display(),
220            "config file missing — bootstrapped defaults"
221        );
222        return Ok((cfg, path));
223    }
224    let text = match std::fs::read_to_string(&path) {
225        Ok(text) => text,
226        Err(e) => {
227            // Mirror save()'s failure breadcrumb: an unreadable config
228            // is never silent.  The io error names the path/cause only
229            // (never file content), so it is safe to log verbatim.
230            tracing::warn!(
231                target: TRACE_TARGET,
232                op = "load",
233                config_path = %path.display(),
234                error = %e,
235                "failed to read config file"
236            );
237            return Err(e).with_context(|| format!("reading {}", path.display()));
238        }
239    };
240    let mut cfg: Config = match toml::from_str(&text) {
241        Ok(cfg) => cfg,
242        Err(e) => {
243            // Deliberately omit the parser detail: toml renders the
244            // offending source span, which can echo a secret value
245            // (e.g. an unterminated `auth_token = "...`).  The path +
246            // category keep the failure operator-visible without
247            // risking a credential leak in journalctl / Sentry.
248            tracing::warn!(
249                target: TRACE_TARGET,
250                op = "load",
251                config_path = %path.display(),
252                "config file is not valid TOML"
253            );
254            return Err(e).context("parsing config.toml");
255        }
256    };
257    cfg.models_root = expand_home(std::mem::take(&mut cfg.models_root));
258    tracing::debug!(
259        target: TRACE_TARGET,
260        op = "load",
261        source = "existing_file",
262        config_path = %path.display(),
263        api_base_url = %cfg.api_base_url,
264        vram_threshold_gb = cfg.vram_threshold_gb,
265        auto_start = cfg.auto_start,
266        models_root = %cfg.models_root.display(),
267        worker_id = cfg.worker_id.as_deref().unwrap_or("(unregistered)"),
268        has_auth_token = cfg.auth_token.is_some(),
269        "loaded config from disk"
270    );
271    Ok((cfg, path))
272}
273
274pub fn save(cfg: &Config, path: &Path) -> Result<()> {
275    match write_config(cfg, path) {
276        Ok(bytes) => {
277            tracing::debug!(
278                target: TRACE_TARGET,
279                op = "save",
280                config_path = %path.display(),
281                vram_threshold_gb = cfg.vram_threshold_gb,
282                auto_start = cfg.auto_start,
283                models_root = %cfg.models_root.display(),
284                bytes = bytes,
285                "persisted config to disk"
286            );
287            Ok(())
288        }
289        Err(e) => {
290            // Log at the source so a failed persist is never silent,
291            // regardless of whether the caller logs the returned Err
292            // (the UI Save button discards it, the auto-register flow
293            // logs it with extra context).  `error` carries an
294            // IO / serialisation message + the path only — never the
295            // config's secret fields — so this stays log-shippable.
296            tracing::warn!(
297                target: TRACE_TARGET,
298                op = "save",
299                config_path = %path.display(),
300                error = %e,
301                "failed to persist config to disk"
302            );
303            Err(e)
304        }
305    }
306}
307
308/// Side-effecting half of [`save`]: serialise + write, returning the
309/// byte count on success.  Split out so `save` can log a structured
310/// event on both the success and failure branch without duplicating
311/// the happy path.
312fn write_config(cfg: &Config, path: &Path) -> Result<usize> {
313    if let Some(parent) = path.parent() {
314        std::fs::create_dir_all(parent)
315            .with_context(|| format!("creating {}", parent.display()))?;
316    }
317    let text = toml::to_string_pretty(cfg).with_context(|| "serialising config")?;
318    let bytes = text.len();
319    write_atomic(path, text.as_bytes())?;
320    Ok(bytes)
321}
322
323/// Persist `bytes` to `path` atomically and owner-only.  The config
324/// carries the worker's identity and registration secrets
325/// (`auth_token`, `registration_secret`), so a plain `fs::write` is
326/// unsafe on two counts:
327///
328/// * **Durability**: an interrupted write (crash, power loss, full
329///   disk) truncates `path` to a half-written, unparseable file,
330///   wiping the worker's registration and forcing a fresh operator
331///   approval.  We stream into a temp file in the *same directory* (so
332///   the final step is a same-filesystem rename, which is atomic) and
333///   rename it over the target.  A failure leaves the previous config
334///   intact and drops the temp file.
335/// * **Confidentiality**: `fs::write` honours the umask and typically
336///   lands `0644`, exposing the secrets to every other local user.
337///   `tempfile` creates the temp file `0600` on Unix and `persist`
338///   keeps that mode through the rename.
339fn write_atomic(path: &Path, bytes: &[u8]) -> Result<()> {
340    use std::io::Write as _;
341    let dir = match path.parent() {
342        Some(p) if !p.as_os_str().is_empty() => p,
343        _ => Path::new("."),
344    };
345    let mut tmp = tempfile::NamedTempFile::new_in(dir)
346        .with_context(|| format!("creating temp file in {}", dir.display()))?;
347    tmp.write_all(bytes)
348        .with_context(|| "writing temp config")?;
349    tmp.as_file()
350        .sync_all()
351        .with_context(|| "flushing temp config to disk")?;
352    tmp.persist(path)
353        .map_err(|e| anyhow!("atomically replacing {}: {}", path.display(), e.error))?;
354    Ok(())
355}
356
357/// Wrap a Config in a mutex for use across the runtime.
358pub type SharedConfig = std::sync::Arc<Mutex<Config>>;
359
360pub fn shared(cfg: Config) -> SharedConfig {
361    std::sync::Arc::new(Mutex::new(cfg))
362}
363
364#[cfg(test)]
365mod tests {
366    use super::*;
367    use tempfile::tempdir;
368
369    #[test]
370    fn start_minimised_defaults_true_for_configs_predating_the_field() {
371        // Operators upgrading from a config.toml written before the
372        // field existed must get the minimised-by-default behaviour.
373        let cfg: Config = toml::from_str(
374            r#"
375            api_base_url = "https://studio.minis.gg/"
376            vram_threshold_gb = 12.0
377            auto_start = true
378            "#,
379        )
380        .unwrap();
381        assert!(cfg.start_minimised);
382    }
383
384    #[test]
385    fn default_values_are_sensible() {
386        let cfg = Config::default();
387        assert_eq!(cfg.api_base_url, "https://studio.minis.gg/");
388        assert!(cfg.auto_start);
389        assert!(
390            cfg.start_minimised,
391            "the UI must start minimised by default"
392        );
393        assert!(cfg.auto_update_enabled);
394        assert_eq!(cfg.auto_update_interval_secs, 1800);
395        assert!(!cfg.auto_update_prerelease);
396        assert!(cfg.auto_update_feed.contains("webbertakken/studio-worker"));
397        assert_eq!(cfg.vram_threshold_gb, 12.0);
398        assert!(cfg.worker_id.is_none());
399        assert!(cfg.auth_token.is_none());
400        // Models root defaults to ~/models (or a temp fallback on
401        // headless boxes without UserDirs).
402        let m = cfg.models_root.to_string_lossy().to_string();
403        assert!(m.ends_with("models") || m.contains("studio-worker-models"));
404    }
405
406    #[test]
407    fn resolve_path_uses_override_when_provided() {
408        let path = resolve_path(Some("/tmp/test-config.toml")).unwrap();
409        assert_eq!(path, PathBuf::from("/tmp/test-config.toml"));
410    }
411
412    #[test]
413    fn resolve_path_defaults_when_no_override() {
414        let path = resolve_path(None).unwrap();
415        let s = path.to_string_lossy();
416        assert!(
417            s.contains("minis-studio-worker") || s.contains("minis.gg.minis-studio-worker"),
418            "unexpected default path: {s}"
419        );
420        assert!(s.ends_with("config.toml"));
421    }
422
423    #[test]
424    fn load_creates_default_when_file_missing() {
425        let dir = tempdir().unwrap();
426        let path = dir.path().join("sub").join("config.toml");
427        let path_str = path.to_string_lossy().to_string();
428        let (cfg, returned_path) = load(Some(&path_str)).unwrap();
429        assert_eq!(returned_path, path);
430        assert_eq!(cfg.api_base_url, "https://studio.minis.gg/");
431        // File should have been written.
432        assert!(path.exists());
433    }
434
435    #[test]
436    fn round_trip_via_save_and_load_preserves_fields() {
437        let dir = tempdir().unwrap();
438        let path = dir.path().join("config.toml");
439        let cfg = Config {
440            worker_id: Some("w-123".into()),
441            auth_token: Some("tok-xyz".into()),
442            vram_threshold_gb: 24.0,
443            auto_update_prerelease: true,
444            models_root: PathBuf::from("/tmp/test-models"),
445            ..Config::default()
446        };
447        save(&cfg, &path).unwrap();
448
449        let path_str = path.to_string_lossy().to_string();
450        let (loaded, _) = load(Some(&path_str)).unwrap();
451        assert_eq!(loaded.api_base_url, cfg.api_base_url);
452        assert_eq!(loaded.worker_id, cfg.worker_id);
453        assert_eq!(loaded.auth_token, cfg.auth_token);
454        assert_eq!(loaded.vram_threshold_gb, cfg.vram_threshold_gb);
455        assert_eq!(loaded.auto_update_prerelease, cfg.auto_update_prerelease);
456        assert_eq!(loaded.models_root, cfg.models_root);
457    }
458
459    #[test]
460    fn shared_wraps_in_arc_mutex() {
461        let cfg = Config::default();
462        let shared = shared(cfg.clone());
463        let guard = shared.lock();
464        assert_eq!(guard.api_base_url, cfg.api_base_url);
465    }
466
467    #[test]
468    fn load_returns_error_on_malformed_toml() {
469        let dir = tempdir().unwrap();
470        let path = dir.path().join("config.toml");
471        std::fs::write(&path, "this :: is = not = toml = :").unwrap();
472        let path_str = path.to_string_lossy().to_string();
473        let err = load(Some(&path_str)).unwrap_err();
474        assert!(err.to_string().contains("parsing config.toml"));
475    }
476
477    #[test]
478    fn load_strips_legacy_engine_fields_silently() {
479        // Older configs had `engine`, `engines`, `auto_enabled`, `label`.
480        // serde::Deserialize on the new struct should ignore them (they
481        // aren't in the schema any more); the worker keeps running.
482        let dir = tempdir().unwrap();
483        let path = dir.path().join("config.toml");
484        let legacy = r#"
485            api_base_url = "https://example.invalid"
486            vram_threshold_gb = 8.0
487            auto_start = true
488            engine = "multi"
489            engines = ["llama", "synthetic"]
490            auto_enabled = false
491            label = "alice's rig"
492        "#;
493        std::fs::write(&path, legacy).unwrap();
494        let (cfg, _) = load(Some(&path.to_string_lossy())).unwrap();
495        assert_eq!(cfg.api_base_url, "https://example.invalid");
496        assert_eq!(cfg.vram_threshold_gb, 8.0);
497    }
498
499    #[test]
500    fn load_expands_leading_tilde_in_models_root() {
501        // Users who hand-edit `config.toml` often write `~/models`;
502        // the worker must expand it, not create a literal `~` dir.
503        let dir = tempdir().unwrap();
504        let path = dir.path().join("config.toml");
505        let raw = r#"
506            api_base_url = "https://x.invalid"
507            vram_threshold_gb = 4.0
508            auto_start = true
509            auto_update_enabled = false
510            auto_update_interval_secs = 1
511            auto_update_feed = "https://x.invalid"
512            auto_update_prerelease = false
513            models_root = "~/models-test"
514        "#;
515        std::fs::write(&path, raw).unwrap();
516        let (cfg, _) = load(Some(&path.to_string_lossy())).unwrap();
517        assert!(
518            cfg.models_root.is_absolute(),
519            "~/ should expand to an absolute path, got {}",
520            cfg.models_root.display()
521        );
522        assert!(cfg.models_root.ends_with("models-test"));
523    }
524
525    #[test]
526    fn expand_home_leaves_absolute_paths_alone() {
527        let p = PathBuf::from("/tmp/anywhere");
528        assert_eq!(expand_home(p.clone()), p);
529    }
530
531    #[test]
532    fn expand_home_handles_bare_tilde() {
533        let expanded = expand_home(PathBuf::from("~"));
534        assert!(
535            expanded.is_absolute() || expanded == Path::new("~"),
536            "bare ~ expands to home (or stays put on weird boxes), got {}",
537            expanded.display()
538        );
539    }
540
541    // The injected-home seams below pin the home-less fallback paths
542    // (containers, `DynamicUser=` systemd units, minimal images where
543    // `UserDirs::new()` returns `None`) without depending on the host's
544    // real home directory.
545
546    #[test]
547    fn models_root_from_uses_home_when_available() {
548        let home = PathBuf::from("/home/someuser");
549        assert_eq!(models_root_from(Some(home.clone())), home.join("models"));
550    }
551
552    #[test]
553    fn models_root_from_falls_back_to_tmp_without_home() {
554        assert_eq!(
555            models_root_from(None),
556            std::env::temp_dir().join("studio-worker-models")
557        );
558    }
559
560    #[test]
561    fn expand_home_with_bare_tilde_uses_injected_home() {
562        let home = PathBuf::from("/home/x");
563        assert_eq!(
564            expand_home_with(PathBuf::from("~"), Some(home.clone())),
565            home
566        );
567    }
568
569    #[test]
570    fn expand_home_with_bare_tilde_without_home_stays_put() {
571        assert_eq!(
572            expand_home_with(PathBuf::from("~"), None),
573            PathBuf::from("~")
574        );
575    }
576
577    #[test]
578    fn expand_home_with_prefix_joins_injected_home() {
579        let home = PathBuf::from("/home/x");
580        assert_eq!(
581            expand_home_with(PathBuf::from("~/models"), Some(home.clone())),
582            home.join("models")
583        );
584    }
585
586    #[test]
587    fn expand_home_with_prefix_without_home_stays_unexpanded() {
588        let p = PathBuf::from("~/models");
589        assert_eq!(expand_home_with(p.clone(), None), p);
590    }
591
592    #[test]
593    fn expand_home_with_leaves_absolute_paths_alone() {
594        let p = PathBuf::from("/tmp/anywhere");
595        assert_eq!(
596            expand_home_with(p.clone(), Some(PathBuf::from("/home/x"))),
597            p
598        );
599    }
600
601    #[cfg(unix)]
602    #[test]
603    fn save_writes_config_owner_only_because_it_holds_secrets() {
604        // config.toml persists `auth_token` + `registration_secret`.
605        // A plain `fs::write` honours the umask and typically lands
606        // `0644`, exposing those credentials to every other local
607        // user.  The atomic temp-file write must leave the file
608        // owner-only (`0600`).
609        use std::os::unix::fs::PermissionsExt;
610        let dir = tempdir().unwrap();
611        let path = dir.path().join("config.toml");
612        let cfg = Config {
613            auth_token: Some("super-secret-token".into()),
614            registration_secret: Some("reg-secret".into()),
615            ..Config::default()
616        };
617        save(&cfg, &path).unwrap();
618        let mode = std::fs::metadata(&path).unwrap().permissions().mode();
619        assert_eq!(
620            mode & 0o077,
621            0,
622            "secrets-bearing config must not be group/world-accessible; got mode {mode:o}"
623        );
624    }
625
626    #[test]
627    fn save_atomically_replaces_existing_config_without_temp_litter() {
628        // A second save must fully replace the file (no stale fields
629        // from a longer previous version) and leave no temp-file
630        // siblings behind from the write-then-rename dance.
631        let dir = tempdir().unwrap();
632        let path = dir.path().join("config.toml");
633
634        let big = Config {
635            api_base_url: "https://a-very-long-host-name.example.invalid/studio/".into(),
636            worker_id: Some("worker-with-a-longish-id-000000".into()),
637            ..Config::default()
638        };
639        save(&big, &path).unwrap();
640
641        let small = Config {
642            api_base_url: "https://x/".into(),
643            ..Config::default()
644        };
645        save(&small, &path).unwrap();
646
647        let (loaded, _) = load(Some(&path.to_string_lossy())).unwrap();
648        assert_eq!(loaded.api_base_url, "https://x/");
649        assert!(
650            loaded.worker_id.is_none(),
651            "a replacing save must not leave the previous worker_id behind"
652        );
653
654        let names: Vec<String> = std::fs::read_dir(dir.path())
655            .unwrap()
656            .map(|e| e.unwrap().file_name().to_string_lossy().to_string())
657            .collect();
658        assert_eq!(
659            names,
660            vec!["config.toml".to_string()],
661            "atomic save must leave only the target file, found: {names:?}"
662        );
663    }
664}