use std::{
cell::{RefCell, RefMut},
collections::{HashMap, HashSet},
fs::File,
io::{BufWriter, Write},
path::Path,
};
use colored::Colorize;
use crate::{
Cache, Error, Mod,
args::Cli,
cache::CacheId,
config::Config,
env::Secret,
format::Formatter,
get_curseforge_mods, get_modrinth_mods,
parser::{
ParsedCurseForgeId, ParsedModrinthId, Parser, export, pack::Pack, packwiz::PackwizMod,
},
request::{CurseForgeId, ModrinthId},
};
fn warn_missing(source: &str, requested: &HashMap<String, CacheId>, returned: &HashSet<String>) {
for id in requested.keys() {
if !returned.contains(id) {
log::warn!(
"{source} returned no result for \"{id}\" -- it will be missing from the list"
);
}
}
}
enum Destination<'a> {
File(&'a Path),
Stdout,
StdoutBlockedBy(&'a Path),
}
impl<'a> Destination<'a> {
fn resolve(cli: &'a Cli) -> Self {
match cli.output.as_deref() {
None => Self::Stdout,
Some(path) if path.as_os_str().is_empty() => Self::Stdout,
Some(path) if cli.force || !path.exists() => Self::File(path),
Some(path) => Self::StdoutBlockedBy(path),
}
}
fn writer(&self) -> Result<BufWriter<Box<dyn Write>>, Error> {
Ok(match self {
Self::File(path) => BufWriter::new(Box::new(File::create(path)?)),
_ => BufWriter::new(Box::new(std::io::stdout().lock())),
})
}
fn report(&self, written: usize) {
match self {
Self::File(path) => log::info!("wrote {written} mod(s) to {}", path.display()),
Self::Stdout => log::info!("listed {written} mod(s)"),
Self::StdoutBlockedBy(path) => {
log::warn!(
"{} already exists; listed {written} mod(s) on stdout instead. Pass --force to overwrite it.",
path.display()
);
}
}
}
}
pub struct App {
cache: RefCell<Cache>,
modrinth_mods: Vec<ParsedModrinthId>,
curseforge_mods: Vec<ParsedCurseForgeId>,
cf_api_key: Option<Secret>,
pack: Option<Pack>,
config: Config,
packwiz_mods: Vec<PackwizMod>,
}
impl App {
pub fn new<P>(
cache: Cache,
parser: P,
cf_api_key: Option<Secret>,
pack: Option<Pack>,
config: Config,
packwiz_mods: Vec<PackwizMod>,
) -> Self
where
P: Parser,
{
let (modrinth_mods, curseforge_mods) = parser.get_mods_owned();
Self {
cache: RefCell::new(cache),
modrinth_mods,
curseforge_mods,
cf_api_key,
pack,
config,
packwiz_mods,
}
}
fn get_mods(&self) -> Result<Vec<Mod>, Error> {
let mut cache = self.cache.borrow_mut();
let mut mods =
Vec::<Mod>::with_capacity(self.modrinth_mods.len() + self.curseforge_mods.len());
let mut mr_mods_ids = Vec::<ModrinthId>::with_capacity(self.modrinth_mods.len());
let mut cf_mods_ids = Vec::<CurseForgeId>::with_capacity(self.curseforge_mods.len());
let mut mr_id_map = HashMap::<String, CacheId>::with_capacity(mr_mods_ids.capacity());
let mut cf_id_map = HashMap::<String, CacheId>::with_capacity(cf_mods_ids.capacity());
for id in self.modrinth_mods.iter().cloned() {
match cache.get_mod(id.clone()).cloned() {
None => {
mr_id_map.insert(id.id.clone(), id.clone().into());
mr_mods_ids.push(id.into());
}
Some(m) => mods.push(m),
}
}
for id in self.curseforge_mods.iter().cloned() {
match cache.get_mod(id.clone()).cloned() {
None => {
cf_id_map.insert(id.id.to_string(), id.clone().into());
cf_mods_ids.push(id.into());
}
Some(m) => mods.push(m),
}
}
if !mr_mods_ids.is_empty() {
let fetched = get_modrinth_mods(mr_mods_ids)?;
let mut returned = HashSet::<String>::with_capacity(fetched.len());
for m in fetched {
returned.insert(m.id.clone());
match mr_id_map.get(&m.id).cloned() {
Some(id) => {
cache.set_mod(id, m.clone());
mods.push(m);
}
None => {
log::warn!(
"Modrinth returned unrequested project \"{}\"; ignoring",
m.id
)
}
}
}
warn_missing("Modrinth", &mr_id_map, &returned);
}
if !cf_mods_ids.is_empty() {
let fetched = get_curseforge_mods(cf_mods_ids, self.cf_api_key.as_ref())?;
let mut returned = HashSet::<String>::with_capacity(fetched.len());
for m in fetched.into_iter().map(Mod::from) {
returned.insert(m.id.clone());
match cf_id_map.get(&m.id).cloned() {
Some(id) => {
cache.set_mod(id, m.clone());
mods.push(m);
}
None => {
log::warn!(
"CurseForge returned unrequested project \"{}\"; ignoring",
m.id
)
}
}
}
warn_missing("CurseForge", &cf_id_map, &returned);
}
Ok(mods)
}
pub fn sorted_mods(&self) -> Result<Vec<Mod>, Error> {
let mut mods = self.get_mods()?;
mods.sort_by(|a, b| {
a.title
.to_lowercase()
.cmp(&b.title.to_lowercase())
.then_with(|| a.id.cmp(&b.id))
});
Ok(mods)
}
pub fn run(&self, cli: Cli) -> Result<(), Error> {
let destination = Destination::resolve(&cli);
if cli.json {
if cli.format.is_some() {
log::warn!("--format is ignored with --json, which emits a whole document");
}
return self.run_export(&destination);
}
let formatter = Formatter::new(cli.format())?;
let mods = self.sorted_mods()?;
let mut out = destination.writer()?;
formatter.write_all(&mut out, &mods)?;
out.flush()?;
destination.report(mods.len());
Ok(())
}
fn run_export(&self, destination: &Destination<'_>) -> Result<(), Error> {
let pack = self.pack.as_ref().ok_or_else(|| {
Error::MissingPackToml(crate::parser::pack::PACK_FILE_NAME.to_owned())
})?;
let mods = self.sorted_mods()?;
let document = export::generate_document(pack, &self.config, &self.packwiz_mods, &mods)?;
let mut out = destination.writer()?;
writeln!(out, "{document}")?;
out.flush()?;
destination.report(mods.len());
Ok(())
}
pub fn close(&self) -> Result<(), Error> {
let mut cache = self.cache.borrow_mut();
let installed: HashSet<String> = self
.modrinth_mods
.iter()
.map(|m| m.id.clone())
.chain(self.curseforge_mods.iter().map(|m| m.id.to_string()))
.collect();
let pruned = cache.retain_only(&installed);
if pruned > 0 {
log::debug!("pruned {pruned} cached mod(s) no longer in the pack");
}
cache.save()
}
}