Skip to main content

foch_cli/cli/
arg.rs

1use clap::{Parser, Subcommand, ValueEnum};
2use clap_verbosity_flag::{Verbosity, WarnLevel};
3use foch::game::eu4::{CWT_SCHEMA_OVERRIDE_ENV, EMBEDDED_CWT_SCHEMA_ID, cwt_schema_override};
4use foch::model::SymbolKind;
5use std::num::NonZeroUsize;
6use std::path::PathBuf;
7use std::str::FromStr;
8use std::sync::OnceLock;
9
10#[derive(Parser, Debug)]
11#[command(
12	author,
13	version,
14	long_version = long_version(),
15	about = "Foch: Paradox mod analysis and merge toolkit",
16	long_about = None
17)]
18pub struct FochCli {
19	/// Without a subcommand, `foch` opens the read-only analysis browser.
20	#[command(subcommand)]
21	pub command: Option<FochCliCommands>,
22
23	#[command(flatten)]
24	pub verbose: Verbosity<WarnLevel>,
25}
26
27/// `--version` names the CWT schema compiled into the binary: the schema
28/// decides output bytes, so two builds write the same mod only if both match.
29fn long_version() -> &'static str {
30	static VERSION: OnceLock<String> = OnceLock::new();
31	VERSION.get_or_init(|| {
32		let mut version = format!(
33			"{}\ncwt-schema {EMBEDDED_CWT_SCHEMA_ID} (embedded)",
34			env!("CARGO_PKG_VERSION")
35		);
36		if let Some(dir) = cwt_schema_override() {
37			version.push_str(&format!(
38				"\ncwt-schema overridden by {CWT_SCHEMA_OVERRIDE_ENV}={}",
39				dir.display()
40			));
41		}
42		version
43	})
44}
45
46#[derive(Subcommand, Debug)]
47pub enum FochCliCommands {
48	Test(TestArgs),
49	Check(CheckArgs),
50	Merge(MergeArgs),
51	Graph(GraphArgs),
52	Simplify(SimplifyArgs),
53	Data(DataArgs),
54	Cache(FochCliCacheArgs),
55	Config(ConfigArgs),
56	Input(InputArgs),
57	Plugin(PluginArgs),
58	Lsp(LspArgs),
59}
60
61#[derive(Parser, Debug)]
62#[command(about = "Manage native DLL plugins (install, select per playset, plan a launch)")]
63pub struct PluginArgs {
64	#[command(subcommand)]
65	pub command: PluginCommand,
66}
67
68#[derive(Subcommand, Debug)]
69pub enum PluginCommand {
70	/// List installed plugin versions and a playset's selection.
71	List(PluginListArgs),
72	/// Import a plugin package from an extracted directory into the store.
73	Import(PluginImportArgs),
74	/// Enable a plugin for a playset, pinned to an installed version.
75	Enable(PluginToggleArgs),
76	/// Disable a plugin for a playset.
77	Disable(PluginToggleArgs),
78	/// Resolve a playset's selection into a launch plan and report problems.
79	Plan(PluginPlanArgs),
80	/// Prepare an isolated runtime and start the selected plugins with EU4.
81	Launch(PluginLaunchArgs),
82	/// Read the actual plugin states from a prepared or running runtime.
83	Status(PluginStatusArgs),
84}
85
86#[derive(Parser, Debug)]
87pub struct PluginListArgs {
88	/// Playset name whose selection to show.
89	#[arg(long, default_value = "default", value_name = "NAME")]
90	pub playset: String,
91	#[arg(long, value_enum, default_value_t = CheckOutputFormat::Text)]
92	pub format: CheckOutputFormat,
93}
94
95#[derive(Parser, Debug)]
96pub struct PluginImportArgs {
97	/// A directory holding an extracted plugin package (with foch-plugin.toml).
98	#[arg(value_name = "DIR")]
99	pub path: PathBuf,
100	/// Bind an upstream release directory to a built-in legacy adapter.
101	#[arg(long, value_name = "PLUGIN_ID")]
102	pub adapter: Option<String>,
103}
104
105#[derive(Parser, Debug)]
106pub struct PluginToggleArgs {
107	/// Stable plugin id.
108	#[arg(value_name = "PLUGIN_ID")]
109	pub id: String,
110	/// Playset name to change.
111	#[arg(long, default_value = "default", value_name = "NAME")]
112	pub playset: String,
113	/// Exact installed version to pin; defaults to the highest installed.
114	#[arg(long, value_name = "VERSION")]
115	pub version: Option<String>,
116}
117
118#[derive(Parser, Debug)]
119pub struct PluginPlanArgs {
120	/// Playset name to plan.
121	#[arg(long, default_value = "default", value_name = "NAME")]
122	pub playset: String,
123	/// EU4 install to target; defaults to the configured or Steam install.
124	#[arg(long, value_name = "PATH")]
125	pub game_path: Option<PathBuf>,
126	#[arg(long, value_enum, default_value_t = CheckOutputFormat::Text)]
127	pub format: CheckOutputFormat,
128}
129
130#[derive(Parser, Debug)]
131pub struct PluginLaunchArgs {
132	#[command(flatten)]
133	pub plan: PluginPlanArgs,
134	/// Built foch_eu4_host.dll; defaults to the directory containing foch.
135	#[arg(long, value_name = "DLL")]
136	pub host_dll: Option<PathBuf>,
137	/// Short directory for new runtime layers; defaults to the temp foch-rt.
138	#[arg(long, value_name = "PATH")]
139	pub runtime_base: Option<PathBuf>,
140	/// Profile to use; defaults to the player's normal EU4 user directory.
141	#[arg(long, value_name = "PATH")]
142	pub user_dir: Option<PathBuf>,
143	/// Stage the frozen plan and files without starting EU4.
144	#[arg(long)]
145	pub prepare_only: bool,
146	/// Additional game arguments, following --.
147	#[arg(last = true)]
148	pub game_args: Vec<std::ffi::OsString>,
149}
150
151#[derive(Parser, Debug)]
152pub struct PluginStatusArgs {
153	/// Runtime directory printed by plugin launch.
154	#[arg(long, value_name = "PATH")]
155	pub run_dir: PathBuf,
156	#[arg(long, value_enum, default_value_t = CheckOutputFormat::Text)]
157	pub format: CheckOutputFormat,
158}
159
160#[derive(Parser, Debug)]
161#[command(
162	about = "Collect or run EU4 event tests from #test annotations",
163	after_help = foch_annotation::builtin::TEST_DESCRIPTION
164)]
165pub struct TestArgs {
166	/// Mod directory, its events directory, an event .txt file, or
167	/// FILE::EVENT[::NAME] to select inside a file.
168	#[arg(default_value = ".", value_name = "PATH")]
169	pub path: PathBuf,
170	#[arg(long, conflicts_with_all = ["out", "no_run"])]
171	pub collect_only: bool,
172	/// Generate the test layer and session bundles into --out without
173	/// launching the game, like `cargo test --no-run`.
174	#[arg(long)]
175	pub no_run: bool,
176	/// Keyword expression over node IDs, such as "reform and not slow".
177	#[arg(short = 'k', value_name = "EXPRESSION")]
178	pub filter: Option<String>,
179	/// Mark expression over #mark labels, such as "war and not slow".
180	#[arg(short = 'm', value_name = "EXPRESSION")]
181	pub marks: Option<String>,
182	/// Also run #ignore cases.
183	#[arg(long, conflicts_with = "ignored")]
184	pub include_ignored: bool,
185	/// Run only #ignore cases.
186	#[arg(long)]
187	pub ignored: bool,
188	/// Run every case in its own game session.
189	#[arg(long)]
190	pub isolate: bool,
191	/// Launch the game despite findings from Foch's own script model.
192	/// Malformed tests still block.
193	#[arg(long)]
194	pub run_anyway: bool,
195	#[arg(long, value_enum, default_value_t = CheckOutputFormat::Text)]
196	pub format: CheckOutputFormat,
197	/// Describe annotations and parameters without a game installation.
198	#[arg(long, num_args = 0..=1, default_missing_value = "", value_name = "NAME", conflicts_with_all = ["collect_only", "out", "filter"])]
199	pub api: Option<String>,
200	/// EU4 install to launch; defaults to the configured or Steam install.
201	#[arg(long, value_name = "PATH")]
202	pub game_path: Option<PathBuf>,
203	/// Real EU4 user directory to guard; defaults to
204	/// Documents/Paradox Interactive/Europa Universalis IV.
205	#[arg(long, value_name = "PATH")]
206	pub eu4_user_dir: Option<PathBuf>,
207	/// Maximum seconds per game launch.
208	#[arg(long, default_value = "300", value_parser = clap::value_parser!(u64).range(1..))]
209	pub timeout: u64,
210	/// Write bundles, logs, result.json and junit.xml here; must be new or
211	/// empty and outside the mod. Defaults to a new temporary directory.
212	#[arg(long, value_name = "PATH")]
213	pub out: Option<PathBuf>,
214}
215
216/// Run the foch language server on stdio. The subcommand intentionally
217/// accepts (and ignores) any trailing arguments so that LSP clients which
218/// append transport-mode hints like `--stdio` to the spawn command line do
219/// not trip clap's unknown-argument check.
220#[derive(Parser, Debug)]
221#[command(
222	about = "Run the foch language server on stdio",
223	after_help = "VS Code extension and other LSP clients spawn this with stdio transport.\nNo arguments are required; trailing args (e.g. `--stdio`) are accepted and ignored."
224)]
225pub struct LspArgs {
226	#[arg(trailing_var_arg = true, allow_hyphen_values = true, hide = true)]
227	pub _passthrough: Vec<String>,
228}
229
230#[derive(Parser, Debug)]
231#[command(
232	about = "Check a playset and report findings, or fix its syntax errors",
233	after_help = "Examples:\n  foch check ./playlist.json\n  foch check ./foch.toml\n  foch check ./playlist.json --strict\n  foch check ./playlist.json --analysis-mode semantic --channel strict\n  foch check ./playlist.json --no-game-base\n  foch check ./playlist.json --format json --output result.json\n\nSyntax fixes, as a linter's:\n  foch check ./my-mod --diff                      # show the fixes, write nothing\n  foch check ./my-mod --fix                       # fix a mod directory in place\n  foch check ./my-mod --fix --unsafe-fixes        # also fix what a merge holds for review\n  foch check ./foch.toml --fix --patch-mod ./fixes # a playset: write a patch mod\n  foch check ./foch.toml --fix --in-place         # a playset: fix the mods, backed up\n  foch check --restore <BACKUP_DIR>               # undo an in-place fix"
234)]
235pub struct CheckArgs {
236	#[arg(default_value = None, value_name = "INPUT_SOURCE")]
237	pub playset_path: Option<PathBuf>,
238
239	#[arg(long, value_enum, default_value_t = CheckOutputFormat::Text)]
240	pub format: CheckOutputFormat,
241
242	#[arg(long)]
243	pub output: Option<PathBuf>,
244
245	#[arg(long)]
246	pub strict: bool,
247
248	#[arg(long, value_enum, default_value_t = AnalysisModeArg::Semantic)]
249	pub analysis_mode: AnalysisModeArg,
250
251	#[arg(long, value_enum, default_value_t = CheckChannelArg::All)]
252	pub channel: CheckChannelArg,
253
254	#[arg(long)]
255	pub parse_issue_report: Option<PathBuf>,
256
257	/// Skip loading vanilla game files; the lowest-precedence enabled mod
258	/// is treated as a synthetic base for diff-and-merge.
259	#[arg(long)]
260	pub no_game_base: bool,
261
262	#[arg(long)]
263	pub no_color: bool,
264
265	/// Fix the syntax errors Foch repairs by itself, as a linter's --fix. A
266	/// mod directory is fixed in place; a playset needs --patch-mod or
267	/// --in-place. Exits with 1 while errors remain unfixed.
268	#[arg(long)]
269	pub fix: bool,
270
271	/// Also apply unsafe fixes, which settle what a merge holds for review:
272	/// leave out a statement that cannot be read with confidence, or apply
273	/// the likeliest repair of a definition that cannot be read at all.
274	#[arg(long)]
275	pub unsafe_fixes: bool,
276
277	/// Show the fixes as a unified diff and write nothing. Exits with 1 when
278	/// there is something to fix.
279	#[arg(long, conflicts_with = "fix")]
280	pub diff: bool,
281
282	/// With --fix: write a patch mod holding only the fixed files into this
283	/// new or empty directory, to load after the mods it fixes, instead of
284	/// changing them.
285	#[arg(
286		long,
287		value_name = "DIR",
288		requires = "fix",
289		conflicts_with = "in_place"
290	)]
291	pub patch_mod: Option<PathBuf>,
292
293	/// With --fix on a playset or a Steam Workshop mod: fix the mods' own
294	/// files, after backing up each original, as a mod directory of one's own
295	/// is fixed. Steam replaces a Workshop mod's files when it updates, which
296	/// undoes the fix.
297	#[arg(long, requires = "fix")]
298	pub in_place: bool,
299
300	/// Put back the originals an earlier in-place --fix backed up into this
301	/// directory, for each file that still holds the fixed bytes.
302	#[arg(long, value_name = "BACKUP_DIR", conflicts_with_all = ["fix", "diff"])]
303	pub restore: Option<PathBuf>,
304
305	/// Load reviewed repairs from this foch.toml instead of the default search
306	/// path.
307	#[arg(long, value_name = "PATH")]
308	pub config: Option<PathBuf>,
309}
310
311#[derive(Clone, Copy, Debug, Eq, PartialEq, ValueEnum)]
312pub enum CheckOutputFormat {
313	Text,
314	Json,
315}
316
317#[derive(Clone, Parser, Debug, Eq, PartialEq)]
318#[command(
319	about = "Analyze a merge, review its frozen plan, then optionally commit it",
320	after_help = "Examples:\n  foch merge ./playlist.json --out ./merged-mod                 # analyze, review, then confirm in a TTY\n  foch merge ./foch.toml --out ./merged-mod --confirm          # analyze and explicitly commit\n  foch merge ./playlist.json --out ./merged-mod --non-interactive  # analysis only\n  foch merge ./playlist.json --out ./new-merged-mod --confirm --non-interactive  # CI: new/empty path\n  foch merge ./playlist.json --out ./merged-mod --force --confirm\n  foch merge ./playlist.json --out ./merged-mod --no-game-base"
321)]
322pub struct MergeArgs {
323	#[arg(default_value = None, value_name = "INPUT_SOURCE")]
324	pub playset_path: Option<PathBuf>,
325
326	#[arg(long)]
327	pub out: PathBuf,
328
329	/// Emit explicit fallbacks for deferred conflicts where supported. Safe
330	/// units are committed with or without this flag.
331	#[arg(long)]
332	pub force: bool,
333
334	/// Skip loading vanilla game files; the lowest-precedence enabled mod
335	/// is treated as a synthetic base for diff-and-merge.
336	#[arg(long)]
337	pub no_game_base: bool,
338
339	/// Also write unchanged vanilla base-game files into the merged output (off by default; the game already ships them).
340	#[arg(long)]
341	pub include_base: bool,
342
343	/// Leave a mod of the current EU4 playset out of the analysis, by
344	/// Workshop id or `#POSITION` as `foch input inspect` lists them.
345	/// Repeatable; only without INPUT_SOURCE. The result does not represent
346	/// the full playset.
347	#[arg(long = "exclude", value_name = "MOD")]
348	pub exclude: Vec<String>,
349
350	/// Merge divergent same-name GUI containers into scroll-stack parents instead of manual conflicts.
351	#[arg(long)]
352	pub gui_scroll_merge: bool,
353
354	/// Treat replace_path declarations as no-ops; merge as if they were absent.
355	#[arg(long)]
356	pub ignore_replace_path: bool,
357
358	/// Drop one declared dependency edge from the local merge DAG (format: mod:dep).
359	#[arg(long = "ignore-dep", value_name = "MOD:DEP")]
360	pub ignore_dep: Vec<IgnoreDepArg>,
361
362	/// Load local foch.toml overrides from this file instead of the default search path.
363	#[arg(long, value_name = "PATH")]
364	pub config: Option<PathBuf>,
365
366	/// Annotate merged definitions with their source mods using inline
367	/// comments plus `.foch/foch-provenance.json` and
368	/// `.foch/foch-merge-trace.json` sidecars. Supported GUI controls append
369	/// sources to their static tooltips; diplomatic actions expose `Base:` /
370	/// `Modified by:` in tooltip localisation. Off by default; output is
371	/// byte-identical when omitted.
372	#[arg(long)]
373	pub provenance: bool,
374
375	/// Commit without the review prompt. A non-empty --out still requires a
376	/// separate TTY overwrite confirmation.
377	#[arg(long)]
378	pub confirm: bool,
379
380	/// Disable TTY-detected prompts. This does not imply --confirm.
381	#[arg(long, alias = "no-interactive")]
382	pub non_interactive: bool,
383
384	/// Show every review unit. By default, show the first 20 of each
385	/// disposition, with totals and explicit counts of omitted units.
386	#[arg(long)]
387	pub review_all: bool,
388
389	/// Use the simple stdin/stderr prompt instead of the ratatui interactive UI.
390	#[arg(long)]
391	pub cli_prompt: bool,
392
393	/// Most merge units to analyze at once (default: the detected CPU count).
394	/// Fewer run while their estimated memory would not fit; results are
395	/// applied in plan order, so output does not depend on it. Interactive
396	/// prompts still come in plan order, with the other units paused.
397	#[arg(long, value_name = "N")]
398	pub jobs: Option<NonZeroUsize>,
399
400	/// Write the complete review as JSON to this file before confirmation:
401	/// the mods and their dependency edges, every unit with its ordered
402	/// contributors, each deferred unit's conflict tree with the competing
403	/// candidates, and every decision point: a choice for one conflict as a
404	/// decision record, and one for its file or directory as the foch.toml
405	/// rule that would persist it. It only replaces an earlier review and is
406	/// never written inside --out, a source mod or the game installation.
407	#[arg(long, value_name = "PATH")]
408	pub review_json: Option<PathBuf>,
409}
410
411impl MergeArgs {
412	/// The arguments after `foch` that parse back to exactly these. Bare
413	/// `foch` shows this for the analysis it runs, so a script or agent can
414	/// run the same one.
415	pub fn command_line(&self) -> Vec<String> {
416		let mut args = vec!["merge".to_string()];
417		if let Some(path) = &self.playset_path {
418			args.push(path.display().to_string());
419		}
420		args.push("--out".to_string());
421		args.push(self.out.display().to_string());
422		let flags = [
423			(self.force, "--force"),
424			(self.no_game_base, "--no-game-base"),
425			(self.include_base, "--include-base"),
426			(self.gui_scroll_merge, "--gui-scroll-merge"),
427			(self.ignore_replace_path, "--ignore-replace-path"),
428			(self.provenance, "--provenance"),
429			(self.confirm, "--confirm"),
430			(self.non_interactive, "--non-interactive"),
431			(self.review_all, "--review-all"),
432			(self.cli_prompt, "--cli-prompt"),
433		];
434		args.extend(
435			flags
436				.into_iter()
437				.filter(|(set, _)| *set)
438				.map(|(_, flag)| flag.to_string()),
439		);
440		for dep in &self.ignore_dep {
441			args.push("--ignore-dep".to_string());
442			args.push(format!("{}:{}", dep.mod_id, dep.dep_id));
443		}
444		if let Some(config) = &self.config {
445			args.push("--config".to_string());
446			args.push(config.display().to_string());
447		}
448		if let Some(jobs) = self.jobs {
449			args.push("--jobs".to_string());
450			args.push(jobs.to_string());
451		}
452		if let Some(path) = &self.review_json {
453			args.push("--review-json".to_string());
454			args.push(path.display().to_string());
455		}
456		for name in &self.exclude {
457			args.push("--exclude".to_string());
458			args.push(name.clone());
459		}
460		args
461	}
462}
463
464#[derive(Clone, Debug, Eq, PartialEq)]
465pub struct IgnoreDepArg {
466	pub mod_id: String,
467	pub dep_id: String,
468}
469
470impl FromStr for IgnoreDepArg {
471	type Err = String;
472
473	fn from_str(value: &str) -> Result<Self, Self::Err> {
474		if value.matches(':').count() != 1 {
475			return Err("expected exactly one ':' in MOD:DEP".to_string());
476		}
477		let (mod_id, dep_id) = value
478			.split_once(':')
479			.expect("exactly one ':' was validated above");
480		let mod_id = mod_id.trim();
481		let dep_id = dep_id.trim();
482		if mod_id.is_empty() || dep_id.is_empty() {
483			return Err("MOD and DEP must both be non-empty".to_string());
484		}
485		Ok(Self {
486			mod_id: mod_id.to_string(),
487			dep_id: dep_id.to_string(),
488		})
489	}
490}
491
492#[derive(Parser, Debug)]
493#[command(
494	about = "Export runtime graphs and family semantic graph reports",
495	after_help = "Examples:\n  foch graph ./playlist.json --out ./graphs\n  foch graph ./foch.toml --out ./graphs\n  foch graph ./playlist.json --out ./graphs --scope mods --format both\n  foch graph ./playlist.json --out ./graphs --root scripted_effect:shared_effect\n  foch graph ./playlist.json --out ./graphs --mode semantic --family common/client_states"
496)]
497pub struct GraphArgs {
498	#[arg(default_value = None, value_name = "INPUT_SOURCE")]
499	pub playset_path: Option<PathBuf>,
500
501	#[arg(long)]
502	pub out: PathBuf,
503
504	/// Skip loading vanilla game files; the lowest-precedence enabled mod
505	/// is treated as a synthetic base for diff-and-merge.
506	#[arg(long)]
507	pub no_game_base: bool,
508
509	/// Write the dependency-graph module partition report instead of graph artifacts.
510	#[arg(long)]
511	pub modules: bool,
512
513	#[arg(long, value_enum, default_value_t = GraphModeArg::Calls)]
514	pub mode: GraphModeArg,
515
516	#[arg(long, value_enum, default_value_t = GraphScopeArg::All)]
517	pub scope: GraphScopeArg,
518
519	#[arg(long, value_enum, default_value_t = GraphArtifactFormatArg::Both)]
520	pub format: GraphArtifactFormatArg,
521
522	#[arg(long)]
523	pub root: Option<String>,
524
525	#[arg(long)]
526	pub family: Option<String>,
527
528	#[arg(long = "definition-kinds", value_delimiter = ',', value_parser = parse_definition_kind_arg)]
529	pub definition_kinds: Vec<SymbolKind>,
530}
531
532const DEFINITION_KIND_VALUES: &[&str] = &[
533	"event",
534	"scripted_effect",
535	"scripted_trigger",
536	"decision",
537	"diplomatic_action",
538	"triggered_modifier",
539];
540
541fn parse_definition_kind_arg(value: &str) -> Result<SymbolKind, String> {
542	let normalized = value.trim().to_ascii_lowercase();
543	match normalized.as_str() {
544		"event" => Ok(SymbolKind::Event),
545		"scripted_effect" => Ok(SymbolKind::ScriptedEffect),
546		"scripted_trigger" => Ok(SymbolKind::ScriptedTrigger),
547		"decision" => Ok(SymbolKind::Decision),
548		"diplomatic_action" => Ok(SymbolKind::DiplomaticAction),
549		"triggered_modifier" => Ok(SymbolKind::TriggeredModifier),
550		"" => Err("definition kind must not be empty".to_string()),
551		_ => Err(format!(
552			"unsupported definition kind '{value}'; expected one of: {}",
553			DEFINITION_KIND_VALUES.join(", ")
554		)),
555	}
556}
557
558#[derive(Clone, Copy, Debug, Eq, PartialEq, ValueEnum)]
559pub enum GraphModeArg {
560	Calls,
561	Semantic,
562}
563
564#[derive(Clone, Copy, Debug, Eq, PartialEq, ValueEnum)]
565pub enum GraphScopeArg {
566	Input,
567	Base,
568	Mods,
569	All,
570}
571
572#[derive(Clone, Copy, Debug, Eq, PartialEq, ValueEnum)]
573pub enum GraphArtifactFormatArg {
574	Json,
575	Dot,
576	Both,
577}
578
579#[derive(Parser, Debug)]
580#[command(
581	about = "Remove base-equivalent definitions from a target mod",
582	after_help = "Examples:\n  foch simplify ./playlist.json --target 1234 --out ./mod-clean\n  foch simplify ./foch.toml --target local_patch --out ./mod-clean\n  foch simplify ./playlist.json --target 1234 --in-place"
583)]
584pub struct SimplifyArgs {
585	#[arg(default_value = None, value_name = "INPUT_SOURCE")]
586	pub playset_path: Option<PathBuf>,
587
588	#[arg(long)]
589	pub target: String,
590
591	/// Separate directory for the simplified copy; the source mod is never
592	/// modified.
593	#[arg(long)]
594	pub out: PathBuf,
595
596	/// Skip loading vanilla game files; the lowest-precedence enabled mod
597	/// is treated as a synthetic base for diff-and-merge.
598	#[arg(long)]
599	pub no_game_base: bool,
600}
601
602#[derive(Parser, Debug)]
603#[command(
604	about = "Manage distributable base game data",
605	after_help = "Examples:\n  foch data list\n  foch data install eu4 --game-version auto\n  foch data build eu4 --from-game-path /path/to/eu4 --game-version auto --install\n  foch data build eu4 --from-game-path /path/to/eu4 --game-version auto --profile-out ./build-profile.json --output-dir ./dist/data --release-asset"
606)]
607pub struct DataArgs {
608	#[command(subcommand)]
609	pub command: FochCliDataCommands,
610}
611
612#[derive(Subcommand, Debug)]
613pub enum FochCliDataCommands {
614	Install(DataInstallArgs),
615	Build(DataBuildArgs),
616	List(DataListArgs),
617}
618
619#[derive(Parser, Debug)]
620pub struct DataInstallArgs {
621	pub game_name: String,
622
623	#[arg(long, default_value = "auto")]
624	pub game_version: String,
625
626	#[arg(long)]
627	pub release_tag: Option<String>,
628}
629
630#[derive(Clone, Parser, Debug, Eq, PartialEq)]
631pub struct DataBuildArgs {
632	pub game_name: String,
633
634	#[arg(long)]
635	pub from_game_path: PathBuf,
636
637	#[arg(long, default_value = "auto")]
638	pub game_version: String,
639
640	#[arg(long)]
641	pub install: bool,
642
643	#[arg(long)]
644	pub output_dir: Option<PathBuf>,
645
646	#[arg(long)]
647	pub release_asset: bool,
648
649	#[arg(long)]
650	pub profile_out: Option<PathBuf>,
651}
652
653impl DataBuildArgs {
654	/// The arguments after `foch` that parse back to exactly these.
655	pub fn command_line(&self) -> Vec<String> {
656		let mut args = vec![
657			"data".to_string(),
658			"build".to_string(),
659			self.game_name.clone(),
660			"--from-game-path".to_string(),
661			self.from_game_path.display().to_string(),
662			"--game-version".to_string(),
663			self.game_version.clone(),
664		];
665		if self.install {
666			args.push("--install".to_string());
667		}
668		if let Some(dir) = &self.output_dir {
669			args.push("--output-dir".to_string());
670			args.push(dir.display().to_string());
671		}
672		if self.release_asset {
673			args.push("--release-asset".to_string());
674		}
675		if let Some(path) = &self.profile_out {
676			args.push("--profile-out".to_string());
677			args.push(path.display().to_string());
678		}
679		args
680	}
681}
682
683#[derive(Parser, Debug)]
684pub struct DataListArgs {
685	#[arg(long)]
686	pub json: bool,
687}
688
689#[derive(Parser, Debug)]
690#[command(
691	about = "Inspect and maintain local foch caches",
692	after_help = "Examples:\n  foch cache stats --layer all\n  foch cache list --layer mods\n  foch cache clean --layer all --byte-cap 1073741824\n  foch cache clear --layer all --yes\n  foch cache where"
693)]
694pub struct FochCliCacheArgs {
695	#[command(subcommand)]
696	pub command: FochCliCacheCommands,
697}
698
699#[derive(Subcommand, Debug)]
700pub enum FochCliCacheCommands {
701	Stats(FochCliCacheStatsArgs),
702	List(FochCliCacheListArgs),
703	Clean(FochCliCacheCleanArgs),
704	Clear(FochCliCacheClearArgs),
705	Where,
706}
707
708#[derive(Parser, Debug)]
709pub struct FochCliCacheStatsArgs {
710	#[arg(long, value_enum)]
711	pub layer: Option<FochCliCacheLayerArg>,
712}
713
714#[derive(Parser, Debug)]
715pub struct FochCliCacheListArgs {
716	#[arg(long, value_enum)]
717	pub layer: Option<FochCliCacheLayerArg>,
718}
719
720#[derive(Parser, Debug)]
721pub struct FochCliCacheCleanArgs {
722	#[arg(long, value_enum)]
723	pub layer: Option<FochCliCacheLayerArg>,
724
725	#[arg(long, default_value_t = 30)]
726	pub older_than: u32,
727
728	#[arg(long)]
729	pub byte_cap: Option<u64>,
730}
731
732#[derive(Parser, Debug)]
733pub struct FochCliCacheClearArgs {
734	#[arg(long, value_enum)]
735	pub layer: Option<FochCliCacheLayerArg>,
736
737	#[arg(long)]
738	pub yes: bool,
739}
740
741#[derive(Clone, Copy, Debug, Eq, PartialEq, ValueEnum)]
742pub enum FochCliCacheLayerArg {
743	Parse,
744	Mods,
745	Diffs,
746	DagBase,
747	CwtRules,
748	All,
749}
750
751#[derive(Parser, Debug)]
752#[command(
753	about = "Inspect a Foch input source",
754	after_help = "Examples:\n  foch input inspect ./foch.toml\n  foch input inspect ~/Documents/Paradox\\ Interactive/Europa\\ Universalis\\ IV/dlc_load.json"
755)]
756pub struct InputArgs {
757	#[command(subcommand)]
758	pub command: FochCliInputCommands,
759}
760
761#[derive(Subcommand, Debug)]
762pub enum FochCliInputCommands {
763	Inspect(InputInspectArgs),
764	Repair(InputRepairArgs),
765}
766
767/// Guide the repair of current-playset mods that cannot be analyzed. Foch
768/// changes nothing itself: it names each broken Workshop item and can open
769/// its page in Steam, where unsubscribing and subscribing again makes Steam
770/// download it afresh.
771#[derive(Parser, Debug)]
772#[command(
773	about = "Guide the repair of current-playset mods that cannot be analyzed",
774	after_help = "Bare `foch` runs the same repair: R opens every broken mod, w the selected one.\n\nExamples:\n  foch input repair\n  foch input repair --open\n  foch input repair --open --mod 1804289844"
775)]
776pub struct InputRepairArgs {
777	/// Open each Workshop page in Steam (`steam://url/CommunityFilePage/<id>`).
778	#[arg(long)]
779	pub open: bool,
780
781	/// Only this mod, by Workshop id or `#POSITION`; repeatable. Any mod can
782	/// be named, broken or not.
783	#[arg(long = "mod", value_name = "MOD")]
784	pub mods: Vec<String>,
785}
786
787#[derive(Parser, Debug)]
788#[command(
789	about = "Show the game and ordered mod inputs Foch will use",
790	after_help = "Without INPUT_SOURCE, inspects the installed EU4 game and its current launcher playset exactly as bare `foch` does, reporting each mod's position, Workshop id and whether it can be analyzed.\n\nExamples:\n  foch input inspect\n  foch input inspect --format json\n  foch input inspect ./foch.toml"
791)]
792pub struct InputInspectArgs {
793	#[arg(value_name = "INPUT_SOURCE")]
794	pub source_path: Option<PathBuf>,
795
796	/// `json` describes the current EU4 input for scripts and agents; it is
797	/// available only without INPUT_SOURCE.
798	#[arg(long, value_enum, default_value_t = CheckOutputFormat::Text)]
799	pub format: CheckOutputFormat,
800}
801
802#[derive(Clone, Copy, Debug, Eq, PartialEq, ValueEnum)]
803pub enum AnalysisModeArg {
804	Basic,
805	Semantic,
806}
807
808#[derive(Clone, Copy, Debug, Eq, PartialEq, ValueEnum)]
809pub enum CheckChannelArg {
810	Strict,
811	All,
812}
813
814#[derive(Parser, Debug)]
815#[command(about = "Inspect and maintain local configuration")]
816pub struct ConfigArgs {
817	#[command(subcommand)]
818	pub command: FochCliConfigCommands,
819}
820
821#[derive(Subcommand, Debug)]
822pub enum FochCliConfigCommands {
823	Set(SetConfigArgs),
824	Show(ShowConfigArgs),
825	Validate,
826}
827
828#[derive(Parser, Debug)]
829pub struct ShowConfigArgs {
830	#[arg(long)]
831	pub json: bool,
832}
833
834#[derive(Parser, Debug)]
835pub struct SetConfigArgs {
836	#[command(subcommand)]
837	pub command: FochCliSetCommands,
838}
839
840#[derive(Subcommand, Debug)]
841pub enum FochCliSetCommands {
842	SteamPath(PathArgs),
843	ParadoxDataPath(PathArgs),
844	GamePath(GamePathArgs),
845}
846
847#[derive(Parser, Debug)]
848pub struct PathArgs {
849	pub path: PathBuf,
850}
851
852#[derive(Parser, Debug)]
853pub struct GamePathArgs {
854	pub game_name: String,
855	pub path: PathBuf,
856}
857
858#[cfg(test)]
859mod tests {
860	use super::*;
861	use clap::Parser;
862
863	#[test]
864	fn ignore_dep_arg_parses_mod_dep_pair() {
865		let parsed = IgnoreDepArg::from_str("3378403419:1999055990").expect("parse pair");
866
867		assert_eq!(parsed.mod_id, "3378403419");
868		assert_eq!(parsed.dep_id, "1999055990");
869	}
870
871	#[test]
872	fn ignore_dep_arg_rejects_invalid_format() {
873		assert!(IgnoreDepArg::from_str("3378403419").is_err());
874		assert!(IgnoreDepArg::from_str("3378403419:1999055990:extra").is_err());
875		assert!(IgnoreDepArg::from_str("3378403419:").is_err());
876	}
877
878	#[test]
879	fn merge_command_accepts_repeatable_ignore_dep_flags() {
880		let cli = FochCli::try_parse_from([
881			"foch",
882			"merge",
883			"playlist.json",
884			"--out",
885			"merged",
886			"--ignore-dep",
887			"a:b",
888			"--ignore-dep",
889			"c:d",
890		])
891		.expect("parse cli");
892
893		let FochCliCommands::Merge(args) = cli.command.expect("subcommand") else {
894			panic!("expected merge command");
895		};
896		assert_eq!(
897			args.ignore_dep,
898			vec![
899				IgnoreDepArg {
900					mod_id: "a".to_string(),
901					dep_id: "b".to_string(),
902				},
903				IgnoreDepArg {
904					mod_id: "c".to_string(),
905					dep_id: "d".to_string(),
906				},
907			]
908		);
909	}
910
911	#[test]
912	fn merge_command_accepts_non_interactive_flag() {
913		let cli = FochCli::try_parse_from([
914			"foch",
915			"merge",
916			"playlist.json",
917			"--out",
918			"merged",
919			"--non-interactive",
920		])
921		.expect("parse cli");
922
923		let FochCliCommands::Merge(args) = cli.command.expect("subcommand") else {
924			panic!("expected merge command");
925		};
926		assert!(args.non_interactive);
927		assert!(!args.confirm);
928	}
929
930	#[test]
931	fn merge_command_accepts_confirm_flag() {
932		let cli = FochCli::try_parse_from([
933			"foch",
934			"merge",
935			"playlist.json",
936			"--out",
937			"merged",
938			"--confirm",
939		])
940		.expect("parse cli");
941
942		let FochCliCommands::Merge(args) = cli.command.expect("subcommand") else {
943			panic!("expected merge command");
944		};
945		assert!(args.confirm);
946		assert!(!args.non_interactive);
947	}
948
949	#[test]
950	fn merge_command_accepts_no_interactive_alias() {
951		let cli = FochCli::try_parse_from([
952			"foch",
953			"merge",
954			"playlist.json",
955			"--out",
956			"merged",
957			"--no-interactive",
958		])
959		.expect("parse cli");
960
961		let FochCliCommands::Merge(args) = cli.command.expect("subcommand") else {
962			panic!("expected merge command");
963		};
964		assert!(args.non_interactive);
965	}
966
967	#[test]
968	fn merge_command_accepts_cli_prompt_flag() {
969		let cli = FochCli::try_parse_from([
970			"foch",
971			"merge",
972			"playlist.json",
973			"--out",
974			"merged",
975			"--cli-prompt",
976		])
977		.expect("parse cli");
978
979		let FochCliCommands::Merge(args) = cli.command.expect("subcommand") else {
980			panic!("expected merge command");
981		};
982		assert!(args.cli_prompt);
983	}
984
985	#[test]
986	fn graph_command_accepts_definition_kinds_flag() {
987		let cli = FochCli::try_parse_from([
988			"foch",
989			"graph",
990			"playlist.json",
991			"--out",
992			"graphs",
993			"--definition-kinds=event,scripted_effect",
994			"--definition-kinds",
995			"triggered_modifier",
996		])
997		.expect("parse cli");
998
999		let FochCliCommands::Graph(args) = cli.command.expect("subcommand") else {
1000			panic!("expected graph command");
1001		};
1002		assert_eq!(
1003			args.definition_kinds,
1004			vec![
1005				SymbolKind::Event,
1006				SymbolKind::ScriptedEffect,
1007				SymbolKind::TriggeredModifier,
1008			]
1009		);
1010	}
1011
1012	#[test]
1013	fn graph_command_rejects_unknown_definition_kind() {
1014		let err = FochCli::try_parse_from([
1015			"foch",
1016			"graph",
1017			"playlist.json",
1018			"--out",
1019			"graphs",
1020			"--definition-kinds=garbage",
1021		])
1022		.expect_err("garbage kind should fail");
1023		let message = err.to_string();
1024		assert!(
1025			message.contains("unsupported definition kind 'garbage'"),
1026			"error: {message}"
1027		);
1028	}
1029}