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::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	// Refuse a review path before the analysis it would follow.
66	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
82/// The frozen input and analysis options of one `foch merge`.
83pub struct PreparedMerge {
84	pub request: InputRequest,
85	pub options: MergeAnalysisOptions,
86}
87
88/// Turn `foch merge` arguments into the input and options analysis takes.
89/// Without INPUT_SOURCE the input is the current EU4 playset, inspected as
90/// bare `foch` and the desktop app inspect it; `current` supplies an
91/// inspection already made. Bare `foch` analyzes through this function with
92/// the arguments it shows as a command, so the two cannot diverge.
93pub 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	// The fingerprint hashes the whole playset file, which an exclusion no
117	// longer matches; the report then carries no fingerprint.
118	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
229/// Where `--review-json` may write. It never replaces a file that is not an
230/// earlier review, so a path naming the playset, a foch.toml or any other
231/// input is refused instead of overwritten, and it never writes into the
232/// output directory, which commit installs as a whole. Both are compared as
233/// the locations a write would reach, which the review is then written to.
234fn 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
274/// The location a write to `path` reaches: its deepest existing ancestor
275/// with every link resolved, then the components that do not exist yet with
276/// `.` and `..` applied. A lexical comparison would let `out/x/../review.json`
277/// slip into `out` and would refuse `out/../review.json`.
278fn 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
303/// The current EU4 input without the named mods. A mod is named by its
304/// Workshop id or `#POSITION`, as `foch input inspect` lists them.
305fn 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		// A unit can write one file per contributing directory. Listing only the
402		// primary path would hide the rest of what the merge produced.
403		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
582/// Compute the playset fingerprint without resolving the complete input.
583///
584/// Launcher playsets use their ordered enabled-mod list and descriptor
585/// versions. Project inputs use only their ordered, trusted Workshop ACF
586/// identities; imports or local unversioned mods fail closed. Neither path
587/// inventories a mod root. The fingerprint also binds foch overrides and
588/// resolutions.
589fn 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
680/// Confirm replacement of the target captured by the engine's opaque token.
681/// Commit revalidates that exact target under the output lock before and after
682/// staging the frozen analyzed bytes.
683fn 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
714/// Drop a `<paradox_data_path>/mod/foch_<slug>.mod` stub pointing at the
715/// mod Foch wrote into `out_dir`, a merge or a patch mod, so the Paradox
716/// launcher lists it under "Mods" as `<label> (<slug>)` without the user
717/// having to hand-write a descriptor; the stub's path is returned.
718///
719/// The launcher only enumerates `.mod` files inside its game-specific mod
720/// directory; the in-`out_dir` `descriptor.mod` we already write isn't
721/// enough on its own. The user still has to open the launcher and toggle
722/// the mod on.
723pub(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
750/// Where the stub for the output named by `descriptor_value` goes, with the
751/// stem shown in its name. Different output directories can share a slug
752/// (`/a/merged` and `/b/merged`, `my merge` and `my_merge`), so a stub is
753/// only replaced when it already names this output; otherwise the first free
754/// `foch_<slug>.mod`, `foch_<slug>_2.mod`, ... is used. A stub that cannot be
755/// read is treated as another output's and left alone.
756fn 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
807/// The launcher stub's file name part, taken from the output directory's
808/// name. The stub is written at that name, so a name that is not UTF-8 is an
809/// error instead of being rendered lossily into some other stub's name.
810fn 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	/// The launcher stub names the merged output with the library's descriptor
851	/// encoder: reading the stub back yields the output directory. Both sides
852	/// are compared canonically, since Windows canonicalizes to a verbatim
853	/// `\\?\` prefix that the descriptor spells as the plain one.
854	#[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, &paradox_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	/// Outputs whose names share a slug get separate stubs instead of the
875	/// later merge silently taking over the earlier one's launcher entry. A
876	/// repeated merge into the same output reuses its own stub.
877	#[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, &paradox_dir, "foch merge").expect("install stub");
890		}
891		install_launcher_stub(&outputs[0], &paradox_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	/// An output whose name holds a line break has no descriptor `path`, so
919	/// no stub is written: neither its `path` value nor its comment line could
920	/// keep the break from starting another descriptor field.
921	#[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, &paradox_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	/// A directory the descriptor format has no `path` text for gets no stub,
940	/// rather than one naming another directory.
941	#[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, &paradox_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	/// The stub's file name comes from the output directory's name, so a name
960	/// that is not UTF-8 is an error, never a lossy name another output could
961	/// also render to. In memory: some filesystems refuse such names. The
962	/// directory does not exist, so its descriptor `path` would be refused as
963	/// well; the install error must be the slug's, which is checked first.
964	#[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, &paradox_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	/// A link whose own name is not UTF-8 can point at a UTF-8 directory, so
996	/// the descriptor `path` has text while the stub's name does not: the slug
997	/// alone refuses it. Linux only: macOS filesystems refuse such names.
998	#[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, &paradox_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	/// The fingerprint reads each launcher descriptor's version only, so a
1034	/// `replace_path` the mod's own descriptor would reject does not unset it.
1035	#[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}