use std::{
io::{self, Write},
path::{Path, PathBuf},
};
use clap::{Args, Parser, Subcommand, ValueEnum};
use crate::{
branding::{brand_icons, BrandIconsRequest},
config::RepoConfig,
context::AppContext,
craft::{apply_rtx_logo_pipeline, CraftRequest},
idstudio::{stage_idstudio_mod, StageIdStudioRequest},
installer::{
clean_game_installation, default_injector_item_id, install_mod, launch_installed_injector,
list_installed_mods, repair_game_installation, resolve_game_root, resolve_restore_manifest,
restore_mod, CleanRequest, InstallRequest, RepairRequest, RestoreRequest,
},
logos::prepare_logo_assets,
manual::{apply_logos_to_armor, build_workpack},
mirror::{
build_export_mirror, default_chunk_size_mb, hydrate_export_mirror, manifest_file_name,
BuildMirrorRequest, HydrateMirrorRequest,
},
paths::home_dir,
repo_state::{build_repo_state, detect_export_source_set, RepoStateOptions},
samuel::import_samuel_exports,
sets::normalize_set_name,
tools::{
discover_injector_zip, discover_model_export_root, discover_samuel_exe,
discover_samuel_export_root, download_injector, download_samuel, launch_samuel,
},
workflow_runtime::{stage_workflow_runtime, StageWorkflowRuntimeRequest},
};
const DEFAULT_PLACEMENT_CONFIG: &str = "config/rtx-pack-logo-placements.json";
const DEFAULT_LOGO_DIR: &str = "build/logos";
const DEFAULT_SOURCE_LOGO_DIR: &str = "assets/source/logos";
const DEFAULT_EDITABLE_EXTENSION: &str = ".png";
const DEFAULT_TARGET_SET: &str = "set16";
const DEFAULT_BACKUP_ROOT: &str = "build/game-mod-backups";
const DEFAULT_TOOL_CACHE_ROOT: &str = "build/tool-download-cache";
#[derive(Debug, Parser)]
#[command(
name = "doom-eternal",
about = "Unified Rust CLI for the Xylex DOOM Eternal skin workflow."
)]
struct Cli {
#[arg(long, global = true, hide = true)]
repo_root: Option<PathBuf>,
#[command(subcommand)]
command: Commands,
}
#[derive(Debug, Subcommand)]
enum Commands {
Status(StatusArgs),
Examples(ExamplesArgs),
BrandIcons(BrandIconsArgs),
#[command(name = "stage-runtime")]
StageRuntime(StageRuntimeArgs),
#[command(name = "stage-idstudio")]
StageIdStudio(StageIdStudioArgs),
Craft(CraftArgs),
Import(ImportArgs),
Install(InstallArgs),
Mirror(MirrorArgs),
Restore(RestoreArgs),
Repair(RepairArgs),
Clean(CleanArgs),
PrepareLogos(PrepareLogosArgs),
ApplyLogos(ApplyLogosArgs),
BuildWorkpack(BuildWorkpackArgs),
Tools(ToolsArgs),
Run(RunArgs),
}
impl Commands {
fn shows_startup_banner(&self) -> bool {
!matches!(self, Self::Status(args) if args.json)
}
}
#[derive(Debug, Args)]
struct StatusArgs {
#[arg(long)]
source_path: Option<PathBuf>,
#[arg(long, default_value = DEFAULT_TARGET_SET)]
target_set: Option<String>,
#[arg(long)]
editable_root: Option<PathBuf>,
#[arg(long)]
manifest: Option<PathBuf>,
#[arg(long)]
output_mod_root: Option<PathBuf>,
#[arg(long)]
zip_output: Option<PathBuf>,
#[arg(long)]
converter_path: Option<PathBuf>,
#[arg(long)]
json: bool,
}
#[derive(Debug, Args)]
struct ExamplesArgs {
#[arg(long)]
source_path: Option<PathBuf>,
#[arg(long, default_value = DEFAULT_TARGET_SET)]
target_set: Option<String>,
#[arg(long)]
editable_root: Option<PathBuf>,
#[arg(long)]
manifest: Option<PathBuf>,
#[arg(long)]
output_mod_root: Option<PathBuf>,
#[arg(long)]
zip_output: Option<PathBuf>,
#[arg(long)]
converter_path: Option<PathBuf>,
#[arg(long)]
game_root: Option<PathBuf>,
#[arg(long)]
samuel_export_root: Option<PathBuf>,
#[arg(long)]
model_export_root: Option<PathBuf>,
#[arg(long)]
samuel_exe: Option<PathBuf>,
#[arg(long)]
injector_zip: Option<PathBuf>,
#[arg(long)]
idstudio_root: Option<PathBuf>,
#[arg(long)]
stage_root: Option<PathBuf>,
#[arg(long, default_value = DEFAULT_BACKUP_ROOT)]
backup_root: PathBuf,
#[arg(long)]
restore_manifest: Option<PathBuf>,
}
#[derive(Debug, Args, Clone)]
struct CraftArgs {
#[arg(long)]
source_path: Option<PathBuf>,
#[arg(long)]
output_mod_root: Option<PathBuf>,
#[arg(long, default_value = DEFAULT_PLACEMENT_CONFIG)]
placement_config: PathBuf,
#[arg(long, default_value = DEFAULT_LOGO_DIR)]
logo_dir: PathBuf,
#[arg(long)]
editable_root: Option<PathBuf>,
#[arg(long, default_value = DEFAULT_EDITABLE_EXTENSION)]
editable_extension: String,
#[arg(long, default_value = DEFAULT_TARGET_SET)]
target_set: Option<String>,
#[arg(long, default_value = "")]
decode_command_template: String,
#[arg(long, default_value = "")]
encode_command_template: String,
#[arg(long)]
converter_path: Option<PathBuf>,
#[arg(long, default_value_t = false)]
disable_builtin_decode: bool,
#[arg(long, default_value_t = false)]
disable_builtin_encode: bool,
#[arg(long)]
zip_output: Option<PathBuf>,
#[arg(long, default_value_t = false)]
no_zip: bool,
#[arg(long, default_value_t = false)]
dry_run: bool,
}
#[derive(Debug, Args)]
struct BrandIconsArgs {
#[arg(
long,
default_value = "assets/source/logos/XYLEX_GROUP_ICON_TRANSPARENT_WHITE.png"
)]
source_icon: PathBuf,
#[arg(long, default_value = "desktop/public/favicon.png")]
favicon_output: PathBuf,
#[arg(long, default_value = "desktop/src-tauri/icons")]
icon_output_dir: PathBuf,
#[arg(long, default_value_t = 1024)]
size: u32,
#[arg(long, default_value_t = 96)]
padding: u32,
#[arg(long, default_value = "#d72638")]
color: String,
#[arg(long, default_value_t = false)]
skip_tauri: bool,
}
#[derive(Debug, Args)]
struct StageRuntimeArgs {
#[arg(long, default_value = "target/release/doom-eternal.exe")]
source_binary: PathBuf,
#[arg(long, default_value = "build/workflow-runtime")]
output_root: PathBuf,
#[arg(long, default_value = "doom-eternal")]
runtime_folder: String,
#[arg(long, default_value_t = false)]
dry_run: bool,
}
#[derive(Debug, Args)]
struct StageIdStudioArgs {
#[arg(long)]
output_mod_root: Option<PathBuf>,
#[arg(long)]
idstudio_root: Option<PathBuf>,
#[arg(long)]
stage_root: Option<PathBuf>,
#[arg(long)]
slug: Option<String>,
#[arg(long, default_value_t = false)]
dry_run: bool,
}
#[derive(Debug, Args)]
struct ImportArgs {
#[arg(long)]
samuel_export_root: Option<PathBuf>,
#[arg(long)]
model_export_root: Option<PathBuf>,
#[arg(long)]
mirror_url: Option<String>,
#[arg(long, default_value = "build/mirror-cache")]
mirror_cache_root: PathBuf,
#[arg(long)]
manifest: Option<PathBuf>,
#[arg(long)]
editable_root: Option<PathBuf>,
#[arg(long, default_value = DEFAULT_TARGET_SET)]
target_set: Option<String>,
#[arg(long)]
source_set: Option<String>,
#[arg(long, default_value_t = false)]
dry_run: bool,
}
#[derive(Debug, Args)]
struct InstallArgs {
#[arg(long)]
mod_zip: Option<PathBuf>,
#[arg(
long,
default_value_t = false,
help = "Open an interactive picker for installable zips under dist/."
)]
picker: bool,
#[arg(long)]
game_root: Option<PathBuf>,
#[arg(long, default_value = DEFAULT_BACKUP_ROOT)]
backup_root: PathBuf,
#[arg(long, default_value_t = false)]
install_tools: bool,
#[arg(long)]
injector_zip: Option<PathBuf>,
#[arg(long, default_value = DEFAULT_TOOL_CACHE_ROOT)]
tool_cache_root: PathBuf,
#[arg(long, default_value_t = default_injector_item_id())]
injector_item_id: i64,
#[arg(long, default_value_t = false)]
skip_injector: bool,
#[arg(long, default_value_t = false)]
wait_for_injector: bool,
#[arg(long, default_value_t = false)]
dry_run: bool,
}
#[derive(Debug, Args)]
struct MirrorArgs {
#[command(subcommand)]
command: MirrorCommand,
}
#[derive(Debug, Subcommand)]
enum MirrorCommand {
Pack(MirrorPackArgs),
Fetch(MirrorFetchArgs),
}
#[derive(Debug, Args)]
struct MirrorPackArgs {
#[arg(long)]
samuel_export_root: Option<PathBuf>,
#[arg(long)]
model_export_root: Option<PathBuf>,
#[arg(long, default_value = "dist/samuel-export-mirror")]
output_root: PathBuf,
#[arg(long, default_value_t = default_chunk_size_mb())]
chunk_size_mb: u64,
#[arg(long, default_value_t = false)]
dry_run: bool,
}
#[derive(Debug, Args)]
struct MirrorFetchArgs {
#[arg(long)]
mirror_url: String,
#[arg(long, default_value = "build/mirror-cache")]
mirror_cache_root: PathBuf,
#[arg(long, default_value_t = false)]
dry_run: bool,
}
#[derive(Debug, Args)]
struct RestoreArgs {
#[arg(long)]
manifest: Option<PathBuf>,
#[arg(long)]
game_root: Option<PathBuf>,
#[arg(long, default_value = DEFAULT_BACKUP_ROOT)]
backup_root: PathBuf,
#[arg(long, default_value_t = false)]
skip_injector: bool,
#[arg(long, default_value_t = false)]
wait_for_injector: bool,
#[arg(long, default_value_t = false)]
dry_run: bool,
}
#[derive(Debug, Args)]
struct RepairArgs {
#[arg(long)]
game_root: Option<PathBuf>,
#[arg(long, default_value_t = false)]
dry_run: bool,
}
#[derive(Debug, Args)]
struct CleanArgs {
#[arg(long)]
game_root: Option<PathBuf>,
#[arg(
long,
default_value_t = false,
help = "Delete EternalModInjector .backup files and clear resource tracking after a repair"
)]
reset_backups: bool,
#[arg(long, default_value_t = false)]
skip_injector: bool,
#[arg(long, default_value_t = false)]
dry_run: bool,
}
#[derive(Debug, Args)]
struct PrepareLogosArgs {
#[arg(long, default_value = DEFAULT_SOURCE_LOGO_DIR)]
source_dir: PathBuf,
#[arg(long, default_value = DEFAULT_LOGO_DIR)]
output_dir: PathBuf,
}
#[derive(Debug, Args)]
struct ApplyLogosArgs {
#[arg(long)]
armor_texture: PathBuf,
#[arg(long, default_value = "build/textures/slayer_armor_albedo_xylex.png")]
output_path: PathBuf,
#[arg(long, default_value = "config/armor-logo-placements.json")]
placement_config: PathBuf,
#[arg(long, default_value = DEFAULT_LOGO_DIR)]
logo_dir: PathBuf,
#[arg(long, default_value = "third-person-armor")]
target: String,
}
#[derive(Debug, Args)]
struct BuildWorkpackArgs {
#[arg(long, default_value = "dist/xylex-slayer-logo-skin-workpack.zip")]
output_path: PathBuf,
#[arg(long)]
armor_texture: Option<PathBuf>,
#[arg(long, default_value = "third-person-armor")]
target: String,
}
#[derive(Debug, Args)]
struct ToolsArgs {
#[command(subcommand)]
command: ToolCommand,
}
#[derive(Debug, Subcommand)]
enum ToolCommand {
Download(ToolDownloadArgs),
Launch(ToolLaunchArgs),
}
#[derive(Debug, Clone, Copy, ValueEnum)]
enum ToolName {
Samuel,
Injector,
}
#[derive(Debug, Args)]
struct ToolDownloadArgs {
#[arg(value_enum)]
tool: ToolName,
#[arg(long, default_value = DEFAULT_TOOL_CACHE_ROOT)]
tool_cache_root: PathBuf,
#[arg(long)]
injector_zip: Option<PathBuf>,
#[arg(long, default_value_t = default_injector_item_id())]
injector_item_id: i64,
#[arg(long, default_value_t = false)]
dry_run: bool,
}
#[derive(Debug, Args)]
struct ToolLaunchArgs {
#[arg(value_enum)]
tool: ToolName,
#[arg(long, default_value = DEFAULT_TOOL_CACHE_ROOT)]
tool_cache_root: PathBuf,
#[arg(long)]
samuel_exe: Option<PathBuf>,
#[arg(long)]
game_root: Option<PathBuf>,
#[arg(long, default_value_t = false)]
wait: bool,
#[arg(long, default_value_t = false)]
dry_run: bool,
}
#[derive(Debug, Args, Clone)]
struct RunArgs {
#[arg(long)]
source_path: Option<PathBuf>,
#[arg(long)]
output_mod_root: Option<PathBuf>,
#[arg(long, default_value = DEFAULT_PLACEMENT_CONFIG)]
placement_config: PathBuf,
#[arg(long, default_value = DEFAULT_LOGO_DIR)]
logo_dir: PathBuf,
#[arg(long)]
editable_root: Option<PathBuf>,
#[arg(long, default_value = DEFAULT_EDITABLE_EXTENSION)]
editable_extension: String,
#[arg(long, default_value = DEFAULT_TARGET_SET)]
target_set: Option<String>,
#[arg(long)]
manifest: Option<PathBuf>,
#[arg(long)]
samuel_export_root: Option<PathBuf>,
#[arg(long)]
model_export_root: Option<PathBuf>,
#[arg(long)]
mirror_url: Option<String>,
#[arg(long, default_value = "build/mirror-cache")]
mirror_cache_root: PathBuf,
#[arg(long)]
source_set: Option<String>,
#[arg(long, default_value = "")]
decode_command_template: String,
#[arg(long, default_value = "")]
encode_command_template: String,
#[arg(long)]
converter_path: Option<PathBuf>,
#[arg(long, default_value_t = false)]
disable_builtin_decode: bool,
#[arg(long, default_value_t = false)]
disable_builtin_encode: bool,
#[arg(long)]
zip_output: Option<PathBuf>,
#[arg(long, default_value_t = false)]
no_zip: bool,
#[arg(long, default_value_t = false)]
install: bool,
#[arg(long)]
mod_zip: Option<PathBuf>,
#[arg(long)]
game_root: Option<PathBuf>,
#[arg(long, default_value = DEFAULT_BACKUP_ROOT)]
backup_root: PathBuf,
#[arg(long, default_value_t = false)]
install_tools: bool,
#[arg(long)]
injector_zip: Option<PathBuf>,
#[arg(long, default_value = DEFAULT_TOOL_CACHE_ROOT)]
tool_cache_root: PathBuf,
#[arg(long, default_value_t = default_injector_item_id())]
injector_item_id: i64,
#[arg(long, default_value_t = false)]
skip_injector: bool,
#[arg(long, default_value_t = false)]
wait_for_injector: bool,
#[arg(long, default_value_t = false)]
dry_run: bool,
}
pub async fn run_from_env() -> i32 {
let cli = Cli::parse();
let ctx = cli
.repo_root
.as_ref()
.map(AppContext::from_anchor)
.unwrap_or_else(AppContext::discover);
if cli.command.shows_startup_banner() {
ctx.console().print_startup_banner();
}
let result = match cli.command {
Commands::Status(args) => run_status_command(&ctx, args),
Commands::Examples(args) => run_examples_command(&ctx, args),
Commands::BrandIcons(args) => run_brand_icons_command(&ctx, args),
Commands::StageRuntime(args) => run_stage_runtime_command(&ctx, args),
Commands::StageIdStudio(args) => run_stage_idstudio_command(&ctx, args),
Commands::Craft(args) => run_craft_command(&ctx, args),
Commands::Import(args) => run_import_command(&ctx, args).await,
Commands::Install(args) => run_install_command(&ctx, args).await,
Commands::Mirror(args) => run_mirror_command(&ctx, args).await,
Commands::Restore(args) => run_restore_command(&ctx, args),
Commands::Repair(args) => run_repair_command(&ctx, args),
Commands::Clean(args) => run_clean_command(&ctx, args),
Commands::PrepareLogos(args) => prepare_logo_assets(&ctx, args.source_dir, args.output_dir),
Commands::ApplyLogos(args) => apply_logos_to_armor(
&ctx,
args.armor_texture,
args.output_path,
args.placement_config,
args.logo_dir,
&args.target,
),
Commands::BuildWorkpack(args) => {
build_workpack(&ctx, args.output_path, args.armor_texture, &args.target)
}
Commands::Tools(args) => run_tools_command(&ctx, args).await,
Commands::Run(args) => run_pipeline_command(&ctx, args).await,
};
match result {
Ok(()) => 0,
Err(error) => {
ctx.console().log_error(error.to_string());
2
}
}
}
fn run_status_command(ctx: &AppContext, args: StatusArgs) -> crate::error::Result<()> {
let state = build_repo_state(
ctx,
RepoStateOptions {
source_path: args.source_path,
target_set: args.target_set,
editable_root: args.editable_root,
manifest: args.manifest,
output_mod_root: args.output_mod_root,
zip_output: args.zip_output,
converter_path: args.converter_path,
},
);
if args.json {
println!("{}", serde_json::to_string_pretty(&state)?);
return Ok(());
}
let converter_line = if state.converter_available {
ctx.console().format_path(&state.converter_path)
} else {
format!(
"{} (missing)",
ctx.console().format_path(&state.converter_path)
)
};
let installed_mods_line = if let Some(game_root) = state.game_root.as_ref() {
let installed_mods = list_installed_mods(game_root)?;
if installed_mods.is_empty() {
"none".to_string()
} else {
installed_mods
.iter()
.map(|record| {
format!(
"{} v{} ({})",
record.name,
record.version,
if record.enabled {
"enabled"
} else {
"disabled"
}
)
})
.collect::<Vec<_>>()
.join(", ")
}
} else {
"game root not detected".to_string()
};
let lines = [
format!(
"Source pack: {}",
state
.source_pack
.as_ref()
.map(|path| ctx.console().format_path(path))
.unwrap_or_else(|| "not found".to_string())
),
format!(
"Source set: {}",
state.source_set.unwrap_or_else(|| "unknown".to_string())
),
format!(
"Target set: {}",
state
.target_set
.unwrap_or_else(|| "not resolved".to_string())
),
format!(
"Editable root: {}",
ctx.console().format_path(&state.editable_root)
),
format!(
"Editable manifest: {}",
state
.manifest_path
.as_ref()
.map(|path| ctx.console().format_path(path))
.unwrap_or_else(|| "not found".to_string())
),
format!(
"Output mod root: {}",
ctx.console().format_path(&state.output_mod_root)
),
format!(
"Output zip: {}",
ctx.console().format_path(&state.zip_output)
),
format!("Converter: {converter_line}"),
format!(
"Game root: {}",
state
.game_root
.as_ref()
.map(|path| ctx.console().format_path(path))
.unwrap_or_else(|| "not detected".to_string())
),
format!("Installed mods: {installed_mods_line}"),
format!(
"idStudio root: {}",
state
.idstudio_root
.as_ref()
.map(|path| ctx.console().format_path(path))
.unwrap_or_else(|| "not detected".to_string())
),
format!(
"Active mods root: {}",
state
.activemods_root
.as_ref()
.map(|path| ctx.console().format_path(path))
.unwrap_or_else(|| "not detected".to_string())
),
];
println!("{}", lines.join("\n"));
Ok(())
}
fn run_examples_command(ctx: &AppContext, args: ExamplesArgs) -> crate::error::Result<()> {
let state = build_repo_state(
ctx,
RepoStateOptions {
source_path: args.source_path,
target_set: args.target_set,
editable_root: args.editable_root,
manifest: args.manifest,
output_mod_root: args.output_mod_root,
zip_output: args.zip_output,
converter_path: args.converter_path,
},
);
let repo_root = ctx.repo().root().to_path_buf();
let source_pack = state
.source_pack
.unwrap_or_else(|| repo_root.join("rtx-slayer"));
let target_set = state
.target_set
.unwrap_or_else(|| DEFAULT_TARGET_SET.to_string());
let editable_root = state.editable_root;
let output_mod_root = state.output_mod_root;
let zip_output = state.zip_output;
let blank_placement_config = ctx
.repo()
.repo_path("config/rtx-pack-blank-black-placements.json");
let blank_output_mod_root = ctx.repo().repo_path("dist/xylex-blank-black-pack");
let blank_zip_output = ctx.repo().repo_path("dist/xylex-blank-black-pack.zip");
let converter_path = state.converter_path;
let repo_config = RepoConfig::load_or_default(ctx.repo());
let game_root = args.game_root.or(state.game_root).unwrap_or_else(|| {
repo_config
.game_root_candidates(ctx.repo())
.into_iter()
.next()
.unwrap_or_else(|| {
PathBuf::from("C:/Program Files (x86)/Steam/steamapps/common/DOOMEternal")
})
});
let downloads = home_dir()
.unwrap_or_else(|| PathBuf::from("C:/Users/floris"))
.join("Downloads");
let tool_cache_root = ctx.repo().repo_path(DEFAULT_TOOL_CACHE_ROOT);
let samuel_export_root = args
.samuel_export_root
.or_else(discover_samuel_export_root)
.unwrap_or_else(|| downloads.join("exports"));
let model_export_root = args
.model_export_root
.or_else(discover_model_export_root)
.unwrap_or_else(|| downloads.join("modelExports"));
let samuel_exe = args
.samuel_exe
.or_else(|| discover_samuel_exe(&tool_cache_root))
.unwrap_or_else(|| downloads.join("SAMUEL-v2.1.2.exe"));
let injector_zip = args
.injector_zip
.or_else(|| discover_injector_zip(&tool_cache_root))
.unwrap_or_else(|| downloads.join("EternalModInjector.zip"));
let idstudio_root = args
.idstudio_root
.or(state.idstudio_root.clone())
.or_else(|| repo_config.discover_idstudio_root(ctx.repo()))
.unwrap_or_else(|| {
home_dir()
.unwrap_or_else(|| PathBuf::from("C:/Users/floris"))
.join("Documents")
.join("id Software")
.join("idStudio")
});
let stage_root = args.stage_root.unwrap_or_else(|| {
idstudio_root.join(
output_mod_root
.file_name()
.map(|value| value.to_string_lossy().to_string())
.unwrap_or_else(|| "xylex-rtx-slayer-pack".to_string()),
)
});
let backup_root = ctx.repo().repo_path(args.backup_root);
let restore_manifest = resolve_restore_manifest(
args.restore_manifest.as_deref(),
&backup_root,
ctx.console(),
)
.unwrap_or_else(|_| backup_root.join("<timestamp>").join("backup-manifest.json"));
let mut lines = vec![
"# Copy/paste PowerShell examples for this checkout.".to_string(),
format!("$RepoRoot = {}", powershell_string(&repo_root)),
format!("$SourcePack = {}", powershell_string(&source_pack)),
format!("$EditableRoot = {}", powershell_string(&editable_root)),
format!("$OutputModRoot = {}", powershell_string(&output_mod_root)),
format!("$ZipOutput = {}", powershell_string(&zip_output)),
format!(
"$BlankPlacementConfig = {}",
powershell_string(&blank_placement_config)
),
format!(
"$BlankOutputModRoot = {}",
powershell_string(&blank_output_mod_root)
),
format!(
"$BlankZipOutput = {}",
powershell_string(&blank_zip_output)
),
format!("$TargetSet = {}", powershell_literal(&target_set)),
format!("$ConverterPath = {}", powershell_string(&converter_path)),
format!(
"$ToolCacheRoot = {}",
powershell_string(ctx.repo().repo_path(DEFAULT_TOOL_CACHE_ROOT))
),
format!(
"$SamuelExportRoot = {}",
powershell_string(&samuel_export_root)
),
format!(
"$ModelExportRoot = {}",
powershell_string(&model_export_root)
),
format!("$SamuelExe = {}", powershell_string(&samuel_exe)),
format!("$GameRoot = {}", powershell_string(&game_root)),
format!("$InjectorZip = {}", powershell_string(&injector_zip)),
format!("$IdStudioRoot = {}", powershell_string(&idstudio_root)),
format!("$IdStudioStageRoot = {}", powershell_string(&stage_root)),
format!(
"$RestoreManifest = {}",
powershell_string(&restore_manifest)
),
String::new(),
"Set-Location $RepoRoot".to_string(),
"function DoomCli { cargo run -p doom-eternal -- $args }".to_string(),
String::new(),
"DoomCli status".to_string(),
String::new(),
"# Download and launch SAMUEL from the Rust workflow cache.".to_string(),
"DoomCli tools download samuel".to_string(),
"DoomCli tools launch samuel".to_string(),
String::new(),
"# Cache the latest EternalModInjector zip. Use the batch launcher only when you explicitly want the legacy window.".to_string(),
"DoomCli tools download injector".to_string(),
"DoomCli tools launch injector --game-root \"$GameRoot\"".to_string(),
String::new(),
];
if samuel_exe.exists() {
lines.extend([
"# Launch SAMUEL when you want fresh texture exports.".to_string(),
"& $SamuelExe".to_string(),
String::new(),
]);
} else {
lines.extend([
"# Update $SamuelExe if your SAMUEL download lives somewhere else.".to_string(),
String::new(),
]);
}
lines.extend([
"# Import texture exports into the repo's editable root.".to_string(),
"# Omit both roots to auto-detect Downloads/exports and Downloads/modelExports."
.to_string(),
"DoomCli import `".to_string(),
" --samuel-export-root \"$SamuelExportRoot\" `".to_string(),
" --model-export-root \"$ModelExportRoot\"".to_string(),
String::new(),
"# Craft the Xylex pack with explicit paths.".to_string(),
"DoomCli craft `".to_string(),
" --source-path \"$SourcePack\" `".to_string(),
" --target-set \"$TargetSet\" `".to_string(),
" --editable-root \"$EditableRoot\" `".to_string(),
" --output-mod-root \"$OutputModRoot\" `".to_string(),
" --zip-output \"$ZipOutput\" `".to_string(),
" --converter-path \"$ConverterPath\"".to_string(),
String::new(),
"# Craft a blank jet-black variant with the RTX torso badge and helmet sigil fully covered."
.to_string(),
"DoomCli craft `".to_string(),
" --source-path \"$SourcePack\" `".to_string(),
" --target-set \"$TargetSet\" `".to_string(),
" --editable-root \"$EditableRoot\" `".to_string(),
" --placement-config \"$BlankPlacementConfig\" `".to_string(),
" --output-mod-root \"$BlankOutputModRoot\" `".to_string(),
" --zip-output \"$BlankZipOutput\" `".to_string(),
" --converter-path \"$ConverterPath\"".to_string(),
String::new(),
"# One command: import -> craft -> install into DOOM Eternal.".to_string(),
"DoomCli run `".to_string(),
" --samuel-export-root \"$SamuelExportRoot\" `".to_string(),
" --model-export-root \"$ModelExportRoot\" `".to_string(),
" --game-root \"$GameRoot\" `".to_string(),
" --install `".to_string(),
" --install-tools".to_string(),
String::new(),
"# Install an already-built zip into the game.".to_string(),
"# This runs the injector worker chain inline from the Rust CLI.".to_string(),
"DoomCli install `".to_string(),
" --mod-zip \"$ZipOutput\" `".to_string(),
" --game-root \"$GameRoot\" `".to_string(),
" --install-tools".to_string(),
String::new(),
"# Use a local injector zip instead of downloading it from GameBanana.".to_string(),
"DoomCli install `".to_string(),
" --mod-zip \"$ZipOutput\" `".to_string(),
" --game-root \"$GameRoot\" `".to_string(),
" --install-tools `".to_string(),
" --injector-zip \"$InjectorZip\"".to_string(),
String::new(),
"# Stage the current edited textures into an idStudio base folder.".to_string(),
"DoomCli stage-idstudio `".to_string(),
" --stage-root \"$IdStudioStageRoot\"".to_string(),
String::new(),
"# Then open idStudio, package from that base folder, and publish via My Creations."
.to_string(),
String::new(),
"# Restore the most recent backed-up payload.".to_string(),
"DoomCli restore `".to_string(),
" --manifest \"$RestoreManifest\"".to_string(),
String::new(),
"# Repair DOOM Eternal through Steam / GOG / Xbox.".to_string(),
"DoomCli repair --game-root \"$GameRoot\"".to_string(),
String::new(),
"# Clean mod payloads and return the install to vanilla.".to_string(),
"DoomCli clean --game-root \"$GameRoot\"".to_string(),
String::new(),
"# After a Steam verify, also reset stale EternalModInjector backups.".to_string(),
"DoomCli clean `".to_string(),
" --game-root \"$GameRoot\" `".to_string(),
" --reset-backups".to_string(),
]);
println!("{}", lines.join("\n"));
Ok(())
}
fn run_brand_icons_command(ctx: &AppContext, args: BrandIconsArgs) -> crate::error::Result<()> {
brand_icons(
ctx,
BrandIconsRequest {
source_icon: args.source_icon,
favicon_output: args.favicon_output,
icon_output_dir: args.icon_output_dir,
size: args.size,
padding: args.padding,
color: args.color,
skip_tauri: args.skip_tauri,
},
)
}
fn run_stage_idstudio_command(
ctx: &AppContext,
args: StageIdStudioArgs,
) -> crate::error::Result<()> {
let stage_root = stage_idstudio_mod(
ctx,
StageIdStudioRequest {
output_mod_root: args.output_mod_root,
stage_root: args.stage_root,
idstudio_root: args.idstudio_root,
slug: args.slug,
dry_run: args.dry_run,
},
)?;
ctx.console().log_success(format!(
"idStudio stage root: {}",
ctx.console().format_path(stage_root)
));
Ok(())
}
fn run_stage_runtime_command(ctx: &AppContext, args: StageRuntimeArgs) -> crate::error::Result<()> {
stage_workflow_runtime(
ctx,
StageWorkflowRuntimeRequest {
source_binary: args.source_binary,
output_root: args.output_root,
runtime_folder: args.runtime_folder,
dry_run: args.dry_run,
},
)?;
Ok(())
}
fn run_craft_command(ctx: &AppContext, args: CraftArgs) -> crate::error::Result<()> {
let (state, source_path, output_mod_root, editable_root, target_set, zip_output) =
resolve_craft_inputs(ctx, &args);
if let Some(target_set) = &target_set {
println!("Using target set: {target_set}");
} else if let Some(source_set) = &state.source_set {
println!("Using source-pack set: {source_set}");
}
apply_rtx_logo_pipeline(
ctx,
CraftRequest {
source_path,
output_mod_root,
placement_config: args.placement_config,
logo_dir: args.logo_dir,
editable_root,
editable_extension: args.editable_extension,
target_set,
decode_command_template: args.decode_command_template,
encode_command_template: args.encode_command_template,
converter_path: args.converter_path,
disable_builtin_decode: args.disable_builtin_decode,
disable_builtin_encode: args.disable_builtin_encode,
zip_output,
dry_run: args.dry_run,
},
)
}
async fn run_import_command(ctx: &AppContext, args: ImportArgs) -> crate::error::Result<()> {
let state = build_repo_state(
ctx,
RepoStateOptions {
target_set: args.target_set,
editable_root: args.editable_root,
manifest: args.manifest,
..RepoStateOptions::default()
},
);
let editable_root = state.editable_root.clone();
let manifest_path = state
.manifest_path
.clone()
.unwrap_or_else(|| editable_root.join("required-editable-textures.json"));
let (samuel_export_root, model_export_root) = resolve_import_roots(
ctx,
args.samuel_export_root.as_deref(),
args.model_export_root.as_deref(),
args.mirror_url.as_deref(),
&args.mirror_cache_root,
args.dry_run,
)
.await?;
let source_set = args
.source_set
.as_deref()
.and_then(normalize_set_name)
.or_else(|| {
samuel_export_root
.as_deref()
.and_then(detect_export_source_set)
});
if let Some(source_set) = &source_set {
println!("Auto-detected SAMUEL source set: {source_set}");
}
import_samuel_exports(
samuel_export_root.as_deref(),
model_export_root.as_deref(),
&manifest_path,
source_set.as_deref(),
args.dry_run,
)
}
async fn run_mirror_command(ctx: &AppContext, args: MirrorArgs) -> crate::error::Result<()> {
match args.command {
MirrorCommand::Pack(args) => {
let (samuel_export_root, model_export_root) = resolve_import_roots(
ctx,
args.samuel_export_root.as_deref(),
args.model_export_root.as_deref(),
None,
Path::new("build/mirror-cache"),
args.dry_run,
)
.await?;
let manifest = build_export_mirror(
ctx.console(),
BuildMirrorRequest {
samuel_export_root,
model_export_root,
output_root: ctx.repo().repo_path(args.output_root),
max_chunk_bytes: args.chunk_size_mb * 1024 * 1024,
dry_run: args.dry_run,
},
)?;
println!(
"Packed {} file(s) into {} chunk(s).",
manifest.total_files,
manifest.chunks.len()
);
}
MirrorCommand::Fetch(args) => {
let (samuel_export_root, model_export_root) = hydrate_export_mirror(
ctx.console(),
HydrateMirrorRequest {
base_url: args.mirror_url,
cache_root: ctx.repo().repo_path(args.mirror_cache_root),
dry_run: args.dry_run,
},
)
.await?;
if let Some(root) = samuel_export_root {
println!("Hydrated exports root: {}", root.display());
}
if let Some(root) = model_export_root {
println!("Hydrated modelExports root: {}", root.display());
}
println!("Mirror manifest file name: {}", manifest_file_name());
}
}
Ok(())
}
async fn run_install_command(ctx: &AppContext, args: InstallArgs) -> crate::error::Result<()> {
let state = build_repo_state(
ctx,
RepoStateOptions {
zip_output: args.mod_zip.clone(),
..RepoStateOptions::default()
},
);
let game_root = resolve_game_root(args.game_root.as_deref(), ctx.repo(), ctx.console())?;
let mod_zip = if args.picker {
pick_mod_zip(ctx, args.mod_zip.as_deref(), &state.zip_output, &game_root)?
} else {
args.mod_zip.unwrap_or(state.zip_output)
};
let manifest_path = install_mod(
ctx.console(),
InstallRequest {
game_root,
mod_zip: ctx.repo().repo_path(mod_zip),
backup_root: ctx.repo().repo_path(args.backup_root),
install_tools: args.install_tools,
injector_zip: args.injector_zip.map(|path| ctx.repo().repo_path(path)),
tool_cache_root: ctx.repo().repo_path(args.tool_cache_root),
injector_item_id: args.injector_item_id,
skip_injector: args.skip_injector,
wait_for_injector: args.wait_for_injector,
dry_run: args.dry_run,
},
)
.await?;
ctx.console().log_success(format!(
"Backup manifest: {}",
ctx.console().format_path(manifest_path)
));
Ok(())
}
#[derive(Debug, Clone)]
struct ModPickerCandidate {
path: PathBuf,
name: String,
version: String,
enabled: bool,
}
fn pick_mod_zip(
ctx: &AppContext,
explicit_mod_zip: Option<&Path>,
default_zip: &Path,
game_root: &Path,
) -> crate::error::Result<PathBuf> {
let installed_mods = list_installed_mods(game_root)?;
println!("Currently enabled mods:");
if installed_mods.is_empty() {
println!(" none");
} else {
for record in &installed_mods {
println!(
" enabled {} v{} {}",
record.name, record.version, record.file_name
);
}
}
let candidates =
discover_mod_picker_candidates(ctx, explicit_mod_zip, default_zip, &installed_mods)?;
if candidates.is_empty() {
return Err(crate::error::DoomError::message(
"No installable mod zips found. Build a zip first or pass --mod-zip.",
));
}
println!();
println!("Installable mod zips:");
for (index, candidate) in candidates.iter().enumerate() {
let marker = if candidate.enabled {
"enabled now"
} else {
"available"
};
println!(
" {}. [{}] {} v{} {}",
index + 1,
marker,
candidate.name,
candidate.version,
ctx.console().format_path(&candidate.path)
);
}
print!("Select mod to install [1-{}]: ", candidates.len());
io::stdout().flush()?;
let mut input = String::new();
io::stdin().read_line(&mut input)?;
let selected = input.trim().parse::<usize>().map_err(|_| {
crate::error::DoomError::message(format!("Invalid picker selection: {}", input.trim()))
})?;
if selected == 0 || selected > candidates.len() {
return Err(crate::error::DoomError::message(format!(
"Picker selection out of range: {selected}"
)));
}
Ok(candidates[selected - 1].path.clone())
}
fn discover_mod_picker_candidates(
ctx: &AppContext,
explicit_mod_zip: Option<&Path>,
default_zip: &Path,
installed_mods: &[crate::installer::InstalledModRecord],
) -> crate::error::Result<Vec<ModPickerCandidate>> {
let mut paths = Vec::new();
if let Some(path) = explicit_mod_zip {
paths.push(ctx.repo().repo_path(path));
}
paths.push(ctx.repo().repo_path(default_zip));
let dist_root = ctx.repo().root().join("dist");
if dist_root.is_dir() {
for entry in std::fs::read_dir(dist_root)? {
let path = entry?.path();
if path
.extension()
.and_then(|value| value.to_str())
.is_some_and(|value| value.eq_ignore_ascii_case("zip"))
{
paths.push(path);
}
}
}
paths.sort();
paths.dedup();
let mut candidates = Vec::new();
for path in paths {
if !path.is_file() {
continue;
}
let file_name = path
.file_name()
.map(|value| value.to_string_lossy().to_string())
.unwrap_or_else(|| path.display().to_string());
let metadata = read_zip_mod_metadata_for_picker(&path).unwrap_or_else(|| {
(
file_name
.strip_suffix(".zip")
.unwrap_or(&file_name)
.replace(['_', '-'], " "),
"unknown".to_string(),
)
});
let enabled = installed_mods
.iter()
.any(|record| record.file_name.eq_ignore_ascii_case(&file_name));
candidates.push(ModPickerCandidate {
path,
name: metadata.0,
version: metadata.1,
enabled,
});
}
candidates.sort_by(|left, right| {
right
.enabled
.cmp(&left.enabled)
.then_with(|| left.name.cmp(&right.name))
.then_with(|| left.path.cmp(&right.path))
});
Ok(candidates)
}
fn read_zip_mod_metadata_for_picker(zip_path: &Path) -> Option<(String, String)> {
let file = std::fs::File::open(zip_path).ok()?;
let mut archive = zip::ZipArchive::new(file).ok()?;
let index = if archive.by_name("EternalMod.json").is_ok() {
None
} else {
let mut found = None;
for index in 0..archive.len() {
let entry = archive.by_index(index).ok()?;
if entry
.name()
.replace('\\', "/")
.ends_with("/EternalMod.json")
{
found = Some(index);
break;
}
}
Some(found?)
};
let mut entry = match index {
None => archive.by_name("EternalMod.json").ok()?,
Some(index) => archive.by_index(index).ok()?,
};
let mut payload = String::new();
std::io::Read::read_to_string(&mut entry, &mut payload).ok()?;
let value = serde_json::from_str::<serde_json::Value>(&payload).ok()?;
let name = value
.get("name")
.and_then(|value| value.as_str())
.map(str::trim)
.filter(|value| !value.is_empty())?
.to_string();
let version = value
.get("version")
.and_then(|value| value.as_str())
.map(str::trim)
.filter(|value| !value.is_empty())
.unwrap_or("unknown")
.to_string();
Some((name, version))
}
fn run_repair_command(ctx: &AppContext, args: RepairArgs) -> crate::error::Result<()> {
let game_root = resolve_game_root(args.game_root.as_deref(), ctx.repo(), ctx.console())?;
repair_game_installation(
ctx.console(),
RepairRequest {
game_root,
dry_run: args.dry_run,
},
)
}
fn run_clean_command(ctx: &AppContext, args: CleanArgs) -> crate::error::Result<()> {
let game_root = resolve_game_root(args.game_root.as_deref(), ctx.repo(), ctx.console())?;
clean_game_installation(
ctx.console(),
CleanRequest {
game_root,
reset_backups: args.reset_backups,
skip_injector: args.skip_injector,
dry_run: args.dry_run,
},
)
}
fn run_restore_command(ctx: &AppContext, args: RestoreArgs) -> crate::error::Result<()> {
let backup_root = ctx.repo().repo_path(args.backup_root);
let manifest_path =
resolve_restore_manifest(args.manifest.as_deref(), &backup_root, ctx.console())?;
let game_root_override = args
.game_root
.as_deref()
.map(|path| resolve_game_root(Some(path), ctx.repo(), ctx.console()))
.transpose()?;
restore_mod(
ctx.console(),
ctx.repo(),
RestoreRequest {
manifest_path: manifest_path.clone(),
game_root_override,
skip_injector: args.skip_injector,
wait_for_injector: args.wait_for_injector,
dry_run: args.dry_run,
},
)?;
ctx.console().log_success(format!(
"Restored from: {}",
ctx.console().format_path(manifest_path)
));
Ok(())
}
async fn run_tools_command(ctx: &AppContext, args: ToolsArgs) -> crate::error::Result<()> {
match args.command {
ToolCommand::Download(args) => {
let tool_cache_root = ctx.repo().repo_path(args.tool_cache_root);
match args.tool {
ToolName::Samuel => {
let exe_path =
download_samuel(ctx.console(), &tool_cache_root, args.dry_run).await?;
ctx.console().log_success(format!(
"SAMUEL executable: {}",
ctx.console().format_path(exe_path)
));
}
ToolName::Injector => {
let local_injector_zip = args
.injector_zip
.as_deref()
.map(|path| ctx.repo().repo_path(path));
let archive_path = download_injector(
ctx.console(),
&tool_cache_root,
local_injector_zip.as_deref(),
args.injector_item_id,
args.dry_run,
)
.await?;
ctx.console().log_success(format!(
"Injector archive: {}",
ctx.console().format_path(archive_path)
));
}
}
}
ToolCommand::Launch(args) => match args.tool {
ToolName::Samuel => {
let tool_cache_root = ctx.repo().repo_path(args.tool_cache_root);
let exe_path = args
.samuel_exe
.map(|path| ctx.repo().repo_path(path))
.or_else(|| discover_samuel_exe(&tool_cache_root))
.ok_or_else(|| {
crate::error::DoomError::message(
"Could not find a SAMUEL executable. Run `tools download samuel` first or pass --samuel-exe.",
)
})?;
launch_samuel(ctx.console(), &exe_path, args.dry_run)?;
}
ToolName::Injector => {
let game_root =
resolve_game_root(args.game_root.as_deref(), ctx.repo(), ctx.console())?;
launch_installed_injector(ctx.console(), &game_root, args.wait, args.dry_run)?;
}
},
}
Ok(())
}
async fn run_pipeline_command(ctx: &AppContext, args: RunArgs) -> crate::error::Result<()> {
let craft_args = CraftArgs {
source_path: args.source_path.clone(),
output_mod_root: args.output_mod_root.clone(),
placement_config: args.placement_config.clone(),
logo_dir: args.logo_dir.clone(),
editable_root: args.editable_root.clone(),
editable_extension: args.editable_extension.clone(),
target_set: args.target_set.clone(),
decode_command_template: args.decode_command_template.clone(),
encode_command_template: args.encode_command_template.clone(),
converter_path: args.converter_path.clone(),
disable_builtin_decode: args.disable_builtin_decode,
disable_builtin_encode: args.disable_builtin_encode,
zip_output: args.zip_output.clone(),
no_zip: args.no_zip,
dry_run: args.dry_run,
};
let (state, source_path, output_mod_root, editable_root, target_set, zip_output) =
resolve_craft_inputs(ctx, &craft_args);
let manifest_path = args
.manifest
.clone()
.map(|path| ctx.repo().repo_path(path))
.or_else(|| state.manifest_path.clone())
.unwrap_or_else(|| editable_root.join("required-editable-textures.json"));
if args.install && args.no_zip {
return Err(crate::error::DoomError::message(
"Cannot use --install with --no-zip. Keep zip output enabled or pass --mod-zip.",
));
}
let (samuel_export_root, model_export_root) = resolve_import_roots(
ctx,
args.samuel_export_root.as_deref(),
args.model_export_root.as_deref(),
args.mirror_url.as_deref(),
&args.mirror_cache_root,
args.dry_run,
)
.await?;
if samuel_export_root.is_some() || model_export_root.is_some() {
if !manifest_path.exists() && !args.dry_run {
println!(
"Generating required editable manifest at {}",
manifest_path.display()
);
if let Err(error) = run_craft_command(ctx, craft_args.clone()) {
if manifest_path.exists() {
println!("{error}");
} else {
return Err(error);
}
}
}
if !manifest_path.exists() && !args.dry_run {
return Err(crate::error::DoomError::message(format!(
"Missing editable manifest after preflight: {}",
manifest_path.display()
)));
}
if manifest_path.exists() {
let source_set = args
.source_set
.as_deref()
.and_then(normalize_set_name)
.or_else(|| {
samuel_export_root
.as_deref()
.and_then(detect_export_source_set)
});
if let Some(source_set) = &source_set {
println!("Auto-detected SAMUEL source set: {source_set}");
}
import_samuel_exports(
samuel_export_root.as_deref(),
model_export_root.as_deref(),
&manifest_path,
source_set.as_deref(),
args.dry_run,
)?;
}
}
apply_rtx_logo_pipeline(
ctx,
CraftRequest {
source_path,
output_mod_root,
placement_config: args.placement_config,
logo_dir: args.logo_dir,
editable_root,
editable_extension: args.editable_extension,
target_set,
decode_command_template: args.decode_command_template,
encode_command_template: args.encode_command_template,
converter_path: args.converter_path,
disable_builtin_decode: args.disable_builtin_decode,
disable_builtin_encode: args.disable_builtin_encode,
zip_output: zip_output.clone(),
dry_run: args.dry_run,
},
)?;
if args.install {
let resolved_mod_zip = args.mod_zip.or(zip_output).ok_or_else(|| {
crate::error::DoomError::message("Could not resolve a mod zip to install.")
})?;
run_install_command(
ctx,
InstallArgs {
mod_zip: Some(resolved_mod_zip),
picker: false,
game_root: args.game_root,
backup_root: args.backup_root,
install_tools: args.install_tools,
injector_zip: args.injector_zip,
tool_cache_root: args.tool_cache_root,
injector_item_id: args.injector_item_id,
skip_injector: args.skip_injector,
wait_for_injector: args.wait_for_injector,
dry_run: args.dry_run,
},
)
.await?;
}
Ok(())
}
async fn resolve_import_roots(
ctx: &AppContext,
samuel_export_root: Option<&Path>,
model_export_root: Option<&Path>,
mirror_url: Option<&str>,
mirror_cache_root: &Path,
dry_run: bool,
) -> crate::error::Result<(Option<PathBuf>, Option<PathBuf>)> {
if samuel_export_root.is_none() && model_export_root.is_none() {
if let Some(mirror_url) = mirror_url {
return hydrate_export_mirror(
ctx.console(),
HydrateMirrorRequest {
base_url: mirror_url.to_string(),
cache_root: ctx.repo().repo_path(mirror_cache_root),
dry_run,
},
)
.await;
}
}
let samuel_export_root = samuel_export_root
.map(|path| ctx.repo().repo_path(path))
.or_else(discover_samuel_export_root);
let model_export_root = model_export_root
.map(|path| ctx.repo().repo_path(path))
.or_else(discover_model_export_root);
Ok((samuel_export_root, model_export_root))
}
fn resolve_craft_inputs(
ctx: &AppContext,
args: &CraftArgs,
) -> (
crate::repo_state::RepoState,
PathBuf,
PathBuf,
PathBuf,
Option<String>,
Option<PathBuf>,
) {
let state = build_repo_state(
ctx,
RepoStateOptions {
source_path: args.source_path.clone(),
target_set: args.target_set.clone(),
editable_root: args.editable_root.clone(),
output_mod_root: args.output_mod_root.clone(),
zip_output: args.zip_output.clone(),
converter_path: args.converter_path.clone(),
..RepoStateOptions::default()
},
);
let source_path = state
.source_pack
.clone()
.unwrap_or_else(|| ctx.repo().root().join("rtx-slayer"));
let output_mod_root = args
.output_mod_root
.clone()
.unwrap_or_else(|| state.output_mod_root.clone());
let editable_root = args
.editable_root
.clone()
.unwrap_or_else(|| state.editable_root.clone());
let target_set = args
.target_set
.as_deref()
.and_then(normalize_set_name)
.or_else(|| state.target_set.clone());
let zip_output = if args.no_zip {
None
} else {
Some(
args.zip_output
.clone()
.unwrap_or_else(|| state.zip_output.clone()),
)
};
(
state,
ctx.repo().repo_path(source_path),
ctx.repo().repo_path(output_mod_root),
ctx.repo().repo_path(editable_root),
target_set,
zip_output.map(|path| ctx.repo().repo_path(path)),
)
}
fn powershell_string(value: impl AsRef<Path>) -> String {
let escaped = value
.as_ref()
.display()
.to_string()
.replace('`', "``")
.replace('"', "`\"");
format!("\"{escaped}\"")
}
fn powershell_literal(value: &str) -> String {
let escaped = value.replace('`', "``").replace('"', "`\"");
format!("\"{escaped}\"")
}
#[cfg(test)]
mod tests {
use super::{
Commands, CraftArgs, StatusArgs, DEFAULT_EDITABLE_EXTENSION, DEFAULT_LOGO_DIR,
DEFAULT_PLACEMENT_CONFIG, DEFAULT_TARGET_SET,
};
use std::path::PathBuf;
#[test]
fn startup_banner_stays_off_for_status_json() {
let command = Commands::Status(StatusArgs {
source_path: None,
target_set: Some(DEFAULT_TARGET_SET.to_string()),
editable_root: None,
manifest: None,
output_mod_root: None,
zip_output: None,
converter_path: None,
json: true,
});
assert!(!command.shows_startup_banner());
}
#[test]
fn startup_banner_stays_on_for_interactive_commands() {
let command = Commands::Craft(CraftArgs {
source_path: Some(PathBuf::from("rtx-slayer")),
output_mod_root: None,
placement_config: PathBuf::from(DEFAULT_PLACEMENT_CONFIG),
logo_dir: PathBuf::from(DEFAULT_LOGO_DIR),
editable_root: None,
editable_extension: DEFAULT_EDITABLE_EXTENSION.to_string(),
target_set: Some(DEFAULT_TARGET_SET.to_string()),
decode_command_template: String::new(),
encode_command_template: String::new(),
converter_path: None,
disable_builtin_decode: false,
disable_builtin_encode: false,
zip_output: None,
no_zip: false,
dry_run: false,
});
assert!(command.shows_startup_banner());
}
}