1use 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
34const 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 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 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 None => fs::canonicalize(std::env::temp_dir())?.join(format!("foch-tests-{run_id}")),
110 };
111 let output = prepare_output(&requested_output, &planned.root)?;
112 let runtime_base = fs::canonicalize(std::env::temp_dir())?.join("foch-rt");
114 fs::create_dir_all(&runtime_base)?;
115 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 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
208fn 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
296fn plan(input: &Path, selection: &Selection, isolate: bool, shared: bool) -> Result<Planned> {
299 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 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
391struct ResolvedDependencies {
394 mods: Vec<DependencyMod>,
395 unresolved: Vec<String>,
396}
397
398fn 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 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
444fn 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
476fn 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 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
493fn 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 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
571fn 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
622fn 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
729fn 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#[derive(Clone, Debug, Serialize)]
799struct SessionRecord {
800 cases: Vec<String>,
801 launch: String,
802 #[serde(flatten)]
803 exit: foch_test::RunnerExit,
804}
805
806#[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 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
852fn 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
877fn 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 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
962fn 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 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 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 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}