1use anyhow::{anyhow, Context, Result};
28use directories::{ProjectDirs, UserDirs};
29use parking_lot::Mutex;
30use serde::{Deserialize, Serialize};
31use std::path::{Path, PathBuf};
32
33const TRACE_TARGET: &str = "studio_worker::config";
36
37#[derive(Debug, Clone, Serialize, Deserialize)]
38pub struct Config {
39 pub api_base_url: String,
41 #[serde(default, skip_serializing_if = "Option::is_none")]
45 pub worker_id: Option<String>,
46 #[serde(default, skip_serializing_if = "Option::is_none")]
49 pub auth_token: Option<String>,
50 pub vram_threshold_gb: f32,
52 #[serde(default = "default_start_minimised")]
57 pub start_minimised: bool,
58 #[serde(default = "default_auto_update_enabled")]
61 pub auto_update_enabled: bool,
62 #[serde(default = "default_auto_update_interval")]
64 pub auto_update_interval_secs: u64,
65 #[serde(default = "default_auto_update_feed")]
67 pub auto_update_feed: String,
68 #[serde(default)]
70 pub auto_update_prerelease: bool,
71 #[serde(default = "default_models_root_persisted")]
75 pub models_root: PathBuf,
76 #[serde(default, skip_serializing_if = "Option::is_none")]
83 pub ws_reconnect_attempts: Option<u32>,
84 #[serde(default, skip_serializing_if = "Option::is_none")]
88 pub local_api_port: Option<u16>,
89 #[serde(default, skip_serializing_if = "Option::is_none")]
92 pub stream_port: Option<u16>,
93 #[serde(default, skip_serializing_if = "Option::is_none")]
98 pub local_api_token: Option<String>,
99 #[serde(default, skip_serializing_if = "Option::is_none")]
103 pub install_id: Option<String>,
104 #[serde(default, skip_serializing_if = "Option::is_none")]
107 pub registration_request_id: Option<String>,
108 #[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
127pub fn default_models_root() -> PathBuf {
131 models_root_from(home_dir())
132}
133
134fn home_dir() -> Option<PathBuf> {
138 UserDirs::new().map(|d| d.home_dir().to_path_buf())
139}
140
141fn 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
155fn expand_home(path: PathBuf) -> PathBuf {
160 expand_home_with(path, home_dir())
161}
162
163fn 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
209pub fn catalog_path_for(config_path: &Path) -> Option<PathBuf> {
215 config_path.parent().map(|dir| dir.join("models.json"))
216}
217
218pub fn residency_path_for(config_path: &Path) -> Option<PathBuf> {
221 config_path.parent().map(|dir| dir.join("residency.json"))
222}
223
224pub 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
241pub 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
256pub 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 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 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 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 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
371fn 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
386pub(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
420pub 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
452pub 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 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 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 assert_eq!(clamp_initial_threshold(12.0, 8.0), 8.0);
548 assert_eq!(clamp_initial_threshold(12.0, 24.0), 12.0);
550 assert_eq!(clamp_initial_threshold(12.0, 12.0), 12.0);
552 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 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 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 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 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 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 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 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 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 #[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 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 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}