1use crate::cli::arg::MergeArgs;
2use crate::cli::handler::{HandlerResult, resolve_input_source};
3use foch::game::eu4::analysis::report::{merge_plan_exit_code, render_merge_report_text};
4use foch::input::{
5 Config, CurrentEu4Input, InputRequest, InputSource, inspect_current_eu4_input,
6 resolve_product_input_manifest,
7};
8use foch::merge::timing::{
9 MergeTimingEntry, TimingRecording, append_merge_timing, merge_timing_history_path, stage,
10};
11use foch::merge::{
12 AnalyzedMerge, CancellationToken, CommitAuthorization, ConflictHandler, InteractiveCliHandler,
13 MERGE_REVIEW_SCHEMA, MergeAnalysisOptions, MergeAnalysisStatus, MergeDisposition,
14 MergeUnitKind, NoopProgressObserver, analyze_merge,
15};
16use foch::model::{MERGE_REPORT_ARTIFACT_PATH, MergeReport, ProductInputManifest};
17use foch::playset::Playset;
18use foch::playset::descriptor::{
19 descriptor_comment_text, descriptor_path_text, escape_descriptor_value,
20 load_launcher_descriptor,
21};
22use foch::project::compute_playset_fingerprint;
23use foch::project::{AppliedDepOverride, Project};
24
25use crate::tui::conflict_handler::InteractiveTuiHandler;
26use std::collections::{BTreeMap, BTreeSet};
27use std::fs;
28use std::io::{self, BufRead, IsTerminal, Write};
29use std::path::{Path, PathBuf};
30
31pub fn handle_merge(merge_args: &MergeArgs, config: Config) -> HandlerResult {
32 let recording = TimingRecording::begin();
33 let result = run_merge(merge_args, config);
34 let timing = recording.finish();
35 let status = match &result {
36 Ok(code) => format!("exit {code}"),
37 Err(_) => "error".to_string(),
38 };
39 let input = merge_args.playset_path.as_ref().map_or_else(
40 || "current EU4 playset".to_string(),
41 |path| path.display().to_string(),
42 );
43 let history = merge_timing_history_path();
44 let entry = MergeTimingEntry {
45 foch_version: env!("CARGO_PKG_VERSION"),
46 analysis_rules_version: foch::game::eu4::base::version::analysis_rules_version(),
47 input: &input,
48 out_dir: &merge_args.out.display().to_string(),
49 status: &status,
50 timing: &timing,
51 };
52 match append_merge_timing(&history, &entry) {
53 Ok(()) => eprintln!(
54 "[merge] timing: total_ms={} recorded in {}",
55 timing.total_ms,
56 history.display()
57 ),
58 Err(error) => eprintln!("[foch] could not record merge timing: {error}"),
59 }
60 result
61}
62
63fn run_merge(merge_args: &MergeArgs, config: Config) -> HandlerResult {
64 let paradox_data_path = config.paradox_data_path.clone();
65 if let Some(path) = &merge_args.review_json {
67 check_review_json_target(path, &merge_args.out)?;
68 }
69 let PreparedMerge { request, options } =
70 stage("prepare_merge", || prepare_merge(merge_args, config, None))?;
71 let analyzed = stage("analyze", || {
72 analyze_merge(
73 request,
74 options,
75 &NoopProgressObserver,
76 &CancellationToken::new(),
77 )
78 })?;
79 report_and_commit(merge_args, analyzed, paradox_data_path)
80}
81
82pub struct PreparedMerge {
84 pub request: InputRequest,
85 pub options: MergeAnalysisOptions,
86}
87
88pub fn prepare_merge(
94 merge_args: &MergeArgs,
95 config: Config,
96 current: Option<CurrentEu4Input>,
97) -> Result<PreparedMerge, Box<dyn std::error::Error>> {
98 let (source, request) = match &merge_args.playset_path {
99 Some(path) => {
100 if !merge_args.exclude.is_empty() {
101 return Err(
102 "`--exclude` selects mods of the current EU4 playset; omit INPUT_SOURCE".into(),
103 );
104 }
105 let source = resolve_input_source(Some(path), &config)?;
106 (source.clone(), InputRequest::new(source, config))
107 }
108 None => {
109 let current = current.unwrap_or_else(inspect_current_eu4_input);
110 let request =
111 current_input_excluding(current, &merge_args.exclude, !merge_args.no_game_base)?;
112 (request.source.clone(), request)
113 }
114 };
115 let local_config = load_local_foch_config(merge_args, &source)?;
116 let fingerprint = if merge_args.exclude.is_empty() {
119 compute_fingerprint_for_source(&request, &local_config)
120 } else {
121 None
122 };
123 let dep_overrides = applied_dep_overrides(merge_args, &local_config);
124 let (interactive_conflict_handler, interactive_resolution_config_path) =
125 build_interactive_conflict_handler(merge_args, &source);
126 Ok(PreparedMerge {
127 request,
128 options: MergeAnalysisOptions {
129 out_dir: merge_args.out.clone(),
130 include_game_base: !merge_args.no_game_base,
131 include_base: merge_args.include_base,
132 gui_scroll_merge: merge_args.gui_scroll_merge,
133 force: merge_args.force,
134 ignore_replace_path: merge_args.ignore_replace_path,
135 dep_overrides,
136 resolution_config_path: merge_args.config.clone().or_else(|| match &source {
137 InputSource::Manifest(path) => Some(path.clone()),
138 InputSource::DlcLoad(_) => None,
139 }),
140 interactive_conflict_handler,
141 interactive_resolution_config_path,
142 playset_fingerprint: fingerprint.clone(),
143 provenance: merge_args.provenance,
144 merge_workers: merge_args
145 .jobs
146 .unwrap_or_else(foch::merge::default_merge_workers),
147 retained_paths: None,
148 },
149 })
150}
151
152fn report_and_commit(
153 merge_args: &MergeArgs,
154 analyzed: AnalyzedMerge,
155 paradox_data_path: Option<PathBuf>,
156) -> HandlerResult {
157 let analysis = analyzed.analysis();
158 let review = stage("render_review", || {
159 render_merge_review_text(&analyzed, merge_args.review_all)
160 });
161 println!("{review}");
162 if let Some(path) = &merge_args.review_json {
163 write_review_json(&analyzed, path, &merge_args.out)?;
164 }
165 let plan_exit_code = merge_plan_exit_code(analysis.plan());
166 if analysis.plan().has_fatal_errors() {
167 return Ok(plan_exit_code);
168 }
169 if !confirm_merge_commit(merge_args, merge_args.out.as_path())? {
170 return Ok(0);
171 }
172
173 let authorization = match analyzed.replacement_target()? {
174 Some(target) => {
175 if !confirm_existing_out_dir(target.path())? {
176 return Ok(1);
177 }
178 CommitAuthorization::ReplaceExisting(target)
179 }
180 None => CommitAuthorization::EmptyTargetOnly,
181 };
182 let execution = stage("commit", || analyzed.commit(authorization))?;
183 println!("{}", render_merge_report_text(&execution.report));
184 if let Some(tip) = render_unresolved_conflict_tip(&execution.report, merge_args.out.as_path()) {
185 eprintln!("{tip}");
186 }
187 if matches!(
188 execution.merge_status.status,
189 foch::model::MergeReportStatus::Ready | foch::model::MergeReportStatus::PartialSuccess
190 ) && let Some(paradox_dir) = paradox_data_path.as_ref()
191 {
192 match install_launcher_stub(&merge_args.out, paradox_dir, "foch merge") {
193 Ok(stub_path) => eprintln!(
194 "[foch] launcher stub installed at {}; enable it in the Paradox Launcher and disable the source mods to use the merge.",
195 stub_path.display()
196 ),
197 Err(err) => eprintln!("[foch] failed to install launcher stub: {err}"),
198 }
199 }
200 Ok(execution.exit_code)
201}
202
203fn write_review_json(analyzed: &AnalyzedMerge, path: &Path, out_dir: &Path) -> io::Result<()> {
204 let destination = check_review_json_target(path, out_dir)?;
205 for root in analyzed.source_roots() {
206 if destination.starts_with(write_location(root)?) {
207 return Err(io::Error::new(
208 io::ErrorKind::InvalidInput,
209 format!(
210 "--review-json {} is inside the read-only input {}; write the review elsewhere",
211 path.display(),
212 root.display()
213 ),
214 ));
215 }
216 }
217 let mut json = serde_json::to_vec_pretty(analyzed.review()).map_err(io::Error::other)?;
218 json.push(b'\n');
219 fs::write(&destination, json).map_err(|error| {
220 io::Error::new(
221 error.kind(),
222 format!("failed to write merge review {}: {error}", path.display()),
223 )
224 })?;
225 eprintln!("[foch] wrote merge review to {}", path.display());
226 Ok(())
227}
228
229fn check_review_json_target(path: &Path, out_dir: &Path) -> io::Result<PathBuf> {
235 let destination = write_location(path)?;
236 if destination.starts_with(write_location(out_dir)?) {
237 return Err(io::Error::new(
238 io::ErrorKind::InvalidInput,
239 format!(
240 "--review-json {} is inside --out {}; write the review outside the merge output",
241 path.display(),
242 out_dir.display()
243 ),
244 ));
245 }
246 match fs::read(&destination) {
247 Ok(bytes) => {
248 let is_review = serde_json::from_slice::<serde_json::Value>(&bytes)
249 .ok()
250 .is_some_and(|value| {
251 value.get("schema").and_then(serde_json::Value::as_str)
252 == Some(MERGE_REVIEW_SCHEMA)
253 });
254 if is_review {
255 Ok(destination)
256 } else {
257 Err(io::Error::new(
258 io::ErrorKind::AlreadyExists,
259 format!(
260 "--review-json {} is an existing file that is not a foch merge review; refusing to overwrite it",
261 path.display()
262 ),
263 ))
264 }
265 }
266 Err(error) if error.kind() == io::ErrorKind::NotFound => Ok(destination),
267 Err(error) => Err(io::Error::new(
268 error.kind(),
269 format!("cannot check --review-json {}: {error}", path.display()),
270 )),
271 }
272}
273
274fn write_location(path: &Path) -> io::Result<PathBuf> {
279 let absolute = std::path::absolute(path)?;
280 let components = absolute.components().collect::<Vec<_>>();
281 for existing in (1..=components.len()).rev() {
282 let ancestor = components[..existing].iter().collect::<PathBuf>();
283 match fs::canonicalize(&ancestor) {
284 Ok(mut location) => {
285 for component in &components[existing..] {
286 match component {
287 std::path::Component::ParentDir => {
288 location.pop();
289 }
290 std::path::Component::CurDir => {}
291 other => location.push(other.as_os_str()),
292 }
293 }
294 return Ok(location);
295 }
296 Err(error) if error.kind() == io::ErrorKind::NotFound => {}
297 Err(error) => return Err(error),
298 }
299 }
300 Ok(absolute)
301}
302
303fn current_input_excluding(
306 input: CurrentEu4Input,
307 exclude: &[String],
308 include_game_base: bool,
309) -> Result<InputRequest, String> {
310 let mods = input
311 .playset
312 .as_ref()
313 .map(|playset| playset.mods.as_slice())
314 .unwrap_or_default();
315 let mut positions = BTreeSet::new();
316 for name in exclude {
317 let position = match name.strip_prefix('#') {
318 Some(position) => position
319 .parse::<usize>()
320 .ok()
321 .filter(|position| mods.iter().any(|m| m.position == *position)),
322 None => mods
323 .iter()
324 .find(|playset_mod| playset_mod.id == *name)
325 .map(|playset_mod| playset_mod.position),
326 };
327 let position = position.ok_or_else(|| {
328 format!(
329 "`--exclude {name}` names no mod of the current EU4 playset; see `foch input inspect`"
330 )
331 })?;
332 positions.insert(position);
333 }
334 let prepared = input
335 .clone()
336 .prepare_excluding(&positions, include_game_base)?;
337 if let Some(recovery) = &prepared.recovery {
338 eprintln!(
339 "[foch] analyzing {} of {} playset mods; excluded:",
340 recovery.included_mod_count, recovery.source_mod_count
341 );
342 for omitted in &recovery.omitted_mods {
343 eprintln!(
344 " #{} {} {} ({})",
345 omitted.position, omitted.id, omitted.name, omitted.reason
346 );
347 }
348 }
349 Ok(prepared.request)
350}
351
352fn render_merge_review_text(analyzed: &AnalyzedMerge, review_all: bool) -> String {
353 const UNITS_PER_DISPOSITION: usize = 20;
354 let analysis = analyzed.analysis();
355 let summary = analyzed.review_summary();
356 let status = match analysis.status() {
357 MergeAnalysisStatus::ReadyToCommit => "ready_to_commit",
358 MergeAnalysisStatus::CommittableWithDeferrals => "committable_with_deferrals",
359 MergeAnalysisStatus::Blocked => "blocked",
360 };
361 let mut output = format!(
362 "Foch Merge Review\nstatus: {status}\nunits: {}\n safe: {}\n copy: {}\n needs_user_choice: {}\n unsupported_input: {}\n engine_failure: {}\n deferred: {}\n",
363 summary.total,
364 summary.safe,
365 summary.copy,
366 summary.needs_user_choice,
367 summary.unsupported_input,
368 summary.engine_failure,
369 summary.deferred,
370 );
371 if !analysis.plan().fatal_errors.is_empty() {
372 output.push_str("fatal errors:\n");
373 for error in &analysis.plan().fatal_errors {
374 output.push_str(&format!(" - {error}\n"));
375 }
376 }
377 output.push_str("review units:\n");
378 let mut counts: BTreeMap<&str, usize> = BTreeMap::new();
379 for unit in analyzed.list_units() {
380 let disposition = match unit.disposition {
381 MergeDisposition::Safe => "safe",
382 MergeDisposition::Copy => "copy",
383 MergeDisposition::NeedsUserChoice => "needs_user_choice",
384 MergeDisposition::UnsupportedInput => "unsupported_input",
385 MergeDisposition::EngineFailure => "engine_failure",
386 MergeDisposition::Deferred => "deferred",
387 };
388 let count: &mut usize = counts.entry(disposition).or_default();
389 *count += 1;
390 if !review_all && *count > UNITS_PER_DISPOSITION {
391 continue;
392 }
393 let kind = match unit.kind {
394 MergeUnitKind::File => "file",
395 MergeUnitKind::DefinitionModule => "definition_module",
396 };
397 output.push_str(&format!(
398 "- [{disposition}] {}\n id: {}\n family: {}\n kind: {kind}\n strategy: {}\n summary: {}\n",
399 unit.path, unit.id, unit.family, unit.strategy, unit.summary,
400 ));
401 for path in &unit.output_paths {
404 output.push_str(&format!(" output: {path}\n"));
405 }
406 if !unit.contributors.is_empty() {
407 let contributors = unit
408 .contributors
409 .iter()
410 .map(|contributor| {
411 format!(
412 "{} ({}, precedence {})",
413 contributor.name, contributor.mod_id, contributor.precedence
414 )
415 })
416 .collect::<Vec<_>>()
417 .join(", ");
418 output.push_str(&format!(" contributors: {contributors}\n"));
419 }
420 for note in &unit.notes {
421 output.push_str(&format!(" note: {note}\n"));
422 }
423 }
424 if !review_all && counts.values().any(|count| *count > UNITS_PER_DISPOSITION) {
425 output.push_str("additional review units (not displayed):\n");
426 for (disposition, count) in counts {
427 if count > UNITS_PER_DISPOSITION {
428 output.push_str(&format!(
429 " {} more {disposition} units\n",
430 count - UNITS_PER_DISPOSITION
431 ));
432 }
433 }
434 output.push_str("Pass --review-all to display every unit before committing.\n");
435 }
436 output
437}
438
439fn confirm_merge_commit(
440 merge_args: &MergeArgs,
441 out_dir: &Path,
442) -> Result<bool, Box<dyn std::error::Error>> {
443 if merge_args.confirm {
444 return Ok(true);
445 }
446
447 if merge_args.non_interactive {
448 eprintln!("[foch] analysis complete; output not written. Pass --confirm to commit it.");
449 return Ok(false);
450 }
451
452 let stdin = std::io::stdin();
453 let stderr = std::io::stderr();
454 if !stdin.is_terminal() || !stderr.is_terminal() {
455 eprintln!("[foch] analysis complete; output not written. Pass --confirm to commit it.");
456 return Ok(false);
457 }
458
459 let mut handle = stderr.lock();
460 write!(
461 handle,
462 "[foch] commit this analyzed merge to {}? [y/N] ",
463 out_dir.display()
464 )?;
465 handle.flush()?;
466 drop(handle);
467
468 let mut answer = String::new();
469 stdin.lock().read_line(&mut answer)?;
470 let answer = answer.trim().to_ascii_lowercase();
471 if answer == "y" || answer == "yes" {
472 Ok(true)
473 } else {
474 eprintln!("[foch] analysis kept for review; output directory not modified");
475 Ok(false)
476 }
477}
478
479fn build_interactive_conflict_handler(
480 merge_args: &MergeArgs,
481 source: &InputSource,
482) -> (Option<Box<dyn ConflictHandler>>, Option<PathBuf>) {
483 if merge_args.non_interactive {
484 return (None, None);
485 }
486
487 if merge_args.cli_prompt {
488 eprintln!(
489 "[foch] interactive mode: simple prompt will appear for unresolved conflicts. Press q to abort, d to defer."
490 );
491 return (
492 Some(Box::new(InteractiveCliHandler::new())),
493 Some(resolve_resolution_config_path(merge_args, source)),
494 );
495 }
496
497 if std::io::stdin().is_terminal() && std::io::stdout().is_terminal() {
498 eprintln!(
499 "[foch] interactive mode: ratatui UI will appear for unresolved conflicts. Press q to abort, d to defer."
500 );
501 return (
502 Some(Box::new(InteractiveTuiHandler::new())),
503 Some(resolve_resolution_config_path(merge_args, source)),
504 );
505 }
506
507 (None, None)
508}
509
510fn render_unresolved_conflict_tip(report: &MergeReport, out_dir: &Path) -> Option<String> {
511 let unresolved_conflicts = report.manual_conflict_count;
512 if unresolved_conflicts == 0 {
513 return None;
514 }
515
516 let report_path = out_dir.join(MERGE_REPORT_ARTIFACT_PATH);
517 let plural = if unresolved_conflicts == 1 { "" } else { "s" };
518 let verb = if unresolved_conflicts == 1 {
519 "was"
520 } else {
521 "were"
522 };
523 let mut lines = vec![
524 format!(
525 "Tip: {unresolved_conflicts} unresolved merge conflict{plural} {verb} SKIPPED (not written to {}).",
526 out_dir.display()
527 ),
528 format!(" 1. Inspect {} for details.", report_path.display()),
529 " 2. Choose a reviewed resolution interactively, or add a supported foch.toml [[resolutions]] entry with handler = \"last_writer\"."
530 .to_string(),
531 ];
532 if let Some(finding) = report.dep_misuse.first() {
533 lines.push(format!(
534 " 3. Possible spurious dep: {} -> {}; try --ignore-dep {}:{}.",
535 finding.mod_display_name,
536 finding.suspicious_dep_display_name,
537 finding.mod_id,
538 finding.suspicious_dep_id
539 ));
540 } else {
541 lines.push(" 3. Resolve skipped files manually, then re-run merge.".to_string());
542 }
543 lines.push(
544 "Foch committed the safe units and withheld only these conflicts; use an explicit resolution when you're ready."
545 .to_string(),
546 );
547 Some(lines.join("\n"))
548}
549
550fn load_local_foch_config(
551 merge_args: &MergeArgs,
552 source: &InputSource,
553) -> Result<Project, Box<dyn std::error::Error>> {
554 if let Some(path) = merge_args.config.as_ref() {
555 Ok(Project::load_from_path(path)?)
556 } else if let InputSource::Manifest(path) = source {
557 Ok(Project::load_from_path(path)?)
558 } else {
559 let playset_root = playset_root_for(source.path());
560 Ok(Project::try_load(&playset_root)?)
561 }
562}
563
564fn applied_dep_overrides(
565 merge_args: &MergeArgs,
566 local_config: &Project,
567) -> Vec<AppliedDepOverride> {
568 let mut overrides: Vec<AppliedDepOverride> = local_config
569 .overrides
570 .iter()
571 .map(AppliedDepOverride::config)
572 .collect();
573 overrides.extend(
574 merge_args
575 .ignore_dep
576 .iter()
577 .map(|item| AppliedDepOverride::cli(item.mod_id.clone(), item.dep_id.clone())),
578 );
579 overrides
580}
581
582fn compute_fingerprint_for_source(
590 request: &InputRequest,
591 local_config: &Project,
592) -> Option<String> {
593 match &request.source {
594 InputSource::DlcLoad(path) => compute_fingerprint_for_playset(path, local_config),
595 InputSource::Manifest(_) => compute_fingerprint_for_manifest(request, local_config),
596 }
597}
598
599fn compute_fingerprint_for_playset(playset_path: &Path, local_config: &Project) -> Option<String> {
600 let playlist = Playset::from_dlc_load(playset_path).ok()?;
601 let playset_root = playset_path.parent().unwrap_or_else(|| Path::new("."));
602 let mut mods: Vec<(String, String)> = Vec::new();
603 for entry in &playlist.mods {
604 if !entry.enabled {
605 continue;
606 }
607 let steam_id = entry.steam_id.clone()?;
608 let descriptor_path = playset_root.join("mod").join(format!("ugc_{steam_id}.mod"));
609 let version = load_launcher_descriptor(&descriptor_path)
610 .ok()
611 .and_then(|descriptor| descriptor.version)?;
612 mods.push((steam_id, version));
613 }
614 Some(compute_playset_fingerprint(
615 &mods,
616 &local_config.overrides,
617 &local_config.resolutions,
618 ))
619}
620
621fn compute_fingerprint_for_manifest(
622 request: &InputRequest,
623 local_config: &Project,
624) -> Option<String> {
625 let manifest = resolve_product_input_manifest(request).ok()?;
626 Some(compute_fingerprint_for_workshop_manifest(
627 &manifest,
628 local_config,
629 ))
630}
631
632fn compute_fingerprint_for_workshop_manifest(
633 manifest: &ProductInputManifest,
634 local_config: &Project,
635) -> String {
636 let mods = manifest
637 .mods
638 .iter()
639 .map(|input| {
640 (
641 input.mod_id.clone(),
642 format!(
643 "steam-acf:{}:{}:{}",
644 input.workshop_identity.app_id,
645 input.workshop_identity.workshop_id,
646 input.workshop_identity.manifest_id
647 ),
648 )
649 })
650 .collect::<Vec<_>>();
651 compute_playset_fingerprint(&mods, &local_config.overrides, &local_config.resolutions)
652}
653
654fn resolve_resolution_config_path(merge_args: &MergeArgs, source: &InputSource) -> PathBuf {
655 if let Some(path) = merge_args.config.as_ref() {
656 return path.clone();
657 }
658
659 if let InputSource::Manifest(path) = source {
660 return path.clone();
661 }
662
663 if let Ok(cwd) = std::env::current_dir() {
664 let cwd_config = cwd.join("foch.toml");
665 if cwd_config.is_file() {
666 return cwd_config;
667 }
668 }
669
670 playset_root_for(source.path()).join("foch.toml")
671}
672
673fn playset_root_for(playset_path: &Path) -> PathBuf {
674 playset_path
675 .parent()
676 .unwrap_or_else(|| Path::new("."))
677 .to_path_buf()
678}
679
680fn confirm_existing_out_dir(out_dir: &Path) -> io::Result<bool> {
684 let stdin = std::io::stdin();
685 let stderr = std::io::stderr();
686 if !stdin.is_terminal() || !stderr.is_terminal() {
687 eprintln!(
688 "[foch] --out {} already exists and is non-empty; refusing to overwrite without a separate interactive confirmation. Delete it manually or run from a TTY.",
689 out_dir.display()
690 );
691 return Ok(false);
692 }
693
694 let mut handle = stderr.lock();
695 write!(
696 handle,
697 "[foch] --out {} already exists and is non-empty. Replace it with the analyzed merge? [y/N] ",
698 out_dir.display()
699 )?;
700 handle.flush()?;
701 drop(handle);
702
703 let mut answer = String::new();
704 stdin.lock().read_line(&mut answer)?;
705 let answer = answer.trim().to_ascii_lowercase();
706 if answer != "y" && answer != "yes" {
707 eprintln!("[foch] aborted; output directory not modified");
708 return Ok(false);
709 }
710
711 Ok(true)
712}
713
714pub(crate) fn install_launcher_stub(
724 out_dir: &Path,
725 paradox_data_path: &Path,
726 label: &str,
727) -> Result<PathBuf, Box<dyn std::error::Error>> {
728 let mod_dir = paradox_data_path.join("mod");
729 fs::create_dir_all(&mod_dir)?;
730 let absolute_out = fs::canonicalize(out_dir).unwrap_or_else(|_| out_dir.to_path_buf());
731 let slug = launcher_stub_slug(out_dir)?;
732 let descriptor_value = descriptor_path_text(&absolute_out).map_err(|reason| {
733 format!(
734 "merged output {} cannot be named in a launcher descriptor: {reason}",
735 absolute_out.display()
736 )
737 })?;
738 let (stub_path, stem) = launcher_stub_path(&mod_dir, &slug, &descriptor_value)?;
739 let display_name = format!("{label} ({stem})");
740 let body = format!(
741 "# foch-managed launcher stub for {}\nname=\"{}\"\npath=\"{}\"\nsupported_version=\"*\"\n",
742 descriptor_comment_text(&out_dir.display().to_string()),
743 escape_descriptor_value(&display_name),
744 descriptor_value
745 );
746 fs::write(&stub_path, body)?;
747 Ok(stub_path)
748}
749
750fn launcher_stub_path(
757 mod_dir: &Path,
758 slug: &str,
759 descriptor_value: &str,
760) -> Result<(PathBuf, String), Box<dyn std::error::Error>> {
761 let stem_for = |index: usize| {
762 if index == 1 {
763 slug.to_string()
764 } else {
765 format!("{slug}_{index}")
766 }
767 };
768 let is_slug_stem = |stem: &str| {
769 stem == slug
770 || stem
771 .strip_prefix(slug)
772 .and_then(|rest| rest.strip_prefix('_'))
773 .is_some_and(|index| !index.is_empty() && index.bytes().all(|b| b.is_ascii_digit()))
774 };
775 for entry in fs::read_dir(mod_dir)? {
776 let entry = entry?;
777 let Some(stem) = entry
778 .file_name()
779 .to_str()
780 .and_then(|name| name.strip_prefix("foch_"))
781 .and_then(|name| name.strip_suffix(".mod"))
782 .filter(|stem| is_slug_stem(stem))
783 .map(str::to_string)
784 else {
785 continue;
786 };
787 let names_this_output = load_launcher_descriptor(&entry.path())
788 .ok()
789 .and_then(|descriptor| descriptor.path)
790 .is_some_and(|path| path.as_os_str() == descriptor_value);
791 if names_this_output {
792 return Ok((entry.path(), stem));
793 }
794 }
795 for index in 1usize.. {
796 let stem = stem_for(index);
797 let path = mod_dir.join(format!("foch_{stem}.mod"));
798 match fs::symlink_metadata(&path) {
799 Err(err) if err.kind() == io::ErrorKind::NotFound => return Ok((path, stem)),
800 Err(err) => return Err(Box::new(err)),
801 Ok(_) => {}
802 }
803 }
804 unreachable!("an unbounded index always reaches a free stub name")
805}
806
807fn launcher_stub_slug(out_dir: &Path) -> Result<String, String> {
811 let raw = match out_dir.file_name() {
812 Some(name) => name.to_str().ok_or_else(|| {
813 format!(
814 "output directory name {} is not valid UTF-8, so it cannot name a launcher stub",
815 out_dir.display()
816 )
817 })?,
818 None => "merge",
819 };
820 Ok(raw
821 .chars()
822 .map(|c| {
823 if c.is_ascii_alphanumeric() || c == '_' || c == '-' {
824 c
825 } else {
826 '_'
827 }
828 })
829 .collect())
830}
831
832#[cfg(test)]
833mod tests {
834 use super::*;
835 use foch::model::ProductInputMod;
836 use foch::playset::steam::{SteamId, WorkshopInstallIdentity};
837
838 fn workshop_manifest(manifest_id: u64) -> ProductInputManifest {
839 ProductInputManifest::new(vec![ProductInputMod {
840 mod_id: "1001".to_string(),
841 precedence: 1,
842 workshop_identity: WorkshopInstallIdentity {
843 app_id: 236_850,
844 workshop_id: SteamId::new(1_001),
845 manifest_id: SteamId::new(manifest_id),
846 },
847 }])
848 }
849
850 #[test]
855 fn launcher_stub_names_the_merged_output_exactly() {
856 let temp = tempfile::tempdir().expect("temp dir");
857 let out_dir = temp.path().join("merged out Ölände");
858 fs::create_dir_all(&out_dir).expect("create output");
859 let paradox_dir = temp.path().join("paradox");
860
861 install_launcher_stub(&out_dir, ¶dox_dir, "foch merge").expect("install stub");
862
863 let stub = paradox_dir.join("mod").join(format!(
864 "foch_{}.mod",
865 launcher_stub_slug(&out_dir).expect("UTF-8 output name")
866 ));
867 let descriptor = load_launcher_descriptor(&stub).expect("read stub");
868 assert_eq!(
869 fs::canonicalize(descriptor.path.expect("stub path")).expect("canonical stub path"),
870 fs::canonicalize(&out_dir).expect("canonical output")
871 );
872 }
873
874 #[test]
878 fn launcher_stubs_for_outputs_sharing_a_slug_do_not_replace_each_other() {
879 let temp = tempfile::tempdir().expect("temp dir");
880 let paradox_dir = temp.path().join("paradox");
881 let outputs = [
882 temp.path().join("a").join("merged"),
883 temp.path().join("b").join("merged"),
884 temp.path().join("my merge"),
885 temp.path().join("my_merge"),
886 ];
887 for out_dir in &outputs {
888 fs::create_dir_all(out_dir).expect("create output");
889 install_launcher_stub(out_dir, ¶dox_dir, "foch merge").expect("install stub");
890 }
891 install_launcher_stub(&outputs[0], ¶dox_dir, "foch merge")
892 .expect("reinstall first stub");
893
894 let mut stubs = fs::read_dir(paradox_dir.join("mod"))
895 .expect("mod dir")
896 .map(|entry| {
897 let path = entry.expect("stub entry").path();
898 let descriptor = load_launcher_descriptor(&path).expect("read stub");
899 (
900 path.file_name().unwrap().to_str().unwrap().to_string(),
901 fs::canonicalize(descriptor.path.expect("stub path")).expect("stub target"),
902 )
903 })
904 .collect::<Vec<_>>();
905 stubs.sort();
906 let canonical = |path: &Path| fs::canonicalize(path).expect("canonical output");
907 assert_eq!(
908 stubs,
909 vec![
910 ("foch_merged.mod".to_string(), canonical(&outputs[0])),
911 ("foch_merged_2.mod".to_string(), canonical(&outputs[1])),
912 ("foch_my_merge.mod".to_string(), canonical(&outputs[2])),
913 ("foch_my_merge_2.mod".to_string(), canonical(&outputs[3])),
914 ]
915 );
916 }
917
918 #[test]
922 fn a_line_break_in_the_output_name_gets_no_stub() {
923 let temp = tempfile::tempdir().expect("temp dir");
924 let out_dir = temp.path().join("x\nreplace_path=common\n#");
925 let paradox_dir = temp.path().join("paradox");
926
927 let error = install_launcher_stub(&out_dir, ¶dox_dir, "foch merge")
928 .expect_err("a line break has no descriptor spelling")
929 .to_string();
930 assert!(error.contains("line break"), "{error}");
931 assert_eq!(
932 fs::read_dir(paradox_dir.join("mod"))
933 .expect("mod dir")
934 .count(),
935 0
936 );
937 }
938
939 #[test]
942 fn launcher_stub_refuses_an_output_directory_holding_a_quote() {
943 let temp = tempfile::tempdir().expect("temp dir");
944 let out_dir = temp.path().join("merged \"out\"");
945 let paradox_dir = temp.path().join("paradox");
946
947 let error = install_launcher_stub(&out_dir, ¶dox_dir, "foch merge")
948 .expect_err("a quote has no descriptor spelling")
949 .to_string();
950 assert!(error.contains("cannot be named"), "{error}");
951 assert_eq!(
952 fs::read_dir(paradox_dir.join("mod"))
953 .expect("mod dir")
954 .count(),
955 0
956 );
957 }
958
959 #[cfg(unix)]
965 #[test]
966 fn launcher_stub_refuses_an_output_directory_name_that_is_not_utf8() {
967 use std::ffi::OsStr;
968 use std::os::unix::ffi::OsStrExt;
969
970 let temp = tempfile::tempdir().expect("temp dir");
971 let paradox_dir = temp.path().join("paradox");
972 for name in [&b"merged\xff"[..], &b"merged\xfe"[..]] {
973 let out_dir = temp.path().join(OsStr::from_bytes(name));
974 let error = launcher_stub_slug(&out_dir).expect_err("not UTF-8");
975 assert!(error.contains("cannot name a launcher stub"), "{error}");
976 let error = install_launcher_stub(&out_dir, ¶dox_dir, "foch merge")
977 .expect_err("not UTF-8")
978 .to_string();
979 assert!(error.contains("cannot name a launcher stub"), "{error}");
980 }
981 assert_eq!(
982 fs::read_dir(paradox_dir.join("mod"))
983 .expect("mod dir")
984 .count(),
985 0,
986 "no stub is written"
987 );
988 assert_eq!(
989 launcher_stub_slug(Path::new("/")).as_deref(),
990 Ok("merge"),
991 "a directory without a name keeps the fallback"
992 );
993 }
994
995 #[cfg(target_os = "linux")]
999 #[test]
1000 fn launcher_stub_refuses_a_link_name_that_is_not_utf8() {
1001 use std::ffi::OsStr;
1002 use std::os::unix::ffi::OsStrExt;
1003
1004 let temp = tempfile::tempdir().expect("temp dir");
1005 let target = temp.path().join("merged");
1006 fs::create_dir_all(&target).expect("create output");
1007 let link = temp.path().join(OsStr::from_bytes(b"merged\xff"));
1008 std::os::unix::fs::symlink(&target, &link).expect("link output");
1009 let paradox_dir = temp.path().join("paradox");
1010
1011 let error = install_launcher_stub(&link, ¶dox_dir, "foch merge")
1012 .expect_err("a link name that is not UTF-8")
1013 .to_string();
1014 assert!(error.contains("cannot name a launcher stub"), "{error}");
1015 assert_eq!(
1016 fs::read_dir(paradox_dir.join("mod"))
1017 .expect("mod dir")
1018 .count(),
1019 0,
1020 "no stub is written"
1021 );
1022 }
1023
1024 #[test]
1025 fn manifest_fingerprint_uses_ordered_acf_identity() {
1026 let config = Project::default();
1027 let first = compute_fingerprint_for_workshop_manifest(&workshop_manifest(2_001), &config);
1028 let second = compute_fingerprint_for_workshop_manifest(&workshop_manifest(2_002), &config);
1029
1030 assert_ne!(first, second);
1031 }
1032
1033 #[test]
1036 fn playset_fingerprint_ignores_a_launcher_descriptor_replace_path() {
1037 let temp = tempfile::tempdir().expect("temp dir");
1038 std::fs::create_dir_all(temp.path().join("mod")).expect("create launcher mod dir");
1039 let dlc_load = temp.path().join("dlc_load.json");
1040 std::fs::write(
1041 &dlc_load,
1042 r#"{"enabled_mods":["mod/ugc_1001.mod"],"disabled_dlcs":[]}"#,
1043 )
1044 .expect("write dlc_load");
1045 let descriptor = |extra: &str| {
1046 std::fs::write(
1047 temp.path().join("mod").join("ugc_1001.mod"),
1048 format!("name=\"Named\"\nremote_file_id=\"1001\"\nversion=\"2.0\"\n{extra}"),
1049 )
1050 .expect("write launcher descriptor");
1051 compute_fingerprint_for_playset(&dlc_load, &Project::default())
1052 };
1053
1054 let plain = descriptor("");
1055 assert!(plain.is_some());
1056 assert_eq!(descriptor("replace_path=\"common/../events\"\n"), plain);
1057 }
1058
1059 #[test]
1060 fn manifest_fingerprint_fails_closed_when_acf_resolution_fails() {
1061 let request =
1062 InputRequest::from_manifest_path(PathBuf::from("missing-foch.toml"), Config::default());
1063 assert!(compute_fingerprint_for_manifest(&request, &Project::default()).is_none());
1064 }
1065}