Skip to main content

node_launchpad/
config.rs

1// Copyright 2024 MaidSafe.net limited.
2//
3// This SAFE Network Software is licensed to you under The General Public License (GPL), version 3.
4// Unless required by applicable law or agreed to in writing, the SAFE Network Software distributed
5// under the GPL Licence is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
6// KIND, either express or implied. Please review the Licences for the specific language governing
7// permissions and limitations relating to use of the SAFE Network Software.
8
9use crate::connection_mode::ConnectionMode;
10use crate::system::get_primary_mount_point;
11use crate::{action::Action, mode::Scene};
12#[cfg(not(windows))]
13use ant_node_manager::config::is_running_as_root;
14use color_eyre::eyre::{Result, eyre};
15use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
16use derive_deref::{Deref, DerefMut};
17use ratatui::style::{Color, Modifier, Style};
18use serde::{Deserialize, Serialize, de::Deserializer};
19use std::collections::HashMap;
20use std::path::PathBuf;
21
22const CONFIG: &str = include_str!("../.config/config.json5");
23
24/// Where to store the Nodes data.
25///
26/// If `base_dir` is the primary mount point, we store in "<base_dir>/$HOME/user_data_dir/autonomi/node".
27///
28/// if not we store in "<base_dir>/autonomi/node".
29///
30/// If should_create is true, the directory will be created if it doesn't exists.
31pub fn get_launchpad_nodes_data_dir_path(
32    base_dir: &PathBuf,
33    should_create: bool,
34) -> Result<PathBuf> {
35    let mut mount_point = PathBuf::new();
36
37    let data_directory: PathBuf = if *base_dir == get_primary_mount_point() {
38        // On Windows, always use C:\ProgramData\antctl\data regardless of root status.
39        // This path is not subject to MSIX filesystem virtualization, which redirects
40        // %APPDATA% paths to a package-specific location. Using dirs_next::data_dir()
41        // would cause the antnode binary to be written to the virtualized path, but the
42        // node registry would store the non-virtualized path, preventing the service
43        // manager from finding the binary.
44        #[cfg(windows)]
45        {
46            let path = PathBuf::from("C:\\ProgramData\\antctl\\data");
47            debug!("Using non-virtualized path for nodes data directory: {path:?}");
48            path
49        }
50        #[cfg(not(windows))]
51        if is_running_as_root() {
52            // The root's data directory isn't accessible to the user `ant`, so we are using an
53            // alternative default path that `ant` can access.
54            let default_data_dir_path = PathBuf::from("/var/antctl/services");
55            debug!(
56                "Running as root; using default path {:?} for nodes data directory instead of primary mount point",
57                default_data_dir_path
58            );
59            default_data_dir_path
60        } else {
61            get_user_data_dir()?
62        }
63    } else {
64        base_dir.clone()
65    };
66    mount_point.push(data_directory);
67    mount_point.push("autonomi");
68    mount_point.push("node");
69    if should_create {
70        debug!("Creating nodes data dir: {:?}", mount_point.as_path());
71        match std::fs::create_dir_all(mount_point.as_path()) {
72            Ok(_) => debug!("Nodes {:?} data dir created successfully", mount_point),
73            Err(e) => {
74                error!(
75                    "Failed to create nodes data dir in {:?}: {:?}",
76                    mount_point, e
77                );
78                return Err(eyre!(
79                    "Failed to create nodes data dir in {:?}",
80                    mount_point
81                ));
82            }
83        }
84    }
85    Ok(mount_point)
86}
87
88#[cfg(not(windows))]
89fn get_user_data_dir() -> Result<PathBuf> {
90    dirs_next::data_dir().ok_or_else(|| eyre!("User data directory is not obtainable",))
91}
92
93/// Where to store the Launchpad config & logs.
94///
95pub fn get_launchpad_data_dir_path() -> Result<PathBuf> {
96    let mut home_dirs =
97        dirs_next::data_dir().ok_or_else(|| eyre!("Data directory is not obtainable"))?;
98    home_dirs.push("autonomi");
99    home_dirs.push("launchpad");
100    std::fs::create_dir_all(home_dirs.as_path())?;
101    Ok(home_dirs)
102}
103
104pub fn get_config_dir() -> Result<PathBuf> {
105    // TODO: consider using dirs_next::config_dir. Configuration and data are different things.
106    let config_dir = get_launchpad_data_dir_path()?.join("config");
107    std::fs::create_dir_all(&config_dir)?;
108    Ok(config_dir)
109}
110
111/// Download and configure WinSW for Windows service management.
112///
113/// WinSW is placed at `C:\ProgramData\antctl\winsw.exe` (via `get_node_manager_path`) rather than
114/// the launchpad's own data directory. This is necessary because when the launchpad is installed via
115/// MSIX, Windows virtualizes `%APPDATA%` paths, redirecting file writes to an MSIX-specific
116/// location. The service management code then cannot find `winsw.exe` at the expected standard
117/// path. Using `C:\ProgramData\antctl` avoids this problem since it is not subject to MSIX
118/// filesystem virtualization.
119#[cfg(windows)]
120pub async fn configure_winsw() -> Result<()> {
121    let winsw_path = ant_node_manager::config::get_node_manager_path()?.join("winsw.exe");
122    ant_node_manager::helpers::configure_winsw(
123        &winsw_path,
124        ant_node_manager::VerbosityLevel::Minimal,
125    )
126    .await?;
127    Ok(())
128}
129
130#[cfg(not(windows))]
131pub async fn configure_winsw() -> Result<()> {
132    Ok(())
133}
134
135#[derive(Clone, Debug, Deserialize, Serialize)]
136pub struct AppData {
137    pub discord_username: String,
138    pub nodes_to_start: usize,
139    pub storage_mountpoint: Option<PathBuf>,
140    pub storage_drive: Option<String>,
141    pub connection_mode: Option<ConnectionMode>,
142    pub port_from: Option<u32>,
143    pub port_to: Option<u32>,
144}
145
146impl Default for AppData {
147    fn default() -> Self {
148        Self {
149            discord_username: "".to_string(),
150            nodes_to_start: 1,
151            storage_mountpoint: None,
152            storage_drive: None,
153            connection_mode: None,
154            port_from: None,
155            port_to: None,
156        }
157    }
158}
159
160impl AppData {
161    pub fn load(custom_path: Option<PathBuf>) -> Result<Self> {
162        let config_path = if let Some(path) = custom_path {
163            path
164        } else {
165            get_config_dir()
166                .map_err(|_| color_eyre::eyre::eyre!("Could not obtain config dir"))?
167                .join("app_data.json")
168        };
169
170        if !config_path.exists() {
171            return Ok(Self::default());
172        }
173
174        let data = std::fs::read_to_string(&config_path).map_err(|e| {
175            error!("Failed to read app data file: {}", e);
176            color_eyre::eyre::eyre!("Failed to read app data file: {}", e)
177        })?;
178
179        let mut app_data: AppData = serde_json::from_str(&data).map_err(|e| {
180            error!("Failed to parse app data: {}", e);
181            color_eyre::eyre::eyre!("Failed to parse app data: {}", e)
182        })?;
183
184        // Don't allow the manual setting to HomeNetwork anymore
185        if let Some(ConnectionMode::HomeNetwork) = app_data.connection_mode {
186            app_data.connection_mode = Some(ConnectionMode::Automatic);
187        }
188
189        Ok(app_data)
190    }
191
192    pub fn save(&self, custom_path: Option<PathBuf>) -> Result<()> {
193        let config_path = if let Some(path) = custom_path {
194            path
195        } else {
196            get_config_dir()
197                .map_err(|_| config::ConfigError::Message("Could not obtain data dir".to_string()))?
198                .join("app_data.json")
199        };
200
201        let serialized_config = serde_json::to_string_pretty(&self)?;
202        std::fs::write(config_path, serialized_config)?;
203
204        Ok(())
205    }
206}
207
208#[derive(Clone, Debug, Default, Deserialize, Serialize)]
209pub struct Config {
210    #[serde(default)]
211    pub keybindings: KeyBindings,
212    #[serde(default)]
213    pub styles: Styles,
214}
215
216impl Config {
217    pub fn new() -> Result<Self, config::ConfigError> {
218        let default_config: Config = json5::from_str(CONFIG).unwrap();
219        let data_dir = get_launchpad_data_dir_path()
220            .map_err(|_| config::ConfigError::Message("Could not obtain data dir".to_string()))?;
221        let config_dir = get_config_dir()
222            .map_err(|_| config::ConfigError::Message("Could not obtain data dir".to_string()))?;
223        let mut builder = config::Config::builder()
224            .set_default("_data_dir", data_dir.to_str().unwrap())?
225            .set_default("_config_dir", config_dir.to_str().unwrap())?;
226
227        let config_files = [
228            ("config.json5", config::FileFormat::Json5),
229            ("config.json", config::FileFormat::Json),
230            ("config.yaml", config::FileFormat::Yaml),
231            ("config.toml", config::FileFormat::Toml),
232            ("config.ini", config::FileFormat::Ini),
233        ];
234        let mut found_config = false;
235        for (file, format) in &config_files {
236            builder = builder.add_source(
237                config::File::from(config_dir.join(file))
238                    .format(*format)
239                    .required(false),
240            );
241            if config_dir.join(file).exists() {
242                found_config = true
243            }
244        }
245        if !found_config {
246            log::error!("No configuration file found. Application may not behave as expected");
247        }
248
249        let mut cfg: Self = builder.build()?.try_deserialize()?;
250
251        for (mode, default_bindings) in default_config.keybindings.iter() {
252            let user_bindings = cfg.keybindings.entry(*mode).or_default();
253            for (key, cmd) in default_bindings.iter() {
254                user_bindings
255                    .entry(key.clone())
256                    .or_insert_with(|| cmd.clone());
257            }
258        }
259        for (mode, default_styles) in default_config.styles.iter() {
260            let user_styles = cfg.styles.entry(*mode).or_default();
261            for (style_key, style) in default_styles.iter() {
262                user_styles
263                    .entry(style_key.clone())
264                    .or_insert_with(|| *style);
265            }
266        }
267
268        Ok(cfg)
269    }
270}
271
272#[derive(Clone, Debug, Default, Deref, DerefMut, Serialize)]
273pub struct KeyBindings(pub HashMap<Scene, HashMap<Vec<KeyEvent>, Action>>);
274
275impl<'de> Deserialize<'de> for KeyBindings {
276    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
277    where
278        D: Deserializer<'de>,
279    {
280        let parsed_map = HashMap::<Scene, HashMap<String, Action>>::deserialize(deserializer)?;
281
282        let keybindings = parsed_map
283            .into_iter()
284            .map(|(mode, inner_map)| {
285                let converted_inner_map = inner_map
286                    .into_iter()
287                    .map(|(key_str, cmd)| (parse_key_sequence(&key_str).unwrap(), cmd))
288                    .collect();
289                (mode, converted_inner_map)
290            })
291            .collect();
292
293        Ok(KeyBindings(keybindings))
294    }
295}
296
297fn parse_key_event(raw: &str) -> Result<KeyEvent, String> {
298    let raw_lower = raw.to_ascii_lowercase();
299    let (remaining, modifiers) = extract_modifiers(&raw_lower);
300    parse_key_code_with_modifiers(remaining, modifiers)
301}
302
303fn extract_modifiers(raw: &str) -> (&str, KeyModifiers) {
304    let mut modifiers = KeyModifiers::empty();
305    let mut current = raw;
306
307    loop {
308        match current {
309            rest if rest.starts_with("ctrl-") => {
310                modifiers.insert(KeyModifiers::CONTROL);
311                current = &rest[5..];
312            }
313            rest if rest.starts_with("alt-") => {
314                modifiers.insert(KeyModifiers::ALT);
315                current = &rest[4..];
316            }
317            rest if rest.starts_with("shift-") => {
318                modifiers.insert(KeyModifiers::SHIFT);
319                current = &rest[6..];
320            }
321            _ => break, // break out of the loop if no known prefix is detected
322        };
323    }
324
325    (current, modifiers)
326}
327
328fn parse_key_code_with_modifiers(
329    raw: &str,
330    mut modifiers: KeyModifiers,
331) -> Result<KeyEvent, String> {
332    let c = match raw {
333        "esc" => KeyCode::Esc,
334        "enter" => KeyCode::Enter,
335        "left" => KeyCode::Left,
336        "right" => KeyCode::Right,
337        "up" => KeyCode::Up,
338        "down" => KeyCode::Down,
339        "home" => KeyCode::Home,
340        "end" => KeyCode::End,
341        "pageup" => KeyCode::PageUp,
342        "pagedown" => KeyCode::PageDown,
343        "backtab" => {
344            modifiers.insert(KeyModifiers::SHIFT);
345            KeyCode::BackTab
346        }
347        "backspace" => KeyCode::Backspace,
348        "delete" => KeyCode::Delete,
349        "insert" => KeyCode::Insert,
350        "f1" => KeyCode::F(1),
351        "f2" => KeyCode::F(2),
352        "f3" => KeyCode::F(3),
353        "f4" => KeyCode::F(4),
354        "f5" => KeyCode::F(5),
355        "f6" => KeyCode::F(6),
356        "f7" => KeyCode::F(7),
357        "f8" => KeyCode::F(8),
358        "f9" => KeyCode::F(9),
359        "f10" => KeyCode::F(10),
360        "f11" => KeyCode::F(11),
361        "f12" => KeyCode::F(12),
362        "space" => KeyCode::Char(' '),
363        "hyphen" => KeyCode::Char('-'),
364        "minus" => KeyCode::Char('-'),
365        "tab" => KeyCode::Tab,
366        c if c.len() == 1 => {
367            let mut c = c.chars().next().unwrap();
368            if modifiers.contains(KeyModifiers::SHIFT) {
369                c = c.to_ascii_uppercase();
370            }
371            KeyCode::Char(c)
372        }
373        _ => return Err(format!("Unable to parse {raw}")),
374    };
375    Ok(KeyEvent::new(c, modifiers))
376}
377
378pub fn key_event_to_string(key_event: &KeyEvent) -> String {
379    let char;
380    let key_code = match key_event.code {
381        KeyCode::Backspace => "backspace",
382        KeyCode::Enter => "enter",
383        KeyCode::Left => "left",
384        KeyCode::Right => "right",
385        KeyCode::Up => "up",
386        KeyCode::Down => "down",
387        KeyCode::Home => "home",
388        KeyCode::End => "end",
389        KeyCode::PageUp => "pageup",
390        KeyCode::PageDown => "pagedown",
391        KeyCode::Tab => "tab",
392        KeyCode::BackTab => "backtab",
393        KeyCode::Delete => "delete",
394        KeyCode::Insert => "insert",
395        KeyCode::F(c) => {
396            char = format!("f({c})");
397            &char
398        }
399        KeyCode::Char(' ') => "space",
400        KeyCode::Char(c) => {
401            char = c.to_string();
402            &char
403        }
404        KeyCode::Esc => "esc",
405        KeyCode::Null => "",
406        KeyCode::CapsLock => "",
407        KeyCode::Menu => "",
408        KeyCode::ScrollLock => "",
409        KeyCode::Media(_) => "",
410        KeyCode::NumLock => "",
411        KeyCode::PrintScreen => "",
412        KeyCode::Pause => "",
413        KeyCode::KeypadBegin => "",
414        KeyCode::Modifier(_) => "",
415    };
416
417    let mut modifiers = Vec::with_capacity(3);
418
419    if key_event.modifiers.intersects(KeyModifiers::CONTROL) {
420        modifiers.push("ctrl");
421    }
422
423    if key_event.modifiers.intersects(KeyModifiers::SHIFT) {
424        modifiers.push("shift");
425    }
426
427    if key_event.modifiers.intersects(KeyModifiers::ALT) {
428        modifiers.push("alt");
429    }
430
431    let mut key = modifiers.join("-");
432
433    if !key.is_empty() {
434        key.push('-');
435    }
436    key.push_str(key_code);
437
438    key
439}
440
441pub fn parse_key_sequence(raw: &str) -> Result<Vec<KeyEvent>, String> {
442    if raw.chars().filter(|c| *c == '>').count() != raw.chars().filter(|c| *c == '<').count() {
443        return Err(format!("Unable to parse `{raw}`"));
444    }
445    let raw = if !raw.contains("><") {
446        let raw = raw.strip_prefix('<').unwrap_or(raw);
447        raw.strip_prefix('>').unwrap_or(raw)
448    } else {
449        raw
450    };
451    let sequences = raw
452        .split("><")
453        .map(|seq| {
454            if let Some(s) = seq.strip_prefix('<') {
455                s
456            } else if let Some(s) = seq.strip_suffix('>') {
457                s
458            } else {
459                seq
460            }
461        })
462        .collect::<Vec<_>>();
463
464    sequences.into_iter().map(parse_key_event).collect()
465}
466
467#[derive(Clone, Debug, Default, Deref, DerefMut, Serialize)]
468pub struct Styles(pub HashMap<Scene, HashMap<String, Style>>);
469
470impl<'de> Deserialize<'de> for Styles {
471    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
472    where
473        D: Deserializer<'de>,
474    {
475        let parsed_map = HashMap::<Scene, HashMap<String, String>>::deserialize(deserializer)?;
476
477        let styles = parsed_map
478            .into_iter()
479            .map(|(mode, inner_map)| {
480                let converted_inner_map = inner_map
481                    .into_iter()
482                    .map(|(str, style)| (str, parse_style(&style)))
483                    .collect();
484                (mode, converted_inner_map)
485            })
486            .collect();
487
488        Ok(Styles(styles))
489    }
490}
491
492pub fn parse_style(line: &str) -> Style {
493    let (foreground, background) =
494        line.split_at(line.to_lowercase().find("on ").unwrap_or(line.len()));
495    let foreground = process_color_string(foreground);
496    let background = process_color_string(&background.replace("on ", ""));
497
498    let mut style = Style::default();
499    if let Some(fg) = parse_color(&foreground.0) {
500        style = style.fg(fg);
501    }
502    if let Some(bg) = parse_color(&background.0) {
503        style = style.bg(bg);
504    }
505    style = style.add_modifier(foreground.1 | background.1);
506    style
507}
508
509fn process_color_string(color_str: &str) -> (String, Modifier) {
510    let color = color_str
511        .replace("grey", "gray")
512        .replace("bright ", "")
513        .replace("bold ", "")
514        .replace("underline ", "")
515        .replace("inverse ", "");
516
517    let mut modifiers = Modifier::empty();
518    if color_str.contains("underline") {
519        modifiers |= Modifier::UNDERLINED;
520    }
521    if color_str.contains("bold") {
522        modifiers |= Modifier::BOLD;
523    }
524    if color_str.contains("inverse") {
525        modifiers |= Modifier::REVERSED;
526    }
527
528    (color, modifiers)
529}
530
531fn parse_color(s: &str) -> Option<Color> {
532    let s = s.trim_start();
533    let s = s.trim_end();
534    if s.contains("bright color") {
535        let s = s.trim_start_matches("bright ");
536        let c = s
537            .trim_start_matches("color")
538            .parse::<u8>()
539            .unwrap_or_default();
540        Some(Color::Indexed(c.wrapping_shl(8)))
541    } else if s.contains("color") {
542        let c = s
543            .trim_start_matches("color")
544            .parse::<u8>()
545            .unwrap_or_default();
546        Some(Color::Indexed(c))
547    } else if s.contains("gray") {
548        let c = 232
549            + s.trim_start_matches("gray")
550                .parse::<u8>()
551                .unwrap_or_default();
552        Some(Color::Indexed(c))
553    } else if s.contains("rgb") {
554        let red = (s.as_bytes()[3] as char).to_digit(10).unwrap_or_default() as u8;
555        let green = (s.as_bytes()[4] as char).to_digit(10).unwrap_or_default() as u8;
556        let blue = (s.as_bytes()[5] as char).to_digit(10).unwrap_or_default() as u8;
557        let c = 16 + red * 36 + green * 6 + blue;
558        Some(Color::Indexed(c))
559    } else if s == "bold black" {
560        Some(Color::Indexed(8))
561    } else if s == "bold red" {
562        Some(Color::Indexed(9))
563    } else if s == "bold green" {
564        Some(Color::Indexed(10))
565    } else if s == "bold yellow" {
566        Some(Color::Indexed(11))
567    } else if s == "bold blue" {
568        Some(Color::Indexed(12))
569    } else if s == "bold magenta" {
570        Some(Color::Indexed(13))
571    } else if s == "bold cyan" {
572        Some(Color::Indexed(14))
573    } else if s == "bold white" {
574        Some(Color::Indexed(15))
575    } else if s == "black" {
576        Some(Color::Indexed(0))
577    } else if s == "red" {
578        Some(Color::Indexed(1))
579    } else if s == "green" {
580        Some(Color::Indexed(2))
581    } else if s == "yellow" {
582        Some(Color::Indexed(3))
583    } else if s == "blue" {
584        Some(Color::Indexed(4))
585    } else if s == "magenta" {
586        Some(Color::Indexed(5))
587    } else if s == "cyan" {
588        Some(Color::Indexed(6))
589    } else if s == "white" {
590        Some(Color::Indexed(7))
591    } else {
592        None
593    }
594}
595
596#[cfg(test)]
597mod tests {
598    use pretty_assertions::assert_eq;
599    use tempfile::tempdir;
600
601    use super::*;
602
603    #[test]
604    fn test_parse_style_default() {
605        let style = parse_style("");
606        assert_eq!(style, Style::default());
607    }
608
609    #[test]
610    fn test_parse_style_foreground() {
611        let style = parse_style("red");
612        assert_eq!(style.fg, Some(Color::Indexed(1)));
613    }
614
615    #[test]
616    fn test_parse_style_background() {
617        let style = parse_style("on blue");
618        assert_eq!(style.bg, Some(Color::Indexed(4)));
619    }
620
621    #[test]
622    fn test_parse_style_modifiers() {
623        let style = parse_style("underline red on blue");
624        assert_eq!(style.fg, Some(Color::Indexed(1)));
625        assert_eq!(style.bg, Some(Color::Indexed(4)));
626    }
627
628    #[test]
629    fn test_process_color_string() {
630        let (color, modifiers) = process_color_string("underline bold inverse gray");
631        assert_eq!(color, "gray");
632        assert!(modifiers.contains(Modifier::UNDERLINED));
633        assert!(modifiers.contains(Modifier::BOLD));
634        assert!(modifiers.contains(Modifier::REVERSED));
635    }
636
637    #[test]
638    fn test_parse_color_rgb() {
639        let color = parse_color("rgb123");
640        let expected = 16 + 36 + 2 * 6 + 3;
641        assert_eq!(color, Some(Color::Indexed(expected)));
642    }
643
644    #[test]
645    fn test_parse_color_unknown() {
646        let color = parse_color("unknown");
647        assert_eq!(color, None);
648    }
649
650    #[test]
651    fn test_config() -> Result<()> {
652        let c = Config::new()?;
653        assert_eq!(
654            c.keybindings
655                .get(&Scene::Status)
656                .unwrap()
657                .get(&parse_key_sequence("<q>").unwrap_or_default())
658                .unwrap(),
659            &Action::Quit
660        );
661        Ok(())
662    }
663
664    #[test]
665    fn test_simple_keys() {
666        assert_eq!(
667            parse_key_event("a").unwrap(),
668            KeyEvent::new(KeyCode::Char('a'), KeyModifiers::empty())
669        );
670
671        assert_eq!(
672            parse_key_event("enter").unwrap(),
673            KeyEvent::new(KeyCode::Enter, KeyModifiers::empty())
674        );
675
676        assert_eq!(
677            parse_key_event("esc").unwrap(),
678            KeyEvent::new(KeyCode::Esc, KeyModifiers::empty())
679        );
680    }
681
682    #[test]
683    fn test_with_modifiers() {
684        assert_eq!(
685            parse_key_event("ctrl-a").unwrap(),
686            KeyEvent::new(KeyCode::Char('a'), KeyModifiers::CONTROL)
687        );
688
689        assert_eq!(
690            parse_key_event("alt-enter").unwrap(),
691            KeyEvent::new(KeyCode::Enter, KeyModifiers::ALT)
692        );
693
694        assert_eq!(
695            parse_key_event("shift-esc").unwrap(),
696            KeyEvent::new(KeyCode::Esc, KeyModifiers::SHIFT)
697        );
698    }
699
700    #[test]
701    fn test_multiple_modifiers() {
702        assert_eq!(
703            parse_key_event("ctrl-alt-a").unwrap(),
704            KeyEvent::new(
705                KeyCode::Char('a'),
706                KeyModifiers::CONTROL | KeyModifiers::ALT
707            )
708        );
709
710        assert_eq!(
711            parse_key_event("ctrl-shift-enter").unwrap(),
712            KeyEvent::new(KeyCode::Enter, KeyModifiers::CONTROL | KeyModifiers::SHIFT)
713        );
714    }
715
716    #[test]
717    fn test_reverse_multiple_modifiers() {
718        assert_eq!(
719            key_event_to_string(&KeyEvent::new(
720                KeyCode::Char('a'),
721                KeyModifiers::CONTROL | KeyModifiers::ALT
722            )),
723            "ctrl-alt-a".to_string()
724        );
725    }
726
727    #[test]
728    fn test_invalid_keys() {
729        assert!(parse_key_event("invalid-key").is_err());
730        assert!(parse_key_event("ctrl-invalid-key").is_err());
731    }
732
733    #[test]
734    fn test_case_insensitivity() {
735        assert_eq!(
736            parse_key_event("CTRL-a").unwrap(),
737            KeyEvent::new(KeyCode::Char('a'), KeyModifiers::CONTROL)
738        );
739
740        assert_eq!(
741            parse_key_event("AlT-eNtEr").unwrap(),
742            KeyEvent::new(KeyCode::Enter, KeyModifiers::ALT)
743        );
744    }
745
746    #[test]
747    fn test_app_data_file_does_not_exist() -> Result<()> {
748        let temp_dir = tempdir()?;
749        let non_existent_path = temp_dir.path().join("non_existent_app_data.json");
750
751        let app_data = AppData::load(Some(non_existent_path))?;
752
753        assert_eq!(app_data.discord_username, "");
754        assert_eq!(app_data.nodes_to_start, 1);
755        assert_eq!(app_data.storage_mountpoint, None);
756        assert_eq!(app_data.storage_drive, None);
757        assert_eq!(app_data.connection_mode, None);
758        assert_eq!(app_data.port_from, None);
759        assert_eq!(app_data.port_to, None);
760
761        Ok(())
762    }
763
764    #[test]
765    fn test_app_data_partial_info() -> Result<()> {
766        let temp_dir = tempdir()?;
767        let partial_data_path = temp_dir.path().join("partial_app_data.json");
768
769        let partial_data = r#"
770        {
771            "discord_username": "test_user",
772            "nodes_to_start": 3
773        }
774        "#;
775
776        std::fs::write(&partial_data_path, partial_data)?;
777
778        let app_data = AppData::load(Some(partial_data_path))?;
779
780        assert_eq!(app_data.discord_username, "test_user");
781        assert_eq!(app_data.nodes_to_start, 3);
782        assert_eq!(app_data.storage_mountpoint, None);
783        assert_eq!(app_data.storage_drive, None);
784        assert_eq!(app_data.connection_mode, None);
785        assert_eq!(app_data.port_from, None);
786        assert_eq!(app_data.port_to, None);
787
788        Ok(())
789    }
790
791    #[test]
792    fn test_app_data_missing_mountpoint() -> Result<()> {
793        let temp_dir = tempdir()?;
794        let missing_mountpoint_path = temp_dir.path().join("missing_mountpoint_app_data.json");
795
796        let missing_mountpoint_data = r#"
797        {
798            "discord_username": "test_user",
799            "nodes_to_start": 3,
800            "storage_drive": "C:"
801        }
802        "#;
803
804        std::fs::write(&missing_mountpoint_path, missing_mountpoint_data)?;
805
806        let app_data = AppData::load(Some(missing_mountpoint_path))?;
807
808        assert_eq!(app_data.discord_username, "test_user");
809        assert_eq!(app_data.nodes_to_start, 3);
810        assert_eq!(app_data.storage_mountpoint, None);
811        assert_eq!(app_data.storage_drive, Some("C:".to_string()));
812        assert_eq!(app_data.connection_mode, None);
813        assert_eq!(app_data.port_from, None);
814        assert_eq!(app_data.port_to, None);
815
816        Ok(())
817    }
818
819    #[test]
820    fn test_app_data_save_and_load() -> Result<()> {
821        let temp_dir = tempdir()?;
822        let test_path = temp_dir.path().join("test_app_data.json");
823
824        let mut app_data = AppData::default();
825        let var_name = &"save_load_user";
826        app_data.discord_username = var_name.to_string();
827        app_data.nodes_to_start = 4;
828        app_data.storage_mountpoint = Some(PathBuf::from("/mnt/test"));
829        app_data.storage_drive = Some("E:".to_string());
830        app_data.connection_mode = Some(ConnectionMode::CustomPorts);
831        app_data.port_from = Some(12000);
832        app_data.port_to = Some(13000);
833
834        // Save to custom path
835        app_data.save(Some(test_path.clone()))?;
836
837        // Load from custom path
838        let loaded_data = AppData::load(Some(test_path))?;
839
840        assert_eq!(loaded_data.discord_username, "save_load_user");
841        assert_eq!(loaded_data.nodes_to_start, 4);
842        assert_eq!(
843            loaded_data.storage_mountpoint,
844            Some(PathBuf::from("/mnt/test"))
845        );
846        assert_eq!(loaded_data.storage_drive, Some("E:".to_string()));
847        assert_eq!(
848            loaded_data.connection_mode,
849            Some(ConnectionMode::CustomPorts)
850        );
851        assert_eq!(loaded_data.port_from, Some(12000));
852        assert_eq!(loaded_data.port_to, Some(13000));
853
854        Ok(())
855    }
856}