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 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 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 #[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}