1use std::path::{Path, PathBuf};
7use std::time::SystemTime;
8
9use dialoguer::theme::ColorfulTheme;
10use dialoguer::{Confirm, Input, Select};
11use owo_colors::OwoColorize;
12
13use nms_save::locate::{
14 self, AccountDir, SaveSlot, group_into_slots, list_accounts, list_saves, nms_save_dir,
15};
16
17fn wizard_theme() -> ColorfulTheme {
23 use dialoguer::console::{Style, style};
24 ColorfulTheme {
25 prompt_style: Style::new().for_stderr().cyan(),
26 active_item_prefix: style(">".to_string()).for_stderr().green().bright(),
27 active_item_style: Style::new().for_stderr(),
28 ..ColorfulTheme::default()
29 }
30}
31
32#[derive(Debug, thiserror::Error)]
34pub enum SetupError {
35 #[error("setup cancelled by user")]
37 Cancelled,
38
39 #[error("no NMS installation found")]
41 NoInstallation,
42
43 #[error(transparent)]
45 Locate(#[from] locate::LocateError),
46
47 #[error(transparent)]
49 Io(#[from] std::io::Error),
50}
51
52pub fn run_setup_wizard() -> Result<PathBuf, SetupError> {
62 let theme = wizard_theme();
63
64 println!();
65 println!("{}", "NMS Copilot Setup".magenta().bold());
66 println!();
67 println!("No save file configured. Let's find your No Man's Sky save file.");
68 println!();
69
70 let save_dir = find_save_directory(&theme)?;
72
73 let account = select_account(&save_dir, &theme)?;
75
76 let save_path = select_save_slot(account.path(), &theme)?;
78
79 println!();
80 println!("Selected: {}", save_path.display());
81
82 if Confirm::with_theme(&theme)
84 .with_prompt("Save these settings to ~/.nms-copilot/config.toml?")
85 .default(true)
86 .interact()
87 .map_err(|_| SetupError::Cancelled)?
88 {
89 save_config_to_file(account.path(), &save_path, "auto")?;
90 println!("Settings saved.");
91 } else {
92 println!("Using selection for this session only.");
93 }
94
95 println!();
96 Ok(save_path)
97}
98
99fn find_save_directory(theme: &ColorfulTheme) -> Result<PathBuf, SetupError> {
101 match nms_save_dir() {
102 Ok(dir) if dir.exists() => {
103 println!("{} {}", "Found NMS save directory:".cyan(), dir.display());
104 Ok(dir)
105 }
106 _ => {
107 println!("Could not auto-detect NMS save directory.");
108 println!("Common locations:");
109 println!(" macOS: ~/Library/Application Support/HelloGames/NMS/");
110 println!(" Windows: %APPDATA%\\HelloGames\\NMS\\");
111 println!(" Linux: ~/.local/share/Steam/steamapps/compatdata/275850/pfx/...");
112 println!();
113
114 let input: String = Input::with_theme(theme)
115 .with_prompt("Enter path to your NMS save directory (or a specific save file)")
116 .interact_text()
117 .map_err(|_| SetupError::Cancelled)?;
118
119 let path = PathBuf::from(input.trim());
120 if !path.exists() {
121 return Err(SetupError::NoInstallation);
122 }
123 Ok(path)
124 }
125 }
126}
127
128fn select_account(save_dir: &Path, theme: &ColorfulTheme) -> Result<AccountDir, SetupError> {
130 if list_saves(save_dir).is_ok()
132 && let Some(parent) = save_dir.parent()
133 && let Ok(accounts) = list_accounts(parent)
134 {
135 let matching: Vec<_> = accounts
136 .into_iter()
137 .filter(|a| a.path() == save_dir)
138 .collect();
139 if let Some(account) = matching.into_iter().next() {
140 println!(
141 "Using account: {} ({})",
142 account.name().yellow(),
143 account.kind()
144 );
145 return Ok(account);
146 }
147 }
148
149 let accounts = list_accounts(save_dir)?;
150
151 if accounts.len() == 1 {
152 let account = accounts.into_iter().next().unwrap();
153 println!(
154 "{} {} ({})",
155 "Found 1 account:".cyan(),
156 account.name().yellow(),
157 account.kind()
158 );
159 return Ok(account);
160 }
161
162 println!("{}:", format!("Found {} accounts", accounts.len()).cyan());
163
164 let labels: Vec<String> = accounts
165 .iter()
166 .map(|a| format!("{} ({})", a.name().yellow(), a.kind()))
167 .collect();
168
169 let selection = Select::with_theme(theme)
170 .with_prompt("Select an account")
171 .items(&labels)
172 .default(0)
173 .interact()
174 .map_err(|_| SetupError::Cancelled)?;
175
176 Ok(accounts.into_iter().nth(selection).unwrap())
177}
178
179fn select_save_slot(account_dir: &Path, theme: &ColorfulTheme) -> Result<PathBuf, SetupError> {
181 let saves = list_saves(account_dir)?;
182 let slots = group_into_slots(&saves);
183
184 if slots.is_empty() {
185 return Err(SetupError::Locate(locate::LocateError::NoSaveFiles(
186 account_dir.to_path_buf(),
187 )));
188 }
189
190 if slots.len() == 1 {
191 let slot = &slots[0];
192 let save = slot.most_recent().unwrap();
193 println!(
194 "{} Slot {} ({}, {})",
195 "Found 1 save slot:".cyan(),
196 slot.slot(),
197 save.save_type(),
198 format_mtime(save.modified())
199 );
200 return Ok(save.path().to_path_buf());
201 }
202
203 println!("{}", format!("Found {} save slots:", slots.len()).cyan());
204
205 let labels: Vec<String> = slots.iter().map(format_slot_label).collect();
206
207 let selection = Select::with_theme(theme)
208 .with_prompt("Select a save slot")
209 .items(&labels)
210 .default(0)
211 .interact()
212 .map_err(|_| SetupError::Cancelled)?;
213
214 let selected = &slots[selection];
215 let save = selected.most_recent().unwrap();
216 Ok(save.path().to_path_buf())
217}
218
219fn format_slot_label(slot: &SaveSlot) -> String {
221 let manual = if slot.manual().is_some() {
222 "manual"
223 } else {
224 "-"
225 };
226 let auto = if slot.auto().is_some() { "auto" } else { "-" };
227
228 let recent = slot
229 .most_recent()
230 .map(|s| format!("{} ({})", s.save_type(), format_mtime(s.modified())))
231 .unwrap_or_default();
232
233 format!(
234 "Slot {:>2} [{}/{}] most recent: {}",
235 slot.slot(),
236 manual,
237 auto,
238 recent
239 )
240}
241
242fn format_mtime(time: SystemTime) -> String {
244 match SystemTime::now().duration_since(time) {
245 Ok(d) => {
246 let hours = d.as_secs() / 3600;
247 let days = d.as_secs() / 86400;
248 if hours == 0 {
249 "just now".to_string()
250 } else if days == 0 {
251 format!("{hours}h ago")
252 } else {
253 format!("{days}d ago")
254 }
255 }
256 Err(_) => "unknown".to_string(),
257 }
258}
259
260fn save_config_to_file(dir: &Path, file: &Path, format: &str) -> std::io::Result<()> {
264 let config_path = crate::paths::config_path();
265 crate::paths::ensure_data_dir()?;
266
267 let config = if config_path.exists() {
268 let content = std::fs::read_to_string(&config_path)?;
269 toml::from_str::<toml::Value>(&content).unwrap_or(toml::Value::Table(toml::map::Map::new()))
270 } else {
271 toml::Value::Table(toml::map::Map::new())
272 };
273
274 let config = update_save_config(config, dir, file, format);
275 let serialized = toml::to_string_pretty(&config).map_err(std::io::Error::other)?;
276 std::fs::write(&config_path, serialized)
277}
278
279fn update_save_config(
280 mut config: toml::Value,
281 dir: &Path,
282 file: &Path,
283 format: &str,
284) -> toml::Value {
285 if !config.is_table() {
286 config = toml::Value::Table(toml::map::Map::new());
287 }
288
289 let root = config
290 .as_table_mut()
291 .expect("config value was normalized to a table");
292 let mut save_table = toml::map::Map::new();
293 save_table.insert(
294 "dir".to_string(),
295 toml::Value::String(dir.display().to_string()),
296 );
297 save_table.insert(
298 "file".to_string(),
299 toml::Value::String(file.display().to_string()),
300 );
301 save_table.insert(
302 "format".to_string(),
303 toml::Value::String(format.to_string()),
304 );
305 root.insert("save".to_string(), toml::Value::Table(save_table));
306
307 config
308}
309
310#[cfg(test)]
311mod tests {
312 use super::*;
313 use crate::config::Config;
314 use std::time::Duration;
315
316 #[test]
317 fn test_format_mtime_just_now() {
318 let now = SystemTime::now();
319 assert_eq!(format_mtime(now), "just now");
320 }
321
322 #[test]
323 fn test_format_mtime_hours_ago() {
324 let time = SystemTime::now() - Duration::from_secs(7200);
325 let result = format_mtime(time);
326 assert!(result.contains("h ago"), "expected 'h ago', got: {result}");
327 }
328
329 #[test]
330 fn test_format_mtime_days_ago() {
331 let time = SystemTime::now() - Duration::from_secs(86400 * 3);
332 let result = format_mtime(time);
333 assert!(result.contains("d ago"), "expected 'd ago', got: {result}");
334 }
335
336 #[test]
337 fn test_format_mtime_future_time() {
338 let time = SystemTime::now() + Duration::from_secs(3600);
339 assert_eq!(format_mtime(time), "unknown");
340 }
341
342 #[test]
343 fn test_format_slot_label_manual_only() {
344 let slot = build_test_slot(1, true, false);
345 let label = format_slot_label(&slot);
346 assert!(label.contains("Slot 1"));
347 assert!(label.contains("manual/-"));
348 assert!(label.contains("Manual"));
349 }
350
351 #[test]
352 fn test_format_slot_label_both() {
353 let slot = build_test_slot(2, true, true);
354 let label = format_slot_label(&slot);
355 assert!(label.contains("Slot 2"));
356 assert!(label.contains("manual/auto"));
357 }
358
359 #[test]
360 fn test_setup_error_display() {
361 let err = SetupError::Cancelled;
362 assert_eq!(err.to_string(), "setup cancelled by user");
363
364 let err = SetupError::NoInstallation;
365 assert_eq!(err.to_string(), "no NMS installation found");
366 }
367
368 #[test]
369 fn test_update_save_config_preserves_mcp_section() {
370 let config = toml::from_str::<toml::Value>(
371 r#"
372 [mcp]
373 host = "127.0.0.1"
374 port = 5055
375 "#,
376 )
377 .unwrap();
378
379 let updated = update_save_config(
380 config,
381 Path::new("/Users/test/NMS/st_123"),
382 Path::new("/Users/test/NMS/st_123/save5.hg"),
383 "auto",
384 );
385
386 let serialized = toml::to_string_pretty(&updated).unwrap();
387 assert!(serialized.contains("[save]"));
388 assert!(serialized.contains("[mcp]"));
389
390 let parsed: Config = toml::from_str(&serialized).unwrap();
391 assert_eq!(
392 parsed.save.file.as_deref().unwrap().to_str().unwrap(),
393 "/Users/test/NMS/st_123/save5.hg"
394 );
395 assert_eq!(parsed.mcp_http_addr().to_string(), "127.0.0.1:5055");
396 }
397
398 fn build_test_slot(slot_num: u8, manual: bool, auto: bool) -> SaveSlot {
400 let dir = tempfile::tempdir().unwrap();
401
402 if manual {
404 let file_index = if slot_num == 1 {
405 "save.hg".to_string()
407 } else {
408 format!("save{}.hg", slot_num * 2 - 1)
409 };
410 std::fs::write(dir.path().join(&file_index), b"manual").unwrap();
411 }
412 if auto {
413 let file_index = format!("save{}.hg", slot_num * 2);
414 std::fs::write(dir.path().join(&file_index), b"auto").unwrap();
415 }
416
417 let saves = list_saves(dir.path()).unwrap();
418 let slots = group_into_slots(&saves);
419 slots.into_iter().find(|s| s.slot() == slot_num).unwrap()
420 }
421}