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