use std::path::{Path, PathBuf};
use clap::{Args, Parser, Subcommand};
use crate::{
context::AppContext,
craft::{CraftRequest, apply_rtx_logo_pipeline},
installer::{InstallRequest, RestoreRequest, default_injector_item_id, install_mod, resolve_game_root, resolve_restore_manifest, restore_mod},
logos::prepare_logo_assets,
manual::{apply_logos_to_armor, build_workpack},
paths::home_dir,
repo_state::{RepoStateOptions, build_repo_state, detect_export_source_set},
samuel::import_samuel_exports,
sets::normalize_set_name,
};
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_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 {
#[command(subcommand)]
command: Commands,
}
#[derive(Debug, Subcommand)]
enum Commands {
Status(StatusArgs),
Examples(ExamplesArgs),
Craft(CraftArgs),
Import(ImportArgs),
Install(InstallArgs),
Restore(RestoreArgs),
PrepareLogos(PrepareLogosArgs),
ApplyLogos(ApplyLogosArgs),
BuildWorkpack(BuildWorkpackArgs),
Run(RunArgs),
}
#[derive(Debug, Args)]
struct StatusArgs {
#[arg(long)]
source_path: Option<PathBuf>,
#[arg(long)]
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)]
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)]
samuel_exe: Option<PathBuf>,
#[arg(long)]
injector_zip: 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)]
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 ImportArgs {
#[arg(long)]
samuel_export_root: PathBuf,
#[arg(long)]
manifest: Option<PathBuf>,
#[arg(long)]
editable_root: Option<PathBuf>,
#[arg(long)]
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)]
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 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 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, 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)]
target_set: Option<String>,
#[arg(long)]
manifest: Option<PathBuf>,
#[arg(long)]
samuel_export_root: Option<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 = AppContext::discover();
let result = match cli.command {
Commands::Status(args) => run_status_command(&ctx, args),
Commands::Examples(args) => run_examples_command(&ctx, args),
Commands::Craft(args) => run_craft_command(&ctx, args),
Commands::Import(args) => run_import_command(&ctx, args),
Commands::Install(args) => run_install_command(&ctx, args).await,
Commands::Restore(args) => run_restore_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::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))
};
println!(
"{}\n{}\n{}\n{}\n{}\n{}\n{}\n{}\n{}",
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())
)
);
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 cli_path = repo_root.join("scripts").join("xylex-doom.ps1");
let source_pack = state.source_pack.unwrap_or_else(|| repo_root.join("rtx-slayer"));
let target_set = state.target_set.unwrap_or_else(|| "set16".to_string());
let editable_root = state.editable_root;
let output_mod_root = state.output_mod_root;
let zip_output = state.zip_output;
let converter_path = state.converter_path;
let game_root = args
.game_root
.or(state.game_root)
.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 samuel_export_root = args.samuel_export_root.unwrap_or_else(|| downloads.join("exports"));
let samuel_exe = args.samuel_exe.unwrap_or_else(|| downloads.join("SAMUEL-v2.1.2.exe"));
let injector_zip = args
.injector_zip
.unwrap_or_else(|| downloads.join("EternalModInjector.zip"));
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!("$Cli = {}", powershell_string(&cli_path)),
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!("$TargetSet = {}", powershell_literal(&target_set)),
format!("$ConverterPath = {}", powershell_string(&converter_path)),
format!("$SamuelExportRoot = {}", powershell_string(&samuel_export_root)),
format!("$SamuelExe = {}", powershell_string(&samuel_exe)),
format!("$GameRoot = {}", powershell_string(&game_root)),
format!("$InjectorZip = {}", powershell_string(&injector_zip)),
format!("$RestoreManifest = {}", powershell_string(&restore_manifest)),
String::new(),
"& $Cli status".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 SAMUEL exports into the repo's editable root.".to_string(),
"& $Cli import `".to_string(),
" --samuel-export-root \"$SamuelExportRoot\"".to_string(),
String::new(),
"# Craft the Xylex pack with explicit paths.".to_string(),
"& $Cli 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(),
"# One command: import -> craft -> install into DOOM Eternal.".to_string(),
"& $Cli run `".to_string(),
" --samuel-export-root \"$SamuelExportRoot\" `".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(),
"& $Cli 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(),
"& $Cli install `".to_string(),
" --mod-zip \"$ZipOutput\" `".to_string(),
" --game-root \"$GameRoot\" `".to_string(),
" --install-tools `".to_string(),
" --injector-zip \"$InjectorZip\"".to_string(),
String::new(),
"# Restore the most recent backed-up payload.".to_string(),
"& $Cli restore `".to_string(),
" --manifest \"$RestoreManifest\"".to_string(),
]);
println!("{}", lines.join("\n"));
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,
},
)
}
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 = ctx.repo().repo_path(args.samuel_export_root);
let source_set = args
.source_set
.as_deref()
.and_then(normalize_set_name)
.or_else(|| detect_export_source_set(&samuel_export_root));
if let Some(source_set) = &source_set {
println!("Auto-detected SAMUEL source set: {source_set}");
}
import_samuel_exports(
&samuel_export_root,
&manifest_path,
source_set.as_deref(),
args.dry_run,
)
}
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 mod_zip = args.mod_zip.unwrap_or(state.zip_output);
let game_root = resolve_game_root(args.game_root.as_deref(), ctx.console())?;
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(())
}
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.console()))
.transpose()?;
restore_mod(
ctx.console(),
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_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.",
));
}
if let Some(samuel_export_root) = args.samuel_export_root.as_deref() {
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(|| detect_export_source_set(&ctx.repo().repo_path(samuel_export_root)));
if let Some(source_set) = &source_set {
println!("Auto-detected SAMUEL source set: {source_set}");
}
import_samuel_exports(
&ctx.repo().repo_path(samuel_export_root),
&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),
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(())
}
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}\"")
}