use std::{
fs,
path::{Path, PathBuf},
time::SystemTime,
};
use serde::Serialize;
use crate::{
bim::default_autoheckin_path,
context::AppContext,
installer::discover_game_root,
manifest::RequiredEditableManifest,
sets::{find_set_tokens, normalize_set_name, parse_set_token},
source_pack::{detect_source_set, discover_local_source_candidates, resolve_source_warehouse},
};
pub const DEFAULT_OUTPUT_MOD_ROOT: &str = "dist/xylex-rtx-slayer-pack";
pub const DEFAULT_ZIP_OUTPUT: &str = "dist/xylex-rtx-slayer-pack.zip";
pub const DEFAULT_EDITABLE_ROOT: &str = "build/rtx-editable-source";
pub const DEFAULT_MANIFEST_NAME: &str = "required-editable-textures.json";
#[derive(Debug, Clone, Default)]
pub struct RepoStateOptions {
pub source_path: Option<PathBuf>,
pub target_set: Option<String>,
pub editable_root: Option<PathBuf>,
pub manifest: Option<PathBuf>,
pub output_mod_root: Option<PathBuf>,
pub zip_output: Option<PathBuf>,
pub converter_path: Option<PathBuf>,
}
#[derive(Debug, Clone)]
pub struct ManifestCandidate {
pub path: PathBuf,
pub source_set: Option<String>,
pub target_set: Option<String>,
pub modified_at: SystemTime,
}
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct RepoState {
pub source_pack: Option<PathBuf>,
pub source_pack_reason: Option<String>,
pub source_set: Option<String>,
pub source_set_reason: Option<String>,
pub target_set: Option<String>,
pub target_set_reason: Option<String>,
pub editable_root: PathBuf,
pub editable_root_reason: String,
pub manifest_path: Option<PathBuf>,
pub manifest_reason: Option<String>,
pub manifest_source_set: Option<String>,
pub manifest_target_set: Option<String>,
pub output_mod_root: PathBuf,
pub zip_output: PathBuf,
pub converter_path: PathBuf,
pub converter_available: bool,
pub converter_reason: Option<String>,
pub game_root: Option<PathBuf>,
pub game_root_reason: Option<String>,
}
pub fn build_repo_state(ctx: &AppContext, options: RepoStateOptions) -> RepoState {
let output_mod_root = options
.output_mod_root
.as_deref()
.map(|path| ctx.repo().repo_path(path))
.unwrap_or_else(|| ctx.repo().repo_path(DEFAULT_OUTPUT_MOD_ROOT));
let zip_output = options
.zip_output
.as_deref()
.map(|path| ctx.repo().repo_path(path))
.unwrap_or_else(|| ctx.repo().repo_path(DEFAULT_ZIP_OUTPUT));
let (source_pack, source_pack_reason) = match options.source_path.as_deref() {
Some(path) => (Some(ctx.repo().repo_path(path)), Some("explicit --source-path".to_string())),
None => detect_source_pack(ctx),
};
let (source_set, source_set_reason) = source_pack
.as_deref()
.map(|source_pack| match resolve_source_warehouse(ctx.repo(), source_pack) {
Ok((warehouse, _)) => match detect_source_set(&warehouse) {
Ok(source_set) => (
Some(source_set),
Some(format!("detected from source pack {}", warehouse.display())),
),
Err(_) => (
None,
Some("source pack exists but warehouse layout was not readable".to_string()),
),
},
Err(_) => (
None,
Some("source pack exists but warehouse layout was not readable".to_string()),
),
})
.unwrap_or((None, None));
let (manifest_path, manifest_reason, manifest_source_set, manifest_target_set) =
infer_manifest_path(ctx, options.manifest.as_deref(), options.target_set.as_deref());
let manifest_candidate = manifest_path
.as_deref()
.filter(|path| path.is_file())
.and_then(load_manifest_candidate);
let (target_set, target_set_reason) = infer_target_set(
options.target_set.as_deref(),
manifest_candidate.as_ref(),
&output_mod_root,
source_set.as_deref(),
);
let (editable_root, editable_root_reason) = infer_editable_root(
ctx,
options.editable_root.as_deref(),
target_set.as_deref(),
manifest_candidate.as_ref(),
);
let (converter_path, converter_reason) = match options.converter_path.as_deref() {
Some(path) => (ctx.repo().repo_path(path), Some("explicit --converter-path".to_string())),
None => (
default_autoheckin_path(ctx.repo()),
Some("repo-default AutoHeckin converter path".to_string()),
),
};
let game_root = discover_game_root();
let game_root_reason = game_root
.as_ref()
.map(|_| "auto-detected DOOM Eternal install root".to_string());
let manifest_exists = manifest_path.as_ref().filter(|path| path.exists()).cloned();
RepoState {
source_pack,
source_pack_reason,
source_set,
source_set_reason,
target_set,
target_set_reason,
editable_root,
editable_root_reason,
manifest_path: manifest_exists,
manifest_reason: manifest_path
.as_ref()
.filter(|path| path.exists())
.and(manifest_reason),
manifest_source_set,
manifest_target_set,
output_mod_root,
zip_output,
converter_path: converter_path.clone(),
converter_available: converter_path.is_file(),
converter_reason,
game_root,
game_root_reason,
}
}
pub fn detect_export_source_set(export_root: &Path) -> Option<String> {
if !export_root.exists() {
return None;
}
let mut sets = std::collections::BTreeSet::new();
for entry in walkdir::WalkDir::new(export_root).into_iter().filter_map(|entry| entry.ok()) {
let path_text = entry.path().to_string_lossy();
for set_name in find_set_tokens(&path_text) {
sets.insert(set_name);
}
}
if sets.len() == 1 {
sets.into_iter().next()
} else {
None
}
}
fn detect_source_pack(ctx: &AppContext) -> (Option<PathBuf>, Option<String>) {
let candidates = discover_local_source_candidates(ctx.repo());
if candidates.is_empty() {
return (None, None);
}
let mut ranked = candidates;
ranked.sort_by(|left, right| {
score_source_candidate(right)
.cmp(&score_source_candidate(left))
.then_with(|| left.to_string_lossy().cmp(&right.to_string_lossy()))
});
(
ranked.first().cloned(),
Some("best local source-pack candidate".to_string()),
)
}
fn infer_target_set(
explicit_target_set: Option<&str>,
manifest: Option<&ManifestCandidate>,
output_mod_root: &Path,
source_set: Option<&str>,
) -> (Option<String>, Option<String>) {
if let Some(target_set) = explicit_target_set.and_then(normalize_set_name) {
return (Some(target_set), Some("explicit --target-set".to_string()));
}
if let Some(manifest) = manifest {
if let Some(target_set) = manifest.target_set.clone() {
return (
Some(target_set),
Some(format!("latest editable manifest {}", manifest.path.display())),
);
}
}
let editable_sets = detect_editable_sets(output_mod_root);
if editable_sets.len() == 1 {
return (
editable_sets.first().cloned(),
Some(format!(
"editable output folder under {}",
output_mod_root.join("editable").display()
)),
);
}
if let Some((set_name, reason)) = detect_build_editable_root_set() {
return (Some(set_name), Some(reason));
}
if let Some(source_set) = source_set {
return (
normalize_set_name(source_set),
Some("source pack fallback".to_string()),
);
}
(None, None)
}
fn infer_editable_root(
ctx: &AppContext,
explicit_editable_root: Option<&Path>,
target_set: Option<&str>,
manifest: Option<&ManifestCandidate>,
) -> (PathBuf, String) {
if let Some(path) = explicit_editable_root {
return (ctx.repo().repo_path(path), "explicit --editable-root".to_string());
}
if let Some(manifest) = manifest {
if manifest.target_set.as_deref() == target_set {
let parent = manifest
.path
.parent()
.map(Path::to_path_buf)
.unwrap_or_else(|| manifest.path.clone());
return (parent.clone(), format!("manifest parent {}", parent.display()));
}
}
if let Some(target_set) = target_set.and_then(normalize_set_name) {
return (
ctx.repo()
.root()
.join("build")
.join(format!("rtx-editable-source-{target_set}")),
"derived from target set".to_string(),
);
}
(
ctx.repo().repo_path(DEFAULT_EDITABLE_ROOT),
"repo default editable root".to_string(),
)
}
fn infer_manifest_path(
ctx: &AppContext,
explicit_manifest: Option<&Path>,
target_set: Option<&str>,
) -> (Option<PathBuf>, Option<String>, Option<String>, Option<String>) {
if let Some(path) = explicit_manifest {
let manifest_path = ctx.repo().repo_path(path);
let candidate = manifest_path.is_file().then(|| load_manifest_candidate(&manifest_path)).flatten();
return (
Some(manifest_path),
Some("explicit --manifest".to_string()),
candidate.as_ref().and_then(|candidate| candidate.source_set.clone()),
candidate.as_ref().and_then(|candidate| candidate.target_set.clone()),
);
}
let candidate = choose_manifest_candidate(ctx, target_set);
if let Some(candidate) = candidate {
return (
Some(candidate.path.clone()),
Some(format!(
"latest matching editable manifest {}",
candidate.path.display()
)),
candidate.source_set.clone(),
candidate.target_set.clone(),
);
}
let normalized_target = target_set.and_then(normalize_set_name);
if let Some(target_set) = normalized_target {
let path = ctx
.repo()
.root()
.join("build")
.join(format!("rtx-editable-source-{target_set}"))
.join(DEFAULT_MANIFEST_NAME);
return (path.exists().then_some(path), None, None, None);
}
(None, None, None, None)
}
fn choose_manifest_candidate(ctx: &AppContext, target_set: Option<&str>) -> Option<ManifestCandidate> {
let manifests = iter_manifest_candidates(ctx);
let normalized_target = target_set.and_then(normalize_set_name);
if let Some(normalized_target) = normalized_target {
for manifest in &manifests {
if manifest.target_set.as_deref() == Some(normalized_target.as_str()) {
return Some(manifest.clone());
}
}
}
manifests.first().cloned()
}
fn iter_manifest_candidates(ctx: &AppContext) -> Vec<ManifestCandidate> {
let build_root = ctx.repo().root().join("build");
let mut candidates = Vec::new();
let entries = match fs::read_dir(&build_root) {
Ok(entries) => entries,
Err(_) => return candidates,
};
for entry in entries.filter_map(|entry| entry.ok()) {
let path = entry.path();
if !path.is_dir() {
continue;
}
let Some(name) = path.file_name().map(|value| value.to_string_lossy().to_string()) else {
continue;
};
if !name.starts_with("rtx-editable-source") {
continue;
}
let manifest_path = path.join(DEFAULT_MANIFEST_NAME);
if let Some(candidate) = load_manifest_candidate(&manifest_path) {
candidates.push(candidate);
}
}
candidates.sort_by(|left, right| {
right
.modified_at
.cmp(&left.modified_at)
.then_with(|| left.path.to_string_lossy().cmp(&right.path.to_string_lossy()))
});
candidates
}
fn load_manifest_candidate(path: &Path) -> Option<ManifestCandidate> {
let payload = fs::read_to_string(path)
.ok()
.and_then(|text| serde_json::from_str::<RequiredEditableManifest>(&text).ok())?;
let modified_at = path
.metadata()
.and_then(|metadata| metadata.modified())
.unwrap_or(SystemTime::UNIX_EPOCH);
Some(ManifestCandidate {
path: path.to_path_buf(),
source_set: normalize_set_name(payload.source_set),
target_set: normalize_set_name(payload.target_set),
modified_at,
})
}
fn detect_editable_sets(editable_root: &Path) -> Vec<String> {
let character_root = editable_root
.join("editable")
.join("models")
.join("customization")
.join("characters")
.join("doomslayer");
let entries = match fs::read_dir(character_root) {
Ok(entries) => entries,
Err(_) => return Vec::new(),
};
let mut sets = entries
.filter_map(|entry| entry.ok())
.map(|entry| entry.path())
.filter(|path| path.is_dir())
.filter_map(|path| {
path.file_name()
.and_then(|value| parse_set_token(value.to_string_lossy()))
})
.collect::<Vec<_>>();
sets.sort();
sets
}
fn detect_build_editable_root_set() -> Option<(String, String)> {
let repo_root = PathBuf::from(env!("CARGO_MANIFEST_DIR"))
.parent()
.and_then(Path::parent)
.map(Path::to_path_buf)?;
let entries = fs::read_dir(repo_root.join("build")).ok()?;
let mut candidates = entries
.filter_map(|entry| entry.ok())
.map(|entry| entry.path())
.filter(|path| path.is_dir())
.filter_map(|path| {
let name = path.file_name()?.to_string_lossy();
let set_name = parse_set_token(name.strip_prefix("rtx-editable-source-")?)?;
let modified_at = path
.metadata()
.and_then(|metadata| metadata.modified())
.unwrap_or(SystemTime::UNIX_EPOCH);
Some((modified_at, set_name, path))
})
.collect::<Vec<_>>();
candidates.sort_by(|left, right| {
right
.0
.cmp(&left.0)
.then_with(|| left.2.to_string_lossy().cmp(&right.2.to_string_lossy()))
});
candidates
.into_iter()
.next()
.map(|(_, set_name, path)| (set_name, format!("latest editable-root folder {}", path.display())))
}
fn score_source_candidate(path: &Path) -> u32 {
let mut score = 0;
let name = path
.file_name()
.map(|value| value.to_string_lossy().to_ascii_lowercase())
.unwrap_or_default();
if name == "rtx-slayer" {
score += 100;
}
if name.contains("rtx") {
score += 20;
}
if name.contains("slayer") {
score += 20;
}
if path.join("EternalMod.json").is_file() {
score += 10;
}
if path
.join("warehouse")
.join("models")
.join("customization")
.join("characters")
.join("doomslayer")
.is_dir()
{
score += 10;
}
score
}