use crate::cli::arg::{GraphArgs, GraphArtifactFormatArg, GraphModeArg, GraphScopeArg};
use crate::cli::handler::{HandlerResult, resolve_input_source};
use foch::graph::{
GraphArtifactFormat, GraphBuildOptions, GraphModeSelection, GraphRootSelector,
GraphScopeSelection, merge_trace_edges_from_trace, run_graph_with_options,
run_module_report_for_request, write_module_report,
};
use foch::input::{Config, InputRequest};
use foch::model::{GamePathBuf, MERGE_TRACE_ARTIFACT_PATH, MergeTraceEntry, SymbolKind};
use std::collections::BTreeMap;
const MODULE_REPORT_MAX_ITERS: usize = 20;
pub fn handle_graph(graph_args: &GraphArgs, config: Config) -> HandlerResult {
if graph_args.modules {
let request = InputRequest::new(
resolve_input_source(graph_args.playset_path.as_deref(), &config)?,
config,
);
let mut report = run_module_report_for_request(
&request,
!graph_args.no_game_base,
MODULE_REPORT_MAX_ITERS,
)?;
let trace_path = graph_args.out.join(MERGE_TRACE_ARTIFACT_PATH);
if trace_path.is_file() {
let trace_text = std::fs::read_to_string(&trace_path)?;
let trace: BTreeMap<GamePathBuf, BTreeMap<String, MergeTraceEntry>> =
serde_json::from_str(&trace_text)?;
report.merge_trace_edges = merge_trace_edges_from_trace(&trace);
}
let report_path = graph_args.out.join(".foch").join("module-report.json");
if let Some(parent) = report_path.parent() {
std::fs::create_dir_all(parent)?;
}
write_module_report(&report_path, &report)?;
println!("module report written to {}", report_path.display());
return Ok(0);
}
if graph_args.mode == GraphModeArg::Semantic {
if graph_args.family.is_none() {
return Err("semantic graph mode requires --family".into());
}
if graph_args.root.is_some() {
return Err("semantic graph mode does not support --root".into());
}
if matches!(graph_args.scope, GraphScopeArg::Base | GraphScopeArg::Mods) {
return Err("semantic graph mode currently supports only input/all scope".into());
}
if graph_args.format != GraphArtifactFormatArg::Both {
return Err("semantic graph mode always writes JSON and HTML; omit --format".into());
}
}
let request = InputRequest::new(
resolve_input_source(graph_args.playset_path.as_deref(), &config)?,
config,
);
let summary = run_graph_with_options(
request,
&graph_args.out,
GraphBuildOptions {
include_game_base: !graph_args.no_game_base,
mode: to_mode(graph_args.mode),
scope: to_scope(graph_args.scope),
format: to_format(graph_args.format),
root: graph_args
.root
.as_deref()
.map(parse_root_selector)
.transpose()?,
family: graph_args.family.clone(),
definition_kinds: graph_args.definition_kinds.clone(),
},
)?;
println!("graph artifacts written to {}", summary.out_dir.display());
Ok(0)
}
fn parse_root_selector(raw: &str) -> Result<GraphRootSelector, Box<dyn std::error::Error>> {
let Some((kind, name)) = raw.split_once(':') else {
return Err("graph root must use <kind:name>".into());
};
let kind = match kind {
"scripted_effect" => SymbolKind::ScriptedEffect,
"scripted_trigger" => SymbolKind::ScriptedTrigger,
"event" => SymbolKind::Event,
"decision" => SymbolKind::Decision,
"diplomatic_action" => SymbolKind::DiplomaticAction,
"triggered_modifier" => SymbolKind::TriggeredModifier,
_ => return Err(format!("unsupported graph root kind {kind}").into()),
};
Ok(GraphRootSelector {
kind,
name: name.to_string(),
})
}
fn to_mode(mode: GraphModeArg) -> GraphModeSelection {
match mode {
GraphModeArg::Calls => GraphModeSelection::Calls,
GraphModeArg::Semantic => GraphModeSelection::Semantic,
}
}
fn to_scope(scope: GraphScopeArg) -> GraphScopeSelection {
match scope {
GraphScopeArg::Input => GraphScopeSelection::Input,
GraphScopeArg::Base => GraphScopeSelection::Base,
GraphScopeArg::Mods => GraphScopeSelection::Mods,
GraphScopeArg::All => GraphScopeSelection::All,
}
}
fn to_format(format: GraphArtifactFormatArg) -> GraphArtifactFormat {
match format {
GraphArtifactFormatArg::Json => GraphArtifactFormat::Json,
GraphArtifactFormatArg::Dot => GraphArtifactFormat::Dot,
GraphArtifactFormatArg::Both => GraphArtifactFormat::Both,
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::cli::arg::{GraphArtifactFormatArg, GraphModeArg, GraphScopeArg};
use foch::model::{MergeTraceContributor, MergeTraceDecision, MergeTracePolicy};
use std::path::PathBuf;
use tempfile::Builder;
fn repository_root() -> PathBuf {
let crate_dir = PathBuf::from(env!("CARGO_MANIFEST_DIR"));
crate_dir
.parent()
.and_then(|apps_dir| apps_dir.parent())
.and_then(|src_dir| src_dir.parent())
.expect("CLI crate lives under <repository>/src/apps")
.to_path_buf()
}
fn graph_handler_tempdir(prefix: &str) -> tempfile::TempDir {
let scratch_root = PathBuf::from(env!("CARGO_MANIFEST_DIR"))
.join("target")
.join("graph-handler");
std::fs::create_dir_all(&scratch_root).expect("create graph handler scratch root");
Builder::new()
.prefix(prefix)
.tempdir_in(&scratch_root)
.expect("create graph handler tempdir")
}
fn write_merge_trace(out: &std::path::Path, output_path: &str) {
let trace_path = out.join(MERGE_TRACE_ARTIFACT_PATH);
std::fs::create_dir_all(trace_path.parent().expect("trace path has parent"))
.expect("create trace dir");
let trace = BTreeMap::from([(
output_path.to_string(),
BTreeMap::from([(
"test_shared_effect".to_string(),
MergeTraceEntry {
contributors: vec![MergeTraceContributor {
mod_id: "mod_a".to_string(),
precedence: 1,
dag_level: 0,
}],
policy: MergeTracePolicy::Union,
decision: MergeTraceDecision::Unioned,
},
)]),
)]);
std::fs::write(
&trace_path,
serde_json::to_string_pretty(&trace).expect("serialize trace"),
)
.expect("write trace");
}
fn minimal_playset() -> PathBuf {
let fixture = repository_root()
.join("src/packages/foch/tests")
.join("fixtures")
.join("playsets")
.join("eu4_minimal_passthrough")
.join("dlc_load.json");
assert!(fixture.is_file(), "playset fixture must exist: {fixture:?}");
fixture
}
fn modules_args(playset: PathBuf, out: PathBuf) -> GraphArgs {
GraphArgs {
playset_path: Some(playset),
out,
no_game_base: true,
modules: true,
mode: GraphModeArg::Calls,
scope: GraphScopeArg::All,
format: GraphArtifactFormatArg::Both,
root: None,
family: None,
definition_kinds: Vec::new(),
}
}
#[test]
fn modules_mode_writes_parseable_report_under_foch_dir() {
let temp_dir = graph_handler_tempdir("modules-mode-");
let out = temp_dir.path().join("out");
write_merge_trace(&out, "common/scripted_effects/test.txt");
let fixture = minimal_playset();
let exit_code = handle_graph(
&modules_args(fixture.clone(), out.clone()),
Config::default(),
)
.expect("modules graph handler succeeds");
assert_eq!(exit_code, 0);
let report_path = out.join(".foch").join("module-report.json");
let report_json = std::fs::read_to_string(&report_path).expect("read module report");
let parsed: serde_json::Value =
serde_json::from_str(&report_json).expect("module report is valid JSON");
assert!(parsed.get("module_count").is_some());
assert!(parsed.get("node_count").is_some());
assert!(parsed.get("mods").is_some());
let edges = parsed
.get("merge_trace_edges")
.and_then(|value| value.as_array())
.expect("module report exposes merge trace edges");
assert_eq!(edges.len(), 1);
assert_eq!(edges[0]["source_mod"], "mod_a");
assert_eq!(edges[0]["policy"], "union");
let first_report = report_json;
handle_graph(&modules_args(fixture, out.clone()), Config::default())
.expect("second modules graph handler succeeds");
let second_report =
std::fs::read_to_string(&report_path).expect("read second module report");
assert_eq!(first_report, second_report);
}
#[test]
fn modules_mode_rejects_a_merge_trace_key_that_is_not_a_game_path() {
for key in [
r"common\scripted_effects\test.txt",
"../test.txt",
"/common/test.txt",
] {
let temp_dir = graph_handler_tempdir("modules-mode-invalid-trace-");
let out = temp_dir.path().join("out");
write_merge_trace(&out, key);
let error = handle_graph(
&modules_args(minimal_playset(), out.clone()),
Config::default(),
)
.expect_err("an invalid trace key fails the modules run");
let message = error.to_string();
assert!(message.contains("invalid game path"), "{key}: {message}");
assert!(message.contains(key), "{key}: {message}");
assert!(
!out.join(".foch").join("module-report.json").exists(),
"{key}: no module report is written from an invalid trace"
);
}
}
}