use std::collections::HashSet; use std::fs; use std::io::{Read, Write}; use std::path::Path; use anyhow::{bail, Context, Result}; use regex::Regex; use serde::Deserialize; // --------------------------------------------------------------------------- // Public event type for GUI progress reporting // --------------------------------------------------------------------------- #[cfg(feature = "gui")] use std::sync::mpsc; #[cfg(feature = "gui")] #[derive(Debug, Clone)] pub enum Event { Log(String), DownloadStart { name: String, total_bytes: u64 }, DownloadProgress { name: String, bytes: u64 }, DownloadComplete { name: String }, DownloadError { name: String, error: String }, Finished { success: bool, message: String }, } // --------------------------------------------------------------------------- // API response types // --------------------------------------------------------------------------- #[derive(Deserialize)] pub struct ModInfo { pub releases: Vec, } #[derive(Deserialize, Clone)] pub struct Release { pub version: String, pub info_json: Option, } #[derive(Deserialize, Clone)] pub struct InfoJson { #[serde(default)] pub dependencies: Vec, } // --------------------------------------------------------------------------- // Dependency model // --------------------------------------------------------------------------- #[derive(Debug, Clone, PartialEq)] pub enum DepType { Base, Required, Optional, Incompatible, } #[derive(Debug, Clone)] pub struct Dependency { pub dep_type: DepType, pub name: String, } // --------------------------------------------------------------------------- // Constants // --------------------------------------------------------------------------- pub const MODINFO_API: &str = "https://re146.dev/factorio/mods/modinfo"; pub const DOWNLOAD_BASE: &str = "https://mods-storage.re146.dev"; fn dep_regex() -> &'static Regex { static RE: std::sync::LazyLock = std::sync::LazyLock::new(|| { Regex::new( r"(?i)^((?:)|(?:\?)|(?:\(\?\))|(?:!)|(?:~))(?:\s?)([%\w\s-]+)(?:\s{0,})(?:((?:=>)|(?:==)|(?:=)|(?:<=)|(?:>=)|(?:<)|(?:>)|(?:))(?:\s{0,})((\d+\.\d+\.\d+)|(\d+\.\d+)))?$", ) .unwrap() }); &RE } // --------------------------------------------------------------------------- // Mod name extraction // --------------------------------------------------------------------------- pub fn extract_mod_name(url_or_name: &str) -> Result { let re = Regex::new(r"(?i)^https?://mods\.factorio\.com/mod/([^\s/#?]{1,})").unwrap(); if let Some(caps) = re.captures(url_or_name) { return Ok(caps[1].to_string()); } let re2 = Regex::new(r"(?i)^https?://mods\.factorio\.com/mods/([^\s/]{1,})/([^\s/#?]{1,})").unwrap(); if let Some(caps) = re2.captures(url_or_name) { return Ok(caps[2].to_string()); } if !url_or_name.contains('/') && !url_or_name.starts_with("http") { return Ok(url_or_name.to_string()); } bail!("Could not extract mod name from: {url_or_name}") } // --------------------------------------------------------------------------- // API helpers // --------------------------------------------------------------------------- pub fn fetch_modinfo(mod_name: &str) -> Result { let url = format!("{MODINFO_API}?id={mod_name}"); let resp = ureq::get(&url) .header("Accept", "application/json") .call() .with_context(|| format!("Failed to fetch mod info for {mod_name}"))?; if resp.status().as_u16() == 404 { bail!("Mod '{mod_name}' not found on the portal"); } let body_text = resp .into_body() .read_to_string() .with_context(|| format!("Failed to read response body for {mod_name}"))?; let modinfo: ModInfo = serde_json::from_str(&body_text) .with_context(|| format!("Failed to parse mod info JSON for {mod_name}"))?; if modinfo.releases.is_empty() { bail!("No releases found for '{mod_name}'"); } Ok(modinfo) } /// Fetch available versions for a mod. Returns (version, display_label) pairs. #[cfg(feature = "gui")] pub fn fetch_versions(mod_name: &str) -> Result> { let modinfo = fetch_modinfo(mod_name)?; let total = modinfo.releases.len(); let mut versions: Vec<(String, String)> = modinfo .releases .iter() .enumerate() .filter_map(|(i, r)| { let v = r.version.clone(); if v.is_empty() { return None; } let is_latest = i == total - 1; let factorio_ver = r .info_json .as_ref() .and_then(|j| j.dependencies.first()) .and_then(|d| { parse_dependency_string(d).and_then(|dep| { if dep.dep_type == DepType::Base && !dep.name.is_empty() { Some(dep.name) } else { None } }) }) .unwrap_or_default(); let mut label = v.clone(); if is_latest { label.push_str(" (latest)"); } if !factorio_ver.is_empty() { label.push_str(&format!(" for {}", factorio_ver)); } Some((v, label)) }) .collect(); versions.reverse(); Ok(versions) } pub fn find_release<'a>(modinfo: &'a ModInfo, target_version: Option<&str>) -> Option<&'a Release> { match target_version { Some(v) => modinfo.releases.iter().find(|r| r.version == v), None => modinfo.releases.last(), } } // --------------------------------------------------------------------------- // Dependency parsing // --------------------------------------------------------------------------- pub fn parse_dependency_string(input: &str) -> Option { let re = dep_regex(); let caps = re.captures(input)?; let name = caps[2].trim().to_string(); if name.eq_ignore_ascii_case("base") { return Some(Dependency { dep_type: DepType::Base, name: "base".into(), }); } let dep_type = match &caps[1] { "?" | "(?)" => DepType::Optional, "!" => DepType::Incompatible, _ => DepType::Required, }; Some(Dependency { dep_type, name: name.trim().to_string(), }) } pub fn parse_dependencies(deps: &[String]) -> (Vec, Vec, Vec) { let mut required = Vec::new(); let mut optional = Vec::new(); let mut incompatible = Vec::new(); for dep_str in deps.iter().rev() { if let Some(dep) = parse_dependency_string(dep_str) { match dep.dep_type { DepType::Base => {} DepType::Required => required.push(dep), DepType::Optional => optional.push(dep), DepType::Incompatible => incompatible.push(dep), } } } (required, optional, incompatible) } // --------------------------------------------------------------------------- // GUI download (channel-based) // --------------------------------------------------------------------------- #[cfg(feature = "gui")] fn gui_download_file( url: &str, file_path: &Path, display_name: &str, tx: &mpsc::Sender, ) -> Result<()> { let resp = ureq::get(url) .call() .with_context(|| format!("Failed to download {display_name}"))?; let status = resp.status().as_u16(); if status == 404 { bail!("File not found on storage: {display_name}"); } if !(200..300).contains(&status) { bail!("HTTP {status} while downloading {display_name}"); } let total_size: u64 = resp .headers() .get("content-length") .and_then(|v| v.to_str().ok()) .and_then(|v| v.parse().ok()) .unwrap_or(0); let _ = tx.send(Event::DownloadStart { name: display_name.to_string(), total_bytes: total_size, }); let mut reader = resp.into_body().into_reader(); let mut file = fs::File::create(file_path) .with_context(|| format!("Failed to create file {}", file_path.display()))?; let mut buf = [0u8; 256 * 1024]; let mut downloaded: u64 = 0; loop { let n = reader .read(&mut buf) .with_context(|| format!("Read error while downloading {display_name}"))?; if n == 0 { break; } file.write_all(&buf[..n]) .with_context(|| format!("Write error for {}", file_path.display()))?; downloaded += n as u64; let _ = tx.send(Event::DownloadProgress { name: display_name.to_string(), bytes: downloaded, }); } let _ = tx.send(Event::DownloadComplete { name: display_name.to_string(), }); Ok(()) } #[cfg(feature = "gui")] fn gui_download_mod_with_deps( mod_name: &str, output: &Path, target_version: Option<&str>, include_optional: bool, downloaded: &mut HashSet, analyzed: &mut HashSet, tx: &mpsc::Sender, ) -> Result<()> { if analyzed.contains(mod_name) { return Ok(()); } if mod_name == "space-age" { let _ = tx.send(Event::Log(format!( "Skipping reserved dependency '{mod_name}'" ))); return Ok(()); } let modinfo = fetch_modinfo(mod_name)?; let release = find_release(&modinfo, target_version) .with_context(|| format!("Version not found for '{mod_name}'"))?; let version = &release.version; let _ = tx.send(Event::Log(format!("Processing: {mod_name} v{version}"))); analyzed.insert(mod_name.to_string()); let file_name = format!("{mod_name}_{version}.zip"); let file_path = output.join(&file_name); if !file_path.exists() { fs::create_dir_all(output)?; let download_url = format!("{DOWNLOAD_BASE}/{mod_name}/{version}.zip"); if let Err(e) = gui_download_file(&download_url, &file_path, &file_name, tx) { let _ = tx.send(Event::DownloadError { name: file_name.clone(), error: e.to_string(), }); return Err(e); } downloaded.insert(mod_name.to_string()); } else { let _ = tx.send(Event::Log(format!("Already downloaded: {file_name}"))); } if let Some(info_json) = &release.info_json { let (required, optional, _incompatible) = parse_dependencies(&info_json.dependencies); let mut deps = required; if include_optional { deps.extend(optional); } for dep in &deps { if dep.name == "base" || downloaded.contains(&dep.name) || analyzed.contains(&dep.name) { continue; } let _ = tx.send(Event::Log(format!("-> Fetching dependency: {}", dep.name))); if let Err(e) = gui_download_mod_with_deps( &dep.name, output, None, include_optional, downloaded, analyzed, tx, ) { let _ = tx.send(Event::Log(format!( "Error processing dependency {}: {}", dep.name, e ))); } } } Ok(()) } /// Entry point for GUI downloads. Runs the full pipeline and sends events via the channel. #[cfg(feature = "gui")] pub fn run_gui_download( mod_url: &str, output: &Path, target_version: Option, include_optional: bool, tx: mpsc::Sender, ) { let result = (|| -> Result<()> { let mod_name = extract_mod_name(mod_url)?; let _ = tx.send(Event::Log(format!("Starting download: {mod_name}"))); let mut downloaded = HashSet::new(); let mut analyzed = HashSet::new(); gui_download_mod_with_deps( &mod_name, output, target_version.as_deref(), include_optional, &mut downloaded, &mut analyzed, &tx, )?; Ok(()) })(); match result { Ok(()) => { let _ = tx.send(Event::Finished { success: true, message: "All mods downloaded successfully.".into(), }); } Err(e) => { let _ = tx.send(Event::Finished { success: false, message: format!("Download failed: {e}"), }); } } } // --------------------------------------------------------------------------- // CLI download (prints progress to stdout) // --------------------------------------------------------------------------- #[cfg(not(feature = "gui"))] fn cli_download_file(url: &str, file_path: &Path, display_name: &str) -> Result<()> { let resp = ureq::get(url) .call() .with_context(|| format!("Failed to download {display_name}"))?; let status = resp.status().as_u16(); if status == 404 { bail!("File not found on storage: {display_name}"); } if !(200..300).contains(&status) { bail!("HTTP {status} while downloading {display_name}"); } let total_size: u64 = resp .headers() .get("content-length") .and_then(|v| v.to_str().ok()) .and_then(|v| v.parse().ok()) .unwrap_or(0); println!( " Downloading: {display_name} ({:.1} MB)", total_size as f64 / 1_048_576.0 ); let mut reader = resp.into_body().into_reader(); let mut file = fs::File::create(file_path) .with_context(|| format!("Failed to create file {}", file_path.display()))?; let mut buf = [0u8; 256 * 1024]; let mut downloaded: u64 = 0; let mut last_pct: u32 = 0; loop { let n = reader .read(&mut buf) .with_context(|| format!("Read error while downloading {display_name}"))?; if n == 0 { break; } file.write_all(&buf[..n]) .with_context(|| format!("Write error for {}", file_path.display()))?; downloaded += n as u64; if total_size > 0 { let pct = (downloaded * 100 / total_size) as u32; if pct / 10 != last_pct / 10 { last_pct = pct; print!("\r {} {}%", display_name, pct); let _ = std::io::Write::flush(&mut std::io::stdout()); } } } println!("\r {} Done.", display_name); Ok(()) } #[cfg(not(feature = "gui"))] fn cli_download_mod_with_deps( mod_name: &str, output: &Path, target_version: Option<&str>, include_optional: bool, downloaded: &mut HashSet, analyzed: &mut HashSet, ) -> Result<()> { if analyzed.contains(mod_name) { return Ok(()); } if mod_name == "space-age" { println!("Skipping reserved dependency '{mod_name}'"); return Ok(()); } let modinfo = fetch_modinfo(mod_name)?; let release = find_release(&modinfo, target_version) .with_context(|| format!("Version not found for '{mod_name}'"))?; let version = &release.version; println!("Processing: {mod_name} v{version}"); analyzed.insert(mod_name.to_string()); let file_name = format!("{mod_name}_{version}.zip"); let file_path = output.join(&file_name); if !file_path.exists() { fs::create_dir_all(output)?; let download_url = format!("{DOWNLOAD_BASE}/{mod_name}/{version}.zip"); if let Err(e) = cli_download_file(&download_url, &file_path, &file_name) { eprintln!("Error downloading {file_name}: {e}"); return Err(e); } downloaded.insert(mod_name.to_string()); } else { println!("Already downloaded: {file_name}"); } if let Some(info_json) = &release.info_json { let (required, optional, _incompatible) = parse_dependencies(&info_json.dependencies); let mut deps = required; if include_optional { deps.extend(optional); } for dep in &deps { if dep.name == "base" || downloaded.contains(&dep.name) || analyzed.contains(&dep.name) { continue; } println!("-> Fetching dependency: {}", dep.name); if let Err(e) = cli_download_mod_with_deps( &dep.name, output, None, include_optional, downloaded, analyzed, ) { eprintln!("Error processing dependency {}: {}", dep.name, e); } } } Ok(()) } /// Entry point for CLI downloads. Prints progress to stdout. #[cfg(not(feature = "gui"))] pub fn run_cli_download( mod_url: &str, output: &Path, target_version: Option<&str>, include_optional: bool, ) -> Result<()> { let mod_name = extract_mod_name(mod_url)?; println!("Starting download: {mod_name}"); println!("Output directory: {}", output.display()); let mut downloaded = HashSet::new(); let mut analyzed = HashSet::new(); cli_download_mod_with_deps( &mod_name, output, target_version, include_optional, &mut downloaded, &mut analyzed, )?; println!("\nAll mods downloaded successfully."); Ok(()) }