Skip to main content

foch_cli/cli/handler/
graph.rs

1use crate::cli::arg::{GraphArgs, GraphArtifactFormatArg, GraphModeArg, GraphScopeArg};
2use crate::cli::handler::{HandlerResult, resolve_input_source};
3use foch::graph::{
4	GraphArtifactFormat, GraphBuildOptions, GraphModeSelection, GraphRootSelector,
5	GraphScopeSelection, merge_trace_edges_from_trace, run_graph_with_options,
6	run_module_report_for_request, write_module_report,
7};
8use foch::input::{Config, InputRequest};
9use foch::model::{GamePathBuf, MERGE_TRACE_ARTIFACT_PATH, MergeTraceEntry, SymbolKind};
10use std::collections::BTreeMap;
11
12const MODULE_REPORT_MAX_ITERS: usize = 20;
13
14pub fn handle_graph(graph_args: &GraphArgs, config: Config) -> HandlerResult {
15	if graph_args.modules {
16		let request = InputRequest::new(
17			resolve_input_source(graph_args.playset_path.as_deref(), &config)?,
18			config,
19		);
20		let mut report = run_module_report_for_request(
21			&request,
22			!graph_args.no_game_base,
23			MODULE_REPORT_MAX_ITERS,
24		)?;
25		let trace_path = graph_args.out.join(MERGE_TRACE_ARTIFACT_PATH);
26		if trace_path.is_file() {
27			let trace_text = std::fs::read_to_string(&trace_path)?;
28			// The trace is keyed by output game path; reading it validates them.
29			let trace: BTreeMap<GamePathBuf, BTreeMap<String, MergeTraceEntry>> =
30				serde_json::from_str(&trace_text)?;
31			report.merge_trace_edges = merge_trace_edges_from_trace(&trace);
32		}
33		let report_path = graph_args.out.join(".foch").join("module-report.json");
34		if let Some(parent) = report_path.parent() {
35			std::fs::create_dir_all(parent)?;
36		}
37		write_module_report(&report_path, &report)?;
38		println!("module report written to {}", report_path.display());
39		return Ok(0);
40	}
41
42	if graph_args.mode == GraphModeArg::Semantic {
43		if graph_args.family.is_none() {
44			return Err("semantic graph mode requires --family".into());
45		}
46		if graph_args.root.is_some() {
47			return Err("semantic graph mode does not support --root".into());
48		}
49		if matches!(graph_args.scope, GraphScopeArg::Base | GraphScopeArg::Mods) {
50			return Err("semantic graph mode currently supports only input/all scope".into());
51		}
52		if graph_args.format != GraphArtifactFormatArg::Both {
53			return Err("semantic graph mode always writes JSON and HTML; omit --format".into());
54		}
55	}
56
57	let request = InputRequest::new(
58		resolve_input_source(graph_args.playset_path.as_deref(), &config)?,
59		config,
60	);
61	let summary = run_graph_with_options(
62		request,
63		&graph_args.out,
64		GraphBuildOptions {
65			include_game_base: !graph_args.no_game_base,
66			mode: to_mode(graph_args.mode),
67			scope: to_scope(graph_args.scope),
68			format: to_format(graph_args.format),
69			root: graph_args
70				.root
71				.as_deref()
72				.map(parse_root_selector)
73				.transpose()?,
74			family: graph_args.family.clone(),
75			definition_kinds: graph_args.definition_kinds.clone(),
76		},
77	)?;
78	println!("graph artifacts written to {}", summary.out_dir.display());
79	Ok(0)
80}
81
82fn parse_root_selector(raw: &str) -> Result<GraphRootSelector, Box<dyn std::error::Error>> {
83	let Some((kind, name)) = raw.split_once(':') else {
84		return Err("graph root must use <kind:name>".into());
85	};
86	let kind = match kind {
87		"scripted_effect" => SymbolKind::ScriptedEffect,
88		"scripted_trigger" => SymbolKind::ScriptedTrigger,
89		"event" => SymbolKind::Event,
90		"decision" => SymbolKind::Decision,
91		"diplomatic_action" => SymbolKind::DiplomaticAction,
92		"triggered_modifier" => SymbolKind::TriggeredModifier,
93		_ => return Err(format!("unsupported graph root kind {kind}").into()),
94	};
95	Ok(GraphRootSelector {
96		kind,
97		name: name.to_string(),
98	})
99}
100
101fn to_mode(mode: GraphModeArg) -> GraphModeSelection {
102	match mode {
103		GraphModeArg::Calls => GraphModeSelection::Calls,
104		GraphModeArg::Semantic => GraphModeSelection::Semantic,
105	}
106}
107
108fn to_scope(scope: GraphScopeArg) -> GraphScopeSelection {
109	match scope {
110		GraphScopeArg::Input => GraphScopeSelection::Input,
111		GraphScopeArg::Base => GraphScopeSelection::Base,
112		GraphScopeArg::Mods => GraphScopeSelection::Mods,
113		GraphScopeArg::All => GraphScopeSelection::All,
114	}
115}
116
117fn to_format(format: GraphArtifactFormatArg) -> GraphArtifactFormat {
118	match format {
119		GraphArtifactFormatArg::Json => GraphArtifactFormat::Json,
120		GraphArtifactFormatArg::Dot => GraphArtifactFormat::Dot,
121		GraphArtifactFormatArg::Both => GraphArtifactFormat::Both,
122	}
123}
124
125#[cfg(test)]
126mod tests {
127	use super::*;
128	use crate::cli::arg::{GraphArtifactFormatArg, GraphModeArg, GraphScopeArg};
129	use foch::model::{MergeTraceContributor, MergeTraceDecision, MergeTracePolicy};
130	use std::path::PathBuf;
131	use tempfile::Builder;
132
133	fn repository_root() -> PathBuf {
134		let crate_dir = PathBuf::from(env!("CARGO_MANIFEST_DIR"));
135		crate_dir
136			.parent()
137			.and_then(|apps_dir| apps_dir.parent())
138			.and_then(|src_dir| src_dir.parent())
139			.expect("CLI crate lives under <repository>/src/apps")
140			.to_path_buf()
141	}
142
143	fn graph_handler_tempdir(prefix: &str) -> tempfile::TempDir {
144		let scratch_root = PathBuf::from(env!("CARGO_MANIFEST_DIR"))
145			.join("target")
146			.join("graph-handler");
147		std::fs::create_dir_all(&scratch_root).expect("create graph handler scratch root");
148		Builder::new()
149			.prefix(prefix)
150			.tempdir_in(&scratch_root)
151			.expect("create graph handler tempdir")
152	}
153
154	/// Writes a merge-trace sidecar under `out` with one entry keyed by
155	/// `output_path`, which is written as given, valid or not.
156	fn write_merge_trace(out: &std::path::Path, output_path: &str) {
157		let trace_path = out.join(MERGE_TRACE_ARTIFACT_PATH);
158		std::fs::create_dir_all(trace_path.parent().expect("trace path has parent"))
159			.expect("create trace dir");
160		let trace = BTreeMap::from([(
161			output_path.to_string(),
162			BTreeMap::from([(
163				"test_shared_effect".to_string(),
164				MergeTraceEntry {
165					contributors: vec![MergeTraceContributor {
166						mod_id: "mod_a".to_string(),
167						precedence: 1,
168						dag_level: 0,
169					}],
170					policy: MergeTracePolicy::Union,
171					decision: MergeTraceDecision::Unioned,
172				},
173			)]),
174		)]);
175		std::fs::write(
176			&trace_path,
177			serde_json::to_string_pretty(&trace).expect("serialize trace"),
178		)
179		.expect("write trace");
180	}
181
182	fn minimal_playset() -> PathBuf {
183		let fixture = repository_root()
184			.join("src/packages/foch/tests")
185			.join("fixtures")
186			.join("playsets")
187			.join("eu4_minimal_passthrough")
188			.join("dlc_load.json");
189		assert!(fixture.is_file(), "playset fixture must exist: {fixture:?}");
190		fixture
191	}
192
193	fn modules_args(playset: PathBuf, out: PathBuf) -> GraphArgs {
194		GraphArgs {
195			playset_path: Some(playset),
196			out,
197			no_game_base: true,
198			modules: true,
199			mode: GraphModeArg::Calls,
200			scope: GraphScopeArg::All,
201			format: GraphArtifactFormatArg::Both,
202			root: None,
203			family: None,
204			definition_kinds: Vec::new(),
205		}
206	}
207
208	#[test]
209	fn modules_mode_writes_parseable_report_under_foch_dir() {
210		let temp_dir = graph_handler_tempdir("modules-mode-");
211		let out = temp_dir.path().join("out");
212		write_merge_trace(&out, "common/scripted_effects/test.txt");
213		let fixture = minimal_playset();
214
215		let exit_code = handle_graph(
216			&modules_args(fixture.clone(), out.clone()),
217			Config::default(),
218		)
219		.expect("modules graph handler succeeds");
220
221		assert_eq!(exit_code, 0);
222		let report_path = out.join(".foch").join("module-report.json");
223		let report_json = std::fs::read_to_string(&report_path).expect("read module report");
224		let parsed: serde_json::Value =
225			serde_json::from_str(&report_json).expect("module report is valid JSON");
226		assert!(parsed.get("module_count").is_some());
227		assert!(parsed.get("node_count").is_some());
228		assert!(parsed.get("mods").is_some());
229		let edges = parsed
230			.get("merge_trace_edges")
231			.and_then(|value| value.as_array())
232			.expect("module report exposes merge trace edges");
233		assert_eq!(edges.len(), 1);
234		assert_eq!(edges[0]["source_mod"], "mod_a");
235		assert_eq!(edges[0]["policy"], "union");
236
237		let first_report = report_json;
238		handle_graph(&modules_args(fixture, out.clone()), Config::default())
239			.expect("second modules graph handler succeeds");
240		let second_report =
241			std::fs::read_to_string(&report_path).expect("read second module report");
242		assert_eq!(first_report, second_report);
243	}
244
245	/// The trace sidecar is keyed by output game path. A key that is not one
246	/// fails the run naming it, instead of becoming an edge for a file the
247	/// merge never wrote.
248	#[test]
249	fn modules_mode_rejects_a_merge_trace_key_that_is_not_a_game_path() {
250		for key in [
251			r"common\scripted_effects\test.txt",
252			"../test.txt",
253			"/common/test.txt",
254		] {
255			let temp_dir = graph_handler_tempdir("modules-mode-invalid-trace-");
256			let out = temp_dir.path().join("out");
257			write_merge_trace(&out, key);
258
259			let error = handle_graph(
260				&modules_args(minimal_playset(), out.clone()),
261				Config::default(),
262			)
263			.expect_err("an invalid trace key fails the modules run");
264
265			let message = error.to_string();
266			assert!(message.contains("invalid game path"), "{key}: {message}");
267			assert!(message.contains(key), "{key}: {message}");
268			assert!(
269				!out.join(".foch").join("module-report.json").exists(),
270				"{key}: no module report is written from an invalid trace"
271			);
272		}
273	}
274}