oliver 0.1.9

Lightweight CLI mod manager for Baldur's Gate 3 on Linux
Documentation
use anyhow::Result;
use clap::{Parser, Subcommand};
use larian_formats::bg3::ModuleInfo;
use oliver::{Settings, DEFAULT_APP_DATA_DIR};
use std::{path::PathBuf, process::ExitCode};

#[derive(Debug, Parser)]
#[command(arg_required_else_help(true), author, version, about)]
struct Cli {
    #[clap(subcommand)]
    command: Command,
}

#[derive(Debug, Subcommand)]
enum Command {
    /// Prints the raw XML contents of a mod file.
    Dump(Dump),

    /// Exports the currently installed mods and the mod order to single compressed file.
    Export(Export),

    /// Imports the mods from compressed file.
    Import(Import),

    /// Install one or more mods.
    Install(Install),

    /// List all mods currently installed use.
    List(List),

    /// Pack loose mod files into an archive loadable by the game.
    Pack(Pack),

    /// Print a summary of the contents in a given file.
    Parse(Parse),

    /// Uninstall one or more mods.
    Uninstall(Uninstall),

    /// Unpacks all of the files contained in a mod.
    Unpack(Unpack),
}

#[derive(Debug, Parser)]
struct Dump {
    /// Path to the file to dump.
    ///
    /// The file must be either a .pak format in the LSPK format.
    #[clap()]
    path: PathBuf,
}

#[derive(Debug, Parser)]
struct Export {
    #[clap(flatten)]
    app_data_path: AppDataPath,

    /// The file where the exported mod data should be written.
    #[clap()]
    destination: PathBuf,
}

#[derive(Debug, Parser)]
struct Import {
    #[clap(flatten)]
    app_data_path: AppDataPath,

    /// The file where the exported mod data should be read.
    #[clap()]
    source: PathBuf,
}

#[derive(Debug, Parser)]
struct List {
    #[clap(flatten)]
    app_data_path: AppDataPath,

    /// Indicate the order that the mods will be loaded by the game.
    #[clap(long, short)]
    order: bool,
}

#[derive(Debug, Parser)]
#[command(arg_required_else_help(true))]
struct Parse {
    /// Paths to the files to summarize.
    ///
    /// Each file must be either a .pak format in the LSPK format or a .lsf file.
    #[clap()]
    paths: Vec<PathBuf>,

    /// Print extra information.
    #[clap(long, short)]
    verbose: bool,
}

#[derive(Debug, Parser)]
#[command(arg_required_else_help(true))]
struct Install {
    /// Print extra information.
    #[clap(long, short)]
    verbose: bool,

    /// Don't perform any actions, but validate whether command would succeed or not.
    ///
    /// If `--verbose` is also specified, invoking with `--dry-run` will indicate the version of
    /// any successfully updated addon.
    #[clap(long, short = 'n')]
    dry_run: bool,

    /// Reinstall even if the same version is already installed.
    #[clap(long, short)]
    refresh: bool,

    #[clap(flatten)]
    app_data_path: AppDataPath,

    /// Paths to the mod files to install. By default, this will only install mods if the version
    /// is newer than the currently installed version (if any exists).
    ///
    /// The mod file can be either a `.pak` file or a zip file containing a `.pak`.
    #[clap()]
    mod_file_paths: Vec<String>,
}

#[derive(Debug, Parser)]
#[command(arg_required_else_help(true))]
struct Uninstall {
    /// Don't perform any actions, but validate whether command would succeed or not.
    #[clap(long, short = 'n')]
    dry_run: bool,

    #[clap(flatten)]
    app_data_path: AppDataPath,

    /// Indexes of the mods in the order to uninstall.
    #[clap()]
    mod_indexes: Vec<usize>,
}

#[derive(Debug, Parser)]
#[command(arg_required_else_help(true))]
struct Pack {
    /// Path to the directory containing loose mod files to pack.
    #[clap()]
    mod_files_root: PathBuf,

