foch 0.1.0

Paradox mod static analysis toolkit with CLI and EU4-focused language tooling
Documentation
use serde_json::json;
use std::fs;
use std::path::Path;
use std::process::Command;
use tempfile::TempDir;

fn write_playlist(path: &Path, mods: serde_json::Value) {
	let playlist = json!({
		"game": "eu4",
		"name": "cli-playset",
		"mods": mods,
	});
	fs::write(
		path,
		serde_json::to_string_pretty(&playlist).expect("serialize playlist"),
	)
	.expect("write playlist");
}

fn write_descriptor(mod_root: &Path, name: &str) {
	fs::create_dir_all(mod_root).expect("create mod root");
	fs::write(
		mod_root.join("descriptor.mod"),
		format!("name=\"{name}\"\nversion=\"1.0.0\"\n"),
	)
	.expect("write descriptor");
}

fn run_foch(args: &[&str], config_dir: &Path) -> (i32, String, String) {
	let output = Command::new(env!("CARGO_BIN_EXE_foch"))
		.env("FOCH_CONFIG_DIR", config_dir)
		.args(args)
		.output()
		.expect("failed to run foch");

	(
		output.status.code().unwrap_or(-1),
		String::from_utf8(output.stdout).expect("stdout utf8"),
		String::from_utf8(output.stderr).expect("stderr utf8"),
	)
}

#[test]
fn missing_playset_path_returns_exit_1() {
	let tmp = TempDir::new().expect("temp dir");
	let missing = tmp.path().join("missing.json");
	let missing_string = missing.display().to_string();
	let args = ["check", missing_string.as_str()];

	let (code, stdout, _stderr) = run_foch(&args, tmp.path());
	assert_eq!(code, 1);
	assert!(stdout.contains("fatal_errors: 1"));
}

#[test]
fn strict_mode_returns_exit_2_when_findings_exist() {
	let tmp = TempDir::new().expect("temp dir");
	let playlist_path = tmp.path().join("playlist.json");

	write_playlist(
		&playlist_path,
		json!([
			{"displayName":"A", "enabled": true, "position": 0, "steamId":"4001"},
			{"displayName":"B", "enabled": true, "position": 1, "steamId":"4001"}
		]),
	);
	write_descriptor(&tmp.path().join("4001"), "mod-a");

	let playlist_str = playlist_path.display().to_string();
	let args = ["check", playlist_str.as_str(), "--strict"];
	let (code, stdout, _stderr) = run_foch(&args, tmp.path());

	assert_eq!(code, 2);
	assert!(stdout.contains("R003"));
}

#[test]
fn check_json_output_can_be_deserialized() {
	let tmp = TempDir::new().expect("temp dir");
	let playlist_path = tmp.path().join("playlist.json");
	let output_path = tmp.path().join("result.json");

	write_playlist(
		&playlist_path,
		json!([
			{"displayName":"A", "enabled": true, "position": 0, "steamId":"5001"}
		]),
	);
	write_descriptor(&tmp.path().join("5001"), "mod-a");

	let playlist_str = playlist_path.display().to_string();
	let output_str = output_path.display().to_string();
	let args = [
		"check",
		playlist_str.as_str(),
		"--format",
		"json",
		"--output",
		output_str.as_str(),
	];

	let (code, _stdout, _stderr) = run_foch(&args, tmp.path());
	assert_eq!(code, 0);

	let content = fs::read_to_string(output_path).expect("read json output");
	let parsed: serde_json::Value = serde_json::from_str(&content).expect("deserialize result");
	assert!(parsed.get("findings").is_some());
}

#[test]
fn check_can_export_graph_json() {
	let tmp = TempDir::new().expect("temp dir");
	let playlist_path = tmp.path().join("playlist.json");
	let graph_path = tmp.path().join("graph.json");

	write_playlist(
		&playlist_path,
		json!([
			{"displayName":"A", "enabled": true, "position": 0, "steamId":"6001"}
		]),
	);
	let mod_root = tmp.path().join("6001");
	write_descriptor(&mod_root, "mod-a");
	fs::create_dir_all(mod_root.join("events")).expect("create events dir");
	fs::write(
		mod_root.join("events").join("a.txt"),
		"namespace = test\ncountry_event = { id = test.1 }\n",
	)
	.expect("write event file");

	let playlist_str = playlist_path.display().to_string();
	let graph_str = graph_path.display().to_string();
	let args = [
		"check",
		playlist_str.as_str(),
		"--graph-out",
		graph_str.as_str(),
		"--graph-format",
		"json",
	];

	let (code, _stdout, _stderr) = run_foch(&args, tmp.path());
	assert_eq!(code, 0);

	let content = fs::read_to_string(graph_path).expect("read graph json");
	let parsed: serde_json::Value = serde_json::from_str(&content).expect("graph output json");
	assert!(parsed.get("scopes").is_some());
}

#[test]
fn config_validate_reports_invalid_paths() {
	let tmp = TempDir::new().expect("temp dir");
	let cfg_file = tmp.path().join("config.toml");
	fs::write(
		cfg_file,
		"steam_root_path = \"/definitely/not-exist\"\nparadox_data_path = \"/still/not-exist\"\n",
	)
	.expect("write config");

	let (code, stdout, _stderr) = run_foch(&["config", "validate"], tmp.path());
	assert_eq!(code, 0);
	assert!(stdout.contains("[ERROR] steam_root_path"));
}