use std::{ fmt::Display, io::{self, Write}, path::Path, str::FromStr, }; use anyhow::{anyhow, bail, Context, Result}; use clap::{crate_version, App, Arg, ArgMatches, SubCommand}; use itertools::Itertools; use log::debug; use termcolor::{Color, ColorChoice, ColorSpec, StandardStream, WriteColor}; use modderbaas::{ minemod::ModContainer, util, Baas, ContentDb, Downloader, MineMod, Snapshot, Source, World, }; fn main() -> Result<()> { stderrlog::new() .module(module_path!()) //.verbosity(1) .verbosity(5) .init() .unwrap(); let matches = App::new("ModderBaas") .version(crate_version!()) .arg( Arg::with_name("world") .long("world") .short("c") .required(false) .help("Select the world that we should operate on") .takes_value(true), ) .subcommand( SubCommand::with_name("enable") .about("Enables a mod and its dependencies") .arg( Arg::with_name("mod") .multiple(true) .required(true) .help("The mod to enable"), ), ) .subcommand( SubCommand::with_name("install") .about("Installs a mod and its dependencies") .arg( Arg::with_name("mod") .multiple(true) .required(true) .help("The mod to install"), ) .arg( Arg::with_name("target") .short("t") .long("target-dir") .help("The mod target directory") .default_value("."), ) .arg( Arg::with_name("dry-run") .short("n") .long("dry-run") .help("Only resolve dependencies, don't actually install any mods") .required(false), ) .arg( Arg::with_name("fix-permissions") .short("p") .long("fix-permissions") .help("Change the owner of the installed mod to match target-dir's") .required(false), ), ) .get_matches(); let mut stdout = StandardStream::stdout(ColorChoice::Auto); let baas = Baas::with_standard_dirs()?; let snapshot = baas .snapshot() .context("Creating the initial snapshot failed")?; let world = select_world(&mut stdout, &matches, &snapshot)?; write!(stdout, "Using world ")?; stdout.set_color(ColorSpec::new().set_fg(Some(Color::Green)))?; writeln!(stdout, "{}", world.world_name()?)?; stdout.reset()?; if let Some(enable) = matches.subcommand_matches("enable") { let mods = enable.values_of("mod").unwrap().collect::>(); enable_mods(&mut stdout, &snapshot, &world, &mods)?; } if let Some(install) = matches.subcommand_matches("install") { let mods = install.values_of("mod").unwrap().collect::>(); install_mods(&mut stdout, &snapshot, &world, &mods, install)?; } Ok(()) } /// Select the world that we want to work on. /// /// If there is only one world available, we use it. /// If there are more worlds, we ask the user for a choice. /// /// If the command line argument is given, it overrides the previous rules. fn select_world( output: &mut StandardStream, cli: &ArgMatches, snapshot: &Snapshot, ) -> Result { debug!("Starting world selection"); let worlds = snapshot.worlds(); if worlds.is_empty() { bail!("No world found"); } if let Some(world_name) = cli.value_of("world") { if let Some(world) = worlds.get(world_name) { return Ok(world.clone()); } else { bail!("Invalid world name given: {}", world_name); } } if worlds.len() == 1 { return Ok(worlds .values() .next() .expect("We just checked the length!") .clone()); } // Here, we cannot do an automatic selection, so ask the user: let mut worlds = worlds.iter().collect::>(); worlds.sort_by_key(|i| i.0); writeln!( output, "The following worlds were found, please select one:" )?; user_choice(&worlds, output).map(|&i| i.clone()) } /// Enables the given list of mods and their dependencies. /// /// Fails if any mod has a dependency that can not be satisfied with the locally available mods. fn enable_mods( output: &mut StandardStream, snapshot: &Snapshot, world: &World, mods: &[&str], ) -> Result<()> { let mut wanted = mods.iter().map(|&s| s.to_owned()).collect::>(); let mut to_enable = Vec::::new(); let game = snapshot .games() .get(&world.game_id()?) .ok_or_else(|| anyhow!("The game definition was not found"))?; let game_mods = game .mods()? .into_iter() .map(|m| m.mod_id()) .collect::, _>>()?; while !wanted.is_empty() { let next_mod = wanted.remove(0); // Do we already have the mod enabled, somehow? if world.mods()?.contains(&next_mod) || game_mods.contains(&next_mod) { continue; } match snapshot.mods().get(&next_mod as &str) { Some(m) => { to_enable.push(m.clone()); wanted.extend(m.dependencies()?); } None => bail!("Mod {} could not be found", next_mod), } } if to_enable.is_empty() { writeln!(output, "Done!")?; return Ok(()); } writeln!(output, "Enabling {} mods:", to_enable.len())?; writeln!(output, "{}", to_enable.iter().join(", "))?; ask_continue(output)?; for m in to_enable { let mod_id = m.mod_id()?; world .enable_mod(&mod_id) .context(format!("Error enabling '{}'", mod_id))?; } writeln!(output, "Done!")?; Ok(()) } /// Install the given mods, installing dependencies if needed. fn install_mods( output: &mut StandardStream, snapshot: &Snapshot, world: &World, mods: &[&str], matches: &ArgMatches, ) -> Result<()> { let target_dir = Path::new(matches.value_of("target").unwrap()); let dry_run = matches.is_present("dry-run"); let fix_permissions = matches.is_present("fix-permissions"); let content_db = ContentDb::new(); let downloader = Downloader::new()?; let mut wanted = mods .iter() .map(|&s| Source::from_str(s)) .collect::, _>>()?; let mut to_install = Vec::>::new(); let mut to_enable = Vec::::new(); let game = snapshot .games() .get(&world.game_id()?) .ok_or_else(|| anyhow!("The game definition was not found"))?; let game_mods = game .mods()? .into_iter() .map(|m| m.mod_id()) .collect::, _>>()?; 'modloop: while !wanted.is_empty() { let next_mod = wanted.remove(0); // Special handling for mods specified by their ID, as those could already exist. if let Source::ModId(ref id) = next_mod { if let Some(m) = snapshot.mods().get(id) { // We have that one, just enable it and its dependencies! to_enable.push(m.clone()); wanted.extend(m.dependencies()?.into_iter().map(Source::ModId)); continue; } else if game_mods.contains(id) { // This mod is already contained in the game, nothing for us to do continue; } // Is this a mod that is already queued for installation? for mod_or_pack in &to_install { for m in mod_or_pack.mods()? { if &m.name()? == id { continue 'modloop; } } } // This mod is not available, so we search the content DB writeln!(output, "Searching for candidates: {}", id)?; let candidates = content_db.resolve(id)?; if candidates.is_empty() { bail!("Could not find a suitable mod for '{}'", id); } else if candidates.len() == 1 { wanted.push(Source::Http(candidates.into_iter().next().unwrap().url)); } else { let items = candidates .into_iter() .map(|c| { ( format!("{} by {} - {}", c.title, c.author, c.short_description), c, ) }) .collect::>(); writeln!( output, "{} candidates found, please select one:", items.len() )?; let candidate = user_choice(&items, output)?; wanted.push(Source::Http(candidate.url.clone())); } } else { let downloaded = downloader .download(&next_mod) .context("Failed to download mod")?; for m in downloaded.mods()? { wanted.extend(m.dependencies()?.into_iter().map(Source::ModId)); } to_install.push(downloaded); } } writeln!(output, "Installing {} new mods:", to_install.len())?; writeln!(output, "{}", to_install.iter().join(", "))?; ask_continue(output)?; if dry_run { for m in to_install { let mod_id = m.name()?; let mod_dir = target_dir.join(&mod_id); writeln!(output, "Installing {} to {:?}", m, mod_dir)?; to_enable.extend(m.mods()?); } for m in to_enable { let mod_id = m.mod_id()?; writeln!(output, "Enabling {}", mod_id)?; } return Ok(()); } for m in to_install { let mod_id = m.name()?; writeln!(output, "Installing {}", mod_id)?; let installed = m .install_to(target_dir) .context(format!("Error installing '{}'", mod_id))?; to_enable.extend(installed.mods()?); #[cfg(unix)] { use nix::{ sys::stat, unistd::{Gid, Uid}, }; if fix_permissions { let perms = stat::stat(target_dir)?; let (uid, gid) = (Uid::from_raw(perms.st_uid), Gid::from_raw(perms.st_gid)); util::chown_recursive(installed.path(), Some(uid), Some(gid))?; } } } for m in to_enable { let mod_id = m.mod_id()?; world .enable_mod(&mod_id) .context(format!("Error enabling '{}'", mod_id))?; } writeln!(output, "Done!")?; Ok(()) } /// Presents the user with a choice of items and awaits a selection. fn user_choice<'i, L: Display, I>( items: &'i [(L, I)], output: &mut StandardStream, ) -> Result<&'i I> { for (i, (label, _)) in items.iter().enumerate() { output.set_color(ColorSpec::new().set_fg(Some(Color::Blue)))?; write!(output, "[{}]", i)?; output.reset()?; writeln!(output, " {}", label)?; } let stdin = io::stdin(); loop { write!(output, "Enter a number: ")?; output.flush()?; let mut buffer = String::new(); stdin.read_line(&mut buffer)?; if let Ok(number) = buffer.trim().parse::() { if number < items.len() { return Ok(&items[number].1); } } } } /// Ask the user whether they want to continue. /// /// Returns `Ok(())` if the program should continue, and an error otherwise. fn ask_continue(output: &mut StandardStream) -> Result<()> { let stdin = io::stdin(); loop { output.set_color(ColorSpec::new().set_fg(Some(Color::Yellow)))?; write!(output, "Continue? [Y/n] ")?; output.reset()?; output.flush()?; let mut buffer = String::new(); stdin.read_line(&mut buffer)?; if buffer == "\n" || buffer == "Y\n" || buffer == "y\n" { return Ok(()); } else if buffer == "N\n" || buffer == "n\n" { bail!("Cancelled by user"); } } }