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