Skip to main content

foch_cli/cli/handler/
merge.rs

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
40/// The frozen input and analysis options of one `foch merge`.
41pub struct PreparedMerge {
42	pub request: InputRequest,
43	pub options: MergeAnalysisOptions,
44}
45
46/// Turn `foch merge` arguments into the input and options analysis takes.
47/// Without INPUT_SOURCE the input is the current EU4 playset, inspected as
48/// bare `foch` and the desktop app inspect it; `current` supplies an
49/// inspection already made. Bare `foch` analyzes through this function with
50/// the arguments it shows as a command, so the two cannot diverge.
51pub 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	// The fingerprint hashes the whole playset file, which an exclusion no
75	// longer matches; the report then carries no fingerprint.
76	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
153/// The current EU4 input without the named mods. A mod is named by its
154/// Workshop id or `#POSITION`, as `foch input inspect` lists them.
155fn 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		// A unit can write one file per contributing directory. Listing only the
252		// primary path would hide the rest of what the merge produced.
253		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
432/// Compute the playset fingerprint without resolving the complete input.
433///
434/// Launcher playsets use their ordered enabled-mod list and descriptor
435/// versions. Project inputs use only their ordered, trusted Workshop ACF
436/// identities; imports or local unversioned mods fail closed. Neither path
437/// inventories a mod root. The fingerprint also binds foch overrides and
438/// resolutions.
439fn 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
530/// Confirm replacement of the target captured by the engine's opaque token.
531/// Commit revalidates that exact target under the output lock before and after
532/// staging the frozen analyzed bytes.
533fn 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
564/// Drop a `<paradox_data_path>/mod/foch_<slug>.mod` stub pointing at the
565/// freshly-merged `out_dir` so the Paradox launcher lists the merge under
566/// "Mods" without the user having to hand-write a descriptor.
567///
568/// The launcher only enumerates `.mod` files inside its game-specific mod
569/// directory; the in-`out_dir` `descriptor.mod` we already write isn't
570/// enough on its own. The user still has to open the launcher and toggle
571/// the merge on (and disable the source mods to avoid double-loading).
572fn 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
602/// Where the stub for the output named by `descriptor_value` goes, with the
603/// stem shown in its name. Different output directories can share a slug
604/// (`/a/merged` and `/b/merged`, `my merge` and `my_merge`), so a stub is
605/// only replaced when it already names this output; otherwise the first free
606/// `foch_<slug>.mod`, `foch_<slug>_2.mod`, ... is used. A stub that cannot be
607/// read is treated as another output's and left alone.
608fn 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
659/// The launcher stub's file name part, taken from the output directory's
660/// name. The stub is written at that name, so a name that is not UTF-8 is an
661/// error instead of being rendered lossily into some other stub's name.
662fn 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	/// The launcher stub names the merged output with the library's descriptor
703	/// encoder: reading the stub back yields the output directory. Both sides
704	/// are compared canonically, since Windows canonicalizes to a verbatim
705	/// `\\?\` prefix that the descriptor spells as the plain one.
706	#[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, &paradox_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	/// Outputs whose names share a slug get separate stubs instead of the
727	/// later merge silently taking over the earlier one's launcher entry. A
728	/// repeated merge into the same output reuses its own stub.
729	#[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, &paradox_dir).expect("install stub");
742		}
743		install_launcher_stub(&outputs[0], &paradox_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	/// An output whose name holds a line break has no descriptor `path`, so
770	/// no stub is written: neither its `path` value nor its comment line could
771	/// keep the break from starting another descriptor field.
772	#[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, &paradox_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	/// A directory the descriptor format has no `path` text for gets no stub,
791	/// rather than one naming another directory.
792	#[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, &paradox_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	/// The stub's file name comes from the output directory's name, so a name
811	/// that is not UTF-8 is an error, never a lossy name another output could
812	/// also render to. In memory: some filesystems refuse such names. The
813	/// directory does not exist, so its descriptor `path` would be refused as
814	/// well; the install error must be the slug's, which is checked first.
815	#[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, &paradox_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	/// A link whose own name is not UTF-8 can point at a UTF-8 directory, so
847	/// the descriptor `path` has text while the stub's name does not: the slug
848	/// alone refuses it. Linux only: macOS filesystems refuse such names.
849	#[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, &paradox_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	/// The fingerprint reads each launcher descriptor's version only, so a
885	/// `replace_path` the mod's own descriptor would reject does not unset it.
886	#[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}