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