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 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 #[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 #[arg(long)]
75 pub no_run: bool,
76 #[arg(short = 'k', value_name = "EXPRESSION")]
78 pub filter: Option<String>,
79 #[arg(short = 'm', value_name = "EXPRESSION")]
81 pub marks: Option<String>,
82 #[arg(long, conflicts_with = "ignored")]
84 pub include_ignored: bool,
85 #[arg(long)]
87 pub ignored: bool,
88 #[arg(long)]
90 pub isolate: bool,
91 #[arg(long)]
94 pub run_anyway: bool,
95 #[arg(long, value_enum, default_value_t = CheckOutputFormat::Text)]
96 pub format: CheckOutputFormat,
97 #[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 #[arg(long, value_name = "PATH")]
102 pub game_path: Option<PathBuf>,
103 #[arg(long, value_name = "PATH")]
106 pub eu4_user_dir: Option<PathBuf>,
107 #[arg(long, default_value = "300", value_parser = clap::value_parser!(u64).range(1..))]
109 pub timeout: u64,
110 #[arg(long, value_name = "PATH")]
113 pub out: Option<PathBuf>,
114}
115
116#[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 #[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 #[arg(long)]
187 pub force: bool,
188
189 #[arg(long)]
192 pub no_game_base: bool,
193
194 #[arg(long)]
196 pub include_base: bool,
197
198 #[arg(long = "exclude", value_name = "MOD")]
203 pub exclude: Vec<String>,
204
205 #[arg(long)]
207 pub gui_scroll_merge: bool,
208
209 #[arg(long)]
211 pub ignore_replace_path: bool,
212
213 #[arg(long = "ignore-dep", value_name = "MOD:DEP")]
215 pub ignore_dep: Vec<IgnoreDepArg>,
216
217 #[arg(long, value_name = "PATH")]
219 pub config: Option<PathBuf>,
220
221 #[arg(long)]
228 pub provenance: bool,
229
230 #[arg(long)]
233 pub confirm: bool,
234
235 #[arg(long, alias = "no-interactive")]
237 pub non_interactive: bool,
238
239 #[arg(long)]
242 pub review_all: bool,
243
244 #[arg(long)]
246 pub cli_prompt: bool,
247
248 #[arg(long, value_name = "N")]
253 pub jobs: Option<NonZeroUsize>,
254}
255
256impl MergeArgs {
257 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 #[arg(long)]
348 pub no_game_base: bool,
349
350 #[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 #[arg(long)]
435 pub out: PathBuf,
436
437 #[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 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#[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 #[arg(long)]
620 pub open: bool,
621
622 #[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 #[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}