Skip to main content

valheim_mod_manager/
cli.rs

1use clap::{Args, Parser, Subcommand, ValueEnum};
2use std::path::PathBuf;
3
4/// Root command-line interface structure for the application.
5///
6/// This struct uses clap's derive macros to parse command-line arguments
7/// and route to the appropriate subcommand handler.
8#[derive(Parser)]
9#[command(version, about, long_about = None)]
10pub struct AppCli {
11  /// Path to a config file, overriding the default XDG location.
12  #[arg(long, global = true)]
13  pub config: Option<PathBuf>,
14  /// Operate on this named profile instead of the game directory.
15  #[arg(long, global = true, conflicts_with = "no_profile")]
16  pub profile: Option<String>,
17  /// Ignore any persisted profile selection and operate on the game directory.
18  #[arg(long, global = true)]
19  pub no_profile: bool,
20  /// The subcommand to execute.
21  #[command(subcommand)]
22  pub command: Command,
23}
24
25/// Top-level commands available to the user.
26#[derive(Subcommand)]
27pub enum Command {
28  /// Update the mod manifest or installed mods.
29  Update(CommandArgs),
30  /// Search for mods by name.
31  Search(SearchArgs),
32  /// List installed mods and their versions.
33  List(ListArgs),
34  /// Install one or more mods and their dependencies.
35  Install(InstallArgs),
36  /// Uninstall one or more mods.
37  Uninstall(UninstallArgs),
38  /// Enable an installed mod, or every installed mod with `--all`.
39  Enable(ToggleArgs),
40  /// Disable an installed mod without uninstalling it, or every installed mod
41  /// with `--all`.
42  Disable(ToggleArgs),
43  /// Adopt the deprecated `mod_list` config key as this target's mods.yml.
44  Migrate,
45  /// Manage named r2modman-compatible profiles.
46  Profile(ProfileArgs),
47  /// Launch the game with this target's mods applied.
48  Launch(LaunchArgs),
49  /// Export this target's mods and config for sharing.
50  Export(ExportArgs),
51  /// Import mods from an export file, a profile directory, or a profile code.
52  Import(ImportArgs),
53}
54
55/// Arguments for the install command.
56#[derive(Args)]
57pub struct InstallArgs {
58  /// One or more `Owner-ModName` identifiers, optionally `Owner-ModName-1.2.3`.
59  #[arg(required = true)]
60  pub mods: Vec<String>,
61  /// Reinstall the named mods even when that version is already installed,
62  /// removing their existing files first. Dependencies are not forced; they are
63  /// still reinstalled if outdated or missing.
64  #[arg(long)]
65  pub force: bool,
66}
67
68/// Arguments for the uninstall command.
69#[derive(Args)]
70pub struct UninstallArgs {
71  /// One or more `Owner-ModName` identifiers.
72  #[arg(required_unless_present = "all")]
73  pub mods: Vec<String>,
74  /// Uninstall every mod recorded in this target's mods.yml, including the mod
75  /// loader, leaving a vanilla install.
76  #[arg(long, conflicts_with = "mods")]
77  pub all: bool,
78  /// Skip the confirmation prompt that `--all` shows.
79  // "Requires --all" is expressed here as `conflicts_with = "mods"`, not as
80  // `requires = "all"`. clap's own requires-validation excuses a missing
81  // "required" argument whenever that argument itself conflicts with
82  // something already present: since `all` is `conflicts_with = "mods"`,
83  // `#[arg(requires = "all")]` here would let `vmm uninstall --yes Owner-Mod`
84  // parse cleanly ("well, demanding `--all` would conflict with the `mods`
85  // you already gave, so the missing `--all` is fine"), silently ignoring
86  // `--yes`. `mods` is required unless `all` is present, so the only way
87  // `yes` and `mods` can coexist is if `yes` is meaningless (`all` absent);
88  // conflicting with `mods` directly rejects exactly that combination instead
89  // of excusing it. A plain comment, not a doc comment, so this rationale
90  // does not leak into `--help` output.
91  #[arg(long, conflicts_with = "mods")]
92  pub yes: bool,
93  /// Uninstall even when mod folders that mods.yml does not record are present
94  /// under the install routes. Those folders are deleted along with the named
95  /// mods, since an uninstall reconciles the whole namespaced tree against
96  /// mods.yml.
97  #[arg(long)]
98  pub force: bool,
99}
100
101/// Arguments for the enable and disable commands.
102#[derive(Args)]
103pub struct ToggleArgs {
104  /// An `Owner-ModName` identifier.
105  #[arg(required_unless_present = "all")]
106  pub name: Option<String>,
107  /// Apply to every mod recorded in this target's mods.yml. The mod loader is
108  /// left alone, since it has no disabled state.
109  #[arg(long, conflicts_with = "name")]
110  pub all: bool,
111}
112
113/// Arguments for the list command.
114#[derive(Args)]
115pub struct ListArgs {
116  /// Output format.
117  #[arg(long, value_enum, default_value_t = ListFormat::Text)]
118  pub format: ListFormat,
119}
120
121/// Output format for the list command.
122#[derive(Clone, ValueEnum)]
123pub enum ListFormat {
124  /// Plain text, one mod per line.
125  Text,
126  /// JSON array.
127  Json,
128}
129
130/// Arguments for the update command.
131#[derive(Args)]
132pub struct CommandArgs {
133  /// The specific update operation to perform.
134  #[command(subcommand)]
135  pub command: UpdatesCommand,
136}
137
138/// Arguments for the search command.
139#[derive(Args)]
140pub struct SearchArgs {
141  /// Search term to find mods by name.
142  pub term: String,
143}
144
145/// Subcommands for the update operation.
146#[derive(Subcommand)]
147pub enum UpdatesCommand {
148  /// Update the mod manifest from the server.
149  Manifest,
150  /// Update installed mods to their latest versions.
151  Mods(UpdateModsArgs),
152}
153
154/// Arguments for the `update mods` command.
155#[derive(Args)]
156pub struct UpdateModsArgs {
157  /// Reinstall every recorded mod even when it is already at its latest
158  /// version, removing its existing files first. Disabled mods stay disabled.
159  #[arg(long)]
160  pub force: bool,
161}
162
163/// Arguments for the profile command.
164#[derive(Args)]
165pub struct ProfileArgs {
166  /// The profile operation to perform.
167  #[command(subcommand)]
168  pub command: ProfileCommand,
169}
170
171/// Operations on named r2modman-compatible profiles.
172#[derive(Subcommand)]
173pub enum ProfileCommand {
174  /// List profiles, marking the selected one.
175  List,
176  /// Create a new, empty profile.
177  Create {
178    /// The profile name.
179    name: String,
180  },
181  /// Delete a profile and everything in it.
182  Delete {
183    /// The profile name.
184    name: String,
185  },
186  /// Rename a profile.
187  Rename {
188    /// The existing profile name.
189    from: String,
190    /// The new profile name.
191    to: String,
192  },
193  /// Copy a profile under a new name.
194  Duplicate {
195    /// The profile to copy.
196    from: String,
197    /// The name of the copy.
198    to: String,
199  },
200  /// Select a profile for subsequent commands.
201  Use {
202    /// The profile name.
203    name: String,
204  },
205  /// Clear the selection, returning to the game directory.
206  Clear,
207}
208
209/// Arguments for the launch command.
210#[derive(Args)]
211pub struct LaunchArgs {
212  /// Launch without mods.
213  #[arg(long)]
214  pub vanilla: bool,
215  /// Print the string to paste into Steam's launch options, without launching.
216  #[arg(long)]
217  pub print_steam_options: bool,
218  /// Extra arguments passed to the game, after `--`.
219  #[arg(last = true)]
220  pub args: Vec<String>,
221}
222
223/// Arguments for the export command.
224#[derive(Args)]
225pub struct ExportArgs {
226  /// Upload the export and print a shareable Thunderstore profile code.
227  #[arg(long)]
228  pub code: bool,
229}
230
231/// Arguments for the import command.
232#[derive(Args)]
233pub struct ImportArgs {
234  /// An `.r2z` file, an r2modman profile directory, or a profile code. Note: a
235  /// directory import copies over the destination without clearing it first, so
236  /// mods already there under a different set can be left behind untracked.
237  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    // Both at once is rejected rather than one silently winning.
337    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    // Opt-in: absent means the untracked-folder pre-flight check applies.
361    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    // Long only, matching `uninstall --force`.
391    assert!(force.get_short().is_none());
392
393    // Opt-in: absent means a mod already at the recorded version is skipped.
394    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    // Opt-in: absent means a mod already at its latest version is skipped.
420    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    // --yes is opt-in: absent means the confirmation prompt applies.
453    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    // Both at once is rejected rather than one silently winning.
461    assert!(AppCli::try_parse_from(["vmm", "uninstall", "--all", "Owner-Mod"]).is_err());
462    // Neither is rejected: an argument-less uninstall has no meaning.
463    assert!(AppCli::try_parse_from(["vmm", "uninstall"]).is_err());
464    // `--yes` means nothing without `--all`, so it is rejected rather than
465    // silently ignored.
466    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    // A bare name still works.
495    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    // Both at once is rejected rather than one silently winning.
516    assert!(AppCli::try_parse_from(["vmm", "enable", "--all", "Owner-Mod"]).is_err());
517    // Neither is rejected too: an argument-less toggle has no meaning.
518    assert!(AppCli::try_parse_from(["vmm", "enable"]).is_err());
519  }
520}