Skip to main content

nms_save/
locate.rs

1//! NMS save file discovery: platform-specific paths, account directories, save files.
2
3use std::fmt;
4use std::path::{Path, PathBuf};
5use std::time::SystemTime;
6
7// ---------------------------------------------------------------------------
8// Errors
9// ---------------------------------------------------------------------------
10
11/// Error returned by save file discovery operations.
12#[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// ---------------------------------------------------------------------------
35// Account directory
36// ---------------------------------------------------------------------------
37
38/// The kind of NMS account directory.
39#[derive(Debug, Clone, PartialEq, Eq)]
40#[non_exhaustive]
41pub enum AccountKind {
42    /// Steam account with numeric Steam ID.
43    Steam(u64),
44    /// GOG "DefaultUser" account.
45    Gog,
46    /// Unrecognized directory name.
47    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/// An NMS account directory (e.g., `st_76561198025707979` or `DefaultUser`).
61#[derive(Debug, Clone, PartialEq, Eq)]
62pub struct AccountDir {
63    path: PathBuf,
64    kind: AccountKind,
65}
66
67impl AccountDir {
68    /// Full path to the account directory.
69    pub fn path(&self) -> &Path {
70        &self.path
71    }
72
73    /// The account kind (Steam, GOG, or Unknown).
74    pub fn kind(&self) -> &AccountKind {
75        &self.kind
76    }
77
78    /// The directory name component (e.g., `st_76561198025707979`).
79    pub fn name(&self) -> &str {
80        self.path.file_name().and_then(|n| n.to_str()).unwrap_or("")
81    }
82}
83
84/// Parse a directory name into an [`AccountKind`].
85fn 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// ---------------------------------------------------------------------------
98// Save file
99// ---------------------------------------------------------------------------
100
101/// Whether a save file is a manual or auto save.
102#[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/// A discovered NMS save file with metadata.
118#[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    /// Full path to the `.hg` file.
128    pub fn path(&self) -> &Path {
129        &self.path
130    }
131
132    /// Save slot number (1-15).
133    pub fn slot(&self) -> u8 {
134        self.slot
135    }
136
137    /// Whether this is a manual or auto save.
138    pub fn save_type(&self) -> SaveType {
139        self.save_type
140    }
141
142    /// File modification time.
143    pub fn modified(&self) -> SystemTime {
144        self.modified
145    }
146
147    /// Path to the corresponding metadata file (`mf_save*.hg`).
148    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/// A paired manual + auto save for a single slot.
159#[derive(Debug, Clone, PartialEq, Eq)]
160pub struct SaveSlot {
161    slot: u8,
162    manual: Option<SaveFile>,
163    auto: Option<SaveFile>,
164}
165
166impl SaveSlot {
167    /// Slot number (1-15).
168    pub fn slot(&self) -> u8 {
169        self.slot
170    }
171
172    /// The manual save for this slot, if present.
173    pub fn manual(&self) -> Option<&SaveFile> {
174        self.manual.as_ref()
175    }
176
177    /// The auto save for this slot, if present.
178    pub fn auto(&self) -> Option<&SaveFile> {
179        self.auto.as_ref()
180    }
181
182    /// The most recently modified save in this slot (manual or auto).
183    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
199// ---------------------------------------------------------------------------
200// Save filename parsing
201// ---------------------------------------------------------------------------
202
203/// Parse a save filename into (slot, save_type).
204///
205/// Returns `None` if the filename doesn't match `save*.hg` or is a metadata file.
206fn 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        // save.hg = file index 1 → slot 1, manual
215        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    // odd index = manual, even = auto
225    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
235// ---------------------------------------------------------------------------
236// Platform-specific directory resolution
237// ---------------------------------------------------------------------------
238
239/// Return the platform-specific NMS save root directory.
240///
241/// - **macOS**: `~/Library/Application Support/HelloGames/NMS/`
242/// - **Windows**: `%APPDATA%\HelloGames\NMS\`
243/// - **Linux**: `~/.local/share/Steam/steamapps/compatdata/275850/pfx/drive_c/users/steamuser/AppData/Roaming/HelloGames/NMS/`
244///
245/// Does NOT verify the directory exists on disk.
246pub 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
273/// Like [`nms_save_dir`] but verifies the directory exists on disk.
274pub 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
283// ---------------------------------------------------------------------------
284// Directory listing
285// ---------------------------------------------------------------------------
286
287/// List all account directories inside the NMS save root.
288///
289/// Returns directories matching `st_*` (Steam), `DefaultUser` (GOG),
290/// and any other subdirectories as [`AccountKind::Unknown`].
291pub 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
318/// List all save files (`save*.hg`, excluding `mf_save*.hg`) in an account directory.
319///
320/// Results are sorted by modification time, newest first.
321pub 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    // Newest first
349    saves.sort_by(|a, b| b.modified.cmp(&a.modified));
350    Ok(saves)
351}
352
353/// Group save files into slot pairs (manual + auto).
354///
355/// Returns slots sorted by slot number. Each slot contains at most one
356/// manual and one auto save.
357pub 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    // Remove empty slots
378    slots.retain(|s| s.manual.is_some() || s.auto.is_some());
379    slots
380}
381
382// ---------------------------------------------------------------------------
383// Convenience finders
384// ---------------------------------------------------------------------------
385
386/// Find the most recently modified save file across all accounts.
387///
388/// Chains [`nms_save_dir_checked`] → [`list_accounts`] → [`list_saves`]
389/// and returns the single newest file.
390pub 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
409/// Find the most recently modified save file in a specific account directory.
410pub fn find_most_recent_save_in(account_dir: &Path) -> Result<SaveFile, LocateError> {
411    let saves = list_saves(account_dir)?;
412    // list_saves already sorts newest-first
413    Ok(saves.into_iter().next().unwrap())
414}
415
416// ---------------------------------------------------------------------------
417// Tests
418// ---------------------------------------------------------------------------
419
420#[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    // -- Filename parsing --
429
430    #[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    // -- Account kind parsing --
479
480    #[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        // st_ prefix but non-numeric ID
504        match parse_account_kind("st_notanumber") {
505            AccountKind::Unknown(_) => {}
506            other => panic!("expected Unknown, got {other:?}"),
507        }
508    }
509
510    // -- Platform path --
511
512    #[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    // -- Integration tests with temp dirs --
521
522    #[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        // Create save files with different mtimes
558        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        // Newest first
567        assert_eq!(saves[0].slot(), 2); // save3.hg = slot 2
568        assert_eq!(saves[0].save_type(), SaveType::Manual);
569        assert_eq!(saves[1].slot(), 1); // save2.hg = slot 1
570        assert_eq!(saves[1].save_type(), SaveType::Auto);
571        assert_eq!(saves[2].slot(), 1); // save.hg = slot 1
572        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}