use anyhow::Result;
use serde_yml::{Mapping, Value};
use std::io::Read;
use std::path::{Path, PathBuf};
use crate::providers::modrinth::Modrinth;
use crate::yml::{DatapackEntry, ModEntry};
#[derive(Debug, Default)]
pub struct Detected {
pub name: Option<String>,
pub minecraft: Option<String>,
pub loader: Option<&'static str>,
pub fabric_loader_version: Option<String>,
pub java_major: Option<u32>,
pub memory: Option<String>,
pub eula: bool,
pub server_properties: Mapping,
pub mods: Vec<ModEntry>,
pub unresolved_mods: Vec<String>,
pub datapacks: Vec<DatapackEntry>,
pub unresolved_datapacks: Vec<String>,
pub unsupported_loader: Option<&'static str>,
pub detected_location: Option<String>,
}
pub async fn detect(root: &Path, resolve_mods: bool) -> Result<Detected> {
let mut d = Detected {
name: root
.file_name()
.and_then(|s| s.to_str())
.map(|s| s.to_string()),
detected_location: compute_detected_location(root),
..Default::default()
};
if let Some(unsup) = detect_unsupported_loader(root) {
d.unsupported_loader = Some(unsup);
} else {
detect_loader_and_version(root, &mut d);
}
if let Some(props) = parse_server_properties(&root.join("server.properties")) {
d.server_properties = props;
}
d.eula = parse_eula(&root.join("eula.txt"));
if let Some(mem) = detect_memory(root) {
d.memory = Some(mem);
}
if let Some(major) = pick_java(d.minecraft.as_deref()) {
d.java_major = Some(major);
}
let mods_dir = root.join("mods");
if mods_dir.is_dir() {
let (entries, unresolved) = detect_mods(&mods_dir, resolve_mods).await?;
d.mods = entries;
d.unresolved_mods = unresolved;
}
let datapacks_dir = root
.join(detected_level_name(&d.server_properties))
.join("datapacks");
if datapacks_dir.is_dir() {
let (entries, unresolved) = detect_datapacks(&datapacks_dir, resolve_mods).await?;
d.datapacks = entries;
d.unresolved_datapacks = unresolved;
}
Ok(d)
}
fn detected_level_name(props: &Mapping) -> String {
props
.get(Value::String("level-name".into()))
.and_then(|v| v.as_str())
.map(|s| s.to_string())
.filter(|s| !s.trim().is_empty())
.unwrap_or_else(|| "world".to_string())
}
fn compute_detected_location(root: &Path) -> Option<String> {
let cwd = std::env::current_dir().ok()?;
let abs_root = root.canonicalize().unwrap_or_else(|_| {
if root.is_absolute() {
root.to_path_buf()
} else {
cwd.join(root)
}
});
let abs_cwd = cwd.canonicalize().unwrap_or(cwd);
if abs_root == abs_cwd {
return None;
}
let rel = abs_root.strip_prefix(&abs_cwd).ok()?;
let s = rel.to_string_lossy().to_string();
if s.is_empty() || s == "." {
None
} else {
Some(s)
}
}
fn detect_unsupported_loader(root: &Path) -> Option<&'static str> {
if root.join("libraries/net/minecraftforge").is_dir() || glob_first(root, "forge-").is_some() {
return Some("forge");
}
if root.join("libraries/net/neoforged").is_dir() || glob_first(root, "neoforge-").is_some() {
return Some("neoforge");
}
if glob_first(root, "paper-").is_some() || root.join("paper.yml").is_file() {
return Some("paper");
}
if glob_first(root, "spigot-").is_some() || glob_first(root, "spigot.jar").is_some() {
return Some("spigot");
}
None
}
fn glob_first(dir: &Path, prefix: &str) -> Option<PathBuf> {
let rd = std::fs::read_dir(dir).ok()?;
for e in rd.flatten() {
if let Some(name) = e.file_name().to_str() {
if name.starts_with(prefix) && name.ends_with(".jar") {
return Some(e.path());
}
}
}
None
}
fn detect_loader_and_version(root: &Path, d: &mut Detected) {
let launcher_props = root.join("fabric-server-launcher.properties");
let fabric_launch = root.join("fabric-server-launch.jar");
if launcher_props.is_file() || fabric_launch.is_file() {
d.loader = Some("fabric");
if let Some(ver) = read_fabric_loader_version(root) {
d.fabric_loader_version = Some(ver);
}
if let Some(server_jar) = fabric_server_jar(&launcher_props, root) {
if let Some(mc) = read_mc_version_from_jar(&server_jar) {
d.minecraft = Some(mc);
return;
}
}
if let Some(mc) = read_mc_version_from_jar(&fabric_launch) {
d.minecraft = Some(mc);
return;
}
}
for candidate in ["server.jar", "minecraft_server.jar"] {
let p = root.join(candidate);
if p.is_file() {
if let Some(mc) = read_mc_version_from_jar(&p) {
d.minecraft = Some(mc);
if d.loader.is_none() {
d.loader = Some("vanilla");
}
return;
}
}
}
if let Ok(rd) = std::fs::read_dir(root) {
for entry in rd.flatten() {
let p = entry.path();
if p.extension().and_then(|s| s.to_str()) == Some("jar") {
if let Some(mc) = read_mc_version_from_jar(&p) {
d.minecraft = Some(mc);
if d.loader.is_none() {
d.loader = Some("vanilla");
}
return;
}
}
}
}
}
fn read_fabric_loader_version(root: &Path) -> Option<String> {
let fabric_dir = root.join(".fabric");
if !fabric_dir.is_dir() {
return None;
}
let rd = std::fs::read_dir(&fabric_dir).ok()?;
for e in rd.flatten() {
if !e.file_type().ok()?.is_dir() {
continue;
}
let name = e.file_name();
let s = name.to_str()?;
if s == "server" || s == "client" {
let inner = std::fs::read_dir(e.path()).ok()?;
for sub in inner.flatten() {
if let Some(v) = sub.file_name().to_str() {
if v.chars().next().is_some_and(|c| c.is_ascii_digit()) {
return Some(v.to_string());
}
}
}
}
}
None
}
fn fabric_server_jar(launcher_props: &Path, root: &Path) -> Option<PathBuf> {
let text = std::fs::read_to_string(launcher_props).ok()?;
for line in text.lines() {
let line = line.trim();
if line.starts_with('#') || line.is_empty() {
continue;
}
if let Some((k, v)) = line.split_once('=') {
if k.trim() == "serverJar" {
let val = v.trim();
let p = root.join(val);
if p.is_file() {
return Some(p);
}
}
}
}
None
}
fn read_mc_version_from_jar(jar: &Path) -> Option<String> {
let file = std::fs::File::open(jar).ok()?;
let mut zip = zip::ZipArchive::new(file).ok()?;
let mut entry = zip.by_name("version.json").ok()?;
let mut buf = String::new();
entry.read_to_string(&mut buf).ok()?;
let v: serde_json::Value = serde_json::from_str(&buf).ok()?;
v.get("name")
.or_else(|| v.get("id"))
.and_then(|s| s.as_str())
.map(|s| s.to_string())
}
fn parse_server_properties(path: &Path) -> Option<Mapping> {
let text = std::fs::read_to_string(path).ok()?;
let mut m = Mapping::new();
for line in text.lines() {
let line = line.trim();
if line.is_empty() || line.starts_with('#') || line.starts_with('!') {
continue;
}
let Some((k, v)) = line.split_once('=') else {
continue;
};
let key = k.trim();
let value = v.trim();
if key.is_empty() {
continue;
}
m.insert(Value::String(key.to_string()), coerce_scalar(value));
}
if m.is_empty() {
None
} else {
Some(m)
}
}
fn coerce_scalar(s: &str) -> Value {
if s.is_empty() {
return Value::String(String::new());
}
if s.eq_ignore_ascii_case("true") {
return Value::Bool(true);
}
if s.eq_ignore_ascii_case("false") {
return Value::Bool(false);
}
if let Ok(n) = s.parse::<i64>() {
return Value::Number(n.into());
}
Value::String(s.to_string())
}
fn parse_eula(path: &Path) -> bool {
let Ok(text) = std::fs::read_to_string(path) else {
return false;
};
for line in text.lines() {
let line = line.trim();
if line.starts_with('#') {
continue;
}
if let Some((k, v)) = line.split_once('=') {
if k.trim().eq_ignore_ascii_case("eula") && v.trim().eq_ignore_ascii_case("true") {
return true;
}
}
}
false
}
fn detect_memory(root: &Path) -> Option<String> {
for script in ["start.sh", "run.sh", "start.bat", "start.cmd", "run.bat"] {
let p = root.join(script);
if let Ok(text) = std::fs::read_to_string(&p) {
if let Some(mem) = extract_xmx(&text) {
return Some(mem);
}
}
}
None
}
fn extract_xmx(text: &str) -> Option<String> {
let bytes = text.as_bytes();
let needle = b"-Xmx";
let mut i = 0;
while i + needle.len() <= bytes.len() {
if &bytes[i..i + needle.len()] == needle {
let mut j = i + needle.len();
let start = j;
while j < bytes.len() && bytes[j].is_ascii_digit() {
j += 1;
}
if j > start {
let mut end = j;
if end < bytes.len()
&& matches!(bytes[end], b'G' | b'g' | b'M' | b'm' | b'K' | b'k')
{
end += 1;
}
let digits = std::str::from_utf8(&bytes[start..j]).ok()?;
let unit = if end > j {
(bytes[j] as char).to_ascii_uppercase().to_string()
} else {
String::new()
};
return Some(format!("{digits}{unit}"));
}
}
i += 1;
}
None
}
fn pick_java(minecraft: Option<&str>) -> Option<u32> {
let required = required_java_major(minecraft);
let installs = crate::runtime::java::discover_all();
let mut matching: Vec<u32> = installs
.into_iter()
.map(|i| i.major)
.filter(|m| *m >= required)
.collect();
matching.sort_unstable();
matching.into_iter().next().or(Some(required))
}
fn required_java_major(minecraft: Option<&str>) -> u32 {
let Some(v) = minecraft else { return 21 };
let mut parts = v.split('.').filter_map(|p| p.parse::<u32>().ok());
let major = parts.next().unwrap_or(0);
let minor = parts.next().unwrap_or(0);
if major >= 2 {
return 21;
}
match minor {
0..=16 => 8,
17..=18 => 17,
19 => 17,
20 => 21,
_ => 21,
}
}
async fn detect_mods(mods_dir: &Path, resolve: bool) -> Result<(Vec<ModEntry>, Vec<String>)> {
let mut jars: Vec<PathBuf> = Vec::new();
for entry in std::fs::read_dir(mods_dir)?.flatten() {
let p = entry.path();
if p.extension().and_then(|s| s.to_str()) == Some("jar") {
jars.push(p);
}
}
jars.sort();
if !resolve {
let names = jars
.iter()
.filter_map(|p| {
p.file_name()
.and_then(|n| n.to_str())
.map(|s| s.to_string())
})
.collect();
return Ok((Vec::new(), names));
}
let modrinth = Modrinth::new();
let mut resolved = Vec::new();
let mut unresolved = Vec::new();
for jar in &jars {
let hash = sha512_file(jar)?;
let filename = jar
.file_name()
.and_then(|n| n.to_str())
.unwrap_or("?")
.to_string();
match modrinth.lookup_by_sha512(&hash).await {
Ok(Some((slug, version_number))) => {
resolved.push(ModEntry::Registry {
id: slug,
provider: "modrinth".to_string(),
version: version_number,
});
}
Ok(None) => unresolved.push(filename),
Err(e) => {
tracing::warn!("modrinth lookup failed for {filename}: {e:#}");
unresolved.push(filename);
}
}
}
Ok((resolved, unresolved))
}
async fn detect_datapacks(
datapacks_dir: &Path,
resolve: bool,
) -> Result<(Vec<DatapackEntry>, Vec<String>)> {
let mut zips: Vec<PathBuf> = Vec::new();
for entry in std::fs::read_dir(datapacks_dir)?.flatten() {
let p = entry.path();
if p.extension().and_then(|s| s.to_str()) == Some("zip") {
zips.push(p);
}
}
zips.sort();
if !resolve {
let names = zips
.iter()
.filter_map(|p| {
p.file_name()
.and_then(|n| n.to_str())
.map(|s| s.to_string())
})
.collect();
return Ok((Vec::new(), names));
}
let modrinth = Modrinth::new();
let mut resolved = Vec::new();
let mut unresolved = Vec::new();
for zip in &zips {
let hash = sha512_file(zip)?;
let filename = zip
.file_name()
.and_then(|n| n.to_str())
.unwrap_or("?")
.to_string();
match modrinth.lookup_by_sha512(&hash).await {
Ok(Some((slug, version_number))) => {
resolved.push(DatapackEntry::Registry {
id: slug,
provider: "modrinth".to_string(),
version: version_number,
});
}
Ok(None) => unresolved.push(filename),
Err(e) => {
tracing::warn!("modrinth lookup failed for datapack {filename}: {e:#}");
unresolved.push(filename);
}
}
}
Ok((resolved, unresolved))
}
fn sha512_file(path: &Path) -> Result<String> {
use sha2::Digest;
let bytes = std::fs::read(path)?;
let mut h = sha2::Sha512::new();
h.update(&bytes);
Ok(hex::encode(h.finalize()))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn coerce_scalar_handles_types() {
assert_eq!(coerce_scalar("true"), Value::Bool(true));
assert_eq!(coerce_scalar("False"), Value::Bool(false));
assert_eq!(coerce_scalar("20"), Value::Number(20i64.into()));
assert_eq!(coerce_scalar("hi"), Value::String("hi".into()));
assert_eq!(coerce_scalar(""), Value::String("".into()));
}
#[test]
fn parses_server_properties() {
let dir = tempfile::tempdir().unwrap();
let p = dir.path().join("server.properties");
std::fs::write(
&p,
"# header comment\nmotd=Hello\nmax-players=20\nonline-mode=true\n",
)
.unwrap();
let m = parse_server_properties(&p).unwrap();
assert_eq!(
m[Value::String("motd".into())],
Value::String("Hello".into())
);
assert_eq!(
m[Value::String("max-players".into())],
Value::Number(20i64.into())
);
assert_eq!(m[Value::String("online-mode".into())], Value::Bool(true));
}
#[test]
fn parses_eula_true() {
let dir = tempfile::tempdir().unwrap();
let p = dir.path().join("eula.txt");
std::fs::write(&p, "# EULA header\neula=true\n").unwrap();
assert!(parse_eula(&p));
}
#[test]
fn parses_eula_false() {
let dir = tempfile::tempdir().unwrap();
let p = dir.path().join("eula.txt");
std::fs::write(&p, "eula=false\n").unwrap();
assert!(!parse_eula(&p));
}
#[test]
fn extracts_xmx_with_unit() {
assert_eq!(
extract_xmx("java -Xmx4G -jar server.jar"),
Some("4G".into())
);
assert_eq!(extract_xmx("-Xmx2048M"), Some("2048M".into()));
assert_eq!(extract_xmx("-Xmx1024"), Some("1024".into()));
assert_eq!(extract_xmx("no flag here"), None);
}
#[test]
fn java_major_matches_mc() {
assert_eq!(required_java_major(Some("1.16.5")), 8);
assert_eq!(required_java_major(Some("1.18.2")), 17);
assert_eq!(required_java_major(Some("1.20.4")), 21);
assert_eq!(required_java_major(Some("1.21.1")), 21);
assert_eq!(required_java_major(None), 21);
}
#[test]
fn detects_eula_and_properties_dir() {
let dir = tempfile::tempdir().unwrap();
std::fs::write(dir.path().join("eula.txt"), "eula=true").unwrap();
std::fs::write(
dir.path().join("server.properties"),
"motd=hi\nmax-players=12\n",
)
.unwrap();
let rt = tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
.unwrap();
let d = rt.block_on(detect(dir.path(), false)).unwrap();
assert!(d.eula);
assert_eq!(
d.server_properties[Value::String("max-players".into())],
Value::Number(12i64.into())
);
}
#[test]
fn lists_datapack_zips_when_resolve_skipped() {
let dir = tempfile::tempdir().unwrap();
let dp = dir.path().join("world").join("datapacks");
std::fs::create_dir_all(&dp).unwrap();
std::fs::write(dp.join("terralith.zip"), b"fake").unwrap();
std::fs::write(dp.join("vault-hunters.zip"), b"fake2").unwrap();
std::fs::write(dp.join("readme.txt"), b"not a pack").unwrap();
let rt = tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
.unwrap();
let d = rt.block_on(detect(dir.path(), false)).unwrap();
assert!(d.datapacks.is_empty());
assert_eq!(
d.unresolved_datapacks,
vec!["terralith.zip".to_string(), "vault-hunters.zip".to_string()]
);
}
#[test]
fn datapack_dir_honors_custom_level_name() {
let dir = tempfile::tempdir().unwrap();
std::fs::write(dir.path().join("server.properties"), "level-name=custom\n").unwrap();
let dp = dir.path().join("custom").join("datapacks");
std::fs::create_dir_all(&dp).unwrap();
std::fs::write(dp.join("pack.zip"), b"fake").unwrap();
let rt = tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
.unwrap();
let d = rt.block_on(detect(dir.path(), false)).unwrap();
assert_eq!(d.unresolved_datapacks, vec!["pack.zip".to_string()]);
}
#[test]
fn detects_unsupported_paper_jar() {
let dir = tempfile::tempdir().unwrap();
std::fs::write(dir.path().join("paper-1.20.4.jar"), b"fake").unwrap();
let rt = tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
.unwrap();
let d = rt.block_on(detect(dir.path(), false)).unwrap();
assert_eq!(d.unsupported_loader, Some("paper"));
}
}