1use std::fmt;
4use std::path::{Path, PathBuf};
5use std::time::SystemTime;
6
7#[derive(Debug, thiserror::Error)]
13#[non_exhaustive]
14pub enum LocateError {
15 #[error("could not determine home/data directory")]
16 NoHomeDir,
17
18 #[error("NMS save directory not found: {0}")]
19 SaveDirNotFound(PathBuf),
20
21 #[error("no account directories found in {0}")]
22 NoAccountDirs(PathBuf),
23
24 #[error("no save files found in {0}")]
25 NoSaveFiles(PathBuf),
26
27 #[error("unsupported platform for NMS save auto-detection")]
28 UnsupportedPlatform,
29
30 #[error(transparent)]
31 Io(#[from] std::io::Error),
32}
33
34#[derive(Debug, Clone, PartialEq, Eq)]
40#[non_exhaustive]
41pub enum AccountKind {
42 Steam(u64),
44 Gog,
46 Unknown(String),
48}
49
50impl fmt::Display for AccountKind {
51 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
52 match self {
53 Self::Steam(id) => write!(f, "Steam ({id})"),
54 Self::Gog => write!(f, "GOG"),
55 Self::Unknown(name) => write!(f, "Unknown ({name})"),
56 }
57 }
58}
59
60#[derive(Debug, Clone, PartialEq, Eq)]
62pub struct AccountDir {
63 path: PathBuf,
64 kind: AccountKind,
65}
66
67impl AccountDir {
68 pub fn path(&self) -> &Path {
70 &self.path
71 }
72
73 pub fn kind(&self) -> &AccountKind {
75 &self.kind
76 }
77
78 pub fn name(&self) -> &str {
80 self.path.file_name().and_then(|n| n.to_str()).unwrap_or("")
81 }
82}
83
84fn parse_account_kind(name: &str) -> AccountKind {
86 if name == "DefaultUser" {
87 return AccountKind::Gog;
88 }
89 if let Some(id_str) = name.strip_prefix("st_") {
90 if let Ok(id) = id_str.parse::<u64>() {
91 return AccountKind::Steam(id);
92 }
93 }
94 AccountKind::Unknown(name.to_string())
95}
96
97#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
103pub enum SaveType {
104 Manual,
105 Auto,
106}
107
108impl fmt::Display for SaveType {
109 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
110 match self {
111 Self::Manual => write!(f, "Manual"),
112 Self::Auto => write!(f, "Auto"),
113 }
114 }
115}
116
117#[derive(Debug, Clone, PartialEq, Eq)]
119pub struct SaveFile {
120 path: PathBuf,
121 slot: u8,
122 save_type: SaveType,
123 modified: SystemTime,
124}
125
126impl SaveFile {
127 pub fn path(&self) -> &Path {
129 &self.path
130 }
131
132 pub fn slot(&self) -> u8 {
134 self.slot
135 }
136
137 pub fn save_type(&self) -> SaveType {
139 self.save_type
140 }
141
142 pub fn modified(&self) -> SystemTime {
144 self.modified
145 }
146
147 pub fn metadata_path(&self) -> PathBuf {
149 let name = self
150 .path
151 .file_name()
152 .and_then(|n| n.to_str())
153 .unwrap_or("save.hg");
154 self.path.with_file_name(format!("mf_{name}"))
155 }
156}
157
158#[derive(Debug, Clone, PartialEq, Eq)]
160pub struct SaveSlot {
161 slot: u8,
162 manual: Option<SaveFile>,
163 auto: Option<SaveFile>,
164}
165
166impl SaveSlot {
167 pub fn slot(&self) -> u8 {
169 self.slot
170 }
171
172 pub fn manual(&self) -> Option<&SaveFile> {
174 self.manual.as_ref()
175 }
176
177 pub fn auto(&self) -> Option<&SaveFile> {
179 self.auto.as_ref()
180 }
181
182 pub fn most_recent(&self) -> Option<&SaveFile> {
184 match (&self.manual, &self.auto) {
185 (Some(m), Some(a)) => {
186 if m.modified >= a.modified {
187 Some(m)
188 } else {
189 Some(a)
190 }
191 }
192 (Some(m), None) => Some(m),
193 (None, Some(a)) => Some(a),
194 (None, None) => None,
195 }
196 }
197}
198
199fn parse_save_filename(name: &str) -> Option<(u8, SaveType)> {
207 if !name.ends_with(".hg") || name.starts_with("mf_") {
208 return None;
209 }
210
211 let stem = name.strip_suffix(".hg")?;
212
213 if stem == "save" {
214 return Some((1, SaveType::Manual));
216 }
217
218 let num_str = stem.strip_prefix("save")?;
219 let file_index: u8 = num_str.parse().ok()?;
220 if file_index < 2 {
221 return None;
222 }
223
224 let slot = file_index.div_ceil(2);
226 let save_type = if file_index % 2 == 0 {
227 SaveType::Auto
228 } else {
229 SaveType::Manual
230 };
231
232 Some((slot, save_type))
233}
234
235pub fn nms_save_dir() -> Result<PathBuf, LocateError> {
247 nms_save_dir_impl()
248}
249
250#[cfg(target_os = "macos")]
251fn nms_save_dir_impl() -> Result<PathBuf, LocateError> {
252 let data = dirs::data_dir().ok_or(LocateError::NoHomeDir)?;
253 Ok(data.join("HelloGames").join("NMS"))
254}
255
256#[cfg(target_os = "windows")]
257fn nms_save_dir_impl() -> Result<PathBuf, LocateError> {
258 let data = dirs::data_dir().ok_or(LocateError::NoHomeDir)?;
259 Ok(data.join("HelloGames").join("NMS"))
260}
261
262#[cfg(target_os = "linux")]
263fn nms_save_dir_impl() -> Result<PathBuf, LocateError> {
264 let home = dirs::home_dir().ok_or(LocateError::NoHomeDir)?;
265 Ok(home.join(".local/share/Steam/steamapps/compatdata/275850/pfx/drive_c/users/steamuser/AppData/Roaming/HelloGames/NMS"))
266}
267
268#[cfg(not(any(target_os = "macos", target_os = "windows", target_os = "linux")))]
269fn nms_save_dir_impl() -> Result<PathBuf, LocateError> {
270 Err(LocateError::UnsupportedPlatform)
271}
272
273pub fn nms_save_dir_checked() -> Result<PathBuf, LocateError> {
275 let dir = nms_save_dir()?;
276 if dir.exists() {
277 Ok(dir)
278 } else {
279 Err(LocateError::SaveDirNotFound(dir))
280 }
281}
282
283pub fn list_accounts(save_dir: &Path) -> Result<Vec<AccountDir>, LocateError> {
292 let mut accounts = Vec::new();
293
294 for entry in std::fs::read_dir(save_dir)? {
295 let entry = entry?;
296 if !entry.file_type()?.is_dir() {
297 continue;
298 }
299 let name = match entry.file_name().into_string() {
300 Ok(n) => n,
301 Err(_) => continue,
302 };
303 let kind = parse_account_kind(&name);
304 accounts.push(AccountDir {
305 path: entry.path(),
306 kind,
307 });
308 }
309
310 if accounts.is_empty() {
311 return Err(LocateError::NoAccountDirs(save_dir.to_path_buf()));
312 }
313
314 accounts.sort_by(|a, b| a.name().cmp(b.name()));
315 Ok(accounts)
316}
317
318pub fn list_saves(account_dir: &Path) -> Result<Vec<SaveFile>, LocateError> {
322 let mut saves = Vec::new();
323
324 for entry in std::fs::read_dir(account_dir)? {
325 let entry = entry?;
326 if !entry.file_type()?.is_file() {
327 continue;
328 }
329 let name = match entry.file_name().into_string() {
330 Ok(n) => n,
331 Err(_) => continue,
332 };
333 if let Some((slot, save_type)) = parse_save_filename(&name) {
334 let modified = entry.metadata()?.modified()?;
335 saves.push(SaveFile {
336 path: entry.path(),
337 slot,
338 save_type,
339 modified,
340 });
341 }
342 }
343
344 if saves.is_empty() {
345 return Err(LocateError::NoSaveFiles(account_dir.to_path_buf()));
346 }
347
348 saves.sort_by(|a, b| b.modified.cmp(&a.modified));
350 Ok(saves)
351}
352
353pub fn group_into_slots(saves: &[SaveFile]) -> Vec<SaveSlot> {
358 let max_slot = saves.iter().map(|s| s.slot).max().unwrap_or(0);
359 let mut slots: Vec<SaveSlot> = (1..=max_slot)
360 .map(|n| SaveSlot {
361 slot: n,
362 manual: None,
363 auto: None,
364 })
365 .collect();
366
367 for save in saves {
368 let idx = (save.slot - 1) as usize;
369 if idx < slots.len() {
370 match save.save_type {
371 SaveType::Manual => slots[idx].manual = Some(save.clone()),
372 SaveType::Auto => slots[idx].auto = Some(save.clone()),
373 }
374 }
375 }
376
377 slots.retain(|s| s.manual.is_some() || s.auto.is_some());
379 slots
380}
381
382pub fn find_most_recent_save() -> Result<SaveFile, LocateError> {
391 let save_dir = nms_save_dir_checked()?;
392 let accounts = list_accounts(&save_dir)?;
393
394 let mut best: Option<SaveFile> = None;
395 for account in &accounts {
396 if let Ok(saves) = list_saves(account.path()) {
397 if let Some(newest) = saves.into_iter().next() {
398 let dominated = best.as_ref().is_none_or(|b| newest.modified > b.modified);
399 if dominated {
400 best = Some(newest);
401 }
402 }
403 }
404 }
405
406 best.ok_or(LocateError::NoSaveFiles(save_dir))
407}
408
409pub fn find_most_recent_save_in(account_dir: &Path) -> Result<SaveFile, LocateError> {
411 let saves = list_saves(account_dir)?;
412 Ok(saves.into_iter().next().unwrap())
414}
415
416#[cfg(test)]
421mod tests {
422 use super::*;
423 use std::fs;
424 use std::thread;
425 use std::time::Duration;
426 use tempfile::TempDir;
427
428 #[test]
431 fn parse_save_hg() {
432 let (slot, st) = parse_save_filename("save.hg").unwrap();
433 assert_eq!(slot, 1);
434 assert_eq!(st, SaveType::Manual);
435 }
436
437 #[test]
438 fn parse_save2_hg() {
439 let (slot, st) = parse_save_filename("save2.hg").unwrap();
440 assert_eq!(slot, 1);
441 assert_eq!(st, SaveType::Auto);
442 }
443
444 #[test]
445 fn parse_save3_hg() {
446 let (slot, st) = parse_save_filename("save3.hg").unwrap();
447 assert_eq!(slot, 2);
448 assert_eq!(st, SaveType::Manual);
449 }
450
451 #[test]
452 fn parse_save4_hg() {
453 let (slot, st) = parse_save_filename("save4.hg").unwrap();
454 assert_eq!(slot, 2);
455 assert_eq!(st, SaveType::Auto);
456 }
457
458 #[test]
459 fn parse_save30_hg() {
460 let (slot, st) = parse_save_filename("save30.hg").unwrap();
461 assert_eq!(slot, 15);
462 assert_eq!(st, SaveType::Auto);
463 }
464
465 #[test]
466 fn parse_mf_save_rejected() {
467 assert!(parse_save_filename("mf_save.hg").is_none());
468 assert!(parse_save_filename("mf_save2.hg").is_none());
469 }
470
471 #[test]
472 fn parse_nonsave_rejected() {
473 assert!(parse_save_filename("readme.txt").is_none());
474 assert!(parse_save_filename("config.hg").is_none());
475 assert!(parse_save_filename("save.json").is_none());
476 }
477
478 #[test]
481 fn account_kind_steam() {
482 match parse_account_kind("st_76561198025707979") {
483 AccountKind::Steam(id) => assert_eq!(id, 76561198025707979),
484 other => panic!("expected Steam, got {other:?}"),
485 }
486 }
487
488 #[test]
489 fn account_kind_gog() {
490 assert_eq!(parse_account_kind("DefaultUser"), AccountKind::Gog);
491 }
492
493 #[test]
494 fn account_kind_unknown() {
495 match parse_account_kind("some_other_dir") {
496 AccountKind::Unknown(name) => assert_eq!(name, "some_other_dir"),
497 other => panic!("expected Unknown, got {other:?}"),
498 }
499 }
500
501 #[test]
502 fn account_kind_steam_bad_id() {
503 match parse_account_kind("st_notanumber") {
505 AccountKind::Unknown(_) => {}
506 other => panic!("expected Unknown, got {other:?}"),
507 }
508 }
509
510 #[test]
513 fn nms_save_dir_contains_hellogames_nms() {
514 let dir = nms_save_dir().unwrap();
515 let s = dir.to_string_lossy();
516 assert!(s.contains("HelloGames"), "path missing HelloGames: {s}");
517 assert!(s.ends_with("NMS"), "path should end with NMS: {s}");
518 }
519
520 #[test]
523 fn list_accounts_finds_steam_and_gog() {
524 let tmp = TempDir::new().unwrap();
525 fs::create_dir(tmp.path().join("st_123")).unwrap();
526 fs::create_dir(tmp.path().join("DefaultUser")).unwrap();
527
528 let accounts = list_accounts(tmp.path()).unwrap();
529 assert_eq!(accounts.len(), 2);
530
531 let kinds: Vec<_> = accounts.iter().map(|a| a.kind().clone()).collect();
532 assert!(kinds.contains(&AccountKind::Gog));
533 assert!(kinds.contains(&AccountKind::Steam(123)));
534 }
535
536 #[test]
537 fn list_accounts_skips_files() {
538 let tmp = TempDir::new().unwrap();
539 fs::create_dir(tmp.path().join("st_123")).unwrap();
540 fs::write(tmp.path().join("not_a_dir.txt"), b"data").unwrap();
541
542 let accounts = list_accounts(tmp.path()).unwrap();
543 assert_eq!(accounts.len(), 1);
544 }
545
546 #[test]
547 fn list_accounts_empty_returns_error() {
548 let tmp = TempDir::new().unwrap();
549 let err = list_accounts(tmp.path()).unwrap_err();
550 assert!(matches!(err, LocateError::NoAccountDirs(_)));
551 }
552
553 #[test]
554 fn list_saves_finds_and_sorts_by_mtime() {
555 let tmp = TempDir::new().unwrap();
556
557 fs::write(tmp.path().join("save.hg"), b"old").unwrap();
559 thread::sleep(Duration::from_millis(50));
560 fs::write(tmp.path().join("save2.hg"), b"newer").unwrap();
561 thread::sleep(Duration::from_millis(50));
562 fs::write(tmp.path().join("save3.hg"), b"newest").unwrap();
563
564 let saves = list_saves(tmp.path()).unwrap();
565 assert_eq!(saves.len(), 3);
566 assert_eq!(saves[0].slot(), 2); assert_eq!(saves[0].save_type(), SaveType::Manual);
569 assert_eq!(saves[1].slot(), 1); assert_eq!(saves[1].save_type(), SaveType::Auto);
571 assert_eq!(saves[2].slot(), 1); assert_eq!(saves[2].save_type(), SaveType::Manual);
573 }
574
575 #[test]
576 fn list_saves_excludes_metadata() {
577 let tmp = TempDir::new().unwrap();
578 fs::write(tmp.path().join("save.hg"), b"data").unwrap();
579 fs::write(tmp.path().join("mf_save.hg"), b"meta").unwrap();
580
581 let saves = list_saves(tmp.path()).unwrap();
582 assert_eq!(saves.len(), 1);
583 assert_eq!(saves[0].slot(), 1);
584 }
585
586 #[test]
587 fn list_saves_empty_dir_returns_error() {
588 let tmp = TempDir::new().unwrap();
589 let err = list_saves(tmp.path()).unwrap_err();
590 assert!(matches!(err, LocateError::NoSaveFiles(_)));
591 }
592
593 #[test]
594 fn group_into_slots_pairs_correctly() {
595 let tmp = TempDir::new().unwrap();
596 fs::write(tmp.path().join("save.hg"), b"m1").unwrap();
597 thread::sleep(Duration::from_millis(50));
598 fs::write(tmp.path().join("save2.hg"), b"a1").unwrap();
599 thread::sleep(Duration::from_millis(50));
600 fs::write(tmp.path().join("save3.hg"), b"m2").unwrap();
601
602 let saves = list_saves(tmp.path()).unwrap();
603 let slots = group_into_slots(&saves);
604
605 assert_eq!(slots.len(), 2);
606 assert_eq!(slots[0].slot(), 1);
607 assert!(slots[0].manual().is_some());
608 assert!(slots[0].auto().is_some());
609 assert_eq!(slots[1].slot(), 2);
610 assert!(slots[1].manual().is_some());
611 assert!(slots[1].auto().is_none());
612 }
613
614 #[test]
615 fn find_most_recent_save_in_picks_newest() {
616 let tmp = TempDir::new().unwrap();
617 fs::write(tmp.path().join("save.hg"), b"old").unwrap();
618 thread::sleep(Duration::from_millis(50));
619 fs::write(tmp.path().join("save3.hg"), b"newest").unwrap();
620
621 let newest = find_most_recent_save_in(tmp.path()).unwrap();
622 assert_eq!(newest.slot(), 2);
623 assert_eq!(newest.save_type(), SaveType::Manual);
624 }
625
626 #[test]
627 fn save_file_metadata_path() {
628 let save = SaveFile {
629 path: PathBuf::from("/tmp/st_123/save3.hg"),
630 slot: 2,
631 save_type: SaveType::Manual,
632 modified: SystemTime::UNIX_EPOCH,
633 };
634 assert_eq!(
635 save.metadata_path(),
636 PathBuf::from("/tmp/st_123/mf_save3.hg")
637 );
638 }
639
640 #[test]
641 fn save_slot_most_recent() {
642 let older = SaveFile {
643 path: PathBuf::from("/tmp/save.hg"),
644 slot: 1,
645 save_type: SaveType::Manual,
646 modified: SystemTime::UNIX_EPOCH,
647 };
648 let newer = SaveFile {
649 path: PathBuf::from("/tmp/save2.hg"),
650 slot: 1,
651 save_type: SaveType::Auto,
652 modified: SystemTime::UNIX_EPOCH + Duration::from_secs(100),
653 };
654 let slot = SaveSlot {
655 slot: 1,
656 manual: Some(older),
657 auto: Some(newer),
658 };
659 let recent = slot.most_recent().unwrap();
660 assert_eq!(recent.save_type(), SaveType::Auto);
661 }
662
663 #[test]
664 fn account_kind_display() {
665 assert_eq!(AccountKind::Steam(12345).to_string(), "Steam (12345)");
666 assert_eq!(AccountKind::Gog.to_string(), "GOG");
667 assert_eq!(
668 AccountKind::Unknown("foo".into()).to_string(),
669 "Unknown (foo)"
670 );
671 }
672
673 #[test]
674 fn save_type_display() {
675 assert_eq!(SaveType::Manual.to_string(), "Manual");
676 assert_eq!(SaveType::Auto.to_string(), "Auto");
677 }
678}