foch 0.1.0

Paradox mod static analysis toolkit with CLI and EU4-focused language tooling
Documentation
use foch::check::model::{CheckRequest, RunOptions};
use foch::check::{run_checks, run_checks_with_options};
use foch::cli::config::Config;
use serde_json::json;
use std::fs;
use std::path::Path;
use tempfile::TempDir;

fn write_playlist(path: &Path, mods: serde_json::Value) {
	let playlist = json!({
		"game": "eu4",
		"name": "test-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, dependencies: &[&str]) {
	fs::create_dir_all(mod_root).expect("create mod root");
	let mut descriptor = format!("name=\"{name}\"\nversion=\"1.0.0\"\n");
	if !dependencies.is_empty() {
		descriptor.push_str("dependencies={\n");
		for dependency in dependencies {
			descriptor.push_str(&format!("\t\"{dependency}\"\n"));
		}
		descriptor.push_str("}\n");
	}
	fs::write(mod_root.join("descriptor.mod"), descriptor).expect("write descriptor");
}

fn write_script_file(mod_root: &Path, relative: &str, content: &str) {
	let script_path = mod_root.join(relative);
	if let Some(parent) = script_path.parent() {
		fs::create_dir_all(parent).expect("create script parent");
	}
	fs::write(script_path, content).expect("write script file");
}

fn write_ugc_metadata(paradox_game_dir: &Path, steam_id: &str, target_path: &Path) {
	let mod_dir = paradox_game_dir.join("mod");
	fs::create_dir_all(&mod_dir).expect("create mod metadata dir");
	let content = format!(
		"name=\"ugc-{steam_id}\"\npath=\"{}\"\nremote_file_id=\"{steam_id}\"\n",
		target_path.display()
	);
	fs::write(mod_dir.join(format!("ugc_{steam_id}.mod")), content).expect("write ugc metadata");
}

fn request_for(playlist_path: &Path) -> CheckRequest {
	CheckRequest {
		playset_path: playlist_path.to_path_buf(),
		config: Config::default(),
	}
}

fn request_with_config(playlist_path: &Path, config: Config) -> CheckRequest {
	CheckRequest {
		playset_path: playlist_path.to_path_buf(),
		config,
	}
}

#[test]
fn invalid_json_creates_r001() {
	let temp = TempDir::new().expect("temp dir");
	let playlist_path = temp.path().join("playlist.json");
	fs::write(&playlist_path, "{broken").expect("write broken json");

	let result = run_checks(request_for(&playlist_path));
	assert!(result.findings.iter().any(|f| f.rule_id == "R001"));
}

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

	write_playlist(
		&playlist_path,
		json!([
			{"displayName":"A", "enabled": true, "position": 0, "steamId":"1001"},
			{"displayName":"B", "enabled": true, "position": 1, "steamId":"1001"}
		]),
	);

	write_descriptor(&temp.path().join("1001"), "mod-a", &[]);

	let result = run_checks(request_for(&playlist_path));
	assert!(result.findings.iter().any(|f| f.rule_id == "R003"));
}

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

	write_playlist(
		&playlist_path,
		json!([
			{"displayName":"A", "enabled": true, "position": 0, "steamId":"1002"}
		]),
	);
	fs::create_dir_all(temp.path().join("1002")).expect("create mod dir");

	let result = run_checks(request_for(&playlist_path));
	assert!(result.findings.iter().any(|f| f.rule_id == "R004"));
}

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

	write_playlist(
		&playlist_path,
		json!([
			{"displayName":"A", "enabled": true, "position": 0, "steamId":"2001"},
			{"displayName":"B", "enabled": true, "position": 1, "steamId":"2002"}
		]),
	);

	let mod_a = temp.path().join("2001");
	write_descriptor(&mod_a, "mod-a", &[]);
	fs::create_dir_all(mod_a.join("common")).expect("create dir");
	fs::write(mod_a.join("common").join("shared.txt"), "from-a").expect("write file");

	let mod_b = temp.path().join("2002");
	write_descriptor(&mod_b, "mod-b", &[]);
	fs::create_dir_all(mod_b.join("common")).expect("create dir");
	fs::write(mod_b.join("common").join("shared.txt"), "from-b").expect("write file");

	let result = run_checks(request_for(&playlist_path));
	assert!(result.findings.iter().any(|f| f.rule_id == "R005"));
}

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

	write_playlist(
		&playlist_path,
		json!([
			{"displayName":"A", "enabled": true, "position": 0, "steamId":"3001"}
		]),
	);

	let mod_a = temp.path().join("3001");
	write_descriptor(&mod_a, "mod-a", &["mod-b"]);

	let result = run_checks(request_for(&playlist_path));
	assert!(result.findings.iter().any(|f| f.rule_id == "R006"));
}

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

	write_playlist(
		&playlist_path,
		json!([
			{"displayName":"A", "enabled": true, "position": 0, "steamId":"7001"},
			{"displayName":"B", "enabled": true, "position": 1, "steamId":"7002"}
		]),
	);

	let mod_a = temp.path().join("7001");
	write_descriptor(&mod_a, "mod-a", &[]);
	write_script_file(
		&mod_a,
		"common/scripted_effects/effects.txt",
		"shared_effect = {\n\tif = { limit = { always = yes } }\n}\n",
	);

	let mod_b = temp.path().join("7002");
	write_descriptor(&mod_b, "mod-b", &[]);
	write_script_file(
		&mod_b,
		"common/scripted_effects/effects.txt",
		"shared_effect = {\n\thidden_effect = { }\n}\n",
	);

	let result = run_checks(request_for(&playlist_path));
	assert!(result.findings.iter().any(|f| f.rule_id == "R007"));
}

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

	write_playlist(
		&playlist_path,
		json!([
			{"displayName":"A", "enabled": true, "position": 0, "steamId":"8001"}
		]),
	);

	let mod_a = temp.path().join("8001");
	write_descriptor(&mod_a, "mod-a", &[]);
	write_script_file(
		&mod_a,
		"events/events.txt",
		"country_event = {\n\tid = test.1\n\tmissing_effect = { FLAG = TEST }\n}\n",
	);

	let result = run_checks(request_for(&playlist_path));
	assert!(result.findings.iter().any(|f| f.rule_id == "R008"));
}

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

	write_playlist(
		&playlist_path,
		json!([
			{"displayName":"A", "enabled": true, "position": 0, "steamId":"9101"}
		]),
	);

	let paradox_root = temp.path().join("Paradox Interactive");
	let paradox_game_dir = paradox_root.join("Europa Universalis IV");
	let mod_root = temp.path().join("real-mod-9101");
	write_descriptor(&mod_root, "mod-a", &[]);
	write_ugc_metadata(&paradox_game_dir, "9101", &mod_root);

	let config = Config {
		steam_root_path: None,
		paradox_data_path: Some(paradox_root),
		game_path: std::collections::HashMap::new(),
	};

	let result = run_checks(request_with_config(&playlist_path, config));
	assert!(
		!result.findings.iter().any(|f| f.rule_id == "R004"),
		"should resolve descriptor.mod through ugc metadata"
	);
}

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

	write_playlist(
		&playlist_path,
		json!([
			{"displayName":"A", "enabled": true, "position": 0, "steamId":"9201"}
		]),
	);

	let steam_root = temp.path().join("Steam");
	let lib2 = temp.path().join("SteamLibrary2");
	fs::create_dir_all(steam_root.join("steamapps")).expect("create steamapps");
	fs::write(
		steam_root.join("steamapps").join("libraryfolders.vdf"),
		format!(
			r#""libraryfolders"
{{
	"0"
	{{
		"path"		"{}"
	}}
	"1"
	{{
		"path"		"{}"
	}}
}}"#,
			steam_root.display(),
			lib2.display()
		),
	)
	.expect("write libraryfolders");

	let workshop_mod_root = lib2
		.join("steamapps")
		.join("workshop")
		.join("content")
		.join("236850")
		.join("9201");
	write_descriptor(&workshop_mod_root, "mod-a", &[]);

	let config = Config {
		steam_root_path: Some(steam_root),
		paradox_data_path: None,
		game_path: std::collections::HashMap::new(),
	};

	let result = run_checks(request_with_config(&playlist_path, config));
	assert!(
		!result.findings.iter().any(|f| f.rule_id == "R004"),
		"should resolve descriptor.mod from steam libraryfolders path"
	);
}

