diff --git a/.gitignore b/.gitignore index 303e17f..58895c9 100644 --- a/.gitignore +++ b/.gitignore @@ -1,7 +1,4 @@ /target -.api_manifest -.last_modified -downloads/* vmm_config.toml /coverage benches/fixtures/*.bin.zst diff --git a/Cargo.lock b/Cargo.lock index 8484c5a..49a2a43 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2759,6 +2759,7 @@ dependencies = [ "toml 0.8.23", "tracing", "tracing-subscriber", + "xdg", "zip", "zstd", ] @@ -3294,6 +3295,12 @@ version = "0.6.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9edde0db4769d2dc68579893f2306b26c6ecfbe0ef499b013d731b7b9247e0b9" +[[package]] +name = "xdg" +version = "2.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "213b7324336b53d2414b2db8537e56544d981803139155afa84f76eeebb7a546" + [[package]] name = "xz2" version = "0.1.7" diff --git a/Cargo.toml b/Cargo.toml index f672e9a..85fb51f 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -33,6 +33,7 @@ reqwest = {version = "0.12.14", features = ["json", "stream", "rustls-tls"], def serde = { version = "1.0", features = ["derive"] } serde_json = "1.0" shellexpand = "3.1" +xdg = "2.5" thiserror = "2.0" time = { version = "0.3", features = ["serde-well-known"] } tokio = { version = "1.32", features = ["full"] } diff --git a/README.md b/README.md index d9156be..0bc27a6 100644 --- a/README.md +++ b/README.md @@ -31,11 +31,19 @@ cargo install --path . ## Configuration -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: +`vmm` looks for configuration in the following order: + +1. A `vmm_config.toml` in the current directory (local config) +2. `~/.config/vmm/vmm_config.toml` (global XDG config) + +If neither exists, a default config is created at the global location on first run. + +You can also specify a config file directly with the `--config` flag, which bypasses the above lookup entirely. + +The config file supports the following settings: - `mod_list`: List of mods to install (in the format `"Owner-ModName"`) - `log_level`: Logging verbosity (`error`, `warn`, `info`, `debug`, `trace`) -- `cache_dir`: Directory to store cached mod information (default: `~/.config/vmm`) - `install_dir`: Optional directory where unzipped mods will be copied (e.g., your Valheim mods folder) Example configuration: @@ -43,7 +51,6 @@ Example configuration: ```toml mod_list = ["denikson-BepInExPack_Valheim", "ValheimModding-Jotunn"] log_level = "info" -cache_dir = "~/.config/vmm" install_dir = "~/some/path/to/Valheim/BepInEx/plugins" ``` @@ -65,6 +72,34 @@ Downloads all mods in your configuration, including their dependencies: vmm update mods ``` +### List Mods + +Lists all mods from your configuration including resolved dependencies: + +```bash +vmm list +``` + +By default outputs one mod per line. Use `--format json` for structured output: + +```bash +vmm list --format json +``` + +**Text output (default):** +``` + denikson-BepInExPack_Valheim 5.4.2202 + ValheimModding-Jotunn 2.28.0 +``` + +**JSON output:** +```json +[ + {"full_name": "denikson-BepInExPack_Valheim", "version": "5.4.2202"}, + {"full_name": "ValheimModding-Jotunn", "version": "2.28.0"} +] +``` + ### Search for Mods Searches available mods by name: @@ -75,6 +110,18 @@ vmm search 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. +## Global Options + +### `--config ` + +Override the config file location, bypassing the local/global lookup: + +```bash +vmm --config /path/to/my/vmm_config.toml update mods +``` + +Downloads and cached data always go to `~/.config/vmm/` regardless of which config file is used. Respects `$XDG_CONFIG_HOME` if set. + ## How It Works 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 ## Directory Structure -- `~/.config/vmm/` (or custom `cache_dir` setting in config): Cache directory for mod information -- `~/.config/vmm/downloads/` (or custom `cache_dir/downloads`): Location for downloaded mod archives and extracted files +- `~/.config/vmm/`: Config and cache directory (respects `$XDG_CONFIG_HOME`) +- `~/.config/vmm/downloads/`: Downloaded mod archives and extracted files -## Advanced Features +## Troubleshooting -### Troubleshooting +If you encounter issues, increase log verbosity in your config: -If you encounter issues: +```toml +log_level = "debug" +``` -1. Increase log verbosity in your config: - ```toml - log_level = "debug" - ``` -2. Run the command again to see more detailed output +Then run the command again to see more detailed output. ## License diff --git a/src/api.rs b/src/api.rs index b770ec0..e35b508 100644 --- a/src/api.rs +++ b/src/api.rs @@ -24,64 +24,52 @@ const API_MANIFEST_FILENAME_V1: &str = "api_manifest.bin.zst"; /// /// # Parameters /// -/// * `cache_dir` - The cache directory path (supports tilde expansion) +/// * `cache_dir` - The cache directory path /// /// # Returns /// /// The full path to the last_modified file fn last_modified_path(cache_dir: &str) -> PathBuf { - let expanded_path = shellexpand::tilde(cache_dir); - let mut path = PathBuf::from(expanded_path.as_ref()); - path.push(LAST_MODIFIED_FILENAME); - path + PathBuf::from(cache_dir).join(LAST_MODIFIED_FILENAME) } /// Returns the path to the v3 API manifest file in the cache directory. /// /// # Parameters /// -/// * `cache_dir` - The cache directory path (supports tilde expansion) +/// * `cache_dir` - The cache directory path /// /// # Returns /// /// The full path to the v3 manifest file fn api_manifest_path_v3(cache_dir: &str) -> PathBuf { - let expanded_path = shellexpand::tilde(cache_dir); - let mut path = PathBuf::from(expanded_path.as_ref()); - path.push(API_MANIFEST_FILENAME_V3); - path + PathBuf::from(cache_dir).join(API_MANIFEST_FILENAME_V3) } /// Returns the path to the v2 API manifest file in the cache directory. /// /// # Parameters /// -/// * `cache_dir` - The cache directory path (supports tilde expansion) +/// * `cache_dir` - The cache directory path /// /// # Returns /// /// The full path to the v2 manifest file fn api_manifest_path_v2(cache_dir: &str) -> PathBuf { - let expanded_path = shellexpand::tilde(cache_dir); - let mut path = PathBuf::from(expanded_path.as_ref()); - path.push(API_MANIFEST_FILENAME_V2); - path + PathBuf::from(cache_dir).join(API_MANIFEST_FILENAME_V2) } /// Returns the path to the v1 API manifest file in the cache directory. /// /// # Parameters /// -/// * `cache_dir` - The cache directory path (supports tilde expansion) +/// * `cache_dir` - The cache directory path /// /// # Returns /// /// The full path to the v1 manifest file fn api_manifest_path_v1(cache_dir: &str) -> PathBuf { - let expanded_path = shellexpand::tilde(cache_dir); - let mut path = PathBuf::from(expanded_path.as_ref()); - path.push(API_MANIFEST_FILENAME_V1); - path + PathBuf::from(cache_dir).join(API_MANIFEST_FILENAME_V1) } /// Retrieves the manifest of available packages. @@ -614,8 +602,7 @@ async fn download_file( progress_style: ProgressStyle, cache_dir: &str, ) -> AppResult<()> { - let expanded_path = shellexpand::tilde(cache_dir); - let mut downloads_directory = PathBuf::from(expanded_path.as_ref()); + let mut downloads_directory = PathBuf::from(cache_dir); downloads_directory.push("downloads"); let mut file_path = downloads_directory.clone(); file_path.push(filename); @@ -678,8 +665,7 @@ mod tests { let temp_dir = tempdir().unwrap(); let cache_dir = temp_dir.path().to_str().unwrap(); - let expanded_path = shellexpand::tilde(cache_dir); - let mut downloads_directory = PathBuf::from(expanded_path.as_ref()); + let mut downloads_directory = PathBuf::from(cache_dir); downloads_directory.push("downloads"); let expected_directory = PathBuf::from(cache_dir).join("downloads"); @@ -701,16 +687,20 @@ mod tests { } #[test] - fn test_path_with_tilde() { - let home_path = "~/some_test_dir"; - let home_dir = std::env::var("HOME").unwrap_or_else(|_| "/home/user".to_string()); - let expected_path = Path::new(&home_dir).join("some_test_dir"); + fn test_path_construction() { + let cache_dir = "/some/cache/dir"; - let last_modified = last_modified_path(home_path); - let api_manifest = api_manifest_path_v3(home_path); + let last_modified = last_modified_path(cache_dir); + let api_manifest = api_manifest_path_v3(cache_dir); - assert_eq!(last_modified.parent().unwrap(), expected_path); - assert_eq!(api_manifest.parent().unwrap(), expected_path); + assert_eq!( + last_modified, + Path::new(cache_dir).join(LAST_MODIFIED_FILENAME) + ); + assert_eq!( + api_manifest, + Path::new(cache_dir).join(API_MANIFEST_FILENAME_V3) + ); } #[test] diff --git a/src/cli.rs b/src/cli.rs index 58e6dec..b9b99fb 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -1,4 +1,5 @@ -use clap::{Args, Parser, Subcommand}; +use clap::{Args, Parser, Subcommand, ValueEnum}; +use std::path::PathBuf; /// Root command-line interface structure for the application. /// @@ -7,6 +8,9 @@ use clap::{Args, Parser, Subcommand}; #[derive(Parser)] #[command(version, about, long_about = None)] pub struct AppCli { + /// Path to a config file, overriding the default XDG location. + #[arg(long, global = true)] + pub config: Option, /// The subcommand to execute. #[command(subcommand)] pub command: Command, @@ -19,6 +23,25 @@ pub enum Command { Update(CommandArgs), /// Search for mods by name. Search(SearchArgs), + /// List all mods from config including resolved dependencies. + List(ListArgs), +} + +/// Arguments for the list command. +#[derive(Args)] +pub struct ListArgs { + /// Output format. + #[arg(long, value_enum, default_value_t = ListFormat::Text)] + pub format: ListFormat, +} + +/// Output format for the list command. +#[derive(Clone, ValueEnum)] +pub enum ListFormat { + /// Plain text, one mod per line. + Text, + /// JSON array. + Json, } /// Arguments for the update command. @@ -122,4 +145,27 @@ mod tests { .contains("Update installed mods to their latest versions") ); } + + #[test] + fn test_command_list() { + let app = AppCli::command(); + let list_command = app.find_subcommand("list").unwrap(); + + assert_eq!(list_command.get_name(), "list"); + assert!(list_command.get_about().is_some()); + assert!( + list_command + .get_about() + .unwrap() + .to_string() + .contains("List all mods from config including resolved dependencies") + ); + + let list_args = list_command.get_arguments().collect::>(); + let format_arg = list_args + .iter() + .find(|a| a.get_id().as_str() == "format") + .unwrap(); + assert!(format_arg.get_default_values().iter().any(|v| v == "text")); + } } diff --git a/src/commands/list.rs b/src/commands/list.rs new file mode 100644 index 0000000..01abddf --- /dev/null +++ b/src/commands/list.rs @@ -0,0 +1,135 @@ +use crate::{api, cli::ListFormat, error::AppResult, package::DependencyGraph}; + +pub async fn run( + cache_dir: &str, + mod_list: Vec, + format: &ListFormat, + api_url: Option<&str>, +) -> AppResult<()> { + let manifest = api::get_manifest(cache_dir, api_url).await?; + let dg = DependencyGraph::new(mod_list); + let urls = dg.resolve_interned(&manifest); + + let mut entries: Vec<&String> = urls.keys().collect(); + entries.sort(); + + match format { + ListFormat::Text => { + for entry in entries { + let name = entry.strip_suffix(".zip").unwrap_or(entry); + if let Some((full_name, version)) = name.rsplit_once('-') { + println!("{} {}", full_name, version); + } + } + } + ListFormat::Json => { + let json_entries: Vec = entries + .iter() + .filter_map(|entry| { + let name = entry.strip_suffix(".zip").unwrap_or(entry); + let (full_name, version) = name.rsplit_once('-')?; + Some(serde_json::json!({ + "full_name": full_name, + "version": version, + })) + }) + .collect(); + println!("{}", serde_json::to_string_pretty(&json_entries)?); + } + } + + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + use mockito::Server; + use tempfile::tempdir; + use tokio::runtime::Runtime; + + fn test_manifest_json() -> &'static str { + r#"[{ + "name": "ModA", + "full_name": "Owner-ModA", + "owner": "Owner", + "package_url": "https://example.com/mods/ModA", + "date_created": "2024-01-01T12:00:00Z", + "date_updated": "2024-01-02T12:00:00Z", + "uuid4": "test-uuid", + "rating_score": 5, + "is_pinned": false, + "is_deprecated": false, + "has_nsfw_content": false, + "categories": ["category1"], + "versions": [{ + "name": "ModA", + "full_name": "Owner-ModA", + "description": "Test description", + "icon": "icon.png", + "version_number": "1.0.0", + "dependencies": [], + "download_url": "https://example.com/mods/ModA/download", + "downloads": 100, + "date_created": "2024-01-01T12:00:00Z", + "website_url": "https://example.com", + "is_active": true, + "uuid4": "test-version-uuid", + "file_size": 1024 + }] + }]"# + } + + fn setup_mock_server(server: &mut mockito::ServerGuard) -> String { + let last_modified = "Wed, 21 Feb 2024 15:30:45 GMT"; + server + .mock("GET", "/c/valheim/api/v1/package/") + .with_status(200) + .with_header("Content-Type", "application/json") + .with_header("Last-Modified", last_modified) + .with_body(test_manifest_json()) + .create(); + format!("{}/c/valheim/api/v1/package/", server.url()) + } + + #[test] + fn test_run_list_text_format_empty_mod_list() { + let mut server = Server::new(); + let api_url = setup_mock_server(&mut server); + let temp_dir = tempdir().unwrap(); + let cache_dir = temp_dir.path().to_str().unwrap(); + + let rt = Runtime::new().unwrap(); + let result = rt.block_on(run(cache_dir, vec![], &ListFormat::Text, Some(&api_url))); + + assert!(result.is_ok()); + } + + #[test] + fn test_run_list_text_format_with_mods() { + let mut server = Server::new(); + let api_url = setup_mock_server(&mut server); + let temp_dir = tempdir().unwrap(); + let cache_dir = temp_dir.path().to_str().unwrap(); + let mod_list = vec!["Owner-ModA".to_string()]; + + let rt = Runtime::new().unwrap(); + let result = rt.block_on(run(cache_dir, mod_list, &ListFormat::Text, Some(&api_url))); + + assert!(result.is_ok()); + } + + #[test] + fn test_run_list_json_format_with_mods() { + let mut server = Server::new(); + let api_url = setup_mock_server(&mut server); + let temp_dir = tempdir().unwrap(); + let cache_dir = temp_dir.path().to_str().unwrap(); + let mod_list = vec!["Owner-ModA".to_string()]; + + let rt = Runtime::new().unwrap(); + let result = rt.block_on(run(cache_dir, mod_list, &ListFormat::Json, Some(&api_url))); + + assert!(result.is_ok()); + } +} diff --git a/src/commands/mod.rs b/src/commands/mod.rs new file mode 100644 index 0000000..4a4a121 --- /dev/null +++ b/src/commands/mod.rs @@ -0,0 +1,3 @@ +pub mod list; +pub mod search; +pub mod update; diff --git a/src/commands/search.rs b/src/commands/search.rs new file mode 100644 index 0000000..a1740ec --- /dev/null +++ b/src/commands/search.rs @@ -0,0 +1,160 @@ +use crate::{api, error::AppResult, intern}; + +pub async fn run(cache_dir: &str, term: &str, api_url: Option<&str>) -> AppResult<()> { + let manifest = api::get_manifest(cache_dir, api_url).await?; + let search_term = term.to_lowercase(); + + let search_results: Vec = (0..manifest.len()) + .filter(|&idx| { + if let Some(name) = manifest.resolve_name_at(idx) { + if name.to_lowercase().contains(&search_term) { + return true; + } + } + + if let Some(full_name) = manifest.resolve_full_name_at(idx) { + if full_name.to_lowercase().contains(&search_term) { + return true; + } + } + + false + }) + .collect(); + + if search_results.is_empty() { + println!("No mods found matching '{}'", term); + } else { + println!("Found {} mods matching '{}':\n", search_results.len(), term); + + for idx in search_results { + let version = manifest + .get_latest_version_at(idx) + .and_then(|ver_idx| { + intern::resolve_option( + &manifest.interner, + manifest.versions.version_numbers[ver_idx], + ) + }) + .unwrap_or_else(|| "Unknown".to_string()); + + let description = manifest + .get_latest_version_at(idx) + .and_then(|ver_idx| { + intern::resolve_option(&manifest.interner, manifest.versions.descriptions[ver_idx]) + }) + .unwrap_or_default(); + + let name = manifest + .resolve_name_at(idx) + .or_else(|| manifest.resolve_full_name_at(idx)) + .unwrap_or_else(|| "Unknown".to_string()); + + let owner = manifest + .resolve_owner_at(idx) + .unwrap_or_else(|| "Unknown".to_string()); + + println!("{}-{} ({})", owner, name, version); + + if !description.is_empty() { + println!(" {}", description); + } + + println!(); + } + } + + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + use mockito::Server; + use tempfile::tempdir; + use tokio::runtime::Runtime; + + fn test_manifest_json() -> &'static str { + r#"[{ + "name": "ModA", + "full_name": "Owner-ModA", + "owner": "Owner", + "package_url": "https://example.com/mods/ModA", + "date_created": "2024-01-01T12:00:00Z", + "date_updated": "2024-01-02T12:00:00Z", + "uuid4": "test-uuid", + "rating_score": 5, + "is_pinned": false, + "is_deprecated": false, + "has_nsfw_content": false, + "categories": ["category1"], + "versions": [{ + "name": "ModA", + "full_name": "Owner-ModA", + "description": "Test description", + "icon": "icon.png", + "version_number": "1.0.0", + "dependencies": [], + "download_url": "https://example.com/mods/ModA/download", + "downloads": 100, + "date_created": "2024-01-01T12:00:00Z", + "website_url": "https://example.com", + "is_active": true, + "uuid4": "test-version-uuid", + "file_size": 1024 + }] + }]"# + } + + fn setup_mock_server(server: &mut mockito::ServerGuard) -> String { + let last_modified = "Wed, 21 Feb 2024 15:30:45 GMT"; + server + .mock("GET", "/c/valheim/api/v1/package/") + .with_status(200) + .with_header("Content-Type", "application/json") + .with_header("Last-Modified", last_modified) + .with_body(test_manifest_json()) + .create(); + format!("{}/c/valheim/api/v1/package/", server.url()) + } + + #[test] + fn test_run_search_no_results() { + let mut server = Server::new(); + let api_url = setup_mock_server(&mut server); + let temp_dir = tempdir().unwrap(); + let cache_dir = temp_dir.path().to_str().unwrap(); + + let rt = Runtime::new().unwrap(); + let result = rt.block_on(run(cache_dir, "nonexistent_mod_xyz", Some(&api_url))); + + assert!(result.is_ok()); + } + + #[test] + fn test_run_search_with_results() { + let mut server = Server::new(); + let api_url = setup_mock_server(&mut server); + let temp_dir = tempdir().unwrap(); + let cache_dir = temp_dir.path().to_str().unwrap(); + + let rt = Runtime::new().unwrap(); + // "moda" matches "ModA" (case-insensitive) — exercises the result display loop + let result = rt.block_on(run(cache_dir, "moda", Some(&api_url))); + + assert!(result.is_ok()); + } + + #[test] + fn test_run_search_case_insensitive() { + let mut server = Server::new(); + let api_url = setup_mock_server(&mut server); + let temp_dir = tempdir().unwrap(); + let cache_dir = temp_dir.path().to_str().unwrap(); + + let rt = Runtime::new().unwrap(); + let result = rt.block_on(run(cache_dir, "MODA", Some(&api_url))); + + assert!(result.is_ok()); + } +} diff --git a/src/commands/update.rs b/src/commands/update.rs new file mode 100644 index 0000000..88381f4 --- /dev/null +++ b/src/commands/update.rs @@ -0,0 +1,120 @@ +use crate::{api, error::AppResult, package::DependencyGraph, zip}; + +pub async fn run_manifest(cache_dir: &str, api_url: Option<&str>) -> AppResult<()> { + tracing::info!("Checking for manifest updates"); + let _ = api::get_manifest(cache_dir, api_url).await?; + Ok(()) +} + +pub async fn run_mods( + cache_dir: &str, + mod_list: Vec, + install_dir: Option<&str>, + api_url: Option<&str>, +) -> AppResult<()> { + let manifest = api::get_manifest(cache_dir, api_url).await?; + + tracing::info!("Building dependency graph for mods"); + + let dg = DependencyGraph::new(mod_list); + let urls = dg.resolve_interned(&manifest); + + tracing::info!("Done building dependency graph, proceeding to download mods if necessary"); + + api::download_files(urls.clone(), cache_dir).await?; + + zip::unzip_downloaded_mods(cache_dir, &urls, install_dir)?; + + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + use mockito::Server; + use std::path::PathBuf; + use tempfile::tempdir; + use tokio::runtime::Runtime; + + fn test_manifest_json() -> &'static str { + r#"[{ + "name": "ModA", + "full_name": "Owner-ModA", + "owner": "Owner", + "package_url": "https://example.com/mods/ModA", + "date_created": "2024-01-01T12:00:00Z", + "date_updated": "2024-01-02T12:00:00Z", + "uuid4": "test-uuid", + "rating_score": 5, + "is_pinned": false, + "is_deprecated": false, + "has_nsfw_content": false, + "categories": ["category1"], + "versions": [{ + "name": "ModA", + "full_name": "Owner-ModA", + "description": "Test description", + "icon": "icon.png", + "version_number": "1.0.0", + "dependencies": [], + "download_url": "https://example.com/mods/ModA/download", + "downloads": 100, + "date_created": "2024-01-01T12:00:00Z", + "website_url": "https://example.com", + "is_active": true, + "uuid4": "test-version-uuid", + "file_size": 1024 + }] + }]"# + } + + #[test] + fn test_run_manifest_success() { + let mut server = Server::new(); + let last_modified = "Wed, 21 Feb 2024 15:30:45 GMT"; + + let _mock = server + .mock("GET", "/c/valheim/api/v1/package/") + .with_status(200) + .with_header("Content-Type", "application/json") + .with_header("Last-Modified", last_modified) + .with_body(test_manifest_json()) + .create(); + + let api_url = format!("{}/c/valheim/api/v1/package/", server.url()); + let temp_dir = tempdir().unwrap(); + let cache_dir = temp_dir.path().to_str().unwrap(); + + let rt = Runtime::new().unwrap(); + let result = rt.block_on(run_manifest(cache_dir, Some(&api_url))); + + assert!(result.is_ok()); + } + + #[test] + fn test_run_mods_with_empty_mod_list() { + let mut server = Server::new(); + let last_modified = "Wed, 21 Feb 2024 15:30:45 GMT"; + + let _mock = server + .mock("GET", "/c/valheim/api/v1/package/") + .with_status(200) + .with_header("Content-Type", "application/json") + .with_header("Last-Modified", last_modified) + .with_body(test_manifest_json()) + .create(); + + let api_url = format!("{}/c/valheim/api/v1/package/", server.url()); + let temp_dir = tempdir().unwrap(); + let cache_dir = temp_dir.path().to_str().unwrap(); + + // download_files with an empty mod list won't create the downloads directory, + // so pre-create it to prevent unzip_downloaded_mods from erroring on a missing dir. + std::fs::create_dir_all(PathBuf::from(cache_dir).join("downloads")).unwrap(); + + let rt = Runtime::new().unwrap(); + let result = rt.block_on(run_mods(cache_dir, vec![], None, Some(&api_url))); + + assert!(result.is_ok()); + } +} diff --git a/src/config.rs b/src/config.rs index 294dddb..af1ee16 100644 --- a/src/config.rs +++ b/src/config.rs @@ -1,7 +1,6 @@ use config::{Config, ConfigError, File}; use serde::{Deserialize, Serialize}; use std::io::Write; -use std::sync::LazyLock; use std::{fs::OpenOptions, path::Path}; use crate::error::{AppError, AppResult}; @@ -16,8 +15,6 @@ pub struct AppConfig { pub mod_list: Vec, /// Logging level (e.g., "error", "warn", "info", "debug", "trace"). pub log_level: String, - /// Directory path for caching downloaded manifests and mod files. - pub cache_dir: String, /// Optional directory path where mods should be installed. pub install_dir: Option, } @@ -27,50 +24,61 @@ impl Default for AppConfig { Self { mod_list: vec![], log_level: "error".into(), - cache_dir: "~/.config/vmm".into(), install_dir: None, } } } -/// Global application configuration instance. +/// The XDG config directory for vmm (~/.config/vmm). /// -/// This static is lazily initialized on first access by reading from vmm_config.toml -/// or creating a new config file with default values if none exists. -/// -/// # Panics -/// -/// Panics if the configuration file cannot be read or parsed. +/// Used as the cache and data directory for downloaded manifests and mod files. #[cfg(not(tarpaulin_include))] -pub static APP_CONFIG: LazyLock = LazyLock::new(|| { - get_config().unwrap_or_else(|err| panic!("An error has occurred getting the config: '{err}'")) +pub static APP_CACHE_DIR: std::sync::LazyLock = std::sync::LazyLock::new(|| { + xdg::BaseDirectories::with_prefix("vmm") + .expect("Failed to initialize XDG base directories") + .get_config_home() + .to_string_lossy() + .into_owned() }); -/// Loads application configuration from vmm_config.toml. -/// -/// If a config.toml doesn't already exist, a new one is created with default values. +/// Loads application configuration. /// -/// # Returns -/// -/// The loaded configuration, or an error if reading/parsing fails. -#[cfg(not(tarpaulin_include))] -fn get_config() -> Result { +/// If `config_override` is provided, loads only from that path. +/// Otherwise, looks for a local `vmm_config.toml` first, then falls back to +/// the XDG config location (`~/.config/vmm/vmm_config.toml`). If neither +/// exists, a default config is created at the XDG location. +pub fn get_config(config_override: Option<&Path>) -> Result { let default_config_data = AppConfig::default(); - let config_path_name = "vmm_config.toml"; - let config_path = Path::new(config_path_name); - if !config_path.exists() { - let _ = create_missing_config_file(config_path, &default_config_data); + let mut builder = Config::builder() + .set_default("mod_list", default_config_data.mod_list.clone())? + .set_default("log_level", default_config_data.log_level.clone())? + .set_default("install_dir", default_config_data.install_dir.clone())?; + + if let Some(path) = config_override { + builder = builder.add_source(File::with_name(path.to_str().unwrap_or_default())); + } else { + let local_path = Path::new("vmm_config.toml"); + let xdg_dirs = + xdg::BaseDirectories::with_prefix("vmm").expect("Failed to initialize XDG base directories"); + let global_path = xdg_dirs.get_config_file("vmm_config.toml"); + + if !local_path.exists() && !global_path.exists() { + if let Ok(path) = xdg_dirs.place_config_file("vmm_config.toml") { + let _ = create_missing_config_file(&path, &default_config_data); + } + } + + if global_path.exists() { + builder = builder.add_source(File::with_name(global_path.to_str().unwrap_or_default())); + } + + if local_path.exists() { + builder = builder.add_source(File::with_name("vmm_config.toml")); + } } - Config::builder() - .set_default("mod_list", default_config_data.mod_list)? - .set_default("log_level", default_config_data.log_level)? - .set_default("cache_dir", default_config_data.cache_dir)? - .set_default("install_dir", default_config_data.install_dir)? - .add_source(File::with_name(config_path_name)) - .build()? - .try_deserialize() + builder.build()?.try_deserialize() } /// Creates a new configuration file with default values. @@ -137,16 +145,9 @@ mod tests { #[test] fn test_custom_config_values() { - let dir = tempdir().unwrap(); - let custom_config = AppConfig { mod_list: vec!["Owner1-ModA".to_string(), "Owner2-ModB".to_string()], log_level: "debug".to_string(), - cache_dir: dir - .path() - .to_str() - .expect("Should get string represenation of temporary directory path") - .to_string(), install_dir: Some("/path/to/install/directory".to_string()), }; @@ -168,4 +169,22 @@ mod tests { assert_eq!(parsed.mod_list[0], "Owner1-ModA"); assert_eq!(parsed.mod_list[1], "Owner2-ModB"); } + + #[test] + fn test_get_config_with_override() { + let dir = tempdir().unwrap(); + let config_path = dir.path().join("override_config.toml"); + + let custom_config = AppConfig { + mod_list: vec!["Owner1-ModA".to_string()], + log_level: "debug".to_string(), + install_dir: None, + }; + + create_missing_config_file(&config_path, &custom_config).unwrap(); + + let loaded = get_config(Some(&config_path)).unwrap(); + assert_eq!(loaded.log_level, "debug"); + assert_eq!(loaded.mod_list, vec!["Owner1-ModA"]); + } } diff --git a/src/logs.rs b/src/logs.rs index 1347a92..145a579 100644 --- a/src/logs.rs +++ b/src/logs.rs @@ -1,20 +1,18 @@ use tracing_subscriber::{EnvFilter, FmtSubscriber}; -use crate::config::APP_CONFIG; - /// Initializes the application's logging system. /// -/// This function sets up tracing with a filter based on the log_level specified -/// in the application configuration. The logging output follows the structured -/// logging format provided by tracing_subscriber. +/// This function sets up tracing with a filter based on the provided log level. +/// The logging output follows the structured logging format provided by +/// tracing_subscriber. /// /// # Panics /// /// Panics if the global default subscriber has already been set or if subscriber /// initialization fails. #[cfg(not(tarpaulin_include))] -pub fn setup_logging() { - let filter = EnvFilter::new(format!("vmm={}", APP_CONFIG.log_level)); +pub fn setup_logging(log_level: &str) { + let filter = EnvFilter::new(format!("vmm={}", log_level)); let subscriber = FmtSubscriber::builder().with_env_filter(filter).finish(); tracing::subscriber::set_global_default(subscriber).expect("setting default subscriber failed"); } diff --git a/src/main.rs b/src/main.rs index 31640eb..5b42b35 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,5 +1,6 @@ mod api; mod cli; +mod commands; mod config; mod error; mod intern; @@ -11,113 +12,38 @@ mod zip; use crate::{cli::AppCli, error::AppResult}; use clap::Parser; use cli::{Command, UpdatesCommand}; -use config::APP_CONFIG; -use package::DependencyGraph; +use config::{APP_CACHE_DIR, get_config}; -/// The main entry point for the Valheim Mod Manager application. -/// -/// This function initializes the logging system, fetches the manifest of available -/// mods, and downloads a predefined set of mods and their dependencies. -/// -/// # Returns -/// -/// `Ok(())` if the program runs successfully, or an error if something fails. #[tokio::main] #[cfg(not(tarpaulin_include))] async fn main() -> AppResult<()> { - logs::setup_logging(); let app = AppCli::parse(); + let config = get_config(app.config.as_deref()) + .unwrap_or_else(|err| panic!("An error has occurred getting the config: '{err}'")); + + logs::setup_logging(&config.log_level); tracing::info!("Starting valheim mod manager"); + let cache_dir: &str = &APP_CACHE_DIR; + match &app.command { Command::Update(subcmd) => match subcmd.command { - UpdatesCommand::Manifest => { - tracing::info!("Checking for manifest updates"); - let _ = api::get_manifest(&APP_CONFIG.cache_dir, None).await?; - } + UpdatesCommand::Manifest => commands::update::run_manifest(cache_dir, None).await?, UpdatesCommand::Mods => { - let manifest = api::get_manifest(&APP_CONFIG.cache_dir, None).await?; - let packages = APP_CONFIG.mod_list.clone(); - - tracing::info!("Building dependency graph for mods"); - - let dg = DependencyGraph::new(packages); - let urls = dg.resolve_interned(&manifest); - - tracing::info!("Done building dependency graph, proceeding to download mods if necessary"); - - api::download_files(urls.clone(), &APP_CONFIG.cache_dir).await?; - - zip::unzip_downloaded_mods(&APP_CONFIG.cache_dir, &urls)?; + commands::update::run_mods( + cache_dir, + config.mod_list.clone(), + config.install_dir.as_deref(), + None, + ) + .await? } }, + Command::List(list_args) => { + commands::list::run(cache_dir, config.mod_list.clone(), &list_args.format, None).await? + } Command::Search(search_args) => { - let manifest = api::get_manifest(&APP_CONFIG.cache_dir, None).await?; - let search_term = search_args.term.to_lowercase(); - - let search_results: Vec = (0..manifest.len()) - .filter(|&idx| { - if let Some(name) = manifest.resolve_name_at(idx) { - if name.to_lowercase().contains(&search_term) { - return true; - } - } - - if let Some(full_name) = manifest.resolve_full_name_at(idx) { - if full_name.to_lowercase().contains(&search_term) { - return true; - } - } - - false - }) - .collect(); - - if search_results.is_empty() { - println!("No mods found matching '{}'", search_args.term); - } else { - println!( - "Found {} mods matching '{}':\n", - search_results.len(), - search_args.term - ); - - for idx in search_results { - let version = manifest - .get_latest_version_at(idx) - .and_then(|ver_idx| { - intern::resolve_option( - &manifest.interner, - manifest.versions.version_numbers[ver_idx], - ) - }) - .unwrap_or_else(|| "Unknown".to_string()); - - let description = manifest - .get_latest_version_at(idx) - .and_then(|ver_idx| { - intern::resolve_option(&manifest.interner, manifest.versions.descriptions[ver_idx]) - }) - .unwrap_or_default(); - - let name = manifest - .resolve_name_at(idx) - .or_else(|| manifest.resolve_full_name_at(idx)) - .unwrap_or_else(|| "Unknown".to_string()); - - let owner = manifest - .resolve_owner_at(idx) - .unwrap_or_else(|| "Unknown".to_string()); - - println!("{}-{} ({})", owner, name, version); - - if !description.is_empty() { - println!(" {}", description); - } - - println!(); - } - } + commands::search::run(cache_dir, &search_args.term, None).await? } } diff --git a/src/zip.rs b/src/zip.rs index 1ffe1a0..a770555 100644 --- a/src/zip.rs +++ b/src/zip.rs @@ -6,7 +6,6 @@ use std::io::{self, Read, Write}; use std::path::{Path, PathBuf}; use tracing::{debug, error, info, warn}; -use crate::config::APP_CONFIG; use crate::error::{AppError, AppResult}; use crate::manifest::Manifest; @@ -21,11 +20,12 @@ use crate::manifest::Manifest; /// # Returns /// /// `Ok(())` if all files are processed successfully, or an error if reading the directory fails. -pub fn unzip_downloaded_mods(cache_dir: &str, urls: &HashMap) -> AppResult<()> { - let expanded_path = shellexpand::tilde(cache_dir); - let mut cache_path = PathBuf::from(expanded_path.as_ref()); - cache_path.push("downloads"); - let downloads_dir = cache_path; +pub fn unzip_downloaded_mods( + cache_dir: &str, + urls: &HashMap, + install_dir: Option<&str>, +) -> AppResult<()> { + let downloads_dir = PathBuf::from(cache_dir).join("downloads"); let entries = fs::read_dir(&downloads_dir)?; info!("Processing mod files for extraction"); @@ -47,7 +47,7 @@ pub fn unzip_downloaded_mods(cache_dir: &str, urls: &HashMap) -> continue; } - process_zip_file(&downloads_dir, &file_path)?; + process_zip_file(&downloads_dir, &file_path, install_dir)?; } info!("Finished processing all mod files"); @@ -170,7 +170,11 @@ fn calculate_crc32(data: &[u8]) -> u32 { /// - The zip file cannot be opened /// - Extraction fails /// - Copying to install directory fails -fn process_zip_file(downloads_dir: &Path, file_path: &PathBuf) -> AppResult<()> { +fn process_zip_file( + downloads_dir: &Path, + file_path: &PathBuf, + install_dir: Option<&str>, +) -> AppResult<()> { let file_name = match file_path.file_name().and_then(|n| n.to_str()) { Some(name) => name, None => { @@ -215,7 +219,7 @@ fn process_zip_file(downloads_dir: &Path, file_path: &PathBuf) -> AppResult<()> unzip(file, &mod_dir)?; } - if let Some(install_dir) = &APP_CONFIG.install_dir { + if let Some(install_dir) = install_dir { copy_mod_to_install_dir(&mod_dir, install_dir, &mod_name)?; } @@ -387,7 +391,7 @@ mod tests { "Owner-ModName-1.0.0.zip".to_string(), "http://example.com/fake".to_string(), ); - let result = unzip_downloaded_mods(cache_dir, &urls); + let result = unzip_downloaded_mods(cache_dir, &urls, None); assert!(result.is_err()); } @@ -416,7 +420,7 @@ mod tests { "Owner-ModName-1.0.0.zip".to_string(), "http://example.com/fake".to_string(), ); - let _result = unzip_downloaded_mods(cache_dir, &urls); + let _result = unzip_downloaded_mods(cache_dir, &urls, None); } #[test] @@ -438,7 +442,7 @@ mod tests { "http://example.com/fake".to_string(), ); - let result = unzip_downloaded_mods(cache_dir, &urls); + let result = unzip_downloaded_mods(cache_dir, &urls, None); assert!(result.is_ok()); let extracted_manifest = @@ -519,7 +523,7 @@ mod tests { let mut file = File::create(&invalid_zip_path).unwrap(); file.write_all(b"This is not a valid zip file").unwrap(); - let result = process_zip_file(&downloads_dir, &invalid_zip_path); + let result = process_zip_file(&downloads_dir, &invalid_zip_path, None); assert!(result.is_err()); if let Err(err) = result { @@ -540,7 +544,7 @@ mod tests { let nonexistent_path = PathBuf::from("/nonexistent/file/that/does/not/exist"); - let result = process_zip_file(&downloads_dir, &nonexistent_path); + let result = process_zip_file(&downloads_dir, &nonexistent_path, None); assert!(result.is_err()); }