use std::{
fs,
path::{Path, PathBuf},
time::SystemTime,
};
use serde::Serialize;
use crate::{
bim::default_autoheckin_path,
config::RepoConfig,
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},
tools::{discover_activemods_root, discover_idstudio_root},
};
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_exists: bool,
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_exists: bool,
pub editable_root_reason: String,
pub manifest_path: Option<PathBuf>,
pub manifest_exists: bool,
pub manifest_reason: Option<String>,
pub manifest_source_set: Option<String>,
pub manifest_target_set: Option<String>,
pub output_mod_root: PathBuf,
pub output_mod_root_exists: bool,
pub zip_output: PathBuf,
pub zip_exists: bool,
pub converter_path: PathBuf,
pub converter_available: bool,
pub converter_reason: Option<String>,
pub game_root: Option<PathBuf>,
pub game_root_exists: bool,
pub game_root_reason: Option<String>,
pub idstudio_root: Option<PathBuf>,
pub idstudio_root_exists: bool,
pub idstudio_root_reason: Option<String>,
pub activemods_root: Option<PathBuf>,
pub activemods_root_exists: bool,
pub activemods_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 repo_config_loaded = RepoConfig::load(ctx.repo()).is_some();
let game_root = discover_game_root(ctx.repo());
let game_root_reason = game_root.as_ref().map(|_| {
if repo_config_loaded {
"derived from config.yaml steam library roots".to_string()
} else {
"auto-detected DOOM Eternal install root".to_string()
}
});
let idstudio_root = discover_idstudio_root(ctx.repo());
let idstudio_root_reason = idstudio_root.as_ref().map(|_| {
if repo_config_loaded {
"derived from config.yaml idSoftware root".to_string()
} else {
"auto-detected idStudio mod root".to_string()
}
});
let activemods_root = discover_activemods_root(ctx.repo());
let activemods_root_reason = activemods_root.as_ref().map(|_| {
if repo_config_loaded {
"derived from config.yaml idSoftware root".to_string()
} else {
"auto-detected DOOM activemods root".to_string()
}
});
let source_pack_exists = source_pack
.as_ref()
.map(|path| path.is_dir())
.unwrap_or(false);
let editable_root_exists = editable_root.is_dir();
let manifest_exists = manifest_path
.as_ref()
.map(|path| path.is_file())
.unwrap_or(false);
let manifest_path_existing = manifest_path
.as_ref()
.filter(|path| path.is_file())
.cloned();
let output_mod_root_exists = output_mod_root.is_dir();
let zip_exists = zip_output.is_file();
let game_root_exists = game_root
.as_ref()
.map(|path| path.is_dir())
.unwrap_or(false);
let idstudio_root_exists = idstudio_root
.as_ref()
.map(|path| path.is_dir())
.unwrap_or(false);
let activemods_root_exists = activemods_root
.as_ref()
.map(|path| path.is_dir())
.unwrap_or(false);
RepoState {
source_pack,
source_pack_exists,
source_pack_reason,
source_set,
source_set_reason,
target_set,
target_set_reason,
editable_root,
editable_root_exists,
editable_root_reason,
manifest_path: manifest_path_existing,
manifest_exists,
manifest_reason: manifest_exists.then_some(manifest_reason).flatten(),
manifest_source_set,
manifest_target_set,
output_mod_root,
output_mod_root_exists,
zip_output,
zip_exists,
converter_path: converter_path.clone(),
converter_available: converter_path.is_file(),
converter_reason,
game_root,
game_root_exists,
game_root_reason,
idstudio_root,
idstudio_root_exists,
idstudio_root_reason,
activemods_root,
activemods_root_exists,
activemods_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());
}
}
return None;
}
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
}
#[cfg(test)]
mod tests {
use std::fs;
use std::path::Path;
use serde_json::json;
use tempfile::TempDir;
use super::{choose_manifest_candidate, DEFAULT_MANIFEST_NAME};
use crate::context::AppContext;
#[test]
fn explicit_target_set_does_not_fallback_to_other_manifest_sets() {
let temp_dir = TempDir::new().expect("tempdir");
let repo_root = temp_dir.path();
create_repo_scaffold(repo_root);
write_manifest(repo_root, "set16");
let ctx = AppContext::from_anchor(repo_root);
assert!(
choose_manifest_candidate(&ctx, Some("set17")).is_none(),
"set17 should not silently reuse a set16 manifest"
);
assert_eq!(
choose_manifest_candidate(&ctx, None).and_then(|candidate| candidate.target_set),
Some("set16".to_string())
);
}
fn create_repo_scaffold(repo_root: &Path) {
fs::create_dir_all(repo_root.join("config")).expect("config dir");
fs::create_dir_all(repo_root.join("mod")).expect("mod dir");
fs::create_dir_all(repo_root.join("assets").join("source").join("logos"))
.expect("logos dir");
fs::write(
repo_root
.join("config")
.join("rtx-pack-logo-placements.json"),
"{}\n",
)
.expect("placement config");
fs::write(repo_root.join("mod").join("EternalMod.json"), "{}\n").expect("mod metadata");
}
fn write_manifest(repo_root: &Path, target_set: &str) {
let manifest_path = repo_root
.join("build")
.join(format!("rtx-editable-source-{target_set}"))
.join(DEFAULT_MANIFEST_NAME);
fs::create_dir_all(
manifest_path
.parent()
.expect("manifest should have parent directory"),
)
.expect("manifest parent");
let payload = json!({
"message": "test manifest",
"sourceSet": "set52",
"targetSet": target_set,
"exports": []
});
fs::write(
manifest_path,
serde_json::to_string_pretty(&payload).expect("serialize manifest"),
)
.expect("write manifest");
}
}