Skip to main content

modde_ui/app/
tool_ops.rs

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}