use std::collections::HashSet;
use std::path::{Path, PathBuf};
use std::process::Command;
use anyhow::{Context, Result};
use tracing::{info, warn};
use modde_core::db::ModdeDb;
use modde_core::fs::walk_files_relative;
use modde_core::paths;
use modde_core::profile::ProfileManager;
use super::load_profile_or_default;
pub async fn handle_run(
executable: PathBuf,
args: Vec<String>,
profile_name: Option<String>,
game_id: Option<String>,
) -> Result<()> {
let pm = ProfileManager::open().context("failed to open profile database")?;
let profile = load_profile_or_default(&pm, profile_name.as_deref(), game_id.as_deref())?;
let game_plugin = modde_games::resolve_game_plugin(&profile.game_id)
.ok_or_else(|| anyhow::anyhow!("unsupported game: '{}'", profile.game_id))?;
let install_dir = game_plugin.detect_install().ok_or_else(|| {
anyhow::anyhow!(
"could not detect install directory for {}",
game_plugin.display_name()
)
})?;
let mod_dir = game_plugin.mod_directory(&install_dir);
info!(mod_dir = %mod_dir.display(), "snapshotting mod directory before tool run");
let before = snapshot_dir(&mod_dir)?;
println!("Running: {} {}", executable.display(), args.join(" "));
let status = Command::new(&executable)
.args(&args)
.current_dir(&install_dir)
.status()
.with_context(|| format!("failed to execute: {}", executable.display()))?;
if !status.success() {
warn!(
exit_code = status.code(),
"tool exited with non-zero status"
);
}
let after = snapshot_dir(&mod_dir)?;
let new_files: Vec<String> = after
.difference(&before)
.cloned()
.collect();
if new_files.is_empty() {
println!("Tool completed. No new files written to mod directory.");
return Ok(());
}
let overwrite_dir = paths::store_dir().join("__overwrite__");
std::fs::create_dir_all(&overwrite_dir)?;
println!(
"\nTool wrote {} new file(s). Moving to Overwrite mod:",
new_files.len()
);
for rel_path in &new_files {
let src = mod_dir.join(rel_path);
let dst = overwrite_dir.join(rel_path);
if let Some(parent) = dst.parent() {
std::fs::create_dir_all(parent)?;
}
std::fs::rename(&src, &dst)
.or_else(|_| {
std::fs::copy(&src, &dst)?;
std::fs::remove_file(&src)
})
.with_context(|| format!("failed to move {} to overwrite", rel_path))?;
println!(" {rel_path}");
}
println!(
"\nOverwrite mod: {}",
overwrite_dir.display()
);
println!("Add '__overwrite__' to your profile mod list to include these files in future deploys.");
Ok(())
}
pub fn handle_list(game_id: &str) -> Result<()> {
let game_plugin = modde_games::resolve_game_plugin(game_id)
.ok_or_else(|| anyhow::anyhow!("unsupported game: '{game_id}'"))?;
let install_dir = game_plugin.detect_install().ok_or_else(|| {
anyhow::anyhow!("could not detect install directory for {}", game_plugin.display_name())
})?;
println!("Game: {} ({})", game_plugin.display_name(), game_id);
println!("Install: {}", install_dir.display());
println!("\nDetected tools:");
let common_tools = [
("xEdit", &["SSEEdit.exe", "FO4Edit.exe", "xEdit.exe"][..]),
("FNIS", &["GenerateFNISforUsers.exe"]),
("Nemesis", &["Nemesis Unlimited Behavior Engine.exe"]),
("BodySlide", &["BodySlide.exe", "BodySlide x64.exe"]),
("Creation Kit", &["CreationKit.exe"]),
("LOOT", &["LOOT.exe"]),
("zEdit", &["zEdit.exe"]),
];
let mut found = false;
for (name, executables) in &common_tools {
for exe in *executables {
let path = install_dir.join(exe);
if path.exists() {
println!(" {name}: {}", path.display());
found = true;
}
}
}
if !found {
println!(" (none detected)");
}
println!("\nRun tools with: modde tool run <executable> [-- args...]");
Ok(())
}
pub fn handle_status(game_id: &str) -> Result<()> {
let db = ModdeDb::open().context("failed to open database")?;
let stored = db.load_tool_configs(game_id)?;
println!("Game: {game_id}\n");
println!("{:<14} {:<14} {:<10} {}", "Tool", "Category", "Status", "Available");
println!("{}", "-".repeat(60));
for tool in modde_games::tools::all_tools() {
let avail = tool.detect_available();
let avail_str = match &avail {
modde_games::tools::ToolAvailability::Available { version } => {
match version {
Some(v) => format!("yes ({v})"),
None => "yes".into(),
}
}
modde_games::tools::ToolAvailability::NotInstalled { .. } => "not installed".into(),
};
let enabled = stored
.iter()
.find(|r| r.tool_id == tool.tool_id())
.map_or(false, |r| r.enabled);
let status = if enabled { "enabled" } else { "disabled" };
let applied_count = db
.load_applied_files(game_id, tool.tool_id())
.map(|f| f.len())
.unwrap_or(0);
let status_str = if applied_count > 0 {
format!("{status} ({applied_count} files)")
} else {
status.to_string()
};
println!(
"{:<14} {:<14} {:<10} {}",
tool.display_name(),
tool.category(),
status_str,
avail_str,
);
}
Ok(())
}
pub fn handle_enable(tool_id: &str, game_id: &str) -> Result<()> {
let tool = modde_games::tools::resolve_tool(tool_id)
.ok_or_else(|| anyhow::anyhow!(
"unknown tool: '{tool_id}'\nAvailable: {}",
modde_games::tools::all_tools()
.iter()
.map(|t| t.tool_id())
.collect::<Vec<_>>()
.join(", ")
))?;
let db = ModdeDb::open().context("failed to open database")?;
let mut config = match db.load_tool_config(game_id, tool_id)? {
Some(row) => modde_games::tools::ToolConfig {
tool_id: row.tool_id,
enabled: true,
settings: serde_json::from_str(&row.settings_json).unwrap_or_default(),
},
None => {
let mut cfg = tool.default_config();
cfg.enabled = true;
cfg
}
};
config.enabled = true;
let settings_json = serde_json::to_string(&config.settings)?;
db.save_tool_config(game_id, tool_id, true, &settings_json)?;
println!("Enabled {} for {game_id}", tool.display_name());
config.set("_game_id", serde_json::json!(game_id));
if let Some(generated) = tool.generate_config(&config) {
if let Some(parent) = generated.path.parent() {
std::fs::create_dir_all(parent)?;
}
std::fs::write(&generated.path, &generated.content)?;
println!(" Config: {}", generated.path.display());
}
Ok(())
}
pub fn handle_disable(tool_id: &str, game_id: &str) -> Result<()> {
let tool = modde_games::tools::resolve_tool(tool_id)
.ok_or_else(|| anyhow::anyhow!("unknown tool: '{tool_id}'"))?;
let db = ModdeDb::open().context("failed to open database")?;
let settings_json = db
.load_tool_config(game_id, tool_id)?
.map(|r| r.settings_json)
.unwrap_or_else(|| "{}".into());
db.save_tool_config(game_id, tool_id, false, &settings_json)?;
println!("Disabled {} for {game_id}", tool.display_name());
Ok(())
}
pub fn handle_configure(tool_id: &str, game_id: &str, settings: &[String]) -> Result<()> {
let tool = modde_games::tools::resolve_tool(tool_id)
.ok_or_else(|| anyhow::anyhow!("unknown tool: '{tool_id}'"))?;
let db = ModdeDb::open().context("failed to open database")?;
let mut config = match db.load_tool_config(game_id, tool_id)? {
Some(row) => modde_games::tools::ToolConfig {
tool_id: row.tool_id,
enabled: row.enabled,
settings: serde_json::from_str(&row.settings_json).unwrap_or_default(),
},
None => tool.default_config(),
};
for setting in settings {
let (key, value) = setting
.split_once('=')
.ok_or_else(|| anyhow::anyhow!("invalid setting format: '{setting}' (expected key=value)"))?;
let json_value = if value == "true" {
serde_json::json!(true)
} else if value == "false" {
serde_json::json!(false)
} else if let Ok(n) = value.parse::<f64>() {
serde_json::json!(n)
} else {
serde_json::json!(value)
};
config.set(key, json_value);
println!(" {key} = {value}");
}
let settings_json = serde_json::to_string(&config.settings)?;
db.save_tool_config(game_id, tool_id, config.enabled, &settings_json)?;
config.set("_game_id", serde_json::json!(game_id));
if let Some(generated) = tool.generate_config(&config) {
if let Some(parent) = generated.path.parent() {
std::fs::create_dir_all(parent)?;
}
std::fs::write(&generated.path, &generated.content)?;
println!(" Config written: {}", generated.path.display());
}
println!("Updated {} config for {game_id}", tool.display_name());
Ok(())
}
pub fn handle_apply(tool_id: &str, game_id: &str) -> Result<()> {
let tool = modde_games::tools::resolve_tool(tool_id)
.ok_or_else(|| anyhow::anyhow!("unknown tool: '{tool_id}'"))?;
let game_plugin = modde_games::resolve_game_plugin(game_id)
.ok_or_else(|| anyhow::anyhow!("unsupported game: '{game_id}'"))?;
let install_dir = game_plugin
.detect_install()
.ok_or_else(|| anyhow::anyhow!("could not detect install dir for {}", game_plugin.display_name()))?;
let db = ModdeDb::open().context("failed to open database")?;
let config = match db.load_tool_config(game_id, tool_id)? {
Some(row) => modde_games::tools::ToolConfig {
tool_id: row.tool_id,
enabled: row.enabled,
settings: serde_json::from_str(&row.settings_json).unwrap_or_default(),
},
None => tool.default_config(),
};
let applied = tool.apply(&install_dir, &config)?;
if applied.files.is_empty() {
println!("No files to apply for {}", tool.display_name());
return Ok(());
}
let rel_paths: Vec<String> = applied
.files
.iter()
.map(|p| p.to_string_lossy().to_string())
.collect();
db.save_applied_files(game_id, tool_id, &rel_paths)?;
println!(
"Applied {} ({} files) to {}",
tool.display_name(),
applied.files.len(),
install_dir.display(),
);
for f in &applied.files {
println!(" {}", f.display());
}
Ok(())
}
pub fn handle_revert(tool_id: &str, game_id: &str) -> Result<()> {
let tool = modde_games::tools::resolve_tool(tool_id)
.ok_or_else(|| anyhow::anyhow!("unknown tool: '{tool_id}'"))?;
let game_plugin = modde_games::resolve_game_plugin(game_id)
.ok_or_else(|| anyhow::anyhow!("unsupported game: '{game_id}'"))?;
let install_dir = game_plugin
.detect_install()
.ok_or_else(|| anyhow::anyhow!("could not detect install dir for {}", game_plugin.display_name()))?;
let db = ModdeDb::open().context("failed to open database")?;
let files = db.load_applied_files(game_id, tool_id)?;
if files.is_empty() {
println!("No applied files to revert for {}", tool.display_name());
return Ok(());
}
let applied = modde_games::tools::AppliedFiles {
files: files.iter().map(PathBuf::from).collect(),
};
tool.revert(&install_dir, &applied)?;
db.clear_applied_files(game_id, tool_id)?;
println!(
"Reverted {} ({} files) from {}",
tool.display_name(),
files.len(),
install_dir.display(),
);
Ok(())
}
fn snapshot_dir(dir: &Path) -> Result<HashSet<String>> {
if !dir.exists() {
return Ok(HashSet::new());
}
let files = walk_files_relative(dir)
.with_context(|| format!("failed to walk directory: {}", dir.display()))?;
Ok(files.into_iter().map(|(rel, _)| rel).collect())
}