1use clap::{Args, Parser, Subcommand, ValueEnum};
2use std::path::PathBuf;
3
4#[derive(Parser)]
9#[command(version, about, long_about = None)]
10pub struct AppCli {
11 #[arg(long, global = true)]
13 pub config: Option<PathBuf>,
14 #[arg(long, global = true, conflicts_with = "no_profile")]
16 pub profile: Option<String>,
17 #[arg(long, global = true)]
19 pub no_profile: bool,
20 #[command(subcommand)]
22 pub command: Command,
23}
24
25#[derive(Subcommand)]
27pub enum Command {
28 Update(CommandArgs),
30 Search(SearchArgs),
32 List(ListArgs),
34 Install(InstallArgs),
36 Uninstall(UninstallArgs),
38 Enable(ToggleArgs),
40 Disable(ToggleArgs),
43 Migrate,
45 Profile(ProfileArgs),
47 Launch(LaunchArgs),
49 Export(ExportArgs),
51 Import(ImportArgs),
53}
54
55#[derive(Args)]
57pub struct InstallArgs {
58 #[arg(required = true)]
60 pub mods: Vec<String>,
61 #[arg(long)]
65 pub force: bool,
66}
67
68#[derive(Args)]
70pub struct UninstallArgs {
71 #[arg(required_unless_present = "all")]
73 pub mods: Vec<String>,
74 #[arg(long, conflicts_with = "mods")]
77 pub all: bool,
78 #[arg(long, conflicts_with = "mods")]
92 pub yes: bool,
93 #[arg(long)]
98 pub force: bool,
99}
100
101#[derive(Args)]
103pub struct ToggleArgs {
104 #[arg(required_unless_present = "all")]
106 pub name: Option<String>,
107 #[arg(long, conflicts_with = "name")]
110 pub all: bool,
111}
112
113#[derive(Args)]
115pub struct ListArgs {
116 #[arg(long, value_enum, default_value_t = ListFormat::Text)]
118 pub format: ListFormat,
119}
120
121#[derive(Clone, ValueEnum)]
123pub enum ListFormat {
124 Text,
126 Json,
128}
129
130#[derive(Args)]
132pub struct CommandArgs {
133 #[command(subcommand)]
135 pub command: UpdatesCommand,
136}
137
138#[derive(Args)]
140pub struct SearchArgs {
141 pub term: String,
143}
144
145#[derive(Subcommand)]
147pub enum UpdatesCommand {
148 Manifest,
150 Mods(UpdateModsArgs),
152}
153
154#[derive(Args)]
156pub struct UpdateModsArgs {
157 #[arg(long)]
160 pub force: bool,
161}
162
163#[derive(Args)]
165pub struct ProfileArgs {
166 #[command(subcommand)]
168 pub command: ProfileCommand,
169}
170
171#[derive(Subcommand)]
173pub enum ProfileCommand {
174 List,
176 Create {
178 name: String,
180 },
181 Delete {
183 name: String,
185 },
186 Rename {
188 from: String,
190 to: String,
192 },
193 Duplicate {
195 from: String,
197 to: String,
199 },
200 Use {
202 name: String,
204 },
205 Clear,
207}
208
209#[derive(Args)]
211pub struct LaunchArgs {
212 #[arg(long)]
214 pub vanilla: bool,
215 #[arg(long)]
217 pub print_steam_options: bool,
218 #[arg(last = true)]
220 pub args: Vec<String>,
221}
222
223#[derive(Args)]
225pub struct ExportArgs {
226 #[arg(long)]
228 pub code: bool,
229}
230
231#[derive(Args)]
233pub struct ImportArgs {
234 pub source: String,
238}
239
240#[cfg(test)]
241mod tests {
242 use super::*;
243 use clap::CommandFactory;
244
245 #[test]
246 fn test_command_search() {
247 let app = AppCli::command();
248 let search_command = app.find_subcommand("search").unwrap();
249
250 assert_eq!(search_command.get_name(), "search");
251 assert!(search_command.get_about().is_some());
252 assert!(
253 search_command
254 .get_about()
255 .unwrap()
256 .to_string()
257 .contains("Search for mods by name")
258 );
259
260 let search_args = search_command.get_arguments().collect::<Vec<_>>();
261 assert_eq!(search_args.len(), 1);
262 assert_eq!(search_args[0].get_id().as_str(), "term");
263 assert!(search_args[0].get_help().is_some());
264 assert!(
265 search_args[0]
266 .get_help()
267 .unwrap()
268 .to_string()
269 .contains("Search term to find mods by name")
270 );
271 }
272
273 #[test]
274 fn test_command_update() {
275 let app = AppCli::command();
276 let update_command = app.find_subcommand("update").unwrap();
277
278 assert_eq!(update_command.get_name(), "update");
279 assert!(update_command.get_about().is_some());
280 assert!(
281 update_command
282 .get_about()
283 .unwrap()
284 .to_string()
285 .contains("Update the mod manifest or installed mods")
286 );
287
288 let update_subcommands = update_command.get_subcommands().collect::<Vec<_>>();
289 assert_eq!(update_subcommands.len(), 2);
290
291 let manifest_cmd = update_subcommands
292 .iter()
293 .find(|cmd| cmd.get_name() == "manifest")
294 .unwrap();
295 let mods_cmd = update_subcommands
296 .iter()
297 .find(|cmd| cmd.get_name() == "mods")
298 .unwrap();
299
300 assert!(manifest_cmd.get_about().is_some());
301 assert!(
302 manifest_cmd
303 .get_about()
304 .unwrap()
305 .to_string()
306 .contains("Update the mod manifest from the server")
307 );
308 assert!(mods_cmd.get_about().is_some());
309 assert!(
310 mods_cmd
311 .get_about()
312 .unwrap()
313 .to_string()
314 .contains("Update installed mods to their latest versions")
315 );
316 }
317
318 #[test]
319 fn profile_flags_are_global_and_mutually_exclusive() {
320 let app = AppCli::command();
321
322 let profile = app
323 .get_arguments()
324 .find(|a| a.get_id().as_str() == "profile")
325 .expect("--profile should be a global argument");
326
327 assert!(profile.is_global_set());
328
329 let no_profile = app
330 .get_arguments()
331 .find(|a| a.get_id().as_str() == "no_profile")
332 .expect("--no-profile should be a global argument");
333
334 assert!(no_profile.is_global_set());
335
336 let conflict = AppCli::try_parse_from([
338 "vmm",
339 "--profile",
340 "experiment",
341 "--no-profile",
342 "search",
343 "jotunn",
344 ]);
345
346 assert!(conflict.is_err());
347 }
348
349 #[test]
350 fn uninstall_takes_mods_and_an_opt_in_force_flag() {
351 let app = AppCli::command();
352 let uninstall = app.find_subcommand("uninstall").unwrap();
353
354 let force = uninstall
355 .get_arguments()
356 .find(|arg| arg.get_id().as_str() == "force")
357 .expect("uninstall should offer --force");
358
359 assert!(force.get_help().is_some());
360 let parsed = AppCli::try_parse_from(["vmm", "uninstall", "Owner-Mod"]).unwrap();
362
363 match parsed.command {
364 Command::Uninstall(args) => {
365 assert_eq!(args.mods, vec!["Owner-Mod".to_string()]);
366 assert!(!args.force);
367 }
368 _ => panic!("expected the uninstall subcommand"),
369 }
370
371 let forced = AppCli::try_parse_from(["vmm", "uninstall", "--force", "Owner-Mod"]).unwrap();
372
373 match forced.command {
374 Command::Uninstall(args) => assert!(args.force),
375 _ => panic!("expected the uninstall subcommand"),
376 }
377 }
378
379 #[test]
380 fn install_takes_mods_and_an_opt_in_force_flag() {
381 let app = AppCli::command();
382 let install = app.find_subcommand("install").unwrap();
383
384 let force = install
385 .get_arguments()
386 .find(|arg| arg.get_id().as_str() == "force")
387 .expect("install should offer --force");
388
389 assert!(force.get_help().is_some());
390 assert!(force.get_short().is_none());
392
393 let parsed = AppCli::try_parse_from(["vmm", "install", "Owner-Mod"]).unwrap();
395
396 match parsed.command {
397 Command::Install(args) => {
398 assert_eq!(args.mods, vec!["Owner-Mod".to_string()]);
399 assert!(!args.force);
400 }
401 _ => panic!("expected the install subcommand"),
402 }
403
404 let forced = AppCli::try_parse_from(["vmm", "install", "--force", "Owner-Mod"]).unwrap();
405
406 match forced.command {
407 Command::Install(args) => {
408 assert_eq!(args.mods, vec!["Owner-Mod".to_string()]);
409 assert!(args.force);
410 }
411 _ => panic!("expected the install subcommand"),
412 }
413 }
414
415 #[test]
416 fn update_mods_takes_an_opt_in_force_flag() {
417 let parsed = AppCli::try_parse_from(["vmm", "update", "mods"]).unwrap();
418
419 match parsed.command {
421 Command::Update(sub) => match sub.command {
422 UpdatesCommand::Mods(args) => assert!(!args.force),
423 UpdatesCommand::Manifest => panic!("expected the mods subcommand"),
424 },
425 _ => panic!("expected the update subcommand"),
426 }
427
428 let forced = AppCli::try_parse_from(["vmm", "update", "mods", "--force"]).unwrap();
429
430 match forced.command {
431 Command::Update(sub) => match sub.command {
432 UpdatesCommand::Mods(args) => assert!(args.force),
433 UpdatesCommand::Manifest => panic!("expected the mods subcommand"),
434 },
435 _ => panic!("expected the update subcommand"),
436 }
437 }
438
439 #[test]
440 fn uninstall_takes_either_mods_or_all_and_an_opt_in_yes() {
441 let all = AppCli::try_parse_from(["vmm", "uninstall", "--all", "--yes"]).unwrap();
442
443 match all.command {
444 Command::Uninstall(args) => {
445 assert!(args.all);
446 assert!(args.yes);
447 assert!(args.mods.is_empty());
448 }
449 _ => panic!("expected the uninstall subcommand"),
450 }
451
452 let prompted = AppCli::try_parse_from(["vmm", "uninstall", "--all"]).unwrap();
454
455 match prompted.command {
456 Command::Uninstall(args) => assert!(!args.yes),
457 _ => panic!("expected the uninstall subcommand"),
458 }
459
460 assert!(AppCli::try_parse_from(["vmm", "uninstall", "--all", "Owner-Mod"]).is_err());
462 assert!(AppCli::try_parse_from(["vmm", "uninstall"]).is_err());
464 assert!(AppCli::try_parse_from(["vmm", "uninstall", "--yes", "Owner-Mod"]).is_err());
467 }
468
469 #[test]
470 fn test_command_list() {
471 let app = AppCli::command();
472 let list_command = app.find_subcommand("list").unwrap();
473
474 assert_eq!(list_command.get_name(), "list");
475 assert!(list_command.get_about().is_some());
476 assert!(
477 list_command
478 .get_about()
479 .unwrap()
480 .to_string()
481 .contains("List installed mods")
482 );
483
484 let list_args = list_command.get_arguments().collect::<Vec<_>>();
485 let format_arg = list_args
486 .iter()
487 .find(|a| a.get_id().as_str() == "format")
488 .unwrap();
489 assert!(format_arg.get_default_values().iter().any(|v| v == "text"));
490 }
491
492 #[test]
493 fn toggle_takes_either_a_mod_name_or_all_but_not_both() {
494 let named = AppCli::try_parse_from(["vmm", "disable", "Owner-Mod"]).unwrap();
496
497 match named.command {
498 Command::Disable(args) => {
499 assert_eq!(args.name.as_deref(), Some("Owner-Mod"));
500 assert!(!args.all);
501 }
502 _ => panic!("expected the disable subcommand"),
503 }
504
505 let all = AppCli::try_parse_from(["vmm", "enable", "--all"]).unwrap();
506
507 match all.command {
508 Command::Enable(args) => {
509 assert!(args.all);
510 assert!(args.name.is_none());
511 }
512 _ => panic!("expected the enable subcommand"),
513 }
514
515 assert!(AppCli::try_parse_from(["vmm", "enable", "--all", "Owner-Mod"]).is_err());
517 assert!(AppCli::try_parse_from(["vmm", "enable"]).is_err());
519 }
520}