Skip to main content

nms_copilot/
setup.rs

1//! Interactive setup wizard for first-time NMS Copilot configuration.
2//!
3//! When the REPL starts with no save file configured and auto-detect fails,
4//! this wizard guides the user through finding and selecting their NMS save file.
5
6use 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
17/// Build the dialoguer theme with our color scheme.
18///
19/// - Prompt text in cyan
20/// - Active item prefix (`>`) in bright green
21/// - Active items in default (account names are pre-colored in yellow)
22fn 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/// Errors that can occur during the setup wizard.
33#[derive(Debug, thiserror::Error)]
34pub enum SetupError {
35    /// The user cancelled the setup wizard.
36    #[error("setup cancelled by user")]
37    Cancelled,
38
39    /// No NMS installation could be found.
40    #[error("no NMS installation found")]
41    NoInstallation,
42
43    /// Error from save file discovery.
44    #[error(transparent)]
45    Locate(#[from] locate::LocateError),
46
47    /// I/O error.
48    #[error(transparent)]
49    Io(#[from] std::io::Error),
50}
51
52/// Run the interactive setup wizard.
53///
54/// Guides the user through selecting an NMS save file by:
55/// 1. Detecting the platform-default NMS save directory
56/// 2. Listing account directories
57/// 3. Listing save slots within the selected account
58/// 4. Optionally saving the selection to `~/.nms-copilot/config.toml`
59///
60/// Returns the resolved path to the selected save file.
61pub 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    // Step 1: Find the NMS save directory
71    let save_dir = find_save_directory(&theme)?;
72
73    // Step 2: Select an account
74    let account = select_account(&save_dir, &theme)?;
75
76    // Step 3: Select a save slot
77    let save_path = select_save_slot(account.path(), &theme)?;
78
79    println!();
80    println!("Selected: {}", save_path.display());
81
82    // Step 4: Offer to save config
83    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
99/// Find the NMS save directory, falling back to user input.
100fn 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
128/// Select an account directory. Auto-selects if there is only one.
129fn select_account(save_dir: &Path, theme: &ColorfulTheme) -> Result<AccountDir, SetupError> {
130    // If the user pointed directly to an account dir (contains save*.hg), use it
131    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
179/// Select a save slot from an account directory. Auto-selects if only one slot.
180fn 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
219/// Format a save slot for display in the selection menu.
220fn 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
242/// Format a modification time as a human-readable relative string.
243fn 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
260/// Save the selected save file settings to the config file.
261///
262/// Merges with any existing config, only updating the `[save]` section.
263fn 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    /// Helper to build a SaveSlot for testing via the locate module.
399    fn build_test_slot(slot_num: u8, manual: bool, auto: bool) -> SaveSlot {
400        let dir = tempfile::tempdir().unwrap();
401
402        // Create save files for the specified slot
403        if manual {
404            let file_index = if slot_num == 1 {
405                // save.hg for slot 1 manual
406                "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}