    /// The name of the mod file to output.
    ///
    /// If this is not specified, oliver will try to infer the name based on the location of the
    /// meta.lsx file in the `Mods/` directory.
    #[clap(long, short)]
    destination: Option<PathBuf>,
}

#[derive(Debug, Parser)]
#[command(arg_required_else_help(true))]
struct Unpack {
    /// The mod file to unpack.
    #[clap()]
    mod_file_path: PathBuf,

    /// The directory to unpack the mod into.
    #[clap(long, short)]
    destination: Option<PathBuf>,
}

#[derive(Debug, Parser)]
struct AppDataPath {
    /// Path to the `AppData` folder that contains the installation.
    ///
    /// If you're not using Steam, this will likely be in your Wine prefix under
    /// `drive_c/users/<username>/AppData`, where `<username>` is the name of your user.
    ///
    /// This directory is expected to contain the file `modsettings.lsx` in the relative path
    /// `Local/Larian Studios/Baldur's Gate 3/PlayerProfiles/Public/`.
    #[clap(
        long,
        default_value_t = DEFAULT_APP_DATA_DIR.into()
    )]
    app_data_path: String,
}

fn run() -> Result<()> {
    static_assertions_next::assert_cfg!(
        target_os = "linux",
        format!(
            "This tool currently only supports Linux. If you're interested in using this on \
             another platform, feel free to let me know by filing an issue at {}!",
            env!("CARGO_PKG_REPOSTIORY")
        )
    );

    match Cli::parse().command {
        Command::Install(Install {
            verbose,
            dry_run,
            refresh,
            mod_file_paths,
            app_data_path,
        }) => Settings::with_app_data_dir(&app_data_path.app_data_path).install_all(
            verbose,
            dry_run,
            refresh,
            mod_file_paths,
        ),
        Command::List(List {
            order,
            app_data_path,
        }) => {
            let settings = Settings::with_app_data_dir(&app_data_path.app_data_path);

            if order {
                print_ordered_mods(&settings)
            } else {
                print_unordered_mods(&settings)
            }
        }
        Command::Parse(Parse { paths, verbose }) => Settings::parse(paths, verbose),
        Command::Export(Export {
            app_data_path,
            destination,
        }) => Settings::with_app_data_dir(&app_data_path.app_data_path).export(&destination),
        Command::Import(Import {
            app_data_path,
            source,
        }) => Settings::with_app_data_dir(&app_data_path.app_data_path).import(&source),
        Command::Uninstall(Uninstall {
            dry_run,
            app_data_path,
            mod_indexes,
        }) => Settings::with_app_data_dir(&app_data_path.app_data_path)
            .uninstall_all(dry_run, mod_indexes),
        Command::Dump(Dump { path }) => Settings::dump(path),
        Command::Pack(Pack {
            mod_files_root,
            destination,
        }) => Settings::pack(mod_files_root, destination),
        Command::Unpack(Unpack {
            mod_file_path,
            destination,
        }) => Settings::unpack(mod_file_path, destination),
    }
}

fn print_unordered_mods(settings: &Settings) -> Result<()> {
    for ModuleInfo { name, uuid, .. } in settings.get_installed_mod_info()?.iter() {
        println!("{uuid}: {name}");
    }

    Ok(())
}

fn print_ordered_mods(settings: &Settings) -> Result<()> {
    let ordered_mods = settings.get_load_order()?;

    let padding = ordered_mods
        .order_len()
        .checked_ilog10()
        .and_then(|i| usize::try_from(i + 1).ok())
        .unwrap_or_default();

    for (i, info) in ordered_mods.ordered().enumerate() {
        let ModuleInfo { name, uuid, .. } = info;
        println!("{i:>padding$}. {uuid}: {name}");
    }

    if ordered_mods.has_extras() {
        println!("\nMods not present in load order:");

        for ModuleInfo { name, uuid, .. } in ordered_mods.extras() {
            println!("?. {uuid} {name}");
        }
    }

    Ok(())
}

fn main() -> ExitCode {
    match run() {
        Ok(()) => ExitCode::SUCCESS,
        Err(e) => {
            eprintln!("{e}");
            ExitCode::FAILURE
        }
    }
}