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	Lsp(LspArgs),
58}
59
60#[derive(Parser, Debug)]
61#[command(
62	about = "Collect or run EU4 event tests from #test annotations",
63	after_help = foch_annotation::builtin::TEST_DESCRIPTION
64)]
65pub struct TestArgs {
66	/// Mod directory, its events directory, an event .txt file, or
67	/// FILE::EVENT[::NAME] to select inside a file.
68	#[arg(default_value = ".", value_name = "PATH")]
69	pub path: PathBuf,
70	#[arg(long, conflicts_with_all = ["out", "no_run"])]
71	pub collect_only: bool,
72	/// Generate the test layer and session bundles into --out without
73	/// launching the game, like `cargo test --no-run`.
74	#[arg(long)]
75	pub no_run: bool,
76	/// Keyword expression over node IDs, such as "reform and not slow".
77	#[arg(short = 'k', value_name = "EXPRESSION")]
78	pub filter: Option<String>,
79	/// Mark expression over #mark labels, such as "war and not slow".
80	#[arg(short = 'm', value_name = "EXPRESSION")]
81	pub marks: Option<String>,
82	/// Also run #ignore cases.
83	#[arg(long, conflicts_with = "ignored")]
84	pub include_ignored: bool,
85	/// Run only #ignore cases.
86	#[arg(long)]
87	pub ignored: bool,
88	/// Run every case in its own game session.
89	#[arg(long)]
90	pub isolate: bool,
91	/// Launch the game despite findings from Foch's own script model.
92	/// Malformed tests still block.
93	#[arg(long)]
94	pub run_anyway: bool,
95	#[arg(long, value_enum, default_value_t = CheckOutputFormat::Text)]
96	pub format: CheckOutputFormat,
97	/// Describe annotations and parameters without a game installation.
98	#[arg(long, num_args = 0..=1, default_missing_value = "", value_name = "NAME", conflicts_with_all = ["collect_only", "out", "filter"])]
99	pub api: Option<String>,
100	/// EU4 install to launch; defaults to the configured or Steam install.
101	#[arg(long, value_name = "PATH")]
102	pub game_path: Option<PathBuf>,
103	/// Real EU4 user directory to guard; defaults to
104	/// Documents/Paradox Interactive/Europa Universalis IV.
105	#[arg(long, value_name = "PATH")]
106	pub eu4_user_dir: Option<PathBuf>,
107	/// Maximum seconds per game launch.
108	#[arg(long, default_value = "300", value_parser = clap::value_parser!(u64).range(1..))]
109	pub timeout: u64,
110	/// Write bundles, logs, result.json and junit.xml here; must be new or
111	/// empty and outside the mod. Defaults to a new temporary directory.
112	#[arg(long, value_name = "PATH")]
113	pub out: Option<PathBuf>,
114}
115
116/// Run the foch language server on stdio. The subcommand intentionally
117/// accepts (and ignores) any trailing arguments so that LSP clients which
118/// append transport-mode hints like `--stdio` to the spawn command line do
119/// not trip clap's unknown-argument check.
120#[derive(Parser, Debug)]
121#[command(
122	about = "Run the foch language server on stdio",
123	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."
124)]
125pub struct LspArgs {
126	#[arg(trailing_var_arg = true, allow_hyphen_values = true, hide = true)]
127	pub _passthrough: Vec<String>,
128}
129
130#[derive(Parser, Debug)]
131#[command(
132	about = "Check a playset and report findings",
133	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"
134)]
135pub struct CheckArgs {
136	#[arg(default_value = None, value_name = "INPUT_SOURCE")]
137	pub playset_path: Option<PathBuf>,
138
139	#[arg(long, value_enum, default_value_t = CheckOutputFormat::Text)]
140	pub format: CheckOutputFormat,
141
142	#[arg(long)]
143	pub output: Option<PathBuf>,
144
145	#[arg(long)]
146	pub strict: bool,
147
148	#[arg(long, value_enum, default_value_t = AnalysisModeArg::Semantic)]
149	pub analysis_mode: AnalysisModeArg,
150
151	#[arg(long, value_enum, default_value_t = CheckChannelArg::All)]
152	pub channel: CheckChannelArg,
153
154	#[arg(long)]
155	pub parse_issue_report: Option<PathBuf>,
156
157	/// Skip loading vanilla game files; the lowest-precedence enabled mod
158	/// is treated as a synthetic base for diff-and-merge.
159	#[arg(long)]
160	pub no_game_base: bool,
161
162	#[arg(long)]
163	pub no_color: bool,
164}
165
166#[derive(Clone, Copy, Debug, Eq, PartialEq, ValueEnum)]
167pub enum CheckOutputFormat {
168	Text,
169	Json,
170}
171
172#[derive(Clone, Parser, Debug, Eq, PartialEq)]
173#[command(
174	about = "Analyze a merge, review its frozen plan, then optionally commit it",
175	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"
176)]
177pub struct MergeArgs {
178	#[arg(default_value = None, value_name = "INPUT_SOURCE")]
179	pub playset_path: Option<PathBuf>,
180
181	#[arg(long)]
182	pub out: PathBuf,
183
184	/// Emit explicit fallbacks for deferred conflicts where supported. Safe
185	/// units are committed with or without this flag.
186	#[arg(long)]
187	pub force: bool,
188
189	/// Skip loading vanilla game files; the lowest-precedence enabled mod
190	/// is treated as a synthetic base for diff-and-merge.
191	#[arg(long)]
192	pub no_game_base: bool,
193
194	/// Also write unchanged vanilla base-game files into the merged output (off by default; the game already ships them).
195	#[arg(long)]
196	pub include_base: bool,
197
198	/// Leave a mod of the current EU4 playset out of the analysis, by
199	/// Workshop id or `#POSITION` as `foch input inspect` lists them.
200	/// Repeatable; only without INPUT_SOURCE. The result does not represent
201	/// the full playset.
202	#[arg(long = "exclude", value_name = "MOD")]
203	pub exclude: Vec<String>,
204
205	/// Merge divergent same-name GUI containers into scroll-stack parents instead of manual conflicts.
206	#[arg(long)]
207	pub gui_scroll_merge: bool,
208
209	/// Treat replace_path declarations as no-ops; merge as if they were absent.
210	#[arg(long)]
211	pub ignore_replace_path: bool,
212
213	/// Drop one declared dependency edge from the local merge DAG (format: mod:dep).
214	#[arg(long = "ignore-dep", value_name = "MOD:DEP")]
215	pub ignore_dep: Vec<IgnoreDepArg>,
216
217	/// Load local foch.toml overrides from this file instead of the default search path.
218	#[arg(long, value_name = "PATH")]
219	pub config: Option<PathBuf>,
220
221	/// Annotate merged definitions with their source mods using inline
222	/// comments plus `.foch/foch-provenance.json` and
223	/// `.foch/foch-merge-trace.json` sidecars. Supported GUI controls append
224	/// sources to their static tooltips; diplomatic actions expose `Base:` /
225	/// `Modified by:` in tooltip localisation. Off by default; output is
226	/// byte-identical when omitted.
227	#[arg(long)]
228	pub provenance: bool,
229
230	/// Commit without the review prompt. A non-empty --out still requires a
231	/// separate TTY overwrite confirmation.
232	#[arg(long)]
233	pub confirm: bool,
234
235	/// Disable TTY-detected prompts. This does not imply --confirm.
236	#[arg(long, alias = "no-interactive")]
237	pub non_interactive: bool,
238
239	/// Show every review unit. By default, show the first 20 of each
240	/// disposition, with totals and explicit counts of omitted units.
241	#[arg(long)]
242	pub review_all: bool,
243
244	/// Use the simple stdin/stderr prompt instead of the ratatui interactive UI.
245	#[arg(long)]
246	pub cli_prompt: bool,
247
248	/// Most merge units to analyze at once (default: the detected CPU count).
249	/// Fewer run while their estimated memory would not fit; results are
250	/// applied in plan order, so output does not depend on it. Interactive
251	/// prompts still come in plan order, with the other units paused.
252	#[arg(long, value_name = "N")]
253	pub jobs: Option<NonZeroUsize>,
254}
255
256impl MergeArgs {
257	/// The arguments after `foch` that parse back to exactly these. Bare
258	/// `foch` shows this for the analysis it runs, so a script or agent can
259	/// run the same one.
260	pub fn command_line(&self) -> Vec<String> {
261		let mut args = vec!["merge".to_string()];
262		if let Some(path) = &self.playset_path {
263			args.push(path.display().to_string());
264		}
265		args.push("--out".to_string());
266		args.push(self.out.display().to_string());
267		let flags = [
268			(self.force, "--force"),
269			(self.no_game_base, "--no-game-base"),
270			(self.include_base, "--include-base"),
271			(self.gui_scroll_merge, "--gui-scroll-merge"),
272			(self.ignore_replace_path, "--ignore-replace-path"),
273			(self.provenance, "--provenance"),
274			(self.confirm, "--confirm"),
275			(self.non_interactive, "--non-interactive"),
276			(self.review_all, "--review-all"),
277			(self.cli_prompt, "--cli-prompt"),
278		];
279		args.extend(
280			flags
281				.into_iter()
282				.filter(|(set, _)| *set)
283				.map(|(_, flag)| flag.to_string()),
284		);
285		for dep in &self.ignore_dep {
286			args.push("--ignore-dep".to_string());
287			args.push(format!("{}:{}", dep.mod_id, dep.dep_id));
288		}
289		if let Some(config) = &self.config {
290			args.push("--config".to_string());
291			args.push(config.display().to_string());
292		}
293		if let Some(jobs) = self.jobs {
294			args.push("--jobs".to_string());
295			args.push(jobs.to_string());
296		}
297		for name in &self.exclude {
298			args.push("--exclude".to_string());
299			args.push(name.clone());
300		}
301		args
302	}
303}
304
305#[derive(Clone, Debug, Eq, PartialEq)]
306pub struct IgnoreDepArg {
307	pub mod_id: String,
308	pub dep_id: String,
309}
310
311impl FromStr for IgnoreDepArg {
312	type Err = String;
313
314	fn from_str(value: &str) -> Result<Self, Self::Err> {
315		if value.matches(':').count() != 1 {
316			return Err("expected exactly one ':' in MOD:DEP".to_string());
317		}
318		let (mod_id, dep_id) = value
319			.split_once(':')
320			.expect("exactly one ':' was validated above");
321		let mod_id = mod_id.trim();
322		let dep_id = dep_id.trim();
323		if mod_id.is_empty() || dep_id.is_empty() {
324			return Err("MOD and DEP must both be non-empty".to_string());
325		}
326		Ok(Self {
327			mod_id: mod_id.to_string(),
328			dep_id: dep_id.to_string(),
329		})
330	}
331}
332
333#[derive(Parser, Debug)]
334#[command(
335	about = "Export runtime graphs and family semantic graph reports",
336	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"
337)]
338pub struct GraphArgs {
339	#[arg(default_value = None, value_name = "INPUT_SOURCE")]
340	pub playset_path: Option<PathBuf>,
341
342	#[arg(long)]
343	pub out: PathBuf,
344
345	/// Skip loading vanilla game files; the lowest-precedence enabled mod
346	/// is treated as a synthetic base for diff-and-merge.
347	#[arg(long)]
348	pub no_game_base: bool,
349
350	/// Write the dependency-graph module partition report instead of graph artifacts.
351	#[arg(long)]
352	pub modules: bool,
353
354	#[arg(long, value_enum, default_value_t = GraphModeArg::Calls)]
355	pub mode: GraphModeArg,
356
357	#[arg(long, value_enum, default_value_t = GraphScopeArg::All)]
358	pub scope: GraphScopeArg,
359
360	#[arg(long, value_enum, default_value_t = GraphArtifactFormatArg::Both)]
361	pub format: GraphArtifactFormatArg,
362
363	#[arg(long)]
364	pub root: Option<String>,
365
366	#[arg(long)]
367	pub family: Option<String>,
368
369	#[arg(long = "definition-kinds", value_delimiter = ',', value_parser = parse_definition_kind_arg)]
370	pub definition_kinds: Vec<SymbolKind>,
371}
372
373const DEFINITION_KIND_VALUES: &[&str] = &[
374	"event",
375	"scripted_effect",
376	"scripted_trigger",
377	"decision",
378	"diplomatic_action",
379	"triggered_modifier",
380];
381
382fn parse_definition_kind_arg(value: &str) -> Result<SymbolKind, String> {
383	let normalized = value.trim().to_ascii_lowercase();
384	match normalized.as_str() {
385		"event" => Ok(SymbolKind::Event),
386		"scripted_effect" => Ok(SymbolKind::ScriptedEffect),
387		"scripted_trigger" => Ok(SymbolKind::ScriptedTrigger),
388		"decision" => Ok(SymbolKind::Decision),
389		"diplomatic_action" => Ok(SymbolKind::DiplomaticAction),
390		"triggered_modifier" => Ok(SymbolKind::TriggeredModifier),
391		"" => Err("definition kind must not be empty".to_string()),
392		_ => Err(format!(
393			"unsupported definition kind '{value}'; expected one of: {}",
394			DEFINITION_KIND_VALUES.join(", ")
395		)),
396	}
397}
398
399#[derive(Clone, Copy, Debug, Eq, PartialEq, ValueEnum)]
400pub enum GraphModeArg {
401	Calls,
402	Semantic,
403}
404
405#[derive(Clone, Copy, Debug, Eq, PartialEq, ValueEnum)]
406pub enum GraphScopeArg {
407	Input,
408	Base,
409	Mods,
410	All,
411}
412
413#[derive(Clone, Copy, Debug, Eq, PartialEq, ValueEnum)]
414pub enum GraphArtifactFormatArg {
415	Json,
416	Dot,
417	Both,
418}
419
420#[derive(Parser, Debug)]
421#[command(
422	about = "Remove base-equivalent definitions from a target mod",
423	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"
424)]
425pub struct SimplifyArgs {
426	#[arg(default_value = None, value_name = "INPUT_SOURCE")]
427	pub playset_path: Option<PathBuf>,
428
429	#[arg(long)]
430	pub target: String,
431
432	/// Separate directory for the simplified copy; the source mod is never
433	/// modified.
434	#[arg(long)]
435	pub out: PathBuf,
436
437	/// Skip loading vanilla game files; the lowest-precedence enabled mod
438	/// is treated as a synthetic base for diff-and-merge.
439	#[arg(long)]
440	pub no_game_base: bool,
441}
442
443#[derive(Parser, Debug)]
444#[command(
445	about = "Manage distributable base game data",
446	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"
447)]
448pub struct DataArgs {
449	#[command(subcommand)]
450	pub command: FochCliDataCommands,
451}
452
453#[derive(Subcommand, Debug)]
454pub enum FochCliDataCommands {
455	Install(DataInstallArgs),
456	Build(DataBuildArgs),
457	List(DataListArgs),
458}
459
460#[derive(Parser, Debug)]
461pub struct DataInstallArgs {
462	pub game_name: String,
463
464	#[arg(long, default_value = "auto")]
465	pub game_version: String,
466
467	#[arg(long)]
468	pub release_tag: Option<String>,
469}
470
471#[derive(Clone, Parser, Debug, Eq, PartialEq)]
472pub struct DataBuildArgs {
473	pub game_name: String,
474
475	#[arg(long)]
476	pub from_game_path: PathBuf,
477
478	#[arg(long, default_value = "auto")]
479	pub game_version: String,
480
481	#[arg(long)]
482	pub install: bool,
483
484	#[arg(long)]
485	pub output_dir: Option<PathBuf>,
486
487	#[arg(long)]
488	pub release_asset: bool,
489
490	#[arg(long)]
491	pub profile_out: Option<PathBuf>,
492}
493
494impl DataBuildArgs {
495	/// The arguments after `foch` that parse back to exactly these.
496	pub fn command_line(&self) -> Vec<String> {
497		let mut args = vec![
498			"data".to_string(),
499			"build".to_string(),
500			self.game_name.clone(),
501			"--from-game-path".to_string(),
502			self.from_game_path.display().to_string(),
503			"--game-version".to_string(),
504			self.game_version.clone(),
505		];
506		if self.install {
507			args.push("--install".to_string());
508		}
509		if let Some(dir) = &self.output_dir {
510			args.push("--output-dir".to_string());
511			args.push(dir.display().to_string());
512		}
513		if self.release_asset {
514			args.push("--release-asset".to_string());
515		}
516		if let Some(path) = &self.profile_out {
517			args.push("--profile-out".to_string());
518			args.push(path.display().to_string());
519		}
520		args
521	}
522}
523
524#[derive(Parser, Debug)]
525pub struct DataListArgs {
526	#[arg(long)]
527	pub json: bool,
528}
529
530#[derive(Parser, Debug)]
531#[command(
532	about = "Inspect and maintain local foch caches",
533	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"
534)]
535pub struct FochCliCacheArgs {
536	#[command(subcommand)]
537	pub command: FochCliCacheCommands,
538}
539
540#[derive(Subcommand, Debug)]
541pub enum FochCliCacheCommands {
542	Stats(FochCliCacheStatsArgs),
543	List(FochCliCacheListArgs),
544	Clean(FochCliCacheCleanArgs),
545	Clear(FochCliCacheClearArgs),
546	Where,
547}
548
549#[derive(Parser, Debug)]
550pub struct FochCliCacheStatsArgs {
551	#[arg(long, value_enum)]
552	pub layer: Option<FochCliCacheLayerArg>,
553}
554
555#[derive(Parser, Debug)]
556pub struct FochCliCacheListArgs {
557	#[arg(long, value_enum)]
558	pub layer: Option<FochCliCacheLayerArg>,
559}
560
561#[derive(Parser, Debug)]
562pub struct FochCliCacheCleanArgs {
563	#[arg(long, value_enum)]
564	pub layer: Option<FochCliCacheLayerArg>,
565
566	#[arg(long, default_value_t = 30)]
567	pub older_than: u32,
568
569	#[arg(long)]
570	pub byte_cap: Option<u64>,
571}
572
573#[derive(Parser, Debug)]
574pub struct FochCliCacheClearArgs {
575	#[arg(long, value_enum)]
576	pub layer: Option<FochCliCacheLayerArg>,
577
578	#[arg(long)]
579	pub yes: bool,
580}
581
582#[derive(Clone, Copy, Debug, Eq, PartialEq, ValueEnum)]
583pub enum FochCliCacheLayerArg {
584	Parse,
585	Mods,
586	Diffs,
587	DagBase,
588	CwtRules,
589	All,
590}
591
592#[derive(Parser, Debug)]
593#[command(
594	about = "Inspect a Foch input source",
595	after_help = "Examples:\n  foch input inspect ./foch.toml\n  foch input inspect ~/Documents/Paradox\\ Interactive/Europa\\ Universalis\\ IV/dlc_load.json"
596)]
597pub struct InputArgs {
598	#[command(subcommand)]
599	pub command: FochCliInputCommands,
600}
601
602#[derive(Subcommand, Debug)]
603pub enum FochCliInputCommands {
604	Inspect(InputInspectArgs),
605	Repair(InputRepairArgs),
606}
607
608/// Guide the repair of current-playset mods that cannot be analyzed. Foch
609/// changes nothing itself: it names each broken Workshop item and can open
610/// its page in Steam, where unsubscribing and subscribing again makes Steam
611/// download it afresh.
612#[derive(Parser, Debug)]
613#[command(
614	about = "Guide the repair of current-playset mods that cannot be analyzed",
615	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"
616)]
617pub struct InputRepairArgs {
618	/// Open each Workshop page in Steam (`steam://url/CommunityFilePage/<id>`).
619	#[arg(long)]
620	pub open: bool,
621
622	/// Only this mod, by Workshop id or `#POSITION`; repeatable. Any mod can
623	/// be named, broken or not.
624	#[arg(long = "mod", value_name = "MOD")]
625	pub mods: Vec<String>,
626}
627
628#[derive(Parser, Debug)]
629#[command(
630	about = "Show the game and ordered mod inputs Foch will use",
631	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"
632)]
633pub struct InputInspectArgs {
634	#[arg(value_name = "INPUT_SOURCE")]
635	pub source_path: Option<PathBuf>,
636
637	/// `json` describes the current EU4 input for scripts and agents; it is
638	/// available only without INPUT_SOURCE.
639	#[arg(long, value_enum, default_value_t = CheckOutputFormat::Text)]
640	pub format: CheckOutputFormat,
641}
642
643#[derive(Clone, Copy, Debug, Eq, PartialEq, ValueEnum)]
644pub enum AnalysisModeArg {
645	Basic,
646	Semantic,
647}
648
649#[derive(Clone, Copy, Debug, Eq, PartialEq, ValueEnum)]
650pub enum CheckChannelArg {
651	Strict,
652	All,
653}
654
655#[derive(Parser, Debug)]
656#[command(about = "Inspect and maintain local configuration")]
657pub struct ConfigArgs {
658	#[command(subcommand)]
659	pub command: FochCliConfigCommands,
660}
661
662#[derive(Subcommand, Debug)]
663pub enum FochCliConfigCommands {
664	Set(SetConfigArgs),
665	Show(ShowConfigArgs),
666	Validate,
667}
668
669#[derive(Parser, Debug)]
670pub struct ShowConfigArgs {
671	#[arg(long)]
672	pub json: bool,
673}
674
675#[derive(Parser, Debug)]
676pub struct SetConfigArgs {
677	#[command(subcommand)]
678	pub command: FochCliSetCommands,
679}
680
681#[derive(Subcommand, Debug)]
682pub enum FochCliSetCommands {
683	SteamPath(PathArgs),
684	ParadoxDataPath(PathArgs),
685	GamePath(GamePathArgs),
686}
687
688#[derive(Parser, Debug)]
689pub struct PathArgs {
690	pub path: PathBuf,
691}
692
693#[derive(Parser, Debug)]
694pub struct GamePathArgs {
695	pub game_name: String,
696	pub path: PathBuf,
697}
698
699#[cfg(test)]
700mod tests {
701	use super::*;
702	use clap::Parser;
703
704	#[test]
705	fn ignore_dep_arg_parses_mod_dep_pair() {
706		let parsed = IgnoreDepArg::from_str("3378403419:1999055990").expect("parse pair");
707
708		assert_eq!(parsed.mod_id, "3378403419");
709		assert_eq!(parsed.dep_id, "1999055990");
710	}
711
712	#[test]
713	fn ignore_dep_arg_rejects_invalid_format() {
714		assert!(IgnoreDepArg::from_str("3378403419").is_err());
715		assert!(IgnoreDepArg::from_str("3378403419:1999055990:extra").is_err());
716		assert!(IgnoreDepArg::from_str("3378403419:").is_err());
717	}
718
719	#[test]
720	fn merge_command_accepts_repeatable_ignore_dep_flags() {
721		let cli = FochCli::try_parse_from([
722			"foch",
723			"merge",
724			"playlist.json",
725			"--out",
726			"merged",
727			"--ignore-dep",
728			"a:b",
729			"--ignore-dep",
730			"c:d",
731		])
732		.expect("parse cli");
733
734		let FochCliCommands::Merge(args) = cli.command.expect("subcommand") else {
735			panic!("expected merge command");
736		};
737		assert_eq!(
738			args.ignore_dep,
739			vec![
740				IgnoreDepArg {
741					mod_id: "a".to_string(),
742					dep_id: "b".to_string(),
743				},
744				IgnoreDepArg {
745					mod_id: "c".to_string(),
746					dep_id: "d".to_string(),
747				},
748			]
749		);
750	}
751
752	#[test]
753	fn merge_command_accepts_non_interactive_flag() {
754		let cli = FochCli::try_parse_from([
755			"foch",
756			"merge",
757			"playlist.json",
758			"--out",
759			"merged",
760			"--non-interactive",
761		])
762		.expect("parse cli");
763
764		let FochCliCommands::Merge(args) = cli.command.expect("subcommand") else {
765			panic!("expected merge command");
766		};
767		assert!(args.non_interactive);
768		assert!(!args.confirm);
769	}
770
771	#[test]
772	fn merge_command_accepts_confirm_flag() {
773		let cli = FochCli::try_parse_from([
774			"foch",
775			"merge",
776			"playlist.json",
777			"--out",
778			"merged",
779			"--confirm",
780		])
781		.expect("parse cli");
782
783		let FochCliCommands::Merge(args) = cli.command.expect("subcommand") else {
784			panic!("expected merge command");
785		};
786		assert!(args.confirm);
787		assert!(!args.non_interactive);
788	}
789
790	#[test]
791	fn merge_command_accepts_no_interactive_alias() {
792		let cli = FochCli::try_parse_from([
793			"foch",
794			"merge",
795			"playlist.json",
796			"--out",
797			"merged",
798			"--no-interactive",
799		])
800		.expect("parse cli");
801
802		let FochCliCommands::Merge(args) = cli.command.expect("subcommand") else {
803			panic!("expected merge command");
804		};
805		assert!(args.non_interactive);
806	}
807
808	#[test]
809	fn merge_command_accepts_cli_prompt_flag() {
810		let cli = FochCli::try_parse_from([
811			"foch",
812			"merge",
813			"playlist.json",
814			"--out",
815			"merged",
816			"--cli-prompt",
817		])
818		.expect("parse cli");
819
820		let FochCliCommands::Merge(args) = cli.command.expect("subcommand") else {
821			panic!("expected merge command");
822		};
823		assert!(args.cli_prompt);
824	}
825
826	#[test]
827	fn graph_command_accepts_definition_kinds_flag() {
828		let cli = FochCli::try_parse_from([
829			"foch",
830			"graph",
831			"playlist.json",
832			"--out",
833			"graphs",
834			"--definition-kinds=event,scripted_effect",
835			"--definition-kinds",
836			"triggered_modifier",
837		])
838		.expect("parse cli");
839
840		let FochCliCommands::Graph(args) = cli.command.expect("subcommand") else {
841			panic!("expected graph command");
842		};
843		assert_eq!(
844			args.definition_kinds,
845			vec![
846				SymbolKind::Event,
847				SymbolKind::ScriptedEffect,
848				SymbolKind::TriggeredModifier,
849			]
850		);
851	}
852
853	#[test]
854	fn graph_command_rejects_unknown_definition_kind() {
855		let err = FochCli::try_parse_from([
856			"foch",
857			"graph",
858			"playlist.json",
859			"--out",
860			"graphs",
861			"--definition-kinds=garbage",
862		])
863		.expect_err("garbage kind should fail");
864		let message = err.to_string();
865		assert!(
866			message.contains("unsupported definition kind 'garbage'"),
867			"error: {message}"
868		);
869	}
870}