Skip to main content

foch_cli/cli/handler/
testing.rs

1//! `foch test`: input and output around `foch-test`.
2//!
3//! This module discovers files, protects output directories, launches the
4//! runner and guards the user's real game profile. Every decision about
5//! cases, sessions and verdicts belongs to `foch-test`.
6
7use super::HandlerResult;
8use crate::cli::arg::{CheckOutputFormat, TestArgs};
9use foch::game::eu4::script::parser::{AstStatement, parse_clausewitz_content};
10use foch::input::load_config_read_only;
11use foch_annotation::builtin::SCHEMAS;
12use foch_annotation::value::scalar;
13use foch_runner::{DependencyMod, Installation, RunOptions};
14use foch_test::judge::CaseResult;
15use foch_test::model::RunId;
16use foch_test::plan::{IgnoredMode, Isolation, Plan, SessionId};
17use foch_test::report::EnvironmentRecord;
18use foch_test::select::{Expr, NodeIdPattern};
19use foch_test::{
20	Bundle, Diagnostic, ExpandOptions, GroupOptions, ProjectFacts, RelPath, Report, RunArtifacts,
21	RunContext, Selector, SourceFile, SourceKind, Status, blocks_run, check_capabilities, collect,
22	compile, expand, group, judge, lint, reconcile,
23};
24use serde::Serialize;
25use std::collections::BTreeMap;
26use std::fs::{self, OpenOptions};
27use std::io::{self, Write};
28use std::path::{Component, Path, PathBuf};
29use std::sync::Arc;
30use std::sync::atomic::{AtomicBool, Ordering};
31use std::time::{Duration, SystemTime, UNIX_EPOCH};
32use walkdir::WalkDir;
33
34/// Runtime layers left by a run that was killed before cleanup are swept once
35/// they are older than this, so a concurrent run's fresh layer is never hit.
36const STALE_LAYER_GRACE: Duration = Duration::from_secs(2 * 3600);
37
38type Result<T> = std::result::Result<T, Box<dyn std::error::Error>>;
39
40pub fn handle_test(args: &TestArgs) -> HandlerResult {
41	if let Some(name) = &args.api {
42		return print_api(name, args.format);
43	}
44	let selection = Selection {
45		filter: args.filter.as_deref(),
46		marks: args.marks.as_deref(),
47		ignored: if args.ignored {
48			IgnoredMode::Only
49		} else if args.include_ignored {
50			IgnoredMode::Include
51		} else {
52			IgnoredMode::Exclude
53		},
54	};
55	let capabilities = foch_runner::capabilities();
56	let planned = plan(
57		&args.path,
58		&selection,
59		args.isolate,
60		capabilities.shared_sessions,
61	)?;
62	if args.collect_only || blocks_run(&planned.diagnostics, args.run_anyway) {
63		print_collection(&planned, args.format)?;
64		let failed =
65			blocks_run(&planned.diagnostics, args.run_anyway) || planned.plan.cases.is_empty();
66		return Ok(i32::from(failed));
67	}
68	if planned.plan.cases.is_empty() {
69		print_collection(&planned, args.format)?;
70		return Ok(1);
71	}
72	if args.no_run {
73		return write_bundles(args, &planned);
74	}
75
76	let run_id = new_run_id()?;
77	let context = RunContext {
78		run_id: run_id.clone(),
79		mod_name: planned.mod_name.clone(),
80	};
81	let mut bundles = Vec::new();
82	for session in &planned.plan.sessions {
83		let bundle = compile(&planned.plan, session.id, &context)?;
84		check_capabilities(&bundle, &capabilities)?;
85		bundles.push(bundle);
86	}
87
88	let config = load_config_read_only()?;
89	let game_root = foch_runner::locate_game(&config, args.game_path.as_deref())?;
90	let real_user_dir = args.eu4_user_dir.clone().or_else(default_eu4_user_dir);
91	// Load the mod's declared dependencies (resolved against installed mods)
92	// so content that relies on them works; unresolved ones are reported.
93	let dependencies = resolve_dependencies(&planned.root, real_user_dir.as_deref());
94	for warning in &dependencies.unresolved {
95		eprintln!("warning: dependency {warning:?} is not installed; testing without it");
96	}
97	// Ctrl-C stops the current launch gracefully: the flag is observed by the
98	// runner's watch loop, which stops the game and lets the layer tear down.
99	let cancel = Arc::new(AtomicBool::new(false));
100	install_cancel_handler(&cancel);
101	let options = RunOptions {
102		timeout: Duration::from_secs(args.timeout),
103		cancel: cancel.clone(),
104	};
105	let requested_output = match &args.out {
106		Some(output) => output.clone(),
107		// System temporary directories may use aliases such as macOS /var.
108		// Resolve this trusted default before enforcing explicit output rules.
109		None => fs::canonicalize(std::env::temp_dir())?.join(format!("foch-tests-{run_id}")),
110	};
111	let output = prepare_output(&requested_output, &planned.root)?;
112	// Keep the game's own files within MAX_PATH: a short base, not `output`.
113	let runtime_base = fs::canonicalize(std::env::temp_dir())?.join("foch-rt");
114	fs::create_dir_all(&runtime_base)?;
115	// Clear layers a previously killed run could not tear down itself.
116	foch_runner::sweep_runtime_base(&runtime_base, STALE_LAYER_GRACE);
117	let installation = Installation {
118		game_root: game_root.clone(),
119		runtime_base,
120		real_user_dir,
121	};
122
123	let mut sessions = Vec::new();
124	let mut results = Vec::new();
125	for bundle in &bundles {
126		if cancel.load(Ordering::Relaxed) {
127			break;
128		}
129		let directory = output.join(format!("session_{:04}", bundle.session.0 + 1));
130		let (record, session_results) = run_one_session(
131			&planned.plan,
132			bundle,
133			&planned.root,
134			&dependencies.mods,
135			&directory,
136			&installation,
137			&options,
138		)?;
139		sessions.push(record);
140		results.extend(session_results);
141	}
142
143	// A failure in a shared session is confirmed alone before it is reported.
144	let suspicious: Vec<_> = results
145		.iter()
146		.filter(|result| result.isolation == Isolation::Shared && !result.status.is_success())
147		.map(|result| result.index)
148		.collect();
149	if !suspicious.is_empty() && !cancel.load(Ordering::Relaxed) {
150		let rerun = planned.plan.isolated_rerun(&suspicious);
151		for session in &rerun.sessions {
152			if cancel.load(Ordering::Relaxed) {
153				break;
154			}
155			let bundle = compile(&rerun, session.id, &context)?;
156			let directory = output.join(format!("rerun_{:04}", session.id.0 + 1));
157			let (record, mut isolated) = run_one_session(
158				&rerun,
159				&bundle,
160				&planned.root,
161				&dependencies.mods,
162				&directory,
163				&installation,
164				&options,
165			)?;
166			sessions.push(record);
167			let isolated = isolated.remove(0);
168			if let Some(shared) = results
169				.iter_mut()
170				.find(|result| result.index == isolated.index)
171			{
172				*shared = reconcile(shared, isolated);
173			}
174		}
175	}
176
177	let report = Report::new(
178		run_id,
179		results,
180		planned.plan.not_run.clone(),
181		EnvironmentRecord {
182			mods: vec![planned.mod_name.clone()],
183			runner: Some(format!("built-in {}", game_root.display())),
184			..EnvironmentRecord::default()
185		},
186	);
187	let view = RunView {
188		source_root: &planned.root,
189		output: &output,
190		success: report.exit_code() == 0,
191		summary: report.summary(),
192		sessions: &sessions,
193		report: &report,
194	};
195	write_new(
196		&output.join("result.json"),
197		&serde_json::to_vec_pretty(&view)?,
198	)?;
199	write_new(&output.join("junit.xml"), report.to_junit().as_bytes())?;
200	if args.format == CheckOutputFormat::Json {
201		print_json(&view)?;
202	} else {
203		print_results(&report, &output);
204	}
205	Ok(report.exit_code())
206}
207
208/// `--no-run`: write every session's bundle without launching the game.
209fn write_bundles(args: &TestArgs, planned: &Planned) -> HandlerResult {
210	let run_id = new_run_id()?;
211	let context = RunContext {
212		run_id: run_id.clone(),
213		mod_name: planned.mod_name.clone(),
214	};
215	let mut compiled = Vec::new();
216	for session in &planned.plan.sessions {
217		compiled.push(compile(&planned.plan, session.id, &context)?);
218	}
219	let requested_output = match &args.out {
220		Some(output) => output.clone(),
221		None => fs::canonicalize(std::env::temp_dir())?.join(format!("foch-tests-{run_id}")),
222	};
223	let output = prepare_output(&requested_output, &planned.root)?;
224	let mut bundles = Vec::new();
225	for bundle in &compiled {
226		let directory = output.join(format!("session_{:04}", bundle.session.0 + 1));
227		bundles.push(materialize(bundle, &planned.root, &directory)?);
228	}
229	if args.format == CheckOutputFormat::Json {
230		print_json(&serde_json::json!({ "run_id": run_id, "output": output, "bundles": bundles }))?;
231	} else {
232		for bundle in bundles {
233			println!("{}", bundle.display());
234		}
235	}
236	Ok(0)
237}
238
239fn print_api(name: &str, format: CheckOutputFormat) -> HandlerResult {
240	let schemas: Vec<_> = SCHEMAS
241		.iter()
242		.filter(|schema| {
243			name.is_empty()
244				|| schema.name == name
245				|| schema.params.iter().any(|param| param.name == name)
246		})
247		.collect();
248	if schemas.is_empty() {
249		return Err(format!("unknown annotation or parameter: {name}").into());
250	}
251	if format == CheckOutputFormat::Json {
252		print_json(&serde_json::json!({ "annotations": schemas }))?;
253		return Ok(0);
254	}
255	for schema in schemas {
256		println!("#{}(...)\n  {}\n", schema.name, schema.description);
257		for param in schema
258			.params
259			.iter()
260			.filter(|param| name.is_empty() || name == schema.name || param.name == name)
261		{
262			println!(
263				"  {} ({}){}: {}\n    {} = {}",
264				param.name,
265				param.value_type.describe(),
266				if param.required { " required" } else { "" },
267				param.description,
268				param.name,
269				param.example
270			);
271		}
272		if let Some(positional) = schema.positional {
273			println!(
274				"  {}: #{}({})",
275				positional.description, schema.name, positional.example
276			);
277		}
278		println!();
279	}
280	Ok(0)
281}
282
283struct Selection<'a> {
284	filter: Option<&'a str>,
285	marks: Option<&'a str>,
286	ignored: IgnoredMode,
287}
288
289struct Planned {
290	root: PathBuf,
291	mod_name: String,
292	plan: Plan,
293	diagnostics: Vec<Diagnostic>,
294}
295
296/// Discover, collect, expand, lint and group. Diagnostics are returned for
297/// the caller to print; a plan is always produced so collection can be shown.
298fn plan(input: &Path, selection: &Selection, isolate: bool, shared: bool) -> Result<Planned> {
299	// `PATH::node` selects inside a file, as in pytest.
300	let text = input.to_string_lossy();
301	let (path, node) = match text.split_once("::") {
302		Some((path, node)) => (PathBuf::from(path), Some(node.to_string())),
303		None => (input.to_path_buf(), None),
304	};
305	let (root, files) = discover(&path)?;
306	let mod_name = mod_name(&root);
307	let mut sources = Vec::new();
308	for file in files {
309		let relative = file
310			.strip_prefix(&root)?
311			.to_string_lossy()
312			.replace('\\', "/");
313		// Files under tests/ are test-only: the game loads events/ but not a
314		// top-level tests/ directory, so these carry test and fixture blocks
315		// (and tests/events/ helper events) rather than inline annotations.
316		let kind = if relative == "tests" || relative.starts_with("tests/") {
317			SourceKind::TestsDir
318		} else {
319			SourceKind::Inline
320		};
321		sources.push(SourceFile {
322			mod_name: mod_name.clone(),
323			path: RelPath::new(&relative)?,
324			kind,
325			text: fs::read_to_string(&file)?,
326		});
327	}
328	let collection = collect(&sources);
329	let mut selector = Selector {
330		keyword: selection.filter.map(Expr::parse).transpose()?,
331		marks: selection.marks.map(Expr::parse).transpose()?,
332		..Selector::default()
333	};
334	if let Some(node) = node {
335		let file = path.canonicalize()?;
336		let relative = file
337			.strip_prefix(&root)?
338			.to_string_lossy()
339			.replace('\\', "/");
340		selector
341			.nodes
342			.push(NodeIdPattern::parse(&format!("{relative}::{node}"))?);
343	} else if path.is_file() {
344		let relative = path
345			.canonicalize()?
346			.strip_prefix(&root)?
347			.to_string_lossy()
348			.replace('\\', "/");
349		selector.nodes.push(NodeIdPattern::parse(&relative)?);
350	}
351	let mut diagnostics = collection.diagnostics.clone();
352	let options = ExpandOptions {
353		selector,
354		ignored: selection.ignored,
355		..ExpandOptions::default()
356	};
357	let plan = match expand(&collection, &options) {
358		Ok(expanded) => {
359			let linted = lint(&expanded, &project_facts());
360			diagnostics.extend(linted.diagnostics.iter().cloned());
361			group(
362				expanded,
363				&linted,
364				&GroupOptions {
365					force_isolate: isolate,
366					runner_shared_sessions: shared,
367					..GroupOptions::default()
368				},
369			)
370		}
371		Err(errors) => {
372			diagnostics.extend(errors);
373			Plan {
374				cases: Vec::new(),
375				not_run: Vec::new(),
376				sessions: Vec::new(),
377				alone_reasons: BTreeMap::new(),
378				events: Vec::new(),
379				helpers: Vec::new(),
380			}
381		}
382	};
383	Ok(Planned {
384		root,
385		mod_name,
386		plan,
387		diagnostics,
388	})
389}
390
391/// The mod's declared dependencies, resolved to installed directories, plus
392/// the names that could not be resolved.
393struct ResolvedDependencies {
394	mods: Vec<DependencyMod>,
395	unresolved: Vec<String>,
396}
397
398/// Resolve the mod under test's declared dependencies (transitively) against
399/// the mods installed in the player's user directory. Dependencies are ordered
400/// so each appears before the mod that declares it. Resolution needs the user
401/// directory's `mod/*.mod` registry; without it, nothing is resolved.
402fn resolve_dependencies(source_root: &Path, user_dir: Option<&Path>) -> ResolvedDependencies {
403	use foch::playset::descriptor::{load_descriptor, load_launcher_descriptor};
404	let mut result = ResolvedDependencies {
405		mods: Vec::new(),
406		unresolved: Vec::new(),
407	};
408	let Ok(root_descriptor) = load_descriptor(&source_root.join("descriptor.mod")) else {
409		return result;
410	};
411	if root_descriptor.dependencies.is_empty() {
412		return result;
413	}
414	// name -> directory, from the launcher's installed-mod registry.
415	let mut index = BTreeMap::new();
416	if let Some(user_dir) = user_dir {
417		for entry in fs::read_dir(user_dir.join("mod"))
418			.into_iter()
419			.flatten()
420			.flatten()
421		{
422			if entry.path().extension().and_then(|v| v.to_str()) != Some("mod") {
423				continue;
424			}
425			if let Ok(descriptor) = load_launcher_descriptor(&entry.path())
426				&& let Some(path) = descriptor.path
427			{
428				index.entry(descriptor.name).or_insert(path);
429			}
430		}
431	}
432	let mut visited = std::collections::BTreeSet::new();
433	let mut emitted = std::collections::BTreeSet::new();
434	visit_dependencies(
435		&root_descriptor.dependencies,
436		&index,
437		&mut visited,
438		&mut emitted,
439		&mut result,
440	);
441	result
442}
443
444/// Post-order over the dependency graph: a dependency's own dependencies are
445/// emitted before it, each mod once, cycles broken by the visited set.
446fn visit_dependencies(
447	names: &[String],
448	index: &BTreeMap<String, PathBuf>,
449	visited: &mut std::collections::BTreeSet<String>,
450	emitted: &mut std::collections::BTreeSet<String>,
451	result: &mut ResolvedDependencies,
452) {
453	use foch::playset::descriptor::load_descriptor;
454	for name in names {
455		if !visited.insert(name.clone()) {
456			continue;
457		}
458		let Some(path) = index.get(name) else {
459			if !result.unresolved.contains(name) {
460				result.unresolved.push(name.clone());
461			}
462			continue;
463		};
464		if let Ok(descriptor) = load_descriptor(&path.join("descriptor.mod")) {
465			visit_dependencies(&descriptor.dependencies, index, visited, emitted, result);
466		}
467		if emitted.insert(name.clone()) {
468			result.mods.push(DependencyMod {
469				name: name.clone(),
470				path: path.clone(),
471			});
472		}
473	}
474}
475
476/// Install a Ctrl-C handler that sets `cancel`, so the run stops after the
477/// current launch is torn down. A second Ctrl-C exits immediately. If a handler
478/// is already installed (e.g. the host set one), the run simply has no handler
479/// rather than failing.
480fn install_cancel_handler(cancel: &Arc<AtomicBool>) {
481	let flag = cancel.clone();
482	let _ = ctrlc::set_handler(move || {
483		if flag.swap(true, Ordering::SeqCst) {
484			// Already asked once; the user wants out now.
485			std::process::exit(130);
486		}
487		eprintln!(
488			"interrupt received; stopping after the current game is torn down (Ctrl-C again to force)"
489		);
490	});
491}
492
493/// Static facts for the pre-launch lint, drawn from the embedded EU4 builtin
494/// catalog. Effect and trigger names let the linter catch an effect used in a
495/// condition block or a trigger used in an effect block before any game runs.
496///
497/// Scope changers, iterators and special blocks are valid in both kinds of
498/// block, so they go in both sets and are never flagged. Country tags and the
499/// full event set are left unknown: they would need the base snapshot, and a
500/// mod-only tag set would wrongly flag vanilla countries. See the static-layer
501/// follow-up for wiring those from the analyzed snapshot.
502fn project_facts() -> ProjectFacts {
503	use foch::game::eu4::base::builtin::{
504		builtin_effect_names, builtin_iterator_names, builtin_scope_changer_names,
505		builtin_special_block_names, builtin_trigger_names,
506	};
507	let shared: std::collections::BTreeSet<String> = builtin_scope_changer_names()
508		.iter()
509		.chain(builtin_iterator_names())
510		.chain(builtin_special_block_names())
511		.cloned()
512		.collect();
513	let with_shared = |names: &[String]| -> std::collections::BTreeSet<String> {
514		names.iter().chain(shared.iter()).cloned().collect()
515	};
516	ProjectFacts {
517		tags: None,
518		events: None,
519		effects: Some(with_shared(builtin_effect_names())),
520		triggers: Some(with_shared(builtin_trigger_names())),
521	}
522}
523
524fn discover(path: &Path) -> Result<(PathBuf, Vec<PathBuf>)> {
525	for ancestor in std::path::absolute(path)?.ancestors() {
526		if fs::symlink_metadata(ancestor)?.file_type().is_symlink() {
527			return Err(format!(
528				"test input must not traverse a symlink: {}",
529				ancestor.display()
530			)
531			.into());
532		}
533	}
534	let metadata = fs::symlink_metadata(path)?;
535	let path = fs::canonicalize(path)?;
536	if metadata.is_file() {
537		if path.extension().and_then(|v| v.to_str()) != Some("txt") {
538			return Err("explicit test input must be an event .txt file".into());
539		}
540		let parent = path.parent().ok_or("test file has no parent")?;
541		let root = parent
542			.ancestors()
543			.find(|p| p.file_name().is_some_and(|v| v == "events"))
544			.and_then(Path::parent)
545			.unwrap_or(parent)
546			.to_path_buf();
547		// Collect the whole mod so fire targets and grouping see every event;
548		// the file itself is selected by node pattern.
549		let mut files = event_files(&root)?;
550		files.extend(tests_files(&root)?);
551		if !files.contains(&path) {
552			files.push(path);
553		}
554		return Ok((root, files));
555	}
556	if !metadata.is_dir() {
557		return Err("test input must be a mod directory or an event .txt file".into());
558	}
559	let root = if path.file_name().is_some_and(|v| v == "events") {
560		path.parent()
561			.ok_or("events directory has no parent")?
562			.to_path_buf()
563	} else {
564		path
565	};
566	let mut files = event_files(&root)?;
567	files.extend(tests_files(&root)?);
568	Ok((root, files))
569}
570
571/// `.txt` files under the mod's `tests/` directory. The game does not load
572/// this directory, so these hold test and fixture blocks (and, under
573/// `tests/events/`, helper events for the generated test layer only).
574fn tests_files(root: &Path) -> Result<Vec<PathBuf>> {
575	let tests = root.join("tests");
576	let mut files = Vec::new();
577	match fs::symlink_metadata(&tests) {
578		Ok(metadata) if metadata.file_type().is_symlink() => {
579			return Err("tests directory must not be a symlink".into());
580		}
581		Ok(_) => {
582			for entry in WalkDir::new(tests).follow_links(false) {
583				let entry = entry?;
584				if entry.file_type().is_file()
585					&& entry.path().extension().and_then(|v| v.to_str()) == Some("txt")
586				{
587					files.push(entry.into_path());
588				}
589			}
590		}
591		Err(err) if err.kind() == io::ErrorKind::NotFound => {}
592		Err(err) => return Err(err.into()),
593	}
594	files.sort();
595	Ok(files)
596}
597
598fn event_files(root: &Path) -> Result<Vec<PathBuf>> {
599	let events = root.join("events");
600	let mut files = Vec::new();
601	match fs::symlink_metadata(&events) {
602		Ok(metadata) if metadata.file_type().is_symlink() => {
603			return Err("events directory must not be a symlink".into());
604		}
605		Ok(_) => {
606			for entry in WalkDir::new(events).follow_links(false) {
607				let entry = entry?;
608				if entry.file_type().is_file()
609					&& entry.path().extension().and_then(|v| v.to_str()) == Some("txt")
610				{
611					files.push(entry.into_path());
612				}
613			}
614		}
615		Err(err) if err.kind() == io::ErrorKind::NotFound => {}
616		Err(err) => return Err(err.into()),
617	}
618	files.sort();
619	Ok(files)
620}
621
622/// The descriptor's `name`, else the directory name.
623fn mod_name(root: &Path) -> String {
624	let from_descriptor = fs::read_to_string(root.join("descriptor.mod"))
625		.ok()
626		.and_then(|text| {
627			let parsed = parse_clausewitz_content(
628				foch::model::GamePath::new("descriptor.mod").unwrap(),
629				&text,
630			);
631			parsed
632				.ast
633				.statements
634				.iter()
635				.find_map(|statement| match statement {
636					AstStatement::Assignment { key, value, .. } if key == "name" => {
637						scalar(value).ok()
638					}
639					_ => None,
640				})
641		});
642	from_descriptor
643		.filter(|name| !name.trim().is_empty() && !name.contains("::"))
644		.or_else(|| {
645			root.file_name()
646				.map(|name| name.to_string_lossy().into_owned())
647		})
648		.unwrap_or_else(|| "mod".into())
649}
650
651#[derive(Serialize)]
652struct CaseView<'a> {
653	node_id: String,
654	kind: &'static str,
655	origin: &'a foch_test::source::SourceSpan,
656	start: &'a foch_test::model::Start,
657	ai: foch_test::model::AiMode,
658	marks: &'a foch_test::model::Marks,
659	session: Option<SessionId>,
660	isolation: Option<Isolation>,
661	alone_reason: Option<&'a foch_test::plan::AloneReason>,
662}
663
664fn print_collection(planned: &Planned, format: CheckOutputFormat) -> Result<()> {
665	let plan = &planned.plan;
666	let views: Vec<_> = plan
667		.cases
668		.iter()
669		.map(|planned_case| {
670			let session = plan
671				.sessions
672				.iter()
673				.find(|session| session.cases.contains(&planned_case.index));
674			CaseView {
675				node_id: planned_case.case.node.to_string(),
676				kind: if planned_case.case.is_smoke() {
677					"smoke"
678				} else {
679					"assertion"
680				},
681				origin: &planned_case.case.origin,
682				start: &planned_case.case.start,
683				ai: planned_case.case.ai,
684				marks: &planned_case.case.marks,
685				session: session.map(|session| session.id),
686				isolation: session.map(|session| session.isolation),
687				alone_reason: plan.alone_reasons.get(&planned_case.index),
688			}
689		})
690		.collect();
691	if format == CheckOutputFormat::Json {
692		print_json(&serde_json::json!({
693			"cases": views,
694			"not_run": plan.not_run,
695			"diagnostics": planned.diagnostics,
696		}))?;
697	} else {
698		for view in &views {
699			let session = match (view.session, view.isolation, view.alone_reason) {
700				(Some(id), Some(Isolation::Shared), _) => format!("session {} shared", id.0 + 1),
701				(Some(id), _, Some(reason)) => {
702					format!("session {} alone: {}", id.0 + 1, alone_label(reason))
703				}
704				(Some(id), ..) => format!("session {}", id.0 + 1),
705				(None, ..) => "not planned".into(),
706			};
707			let ai = match view.ai {
708				foch_test::model::AiMode::On => "on",
709				foch_test::model::AiMode::Off => "off",
710			};
711			println!(
712				"{}\t{}\t{}\t{} {}\tai={ai}\t{session}",
713				view.kind, view.origin, view.node_id, view.start.date, view.start.tag
714			);
715		}
716		for not_run in &plan.not_run {
717			println!("{:?}\t{}\t{}", not_run.reason, not_run.origin, not_run.node);
718		}
719		for diagnostic in &planned.diagnostics {
720			eprintln!("{diagnostic}");
721		}
722	}
723	if plan.cases.is_empty() {
724		eprintln!("no tests selected (check PATH, -k and -m)");
725	}
726	Ok(())
727}
728
729/// `{"reason": "unknown_effect", "detail": "..."}` -> `unknown effect (...)`.
730fn alone_label(reason: &foch_test::plan::AloneReason) -> String {
731	let value = serde_json::to_value(reason).unwrap_or_default();
732	let mut label = value["reason"]
733		.as_str()
734		.unwrap_or_default()
735		.replace('_', " ");
736	if let Some(detail) = value["detail"].as_str() {
737		label.push_str(&format!(" ({detail})"));
738	}
739	label
740}
741
742fn print_results(report: &Report, output: &Path) {
743	for result in &report.results {
744		println!(
745			"{}\t{}\t{}\t{} {}\t{:.1}s",
746			status_label(result.status),
747			result.origin,
748			result.node,
749			result.start.date,
750			result.start.tag,
751			result.timing.wall_ms as f64 / 1000.0
752		);
753		for failure in &result.failures {
754			match &failure.span {
755				Some(span) => println!("  {span}  {}: {}", failure.check, failure.message),
756				None => println!("  {}: {}", failure.check, failure.message),
757			}
758		}
759		if !result.missing_checks.is_empty() {
760			println!("  missing: {}", result.missing_checks.join(", "));
761		}
762		for note in &result.notes {
763			println!("  {note}");
764		}
765	}
766	let summary = report.summary();
767	println!(
768		"{} passed, {} smoke, {} failed, {} errors, {} skipped in {:.1}s",
769		summary.passed + summary.xfailed,
770		summary.smoke,
771		summary.failed + summary.xpassed,
772		summary.setup_error + summary.incomplete + summary.protocol_error + summary.runtime_error,
773		summary.skipped + summary.ignored,
774		summary.wall_ms as f64 / 1000.0
775	);
776	println!("Artifacts: {}", output.display());
777}
778
779fn status_label(status: Status) -> String {
780	serde_json::to_value(status)
781		.ok()
782		.and_then(|value| value["status"].as_str().map(str::to_uppercase))
783		.unwrap_or_else(|| format!("{status:?}"))
784}
785
786#[derive(Serialize)]
787struct RunView<'a> {
788	source_root: &'a Path,
789	output: &'a Path,
790	success: bool,
791	summary: foch_test::report::Summary,
792	sessions: &'a [SessionRecord],
793	#[serde(flatten)]
794	report: &'a Report,
795}
796
797/// What the CLI observed about one game launch.
798#[derive(Clone, Debug, Serialize)]
799struct SessionRecord {
800	cases: Vec<String>,
801	launch: String,
802	#[serde(flatten)]
803	exit: foch_test::RunnerExit,
804}
805
806/// Build one session's test layer, launch the game through `foch-runner`,
807/// judge the log, and record what was launched.
808#[allow(clippy::too_many_arguments)]
809fn run_one_session(
810	plan: &Plan,
811	bundle: &Bundle,
812	source_root: &Path,
813	dependencies: &[DependencyMod],
814	directory: &Path,
815	installation: &Installation,
816	options: &RunOptions,
817) -> Result<(SessionRecord, Vec<CaseResult>)> {
818	// Write the full bundle (test layer, commands, manifest) for inspection;
819	// the runner reuses the test layer it leaves in `directory`.
820	materialize(bundle, source_root, directory)?;
821	let outcome = foch_runner::run_session(
822		bundle,
823		source_root,
824		dependencies,
825		directory,
826		installation,
827		options,
828	)?;
829	let results = judge(
830		bundle,
831		plan,
832		&RunArtifacts {
833			game_log: outcome.game_log.as_deref(),
834			error_log: outcome.error_log.as_deref(),
835			exit: outcome.exit.clone(),
836			timing: outcome.timing,
837			profile_untouched: outcome.profile_untouched,
838		},
839	);
840	let record = SessionRecord {
841		cases: bundle
842			.cases
843			.iter()
844			.map(|&index| plan.case(index).case.node.to_string())
845			.collect(),
846		launch: outcome.launch_detail,
847		exit: outcome.exit,
848	};
849	Ok((record, results))
850}
851
852/// The player's real EU4 user directory under their Documents folder.
853fn default_eu4_user_dir() -> Option<PathBuf> {
854	dirs::document_dir().map(|documents| {
855		documents
856			.join("Paradox Interactive")
857			.join("Europa Universalis IV")
858	})
859}
860
861fn new_run_id() -> Result<RunId> {
862	let nanos = SystemTime::now()
863		.duration_since(UNIX_EPOCH)
864		.unwrap_or_default()
865		.as_nanos();
866	Ok(RunId::parse(&format!("{}_{nanos}", std::process::id()))?)
867}
868
869fn print_json(value: &impl Serialize) -> Result<()> {
870	let stdout = io::stdout();
871	let mut stdout = stdout.lock();
872	serde_json::to_writer_pretty(&mut stdout, value)?;
873	writeln!(stdout)?;
874	Ok(())
875}
876
877// Resolve nonexistent output components before writing, so lexical '..' and
878// existing symlinks cannot route a new directory into the source mod.
879fn resolved_output(path: &Path) -> Result<PathBuf> {
880	let absolute = std::path::absolute(path)?;
881	let mut resolved = PathBuf::new();
882	for component in absolute.components() {
883		match component {
884			Component::Prefix(prefix) => resolved.push(prefix.as_os_str()),
885			Component::RootDir => resolved.push(component.as_os_str()),
886			Component::CurDir => {}
887			Component::ParentDir => {
888				resolved.pop();
889			}
890			component => {
891				resolved.push(component.as_os_str());
892				match fs::symlink_metadata(&resolved) {
893					Ok(metadata) if metadata.file_type().is_symlink() => {
894						return Err(format!(
895							"output must not traverse a symlink: {}",
896							resolved.display()
897						)
898						.into());
899					}
900					Ok(_) => {}
901					Err(err) if err.kind() == io::ErrorKind::NotFound => {}
902					Err(err) => {
903						return Err(format!(
904							"cannot inspect output component {}: {err}",
905							resolved.display()
906						)
907						.into());
908					}
909				}
910			}
911		}
912	}
913	let mut missing = Vec::new();
914	loop {
915		match fs::symlink_metadata(&resolved) {
916			Ok(_) => break,
917			Err(error) if error.kind() == io::ErrorKind::NotFound => {
918				missing.push(
919					resolved
920						.file_name()
921						.ok_or("output has no existing ancestor")?
922						.to_os_string(),
923				);
924				resolved.pop();
925			}
926			Err(error) => return Err(error.into()),
927		}
928	}
929	// Canonicalize only the closest existing ancestor. On Windows, opening an
930	// otherwise traversable ancestor (such as a protected home) may be denied.
931	let mut resolved = fs::canonicalize(resolved)?;
932	for component in missing.into_iter().rev() {
933		resolved.push(component);
934	}
935	Ok(resolved)
936}
937
938fn prepare_output(path: &Path, source_root: &Path) -> Result<PathBuf> {
939	let output = resolved_output(path)?;
940	if output.starts_with(source_root) {
941		return Err("test output must be outside the source mod".into());
942	}
943	if output.exists() {
944		if !output.is_dir() || fs::read_dir(&output)?.next().transpose()?.is_some() {
945			return Err(format!("test output must be new or empty: {}", output.display()).into());
946		}
947	} else {
948		if let Some(parent) = output.parent() {
949			fs::create_dir_all(parent)?;
950		}
951		fs::create_dir(&output)?;
952	}
953	Ok(fs::canonicalize(output)?)
954}
955
956fn write_new(path: &Path, bytes: &[u8]) -> Result<()> {
957	let mut file = OpenOptions::new().write(true).create_new(true).open(path)?;
958	file.write_all(bytes)?;
959	Ok(())
960}
961
962/// Writes one session's bundle and returns the absolute `bundle.json` path.
963fn materialize(bundle: &Bundle, source_root: &Path, directory: &Path) -> Result<PathBuf> {
964	fs::create_dir(directory)?;
965	for (relative, content) in &bundle.files {
966		let relative = Path::new(relative);
967		if relative
968			.components()
969			.any(|c| !matches!(c, Component::Normal(_)))
970		{
971			return Err("compiler produced an unsafe artifact path".into());
972		}
973		let destination = directory.join(relative);
974		fs::create_dir_all(destination.parent().ok_or("artifact has no parent")?)?;
975		write_new(&destination, content.as_bytes())?;
976	}
977	let mut manifest = serde_json::to_value(bundle)?;
978	manifest["source_root"] = serde_json::to_value(source_root)?;
979	let path = directory.join("bundle.json");
980	write_new(&path, &serde_json::to_vec_pretty(&manifest)?)?;
981	Ok(path)
982}
983
984#[cfg(test)]
985mod tests {
986	use super::*;
987
988	fn write(path: &Path, text: &str) {
989		fs::create_dir_all(path.parent().unwrap()).unwrap();
990		fs::write(path, text).unwrap();
991	}
992
993	fn forward(path: &Path) -> String {
994		path.display().to_string().replace('\\', "/")
995	}
996
997	#[test]
998	fn dependencies_resolve_transitively_and_report_missing() {
999		let temp = tempfile::tempdir().unwrap();
1000		let base = temp.path();
1001		// Installed mods: Dep A depends on Dep B; Dep B stands alone.
1002		let dep_a = base.join("installed/a");
1003		let dep_b = base.join("installed/b");
1004		write(
1005			&dep_a.join("descriptor.mod"),
1006			"name=\"Dep A\"\ndependencies={ \"Dep B\" }\n",
1007		);
1008		write(&dep_b.join("descriptor.mod"), "name=\"Dep B\"\n");
1009		// The launcher registry under the user directory names both.
1010		let user_dir = base.join("user");
1011		write(
1012			&user_dir.join("mod/a.mod"),
1013			&format!("name=\"Dep A\"\npath=\"{}\"\n", forward(&dep_a)),
1014		);
1015		write(
1016			&user_dir.join("mod/b.mod"),
1017			&format!("name=\"Dep B\"\npath=\"{}\"\n", forward(&dep_b)),
1018		);
1019		// The mod under test depends on Dep A and a mod that is not installed.
1020		let source = base.join("source");
1021		write(
1022			&source.join("descriptor.mod"),
1023			"name=\"Src\"\ndependencies={ \"Dep A\" \"Ghost\" }\n",
1024		);
1025
1026		let resolved = resolve_dependencies(&source, Some(&user_dir));
1027		let order: Vec<_> = resolved.mods.iter().map(|m| m.name.as_str()).collect();
1028		assert_eq!(
1029			order,
1030			["Dep B", "Dep A"],
1031			"a dependency precedes its dependent"
1032		);
1033		assert_eq!(resolved.unresolved, ["Ghost"]);
1034	}
1035
1036	#[test]
1037	fn a_mod_without_dependencies_resolves_to_nothing() {
1038		let temp = tempfile::tempdir().unwrap();
1039		let source = temp.path().join("source");
1040		write(&source.join("descriptor.mod"), "name=\"Src\"\n");
1041		let resolved = resolve_dependencies(&source, None);
1042		assert!(resolved.mods.is_empty() && resolved.unresolved.is_empty());
1043	}
1044}