1use std::collections::{BTreeSet, HashMap, HashSet};
2use std::path::{Path, PathBuf};
3
4use modde_core::profile::ProfileManager;
5use modde_core::resolver::GameId;
6
7use super::state::ToolLoadRequest;
8use super::tool_settings::{
9 apply_derived_tool_settings, build_tool_derived_facts, current_tool_config,
10 format_tool_availability, normalize_tool_settings_for_specs, patch_tool_setting_options,
11 save_tool_settings, set_tool_options, sync_optiscaler_release_options, tool_apply_is_pending,
12 tool_apply_signature, tool_options,
13};
14use super::{
15 ExecutableDraft, ExecutableUiEntry, ToolApplyResult, ToolHistoryUiEntry, ToolLoadSnapshot,
16 ToolOptionCatalog, ToolReleaseSupport, ToolRevertResult, ToolUiEntry,
17};
18
19pub(super) async fn load_tool_releases(
20 tool_id: String,
21) -> Result<Vec<modde_games::tools::ToolReleaseSummary>, String> {
22 let tool = modde_games::tools::resolve_tool(&tool_id)
23 .ok_or_else(|| format!("Tool is not registered: {tool_id}"))?;
24 if !tool.supports_releases() {
25 return Err(format!(
26 "{} does not support release selection",
27 tool.display_name()
28 ));
29 }
30 tool.list_releases().await.map_err(|err| err.to_string())
31}
32
33pub(super) async fn load_proton_versions() -> Result<Vec<String>, String> {
34 modde_games::tools::proton::list_ge_proton_versions()
35 .await
36 .map_err(|err| err.to_string())
37}
38
39pub(super) async fn install_selected_tool_release(
40 game_id: String,
41 tool_id: String,
42) -> Result<String, String> {
43 let tool = modde_games::tools::resolve_tool(&tool_id)
44 .ok_or_else(|| format!("Tool is not registered: {tool_id}"))?;
45 let config = current_tool_config(&game_id, &tool_id)?;
46 let selected_tag = config
47 .get_str("release_tag")
48 .unwrap_or("latest")
49 .to_string();
50 let selected_asset = config.get_str("release_asset").unwrap_or("").to_string();
51 if selected_asset.trim().is_empty() {
52 return Err(format!(
53 "Select a {} release asset before installing",
54 tool.display_name()
55 ));
56 }
57 let config = tool
58 .install_release(&game_id, config, &selected_tag, &selected_asset)
59 .await
60 .map_err(|err| err.to_string())?;
61 save_tool_settings(&game_id, &tool_id, &config)?;
62 Ok(format!(
63 "Installed {} {}",
64 tool.display_name(),
65 config.get_str("release_tag").unwrap_or("release")
66 ))
67}
68
69pub(super) async fn install_selected_proton_version(game_id: String) -> Result<String, String> {
70 let db = modde_core::db::ModdeDb::open().map_err(|err| err.to_string())?;
71 let tool = modde_games::tools::resolve_tool("proton")
72 .ok_or_else(|| "Proton tool is not registered".to_string())?;
73 let row = db
74 .load_tool_config(&GameId::from(game_id.as_str()), "proton")
75 .map_err(|err| err.to_string())?;
76 let config = row.map_or_else(
77 || tool.default_config(),
78 |row| modde_games::tools::ToolConfig {
79 tool_id: row.tool_id,
80 enabled: row.enabled,
81 settings: serde_json::from_str(&row.settings_json).unwrap_or_default(),
82 },
83 );
84 let version = config.get_str("selected_version").unwrap_or("latest");
85 let target = config.get_str("install_target").unwrap_or("steam");
86 modde_games::tools::proton::install_ge_proton_with_protonup_rs(version, target)
87 .map_err(|err| err.to_string())?;
88 Ok(format!("Installed GEProton {version} for {target}"))
89}
90
91pub(super) async fn load_tools_state(request: ToolLoadRequest) -> Result<ToolLoadSnapshot, String> {
92 tokio::task::spawn_blocking(move || load_tools_state_blocking(request))
93 .await
94 .map_err(|err| err.to_string())?
95}
96
97pub(super) async fn load_executables_for_game(
98 game_id: String,
99) -> Result<Vec<ExecutableUiEntry>, String> {
100 tokio::task::spawn_blocking(move || {
101 let db = modde_core::db::ModdeDb::open().map_err(|err| err.to_string())?;
102 db.load_executable_configs(&GameId::from(game_id.as_str()))
103 .map_err(|err| err.to_string())
104 .map(|rows| rows.into_iter().map(ExecutableUiEntry::from_row).collect())
105 })
106 .await
107 .map_err(|err| err.to_string())?
108}
109
110pub(super) fn load_tools_state_blocking(
111 request: ToolLoadRequest,
112) -> Result<ToolLoadSnapshot, String> {
113 let db = modde_core::db::ModdeDb::open().map_err(|err| err.to_string())?;
114 let detected =
115 modde_games::detection::find_detected_game(&GameId::from(request.game_id.as_str()));
116 let game_dir = request.configured_game_dir.clone().or_else(|| {
117 detected
118 .as_ref()
119 .map(|detected| detected.install_path.clone())
120 .or_else(|| {
121 modde_games::resolve_game_plugin(&request.game_id)
122 .and_then(modde_games::GamePlugin::detect_install)
123 })
124 });
125 let context = Some(modde_games::tools::ToolGameContext::from_parts(
126 &request.game_id,
127 request.display_name.clone(),
128 game_dir.clone(),
129 detected.as_ref(),
130 ));
131 let game_dir_configured = game_dir.is_some();
132 let mut option_catalog = request.tool_option_catalog.clone();
133 if tool_options(&option_catalog, "proton", "selected_version").is_none() {
134 set_tool_options(
135 &mut option_catalog,
136 "proton",
137 "selected_version",
138 modde_games::tools::proton::proton_version_options(),
139 );
140 }
141 if !request.optiscaler_releases.is_empty()
142 && let Ok(mut config) = current_tool_config(&request.game_id, "optiscaler")
143 {
144 sync_optiscaler_release_options(
145 &mut option_catalog,
146 &request.optiscaler_releases,
147 &mut config,
148 );
149 }
150
151 let entries = modde_games::tools::all_tools()
152 .iter()
153 .map(|tool| {
154 build_tool_ui_entry(
155 &db,
156 &request.game_id,
157 game_dir.as_deref(),
158 context.as_ref(),
159 *tool,
160 &option_catalog,
161 )
162 })
163 .collect::<Vec<_>>();
164 let active_tool_id = request
165 .previous_active_tool_id
166 .filter(|active| entries.iter().any(|entry| entry.tool_id == *active))
167 .or_else(|| entries.first().map(|entry| entry.tool_id.clone()));
168 let executables = db
169 .load_executable_configs(&GameId::from(request.game_id.as_str()))
170 .map_err(|err| err.to_string())?
171 .into_iter()
172 .map(ExecutableUiEntry::from_row)
173 .collect();
174
175 Ok(ToolLoadSnapshot {
176 entries,
177 active_tool_id,
178 game_label: Some(request.display_name),
179 game_dir_configured,
180 tool_option_catalog: option_catalog,
181 executables,
182 })
183}
184
185#[allow(clippy::too_many_arguments)]
186pub(super) fn build_tool_ui_entry(
187 db: &modde_core::db::ModdeDb,
188 game_id: &str,
189 game_dir: Option<&std::path::Path>,
190 context: Option<&modde_games::tools::ToolGameContext>,
191 tool: &'static dyn modde_games::tools::GameTool,
192 option_catalog: &ToolOptionCatalog,
193) -> ToolUiEntry {
194 let typed_game_id = GameId::from(game_id);
195 let row = db
196 .load_tool_config(&typed_game_id, tool.tool_id())
197 .ok()
198 .flatten();
199 let availability = tool.detect_available();
200 let applied_files = db
201 .load_applied_files(&typed_game_id, tool.tool_id())
202 .unwrap_or_default();
203 let status_message = match &availability {
204 modde_games::tools::ToolAvailability::Available {
205 version: Some(version),
206 } => Some(format!("Detected {version}")),
207 modde_games::tools::ToolAvailability::NotInstalled { install_hint } => {
208 Some(install_hint.clone())
209 }
210 modde_games::tools::ToolAvailability::Available { version: None } => None,
211 };
212 let availability_text = format_tool_availability(&availability);
213 let mut config = row.as_ref().map_or_else(
214 || tool.default_config_for(context),
215 |row| modde_games::tools::ToolConfig {
216 tool_id: row.tool_id.clone(),
217 enabled: row.enabled,
218 settings: serde_json::from_str(&row.settings_json).unwrap_or_default(),
219 },
220 );
221 let release_config_normalized = tool.tool_id() == "optiscaler"
222 && modde_games::tools::optiscaler::normalize_optiscaler_release_config(&mut config);
223 let mut setting_specs = tool.settings_schema_for(context, &config);
224 let normalized_settings = normalize_tool_settings_for_specs(&config.settings, &setting_specs);
225 if release_config_normalized || normalized_settings != config.settings {
226 config.settings = normalized_settings;
227 if let Ok(settings_json) = serde_json::to_string(&config.settings) {
228 let _ = db.save_tool_config(
229 &typed_game_id,
230 tool.tool_id(),
231 config.enabled,
232 &settings_json,
233 );
234 }
235 setting_specs = tool.settings_schema_for(context, &config);
236 }
237 config.set("_game_id", serde_json::json!(game_id));
238 apply_derived_tool_settings(&mut config, context);
239 let mut apply_pending = tool_apply_is_pending(&config, &applied_files);
240 let mut apply_missing_inputs = Vec::new();
241 let generated_config_path = tool
242 .generate_config_for(context, &config)
243 .map(|generated| generated.path.display().to_string());
244 let env_preview = tool.env_vars_for(context, &config).into_iter().collect();
245 let dll_overrides = tool
246 .wine_dll_overrides_for(context, &config)
247 .into_iter()
248 .collect();
249 let wrapper_preview = tool
250 .wrapper_command(&config)
251 .map(|wrapper| {
252 if wrapper.args.is_empty() {
253 vec![wrapper.exe]
254 } else {
255 vec![format!("{} {}", wrapper.exe, wrapper.args)]
256 }
257 })
258 .unwrap_or_default();
259 patch_tool_setting_options(tool.tool_id(), &mut setting_specs, option_catalog);
260 let mut derived_facts = build_tool_derived_facts(context);
261 if matches!(tool.tool_id(), "reshade" | "optiscaler")
262 && let Some(game_dir) = game_dir
263 {
264 match tool.preview_apply_for(game_dir, context, &config) {
265 Ok(preview) => {
266 let has_changes = preview.has_changes();
267 apply_missing_inputs = preview.missing_inputs.clone();
268 apply_pending = apply_missing_inputs.is_empty() && has_changes;
269 let summary = if !apply_missing_inputs.is_empty() {
270 format!("missing input: {}", apply_missing_inputs.join("; "))
271 } else if has_changes {
272 format!(
273 "{} changed / {} unchanged",
274 preview.changed_files.len(),
275 preview.unchanged_files.len()
276 )
277 } else {
278 format!("no changes ({} file(s))", preview.planned_files.len())
279 };
280 derived_facts.push(("Apply preview".to_string(), summary));
281 }
282 Err(err) => {
283 derived_facts.push(("Apply preview".to_string(), format!("failed: {err}")));
284 }
285 }
286 }
287 let (optiscaler_state, optiscaler_latest_backup, optiscaler_detected_files) =
288 if tool.tool_id() == "optiscaler" {
289 let managed = modde_games::tools::optiscaler::managed_paths_from_config(&config);
290 if let Some(game_dir) = game_dir {
291 if let Ok(state) = modde_games::tools::optiscaler::scan_optiscaler_install(
292 game_id, game_dir, &managed,
293 ) {
294 if !matches!(
295 state.status,
296 modde_games::tools::optiscaler::OptiScalerInstallStatus::Managed
297 | modde_games::tools::optiscaler::OptiScalerInstallStatus::PartiallyManaged
298 ) {
299 apply_pending = true;
300 }
301 derived_facts.push(("OptiScaler state".to_string(), state.summary()));
302 if let Some(path) = &state.config_path {
303 derived_facts.push((
304 "OptiScaler config".to_string(),
305 format!(
306 "{} ({} setting(s))",
307 path.display(),
308 state.ini_settings.len()
309 ),
310 ));
311 }
312 if let Some(path) = &state.latest_backup {
313 derived_facts
314 .push(("OptiScaler backup".to_string(), path.display().to_string()));
315 }
316 (
317 Some(state.summary()),
318 state.latest_backup.map(|path| path.display().to_string()),
319 state.recognized_files.len(),
320 )
321 } else {
322 (None, None, 0)
323 }
324 } else {
325 (None, None, 0)
326 }
327 } else {
328 (None, None, 0)
329 };
330 let setting_history = db
331 .list_tool_setting_history(&typed_game_id, tool.tool_id(), 8)
332 .unwrap_or_default()
333 .into_iter()
334 .map(ToolHistoryUiEntry::from_node)
335 .collect();
336
337 ToolUiEntry {
338 tool_id: tool.tool_id().to_string(),
339 display_name: tool.display_name().to_string(),
340 description: tool.description().to_string(),
341 category: tool.category().to_string(),
342 available: availability.is_available(),
343 availability_text,
344 enabled: config.enabled,
345 settings: config.settings.clone(),
346 setting_specs,
347 generated_config_path,
348 applied_files,
349 has_file_patching: matches!(tool.tool_id(), "reshade" | "optiscaler"),
350 release_support: ToolReleaseSupport::from_supports_releases(tool.supports_releases()),
351 status_message,
352 env_preview,
353 dll_overrides,
354 wrapper_preview,
355 derived_facts,
356 optiscaler_state,
357 optiscaler_latest_backup,
358 optiscaler_detected_files,
359 apply_pending,
360 apply_missing_inputs,
361 setting_history,
362 }
363}
364
365pub(super) async fn apply_tool_for_game(
366 game_id: String,
367 game_dir: PathBuf,
368 tool_id: String,
369 context: Option<modde_games::tools::ToolGameContext>,
370) -> Result<ToolApplyResult, String> {
371 let db = modde_core::db::ModdeDb::open().map_err(|err| err.to_string())?;
372 let typed_game_id = GameId::from(game_id.as_str());
373 let tool = modde_games::tools::resolve_tool(&tool_id)
374 .ok_or_else(|| format!("Unknown tool: {tool_id}"))?;
375 let row = db
376 .load_tool_config(&typed_game_id, &tool_id)
377 .map_err(|err| err.to_string())?;
378 let mut config = row.map_or_else(
379 || tool.default_config_for(context.as_ref()),
380 |row| modde_games::tools::ToolConfig {
381 tool_id: row.tool_id,
382 enabled: row.enabled,
383 settings: serde_json::from_str(&row.settings_json).unwrap_or_default(),
384 },
385 );
386 config.enabled = true;
387 config.set("_game_id", serde_json::json!(game_id));
388 apply_derived_tool_settings(&mut config, context.as_ref());
389 if tool_id == "optiscaler" {
390 modde_games::tools::optiscaler::apply_game_defaults(&mut config, context.as_ref());
391 }
392
393 let applied = tool
394 .apply_for(&game_dir, context.as_ref(), &config)
395 .map_err(|err| err.to_string())?;
396 let paths = applied
397 .files
398 .iter()
399 .map(|path| path.to_string_lossy().replace('\\', "/"))
400 .collect::<Vec<_>>();
401 let validation_message = if tool_id == "optiscaler" {
402 validate_optiscaler_apply(&game_id, &game_dir, &config, &applied)?;
403 Some("validated managed install".to_string())
404 } else {
405 None
406 };
407
408 if tool_id == "optiscaler" {
409 config.set(
410 "managed_manifest",
411 modde_games::tools::optiscaler::managed_manifest_json(&game_dir, &applied),
412 );
413 }
414 let apply_signature = tool_apply_signature(&config.settings);
415 config.set("_last_applied_settings", apply_signature);
416 let settings_json = serde_json::to_string(&config.settings).map_err(|err| err.to_string())?;
417 db.save_tool_config_with_reason(&typed_game_id, &tool_id, true, &settings_json, "ui:apply")
418 .map_err(|err| err.to_string())?;
419 db.clear_applied_files(&typed_game_id, &tool_id)
420 .map_err(|err| err.to_string())?;
421 db.save_applied_files(&typed_game_id, &tool_id, &paths)
422 .map_err(|err| err.to_string())?;
423 modde_games::launcher::generate_tool_configs(&typed_game_id, &db)
424 .map_err(|err| err.to_string())?;
425
426 Ok(ToolApplyResult {
427 display_name: tool.display_name().to_string(),
428 applied_file_count: paths.len(),
429 validation_message,
430 })
431}
432
433pub(super) async fn revert_tool_for_game(
434 game_id: String,
435 game_dir: PathBuf,
436 tool_id: String,
437) -> Result<ToolRevertResult, String> {
438 let db = modde_core::db::ModdeDb::open().map_err(|err| err.to_string())?;
439 let typed_game_id = GameId::from(game_id.as_str());
440 let tool = modde_games::tools::resolve_tool(&tool_id)
441 .ok_or_else(|| format!("Unknown tool: {tool_id}"))?;
442 let applied_paths = db
443 .load_applied_files(&typed_game_id, &tool_id)
444 .map_err(|err| err.to_string())?;
445 let applied = modde_games::tools::AppliedFiles {
446 files: applied_paths.iter().map(PathBuf::from).collect(),
447 };
448 tool.revert(&game_dir, &applied)
449 .map_err(|err| err.to_string())?;
450 db.clear_applied_files(&typed_game_id, &tool_id)
451 .map_err(|err| err.to_string())?;
452 modde_games::launcher::generate_tool_configs(&typed_game_id, &db)
453 .map_err(|err| err.to_string())?;
454 Ok(ToolRevertResult {
455 display_name: tool.display_name().to_string(),
456 })
457}
458
459pub(super) async fn deactivate_optiscaler_for_game(
460 game_id: String,
461 game_dir: PathBuf,
462) -> Result<ToolRevertResult, String> {
463 let db = modde_core::db::ModdeDb::open().map_err(|err| err.to_string())?;
464 let typed_game_id = GameId::from(game_id.as_str());
465 let tool = modde_games::tools::resolve_tool("optiscaler")
466 .ok_or_else(|| "OptiScaler tool is not registered".to_string())?;
467 let applied_paths = db
468 .load_applied_files(&typed_game_id, "optiscaler")
469 .map_err(|err| err.to_string())?;
470 if !applied_paths.is_empty() {
471 let applied = modde_games::tools::AppliedFiles {
472 files: applied_paths.iter().map(PathBuf::from).collect(),
473 };
474 tool.revert(&game_dir, &applied)
475 .map_err(|err| err.to_string())?;
476 db.clear_applied_files(&typed_game_id, "optiscaler")
477 .map_err(|err| err.to_string())?;
478 }
479
480 let settings_json = db
481 .load_tool_config(&typed_game_id, "optiscaler")
482 .map_err(|err| err.to_string())?
483 .map_or_else(|| "{}".to_string(), |row| row.settings_json);
484 db.save_tool_config_with_reason(
485 &typed_game_id,
486 "optiscaler",
487 false,
488 &settings_json,
489 "ui:deactivate",
490 )
491 .map_err(|err| err.to_string())?;
492 modde_games::launcher::generate_tool_configs(&typed_game_id, &db)
493 .map_err(|err| err.to_string())?;
494
495 Ok(ToolRevertResult {
496 display_name: tool.display_name().to_string(),
497 })
498}
499
500pub(super) async fn restore_tool_settings_for_game(
501 game_id: String,
502 tool_id: String,
503 node_id: String,
504) -> Result<String, String> {
505 let db = modde_core::db::ModdeDb::open().map_err(|err| err.to_string())?;
506 let typed_game_id = GameId::from(game_id.as_str());
507 db.restore_tool_setting_node(&typed_game_id, &tool_id, &node_id)
508 .map_err(|err| err.to_string())?;
509 modde_games::launcher::generate_tool_configs(&typed_game_id, &db)
510 .map_err(|err| err.to_string())?;
511 let display_name = modde_games::tools::resolve_tool(&tool_id)
512 .map_or_else(|| tool_id.clone(), |tool| tool.display_name().to_string());
513 Ok(format!("Restored {display_name} settings version"))
514}
515
516pub(super) async fn save_executable_for_game(
517 row: modde_core::db::ExecutableConfigRow,
518) -> Result<String, String> {
519 tokio::task::spawn_blocking(move || {
520 let db = modde_core::db::ModdeDb::open().map_err(|err| err.to_string())?;
521 let name = row.name.clone();
522 let game_id = row.game_id.clone();
523 db.save_executable_config(&row)
524 .map_err(|err| err.to_string())?;
525 Ok(format!("Saved executable '{name}' for {game_id}"))
526 })
527 .await
528 .map_err(|err| err.to_string())?
529}
530
531pub(super) async fn remove_executable_for_game(
532 game_id: String,
533 name: String,
534) -> Result<String, String> {
535 tokio::task::spawn_blocking(move || {
536 let db = modde_core::db::ModdeDb::open().map_err(|err| err.to_string())?;
537 if db
538 .delete_executable_config(&GameId::from(game_id.as_str()), &name)
539 .map_err(|err| err.to_string())?
540 {
541 Ok(format!("Removed executable '{name}'"))
542 } else {
543 Err(format!(
544 "No executable named '{name}' is configured for {game_id}"
545 ))
546 }
547 })
548 .await
549 .map_err(|err| err.to_string())?
550}
551
552pub(super) async fn run_saved_executable_for_game(
553 game_id: String,
554 name: String,
555 profile_name: Option<String>,
556) -> Result<String, String> {
557 tokio::task::spawn_blocking(move || {
558 let db = modde_core::db::ModdeDb::open().map_err(|err| err.to_string())?;
559 let row = db
560 .load_executable_config(&GameId::from(game_id.as_str()), &name)
561 .map_err(|err| err.to_string())?
562 .ok_or_else(|| format!("No executable named '{name}' is configured for {game_id}"))?;
563 run_executable_row(row, profile_name)
564 })
565 .await
566 .map_err(|err| err.to_string())?
567}
568
569pub(super) fn run_executable_row(
570 row: modde_core::db::ExecutableConfigRow,
571 profile_name: Option<String>,
572) -> Result<String, String> {
573 let pm = ProfileManager::open().map_err(|err| err.to_string())?;
574 let row_game_id = GameId::from(row.game_id.as_str());
575 let profile = if let Some(profile_name) = profile_name {
576 pm.load(&profile_name, Some(&row_game_id))
577 .map_err(|err| err.to_string())?
578 } else {
579 let summaries = pm.list().map_err(|err| err.to_string())?;
580 let first = summaries
581 .iter()
582 .find(|profile| profile.game_id.as_str() == row.game_id)
583 .ok_or_else(|| format!("No profile found for {}", row.game_id))?;
584 pm.load(&first.name, Some(&row_game_id))
585 .map_err(|err| err.to_string())?
586 };
587 let game_plugin = modde_games::resolve_game_plugin(profile.game_id.as_str())
588 .ok_or_else(|| format!("Unsupported game: {}", profile.game_id))?;
589 let install_dir = game_plugin.detect_install().ok_or_else(|| {
590 format!(
591 "Could not detect install directory for {}",
592 game_plugin.display_name()
593 )
594 })?;
595 let mod_dir = game_plugin
596 .mod_root(&install_dir)
597 .map_err(|err| err.to_string())?;
598 let before = snapshot_dir_for_executable(&mod_dir)?;
599 let args: Vec<String> = serde_json::from_str(&row.arguments_json)
600 .map_err(|err| format!("Stored arguments are invalid JSON: {err}"))?;
601 let environment: HashMap<String, String> = serde_json::from_str(&row.environment_json)
602 .map_err(|err| format!("Stored environment is invalid JSON: {err}"))?;
603 let working_dir = row.working_dir.as_ref().unwrap_or(&install_dir);
604 let mut command = std::process::Command::new(&row.executable_path);
605 command.args(args).current_dir(working_dir);
606 for (key, value) in environment {
607 command.env(key, value);
608 }
609 if let Some(overrides) = &row.wine_dll_overrides {
610 command.env("WINEDLLOVERRIDES", overrides);
611 }
612 let status = command
613 .status()
614 .map_err(|err| format!("Failed to execute {}: {err}", row.executable_path.display()))?;
615 let after = snapshot_dir_for_executable(&mod_dir)?;
616 let new_files = after.difference(&before).cloned().collect::<Vec<_>>();
617 if !new_files.is_empty() {
618 let output_dir = modde_core::paths::store_dir().join(&row.output_mod);
619 std::fs::create_dir_all(&output_dir).map_err(|err| err.to_string())?;
620 for rel_path in &new_files {
621 let src = mod_dir.join(rel_path);
622 let dst = output_dir.join(rel_path);
623 if let Some(parent) = dst.parent() {
624 std::fs::create_dir_all(parent).map_err(|err| err.to_string())?;
625 }
626 std::fs::rename(&src, &dst)
627 .or_else(|_| {
628 std::fs::copy(&src, &dst)?;
629 std::fs::remove_file(&src)
630 })
631 .map_err(|err| format!("Failed to move {rel_path} to output mod: {err}"))?;
632 }
633 }
634 let suffix = if status.success() {
635 String::new()
636 } else {
637 format!("; process exited with status {status}")
638 };
639 Ok(format!(
640 "Ran '{}' and captured {} file(s) to {}{}",
641 row.name,
642 new_files.len(),
643 row.output_mod,
644 suffix
645 ))
646}
647
648pub(super) fn snapshot_dir_for_executable(dir: &Path) -> Result<HashSet<String>, String> {
649 if !dir.exists() {
650 return Ok(HashSet::new());
651 }
652 modde_core::fs::walk_files_relative(dir)
653 .map(|files| files.into_iter().map(|(rel, _)| rel).collect())
654 .map_err(|err| err.to_string())
655}
656
657pub fn parse_executable_environment(input: &str) -> Result<HashMap<String, String>, String> {
658 let mut env = HashMap::new();
659 for (idx, line) in input.lines().enumerate() {
660 let line = line.trim();
661 if line.is_empty() {
662 continue;
663 }
664 let Some((key, value)) = line.split_once('=') else {
665 return Err(format!("Environment line {} must be KEY=VALUE", idx + 1));
666 };
667 let key = key.trim();
668 if key.is_empty() {
669 return Err(format!("Environment line {} has an empty key", idx + 1));
670 }
671 env.insert(key.to_string(), value.trim().to_string());
672 }
673 Ok(env)
674}
675
676pub(super) fn executable_draft_to_row(
677 game_id: &str,
678 draft: &ExecutableDraft,
679) -> Result<modde_core::db::ExecutableConfigRow, String> {
680 let name = draft.name.trim();
681 if name.is_empty() {
682 return Err("Executable name is required".to_string());
683 }
684 let executable_path = draft.executable_path.trim();
685 if executable_path.is_empty() {
686 return Err("Executable path is required".to_string());
687 }
688 let output_mod = if draft.output_mod.trim().is_empty() {
689 "__overwrite__"
690 } else {
691 draft.output_mod.trim()
692 };
693 let args = draft
694 .arguments
695 .split_whitespace()
696 .map(str::to_string)
697 .collect::<Vec<_>>();
698 let env = parse_executable_environment(&draft.environment)?;
699 Ok(modde_core::db::ExecutableConfigRow {
700 game_id: game_id.to_string(),
701 name: name.to_string(),
702 executable_path: PathBuf::from(executable_path),
703 arguments_json: serde_json::to_string(&args).map_err(|err| err.to_string())?,
704 working_dir: (!draft.working_dir.trim().is_empty())
705 .then(|| PathBuf::from(draft.working_dir.trim())),
706 environment_json: serde_json::to_string(&env).map_err(|err| err.to_string())?,
707 wine_dll_overrides: (!draft.wine_dll_overrides.trim().is_empty())
708 .then(|| draft.wine_dll_overrides.trim().to_string()),
709 output_mod: output_mod.to_string(),
710 enabled: true,
711 })
712}
713
714pub(super) fn validate_optiscaler_apply(
715 game_id: &str,
716 game_dir: &std::path::Path,
717 config: &modde_games::tools::ToolConfig,
718 applied: &modde_games::tools::AppliedFiles,
719) -> Result<(), String> {
720 let managed_paths = applied
721 .files
722 .iter()
723 .map(|path| {
724 path.to_string_lossy()
725 .replace('\\', "/")
726 .to_ascii_lowercase()
727 })
728 .collect::<BTreeSet<_>>();
729 let state =
730 modde_games::tools::optiscaler::scan_optiscaler_install(game_id, game_dir, &managed_paths)
731 .map_err(|err| err.to_string())?;
732 if !matches!(
733 state.status,
734 modde_games::tools::optiscaler::OptiScalerInstallStatus::Managed
735 | modde_games::tools::optiscaler::OptiScalerInstallStatus::PartiallyManaged
736 ) {
737 return Err(format!(
738 "OptiScaler validation failed: install is {} after apply",
739 state.status
740 ));
741 }
742 let proxy_dll = config
743 .get_str("proxy_dll")
744 .or_else(|| config.get_str("dll_name"))
745 .unwrap_or("dxgi.dll");
746 if !state
747 .proxy_dlls
748 .iter()
749 .any(|dll| dll.eq_ignore_ascii_case(proxy_dll))
750 {
751 return Err(format!(
752 "OptiScaler validation failed: missing configured proxy DLL {proxy_dll}"
753 ));
754 }
755 if state.config_path.is_none() {
756 return Err("OptiScaler validation failed: missing OptiScaler.ini".to_string());
757 }
758 Ok(())
759}