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 #[command(subcommand)]
21 pub command: Option<FochCliCommands>,
22
23 #[command(flatten)]
24 pub verbose: Verbosity<WarnLevel>,
25}
26
27fn 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(PluginListArgs),
72 Import(PluginImportArgs),
74 Enable(PluginToggleArgs),
76 Disable(PluginToggleArgs),
78 Plan(PluginPlanArgs),
80 Launch(PluginLaunchArgs),
82 Status(PluginStatusArgs),
84}
85
86#[derive(Parser, Debug)]
87pub struct PluginListArgs {
88 #[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 #[arg(value_name = "DIR")]
99 pub path: PathBuf,
100 #[arg(long, value_name = "PLUGIN_ID")]
102 pub adapter: Option<String>,
103}
104
105#[derive(Parser, Debug)]
106pub struct PluginToggleArgs {
107 #[arg(value_name = "PLUGIN_ID")]
109 pub id: String,
110 #[arg(long, default_value = "default", value_name = "NAME")]
112 pub playset: String,
113 #[arg(long, value_name = "VERSION")]
115 pub version: Option<String>,
116}
117
118#[derive(Parser, Debug)]
119pub struct PluginPlanArgs {
120 #[arg(long, default_value = "default", value_name = "NAME")]
122 pub playset: String,
123 #[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 #[arg(long, value_name = "DLL")]
136 pub host_dll: Option<PathBuf>,
137 #[arg(long, value_name = "PATH")]
139 pub runtime_base: Option<PathBuf>,
140 #[arg(long, value_name = "PATH")]
142 pub user_dir: Option<PathBuf>,
143 #[arg(long)]
145 pub prepare_only: bool,
146 #[arg(last = true)]
148 pub game_args: Vec<std::ffi::OsString>,
149}
150
151#[derive(Parser, Debug)]
152pub struct PluginStatusArgs {
153 #[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 #[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 #[arg(long)]
175 pub no_run: bool,
176 #[arg(short = 'k', value_name = "EXPRESSION")]
178 pub filter: Option<String>,
179 #[arg(short = 'm', value_name = "EXPRESSION")]
181 pub marks: Option<String>,
182 #[arg(long, conflicts_with = "ignored")]
184 pub include_ignored: bool,
185 #[arg(long)]
187 pub ignored: bool,
188 #[arg(long)]
190 pub isolate: bool,
191 #[arg(long)]
194 pub run_anyway: bool,
195 #[arg(long, value_enum, default_value_t = CheckOutputFormat::Text)]
196 pub format: CheckOutputFormat,
197 #[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 #[arg(long, value_name = "PATH")]
202 pub game_path: Option<PathBuf>,
203 #[arg(long, value_name = "PATH")]
206 pub eu4_user_dir: Option<PathBuf>,
207 #[arg(long, default_value = "300", value_parser = clap::value_parser!(u64).range(1..))]
209 pub timeout: u64,
210 #[arg(long, value_name = "PATH")]
213 pub out: Option<PathBuf>,
214}
215
216#[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 #[arg(long)]
260 pub no_game_base: bool,
261
262 #[arg(long)]
263 pub no_color: bool,
264
265 #[arg(long)]
269 pub fix: bool,
270
271 #[arg(long)]
275 pub unsafe_fixes: bool,
276
277 #[arg(long, conflicts_with = "fix")]
280 pub diff: bool,
281
282 #[arg(
286 long,
287 value_name = "DIR",
288 requires = "fix",
289 conflicts_with = "in_place"
290 )]
291 pub patch_mod: Option<PathBuf>,
292
293 #[arg(long, requires = "fix")]
298 pub in_place: bool,
299
300 #[arg(long, value_name = "BACKUP_DIR", conflicts_with_all = ["fix", "diff"])]
303 pub restore: Option<PathBuf>,
304
305 #[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 #[arg(long)]
332 pub force: bool,
333
334 #[arg(long)]
337 pub no_game_base: bool,
338
339 #[arg(long)]
341 pub include_base: bool,
342
343 #[arg(long = "exclude", value_name = "MOD")]
348 pub exclude: Vec<String>,
349
350 #[arg(long)]
352 pub gui_scroll_merge: bool,
353
354 #[arg(long)]
356 pub ignore_replace_path: bool,
357
358 #[arg(long = "ignore-dep", value_name = "MOD:DEP")]
360 pub ignore_dep: Vec<IgnoreDepArg>,
361
362 #[arg(long, value_name = "PATH")]
364 pub config: Option<PathBuf>,
365
366 #[arg(long)]
373 pub provenance: bool,
374
375 #[arg(long)]
378 pub confirm: bool,
379
380 #[arg(long, alias = "no-interactive")]
382 pub non_interactive: bool,
383
384 #[arg(long)]
387 pub review_all: bool,
388
389 #[arg(long)]
391 pub cli_prompt: bool,
392
393 #[arg(long, value_name = "N")]
398 pub jobs: Option<NonZeroUsize>,
399
400 #[arg(long, value_name = "PATH")]
408 pub review_json: Option<PathBuf>,
409}
410
411impl MergeArgs {
412 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 #[arg(long)]
507 pub no_game_base: bool,
508
509 #[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 #[arg(long)]
594 pub out: PathBuf,
595
596 #[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 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#[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 #[arg(long)]
779 pub open: bool,
780
781 #[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 #[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}