#[test]
fn include_game_base_resolves_event_reference_from_base_game_symbols() {
	let temp = TempDir::new().expect("temp dir");
	let playlist_path = temp.path().join("playlist.json");
	let mod_root = temp.path().join("9301");
	let game_root = temp.path().join("eu4-game");

	write_playlist(
		&playlist_path,
		json!([
			{"displayName":"A", "enabled": true, "position": 0, "steamId":"9301"}
		]),
	);
	write_descriptor(&mod_root, "mod-a", &[]);
	write_script_file(
		&mod_root,
		"events/ref.txt",
		"namespace = test\ncountry_event = { id = test.1 option = { country_event = { id = base.1 } } }\n",
	);
	write_script_file(
		&game_root,
		"events/base.txt",
		"namespace = base\ncountry_event = { id = base.1 option = { name = ok } }\n",
	);

	let mut game_path = std::collections::HashMap::new();
	game_path.insert("eu4".to_string(), game_root);
	let config = Config {
		steam_root_path: None,
		paradox_data_path: None,
		game_path,
	};

	let without_game = run_checks_with_options(
		request_with_config(&playlist_path, config.clone()),
		RunOptions::default(),
	);
	assert!(
		without_game
			.findings
			.iter()
			.any(|f| { f.rule_id == "S002" && f.message.contains("event base.1") })
	);

	let with_game = run_checks_with_options(
		request_with_config(&playlist_path, config),
		RunOptions {
			include_game_base: true,
			..RunOptions::default()
		},
	);
	assert!(
		!with_game
			.findings
			.iter()
			.any(|f| { f.rule_id == "S002" && f.message.contains("event base.1") })
	);
}