//! The main body of the application. // Uses #[cfg(not(windows))] use std::os::unix::fs::symlink; #[cfg(windows)] use std::os::windows::fs::{symlink_dir, symlink_file}; use std::{ collections::{HashMap, VecDeque}, fs::{ read_to_string as read_file_to_string, remove_dir, remove_file, symlink_metadata, write as write_string_to_file, }, io::stdin, path::Path, }; use anyhow::{Context, Error, Result}; use serde::Deserialize; use toml::from_str as from_toml_str; use yansi::Paint; use super::{HEADER_STYLE, INPUT_STYLE, MESSAGE_STYLE, RESULT_STYLE, VALUE_STYLE}; use crate::ERROR_STYLE; // Constants #[cfg(windows)] const STARTER_CONFIG_CONTENTS: &str = include_str!("../static/starter-loadouts-config-windows.toml"); #[cfg(not(windows))] const STARTER_CONFIG_CONTENTS: &str = include_str!("../static/starter-loadouts-config-unix.toml"); // Type Definitions type LoadoutName = String; type FileTarget = String; type FilePath = String; /// The loadouts config. #[derive(Debug, Deserialize)] struct LoadoutsConfig { targets: HashMap, loadouts: Vec, } /// A loadout, defining destination files for each target. #[derive(Debug, Deserialize)] struct Loadout { name: LoadoutName, parent: Option, files: HashMap, } /// The main loop of the application. On each loop it reads the config, provides /// the user with options, then awaits the user's decision and acts upon it. pub fn loadout_loop(config_path: &Path) -> Result<()> { let mut previous_selection = None; let mut input_buffer = String::new(); let stdin = stdin(); loop { // Read the config contents or prompt the user to make a starter one if it // doesn't exist let file_contents = match read_file_to_string(config_path) { Ok(contents) => contents, Err(error) => { println!( "{} {}", MESSAGE_STYLE.paint( "Unable to read the loadouts config file. Would you like a starter one to \ be created?" ), INPUT_STYLE.paint("(y/n)") ); input_buffer.clear(); stdin .read_line(&mut input_buffer) .with_context(|| "failed to get user input successfully")?; input_buffer = input_buffer.to_lowercase(); let user_input = input_buffer.trim_start(); if user_input.starts_with('y') { write_string_to_file(config_path, STARTER_CONFIG_CONTENTS).with_context( || { format!( "unable to write the starter config file \"{}\"", config_path.display() ) }, )?; println!( "{}", MESSAGE_STYLE.paint(format!( "A starter config file has been created at \"{}\". You will have to \ edit it to add your loadouts before you can use this tool.", VALUE_STYLE.paint(config_path.display()) )) ); continue; } return Err(error).with_context(|| { format!( "unable to read loadouts config file \"{}\" and the user declined to make \ a starter copy", config_path.display() ) }); } }; // Deserialize the config let loadouts_config = from_toml_str::(file_contents.as_str()) .with_context(|| "unable to deserialize the loadouts config file")?; // Calculate the width to pad entries to so they remain lined up let number_width = (loadouts_config.loadouts.len() - 1).log10() as usize + 1; // Give the user their options if previous_selection.is_none() { println!("{}", HEADER_STYLE.paint("Actions:")); println!( "\t{} Refresh config", INPUT_STYLE.paint(format!("{:>width$}.", "R", width = number_width)) ); println!( "\t{} Exit", INPUT_STYLE.paint(format!( "{:>width$}.", if number_width >= 5 { "E/Q/X" } else if number_width >= 3 { "E/Q" } else { "E" }, width = number_width )) ); println!( "{} (type the index number or the start of the name)", HEADER_STYLE.paint("Loadouts:") ); } else { println!("{}", HEADER_STYLE.paint("Loadouts:")); } for (index, loadout) in loadouts_config.loadouts.iter().enumerate() { let matches_previous_selection = if let Some(previous) = &previous_selection { loadout.name.eq(previous) } else { false }; println!( "\t{} {}", INPUT_STYLE.paint(format!("{:>width$}.", index, width = number_width)), if matches_previous_selection { Paint::new(&loadout.name).bold() } else { Paint::new(&loadout.name) } ); } // Get the user's choice input_buffer.clear(); stdin .read_line(&mut input_buffer) .with_context(|| "failed to get user input successfully")?; input_buffer = input_buffer.to_lowercase(); let user_input = input_buffer.trim_end_matches(is_newline).trim(); // Process the choice if user_input.is_empty() { continue; } match user_input.parse::() { Ok(i) => { if i < loadouts_config.loadouts.len() { previous_selection = Some(loadouts_config.loadouts[i].name.clone()); load_loadout(&loadouts_config, loadouts_config.loadouts[i].name.as_str())?; } else { println!( "{}", ERROR_STYLE.paint("Unrecognized command. Please try again.") ); } } Err(_) => match user_input { "r" => continue, "e" | "q" | "x" => break, input => { let mut found_loadout_name = None; for loadout in &loadouts_config.loadouts { let loadout_name_prepared = loadout.name.to_lowercase(); if loadout_name_prepared.starts_with(input) { found_loadout_name = Some(loadout.name.as_str()); break; } } if let Some(loadout_name) = found_loadout_name { previous_selection = Some(loadout_name.to_owned()); load_loadout(&loadouts_config, loadout_name)?; continue; } println!( "{}", ERROR_STYLE.paint("Unrecognized command. Please try again.") ); continue; } }, } } Ok(()) } /// Loads a loadout, managing the symlinks as necessary. fn load_loadout(config: &LoadoutsConfig, loadout_name: &str) -> Result<()> { // Build a chain of loadouts // This could be sped up, but it's not likely to ever get enough significant use // to be worth the changes necessary let mut loadout_chain = VecDeque::with_capacity(1); let mut search_name = loadout_name; 'chain_builder: loop { 'name_search: for loadout in &config.loadouts { if loadout.name.as_str() == search_name { loadout_chain.push_back(loadout); if let Some(parent_name) = &loadout.parent { search_name = parent_name.as_str(); break 'name_search; } break 'chain_builder; } } } // Follow the parental chain, and load the set of file targets to replace let mut file_mappings = HashMap::new(); while let Some(loadout) = loadout_chain.pop_back() { for mapping in &loadout.files { file_mappings.insert(mapping.0, mapping.1); } } // Load all the file links for (target_name, source) in file_mappings { // Get the target path based on the identifier provided let target = config.targets.get(target_name).ok_or_else(|| { Error::msg(format!( "target with identifier \"{}\" could not be found", target_name )) })?; // Get the source path metadata and error if it does not exist let source_metadata = symlink_metadata(source) .with_context(|| format!("source path \"{}\" does not exist", source))?; // Remove the existing target symlink unless it's an actual file // We don't want to accidentally delete a user's real file if let Ok(symlink_info) = symlink_metadata(target) { let file_type = symlink_info.file_type(); if file_type.is_symlink() { // Windows directory symlinks must be removed as directories #[cfg(windows)] { remove_file(target) .or_else(|_| remove_dir(target)) .with_context(|| { format!( "unable to remove the symlink \"{}\" as both a file and a \ directory", target ) })?; } #[cfg(not(windows))] { remove_file(target).with_context(|| { Error::msg(format!("unable to remove the symlink \"{}\"", target)) })?; } } else { return Err(Error::msg(format!( "target path \"{}\" exists already and is not a symbolic link", target ))); } } // Create a new symlink for the target #[cfg(windows)] { if source_metadata.is_dir() { symlink_dir(source, target).with_context(|| { Error::msg(format!( "unable to create a symbolic link (directory) at \"{}\" pointing to \"{}\"", target, source )) })?; } else { symlink_file(source, target).with_context(|| { Error::msg(format!( "unable to create a symbolic link (file) at \"{}\" pointing to \"{}\"", target, source )) })?; } } #[cfg(not(windows))] { symlink(source, target).with_context(|| { Error::msg(format!( "unable to create a symbolic link at \"{}\" pointing to \"{}\"", target, source )) })?; } } println!("{} {}", RESULT_STYLE.paint("Loaded:"), loadout_name); Ok(()) } /// A convenience function for removing newlines and carriage returns from user /// input. Technically the carriage return isn't a newline character, but since /// it always comes with a newline character we need to remove it as well. fn is_newline(c: char) -> bool { c == '\n' || c == '\r' }