notalterra/guard.rs
1//! Game-running guard and transaction logging.
2//!
3//! Before launch and before each destructive operation, check whether
4//! Subnautica 2 is running — the game holds file locks on `.sav` files
5//! while active.
6
7use anyhow::Result;
8use chrono::Local;
9use std::fs::{self, OpenOptions};
10use std::io::Write;
11use std::path::{Path, PathBuf};
12
13// ── process detection ──────────────────────────────────────────────────────
14
15/// Return `true` if Subnautica 2 appears to be running.
16///
17/// Always returns `false` — process detection (`tasklist` / `pgrep`) is
18/// intentionally disabled to avoid Windows Defender false positives
19/// (Trojan:Win32/Wacatac.C!ml). Users are reminded to close the game
20/// manually before using the tool.
21#[cfg(target_os = "windows")]
22pub fn game_running() -> bool {
23 // Process detection disabled — avoid AV false-positives.
24 // Close Subnautica 2 manually before using NotAlterra.
25 false
26}
27/// Check if Subnautica 2 is currently running (Linux).
28///
29/// Always returns `false` — process detection via `pgrep` is disabled to
30/// avoid false positives. Users are reminded to close the game manually.
31#[cfg(not(target_os = "windows"))]
32pub fn game_running() -> bool {
33 false
34}
35#[allow(dead_code)]
36/// Check if Subnautica 2 is running via pgrep (Linux). Dormant.
37fn _game_running_linux() -> bool {
38 let patterns = &["Subnautica2", "Subnautica2-Win64-Shipping"];
39 for pat in patterns {
40 if let Ok(out) = std::process::Command::new("pgrep")
41 .args(["-ci", pat])
42 .output()
43 {
44 let s = String::from_utf8_lossy(&out.stdout);
45 if let Ok(n) = s.trim().parse::<u32>() {
46 if n > 0 {
47 return true;
48 }
49 }
50 }
51 }
52 false
53}
54
55// ── transaction logging ────────────────────────────────────────────────────
56
57/// Migrate the old `transaction.log` (next to the binary) into the new
58/// `logs/` directory. Appends old entries to the new file, then renames
59/// the old file to `.migrated` so it won't be migrated again.
60/// Returns `true` if an old log was found and handled.
61pub fn migrate_old_log() -> bool {
62 let old = exe_dir().join("transaction.log");
63 if !old.exists() {
64 return false;
65 }
66 let new = log_path();
67 // Read old content
68 if let Ok(content) = std::fs::read_to_string(&old) {
69 if !content.trim().is_empty() {
70 // Append to new log with a migration header
71 let header = format!(
72 "───── migrated from old location [{ts}] ─────\n",
73 ts = chrono::Local::now().format("%Y-%m-%d %H:%M:%S")
74 );
75 if let Ok(mut f) = std::fs::OpenOptions::new()
76 .create(true)
77 .append(true)
78 .open(&new)
79 {
80 use std::io::Write;
81 let _ = write!(f, "{header}{content}");
82 }
83 }
84 }
85 // Rename old file so it won't be processed again
86 let migrated = old.with_extension("log.migrated");
87 let _ = std::fs::rename(&old, &migrated);
88 true
89}
90
91/// Path to `transaction.log` inside the `logs/` directory under the
92/// platform config root. All timestamped actions are appended here for
93/// audit trail purposes.
94pub fn log_path() -> PathBuf {
95 let p = dirs::data_local_dir()
96 .map(|d| d.join("NotAlterra").join("logs").join("transaction.log"))
97 .unwrap_or_else(|| exe_dir().join("logs").join("transaction.log"));
98 if let Some(parent) = p.parent() {
99 std::fs::create_dir_all(parent).ok();
100 }
101 p
102}
103
104/// Return the directory containing the running executable.
105fn exe_dir() -> PathBuf {
106 std::env::current_exe()
107 .ok()
108 .and_then(|p| p.parent().map(Path::to_path_buf))
109 .unwrap_or_else(|| PathBuf::from("."))
110}
111
112/// Maximum lines before rotation.
113const MAX_LOG_LINES: usize = 10_000;
114
115/// Append a timestamped log entry to `transaction.log`.
116///
117/// Format: `YYYY-MM-DD HH:MM:SS | ACTION | detail | result`
118/// Auto-rotates if the log exceeds 10,000 lines — the oldest lines are
119/// discarded, keeping only the most recent 10,000.
120pub fn log_action(action: &str, detail: &str, result: &str, log_path: &Path) -> Result<()> {
121 let stamp = Local::now().format("%Y-%m-%d %H:%M:%S");
122 let line = format!("{stamp} | {action:<8} | {detail} | {result}\n");
123
124 // Rotate if needed
125 if log_path.exists() {
126 if let Ok(content) = fs::read_to_string(log_path) {
127 let lines: Vec<&str> = content.lines().collect();
128 if lines.len() > MAX_LOG_LINES {
129 let keep: String = lines[lines.len() - MAX_LOG_LINES..].join("\n");
130 fs::write(log_path, keep + "\n").ok();
131 }
132 }
133 }
134
135 let mut f = OpenOptions::new()
136 .create(true)
137 .append(true)
138 .open(log_path)?;
139 f.write_all(line.as_bytes())?;
140 Ok(())
141}
142/// Truncate a filesystem path to start at `Subnautica2/` or `Subnautica2\`,
143/// stripping the user-specific prefix for privacy-safe logging.
144///
145/// Returns the original path unchanged if `Subnautica2` is not found in the
146/// input (e.g. custom paths entered via `Set save folder`).
147pub fn sanitize_path(p: &str) -> String {
148 let needle = "Subnautica2";
149 let sep = if p.contains('\\') { "\\" } else { "/" };
150 if let Some(pos) = p.find(needle) {
151 format!("...{sep}{}", &p[pos..])
152 } else {
153 p.to_string()
154 }
155}
156
157/// Check whether a path looks like a network/UNC path — for warning purposes.
158/// Matches paths starting with `\\` (Windows) or `//` (Linux).
159pub fn is_network_path(p: &str) -> bool {
160 p.starts_with("\\\\") || p.starts_with("//")
161}
162
163/// Estimate free space on the volume containing `path` in bytes.
164/// Returns `None` on platforms or filesystems where we can't determine this.
165pub fn available_space(_path: &Path) -> Option<u64> {
166 // Advisory only — the PowerShell original had a try/catch fallback.
167 // For a cross-platform build without platform-specific FFI, we
168 // return None and skip the disk-space warning.
169 None
170}