Skip to content

Commit 69f3afe

Browse files
authored
Merge pull request #18 from endoze/add-list-mods-subcommand
feat!: add list subcommand and XDG-based config discovery
2 parents 5c10729 + 1c47ad1 commit 69f3afe

14 files changed

Lines changed: 655 additions & 204 deletions

File tree

.gitignore

Lines changed: 0 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,4 @@
11
/target
2-
.api_manifest
3-
.last_modified
4-
downloads/*
52
vmm_config.toml
63
/coverage
74
benches/fixtures/*.bin.zst

Cargo.lock

Lines changed: 7 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

Cargo.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@ reqwest = {version = "0.12.14", features = ["json", "stream", "rustls-tls"], def
3333
serde = { version = "1.0", features = ["derive"] }
3434
serde_json = "1.0"
3535
shellexpand = "3.1"
36+
xdg = "2.5"
3637
thiserror = "2.0"
3738
time = { version = "0.3", features = ["serde-well-known"] }
3839
tokio = { version = "1.32", features = ["full"] }

README.md

Lines changed: 58 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -31,19 +31,26 @@ cargo install --path .
3131

3232
## Configuration
3333

34-
The first time you run `vmm`, it will create a default configuration file at `vmm_config.toml` in your current directory. You can edit this file to customize:
34+
`vmm` looks for configuration in the following order:
35+
36+
1. A `vmm_config.toml` in the current directory (local config)
37+
2. `~/.config/vmm/vmm_config.toml` (global XDG config)
38+
39+
If neither exists, a default config is created at the global location on first run.
40+
41+
You can also specify a config file directly with the `--config` flag, which bypasses the above lookup entirely.
42+
43+
The config file supports the following settings:
3544

3645
- `mod_list`: List of mods to install (in the format `"Owner-ModName"`)
3746
- `log_level`: Logging verbosity (`error`, `warn`, `info`, `debug`, `trace`)
38-
- `cache_dir`: Directory to store cached mod information (default: `~/.config/vmm`)
3947
- `install_dir`: Optional directory where unzipped mods will be copied (e.g., your Valheim mods folder)
4048

4149
Example configuration:
4250

4351
```toml
4452
mod_list = ["denikson-BepInExPack_Valheim", "ValheimModding-Jotunn"]
4553
log_level = "info"
46-
cache_dir = "~/.config/vmm"
4754
install_dir = "~/some/path/to/Valheim/BepInEx/plugins"
4855
```
4956

@@ -65,6 +72,34 @@ Downloads all mods in your configuration, including their dependencies:
6572
vmm update mods
6673
```
6774

75+
### List Mods
76+
77+
Lists all mods from your configuration including resolved dependencies:
78+
79+
```bash
80+
vmm list
81+
```
82+
83+
By default outputs one mod per line. Use `--format json` for structured output:
84+
85+
```bash
86+
vmm list --format json
87+
```
88+
89+
**Text output (default):**
90+
```
91+
denikson-BepInExPack_Valheim 5.4.2202
92+
ValheimModding-Jotunn 2.28.0
93+
```
94+
95+
**JSON output:**
96+
```json
97+
[
98+
{"full_name": "denikson-BepInExPack_Valheim", "version": "5.4.2202"},
99+
{"full_name": "ValheimModding-Jotunn", "version": "2.28.0"}
100+
]
101+
```
102+
68103
### Search for Mods
69104

70105
Searches available mods by name:
@@ -75,6 +110,18 @@ vmm search <term>
75110

76111
This performs a case-insensitive search for mods containing the specified term in their name, displaying matching mods with their owner, name, version, and description.
77112

113+
## Global Options
114+
115+
### `--config <path>`
116+
117+
Override the config file location, bypassing the local/global lookup:
118+
119+
```bash
120+
vmm --config /path/to/my/vmm_config.toml update mods
121+
```
122+
123+
Downloads and cached data always go to `~/.config/vmm/` regardless of which config file is used. Respects `$XDG_CONFIG_HOME` if set.
124+
78125
## How It Works
79126

80127
1. Reads your configuration to determine which mods to download
@@ -86,20 +133,18 @@ This performs a case-insensitive search for mods containing the specified term i
86133

87134
## Directory Structure
88135

89-
- `~/.config/vmm/` (or custom `cache_dir` setting in config): Cache directory for mod information
90-
- `~/.config/vmm/downloads/` (or custom `cache_dir/downloads`): Location for downloaded mod archives and extracted files
136+
- `~/.config/vmm/`: Config and cache directory (respects `$XDG_CONFIG_HOME`)
137+
- `~/.config/vmm/downloads/`: Downloaded mod archives and extracted files
91138

92-
## Advanced Features
139+
## Troubleshooting
93140

94-
### Troubleshooting
141+
If you encounter issues, increase log verbosity in your config:
95142

96-
If you encounter issues:
143+
```toml
144+
log_level = "debug"
145+
```
97146

98-
1. Increase log verbosity in your config:
99-
```toml
100-
log_level = "debug"
101-
```
102-
2. Run the command again to see more detailed output
147+
Then run the command again to see more detailed output.
103148

104149
## License
105150

src/api.rs

Lines changed: 22 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -24,64 +24,52 @@ const API_MANIFEST_FILENAME_V1: &str = "api_manifest.bin.zst";
2424
///
2525
/// # Parameters
2626
///
27-
/// * `cache_dir` - The cache directory path (supports tilde expansion)
27+
/// * `cache_dir` - The cache directory path
2828
///
2929
/// # Returns
3030
///
3131
/// The full path to the last_modified file
3232
fn last_modified_path(cache_dir: &str) -> PathBuf {
33-
let expanded_path = shellexpand::tilde(cache_dir);
34-
let mut path = PathBuf::from(expanded_path.as_ref());
35-
path.push(LAST_MODIFIED_FILENAME);
36-
path
33+
PathBuf::from(cache_dir).join(LAST_MODIFIED_FILENAME)
3734
}
3835

3936
/// Returns the path to the v3 API manifest file in the cache directory.
4037
///
4138
/// # Parameters
4239
///
43-
/// * `cache_dir` - The cache directory path (supports tilde expansion)
40+
/// * `cache_dir` - The cache directory path
4441
///
4542
/// # Returns
4643
///
4744
/// The full path to the v3 manifest file
4845
fn api_manifest_path_v3(cache_dir: &str) -> PathBuf {
49-
let expanded_path = shellexpand::tilde(cache_dir);
50-
let mut path = PathBuf::from(expanded_path.as_ref());
51-
path.push(API_MANIFEST_FILENAME_V3);
52-
path
46+
PathBuf::from(cache_dir).join(API_MANIFEST_FILENAME_V3)
5347
}
5448

5549
/// Returns the path to the v2 API manifest file in the cache directory.
5650
///
5751
/// # Parameters
5852
///
59-
/// * `cache_dir` - The cache directory path (supports tilde expansion)
53+
/// * `cache_dir` - The cache directory path
6054
///
6155
/// # Returns
6256
///
6357
/// The full path to the v2 manifest file
6458
fn api_manifest_path_v2(cache_dir: &str) -> PathBuf {
65-
let expanded_path = shellexpand::tilde(cache_dir);
66-
let mut path = PathBuf::from(expanded_path.as_ref());
67-
path.push(API_MANIFEST_FILENAME_V2);
68-
path
59+
PathBuf::from(cache_dir).join(API_MANIFEST_FILENAME_V2)
6960
}
7061

7162
/// Returns the path to the v1 API manifest file in the cache directory.
7263
///
7364
/// # Parameters
7465
///
75-
/// * `cache_dir` - The cache directory path (supports tilde expansion)
66+
/// * `cache_dir` - The cache directory path
7667
///
7768
/// # Returns
7869
///
7970
/// The full path to the v1 manifest file
8071
fn api_manifest_path_v1(cache_dir: &str) -> PathBuf {
81-
let expanded_path = shellexpand::tilde(cache_dir);
82-
let mut path = PathBuf::from(expanded_path.as_ref());
83-
path.push(API_MANIFEST_FILENAME_V1);
84-
path
72+
PathBuf::from(cache_dir).join(API_MANIFEST_FILENAME_V1)
8573
}
8674

8775
/// Retrieves the manifest of available packages.
@@ -614,8 +602,7 @@ async fn download_file(
614602
progress_style: ProgressStyle,
615603
cache_dir: &str,
616604
) -> AppResult<()> {
617-
let expanded_path = shellexpand::tilde(cache_dir);
618-
let mut downloads_directory = PathBuf::from(expanded_path.as_ref());
605+
let mut downloads_directory = PathBuf::from(cache_dir);
619606
downloads_directory.push("downloads");
620607
let mut file_path = downloads_directory.clone();
621608
file_path.push(filename);
@@ -678,8 +665,7 @@ mod tests {
678665
let temp_dir = tempdir().unwrap();
679666
let cache_dir = temp_dir.path().to_str().unwrap();
680667

681-
let expanded_path = shellexpand::tilde(cache_dir);
682-
let mut downloads_directory = PathBuf::from(expanded_path.as_ref());
668+
let mut downloads_directory = PathBuf::from(cache_dir);
683669
downloads_directory.push("downloads");
684670

685671
let expected_directory = PathBuf::from(cache_dir).join("downloads");
@@ -701,16 +687,20 @@ mod tests {
701687
}
702688

703689
#[test]
704-
fn test_path_with_tilde() {
705-
let home_path = "~/some_test_dir";
706-
let home_dir = std::env::var("HOME").unwrap_or_else(|_| "/home/user".to_string());
707-
let expected_path = Path::new(&home_dir).join("some_test_dir");
690+
fn test_path_construction() {
691+
let cache_dir = "/some/cache/dir";
708692

709-
let last_modified = last_modified_path(home_path);
710-
let api_manifest = api_manifest_path_v3(home_path);
693+
let last_modified = last_modified_path(cache_dir);
694+
let api_manifest = api_manifest_path_v3(cache_dir);
711695

712-
assert_eq!(last_modified.parent().unwrap(), expected_path);
713-
assert_eq!(api_manifest.parent().unwrap(), expected_path);
696+
assert_eq!(
697+
last_modified,
698+
Path::new(cache_dir).join(LAST_MODIFIED_FILENAME)
699+
);
700+
assert_eq!(
701+
api_manifest,
702+
Path::new(cache_dir).join(API_MANIFEST_FILENAME_V3)
703+
);
714704
}
715705

716706
#[test]

src/cli.rs

Lines changed: 47 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
1-
use clap::{Args, Parser, Subcommand};
1+
use clap::{Args, Parser, Subcommand, ValueEnum};
2+
use std::path::PathBuf;
23

34
/// Root command-line interface structure for the application.
45
///
@@ -7,6 +8,9 @@ use clap::{Args, Parser, Subcommand};
78
#[derive(Parser)]
89
#[command(version, about, long_about = None)]
910
pub struct AppCli {
11+
/// Path to a config file, overriding the default XDG location.
12+
#[arg(long, global = true)]
13+
pub config: Option<PathBuf>,
1014
/// The subcommand to execute.
1115
#[command(subcommand)]
1216
pub command: Command,
@@ -19,6 +23,25 @@ pub enum Command {
1923
Update(CommandArgs),
2024
/// Search for mods by name.
2125
Search(SearchArgs),
26+
/// List all mods from config including resolved dependencies.
27+
List(ListArgs),
28+
}
29+
30+
/// Arguments for the list command.
31+
#[derive(Args)]
32+
pub struct ListArgs {
33+
/// Output format.
34+
#[arg(long, value_enum, default_value_t = ListFormat::Text)]
35+
pub format: ListFormat,
36+
}
37+
38+
/// Output format for the list command.
39+
#[derive(Clone, ValueEnum)]
40+
pub enum ListFormat {
41+
/// Plain text, one mod per line.
42+
Text,
43+
/// JSON array.
44+
Json,
2245
}
2346

2447
/// Arguments for the update command.
@@ -122,4 +145,27 @@ mod tests {
122145
.contains("Update installed mods to their latest versions")
123146
);
124147
}
148+
149+
#[test]
150+
fn test_command_list() {
151+
let app = AppCli::command();
152+
let list_command = app.find_subcommand("list").unwrap();
153+
154+
assert_eq!(list_command.get_name(), "list");
155+
assert!(list_command.get_about().is_some());
156+
assert!(
157+
list_command
158+
.get_about()
159+
.unwrap()
160+
.to_string()
161+
.contains("List all mods from config including resolved dependencies")
162+
);
163+
164+
let list_args = list_command.get_arguments().collect::<Vec<_>>();
165+
let format_arg = list_args
166+
.iter()
167+
.find(|a| a.get_id().as_str() == "format")
168+
.unwrap();
169+
assert!(format_arg.get_default_values().iter().any(|v| v == "text"));
170+
}
125171
}

0 commit comments

Comments
 (0)