diff --git a/src/app/app_tests.rs b/src/app/app_tests.rs index ffefe313..abe7c1d0 100644 --- a/src/app/app_tests.rs +++ b/src/app/app_tests.rs @@ -15,6 +15,7 @@ mod tests { use crate::models::{HorizontallyScrollableText, TabRoute}; use crate::network::NetworkEvent; use crate::network::radarr_network::RadarrEvent; + use crate::network::sonarr_network::SonarrEvent; use tokio_util::sync::CancellationToken; #[test] @@ -145,6 +146,36 @@ mod tests { assert!(app.is_routing); } + #[tokio::test] + async fn test_named_tab_uses_selected_servarr_route_and_network_events() { + let (network_tx, mut network_rx) = mpsc::channel(500); + let mut app = App::new( + network_tx, + AppConfig { + radarr: Some(vec![ServarrConfig { + name: Some("Movies".to_owned()), + ..ServarrConfig::default() + }]), + sonarr: Some(vec![ServarrConfig { + name: Some("Shows".to_owned()), + ..ServarrConfig::default() + }]), + ..AppConfig::default() + }, + CancellationToken::new(), + ); + + assert!(app.server_tabs.select_tab_by_title("Shows")); + assert_eq!(app.get_current_route(), ActiveSonarrBlock::Series.into()); + + app.on_tick().await; + + assert_eq!( + network_rx.recv().await.unwrap(), + SonarrEvent::GetQualityProfiles.into() + ); + } + #[test] fn test_reset_cancellation_token() { let mut app = App { diff --git a/src/app/mod.rs b/src/app/mod.rs index a660243d..d511fcf4 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -240,14 +240,11 @@ impl App<'_> { } pub fn get_current_route(&self) -> Route { - *self.navigation_stack.last().unwrap_or( - &self - .server_tabs - .tabs - .first() - .expect("At least one server tab must exist") - .route, - ) + self + .navigation_stack + .last() + .copied() + .unwrap_or_else(|| self.server_tabs.get_active_route()) } } diff --git a/src/main.rs b/src/main.rs index a40173f4..d9dfcfef 100644 --- a/src/main.rs +++ b/src/main.rs @@ -171,13 +171,40 @@ async fn main() -> Result<()> { cancellation_token.clone(), ))); + if args.command.is_none() + && let Some(name) = args.global.servarr_name.as_deref() + { + let mut app = app.lock().await; + anyhow::ensure!( + app.server_tabs.select_tab_by_title(name.trim()), + "A Servarr titled '{}' was not found in your configuration file", + name.trim() + ); + } + match args.command { Some(command) => match command { Command::Radarr(_) | Command::Sonarr(_) | Command::Lidarr(_) => { if spinner_disabled { - start_cli_no_spinner(config, reqwest_client, cancellation_token, app, command).await; + start_cli_no_spinner( + config, + reqwest_client, + cancellation_token, + app, + command, + args.global.servarr_name.as_deref(), + ) + .await; } else { - start_cli_with_spinner(config, reqwest_client, cancellation_token, app, command).await; + start_cli_with_spinner( + config, + reqwest_client, + cancellation_token, + app, + command, + args.global.servarr_name.as_deref(), + ) + .await; } } Command::Completions { shell } => { diff --git a/src/utils.rs b/src/utils.rs index 788a6a78..75f7a9ac 100644 --- a/src/utils.rs +++ b/src/utils.rs @@ -23,6 +23,7 @@ use tokio_util::sync::CancellationToken; use crate::app::{App, AppConfig, log_and_print_error}; use crate::cli::{self, Command}; +use crate::models::Route; use crate::network::Network; use crate::ui::theme::ThemeDefinitionsWrapper; @@ -285,12 +286,16 @@ pub(super) async fn start_cli_with_spinner( cancellation_token: CancellationToken, app: Arc>>, command: Command, + servarr_name: Option<&str>, ) { config.verify_config_present_for_cli(&command); { let mut app = app.lock().await; app.cli_mode = true; - select_cli_configuration(&mut app, &config, &command, None); + select_cli_configuration(&mut app, &config, &command, servarr_name).unwrap_or_else(|error| { + log_and_print_error(error.to_string()); + process::exit(1); + }); } let pb = render_spinner(); let app_nw = Arc::clone(&app); @@ -314,12 +319,16 @@ pub(super) async fn start_cli_no_spinner( cancellation_token: CancellationToken, app: Arc>>, command: Command, + servarr_name: Option<&str>, ) { config.verify_config_present_for_cli(&command); { let mut app = app.lock().await; app.cli_mode = true; - select_cli_configuration(&mut app, &config, &command, None); + select_cli_configuration(&mut app, &config, &command, servarr_name).unwrap_or_else(|error| { + log_and_print_error(error.to_string()); + process::exit(1); + }); } let app_nw = Arc::clone(&app); let mut network = Network::new(&app_nw, cancellation_token, reqwest_client); @@ -338,16 +347,22 @@ pub fn select_cli_configuration( app: &mut App<'_>, config: &AppConfig, command: &Command, - servarr_name_arg: Option, -) { + servarr_name_arg: Option<&str>, +) -> Result<()> { if let Some(servarr_name) = servarr_name_arg { let trimmed_name = servarr_name.trim(); - if !app.server_tabs.select_tab_by_title(trimmed_name) { - log_and_print_error(format!( - "A Servarr titled '{trimmed_name}' was not found in your configuration file" - )); - process::exit(1); - } + let selected_index = app.server_tabs.tabs.iter().position(|tab| { + tab.title == trimmed_name + && matches!( + (&tab.route, command), + (Route::Radarr(..), Command::Radarr(_)) + | (Route::Sonarr(..), Command::Sonarr(_)) + | (Route::Lidarr(..), Command::Lidarr(_)) + ) + }); + app.server_tabs.index = selected_index.ok_or_else(|| { + anyhow!("A Servarr titled '{trimmed_name}' matching this command was not found in your configuration file") + })?; } else { match command { Command::Radarr(_) => { @@ -368,4 +383,5 @@ pub fn select_cli_configuration( _ => (), } } + Ok(()) } diff --git a/src/utils_tests.rs b/src/utils_tests.rs index 9434f80c..83761c37 100644 --- a/src/utils_tests.rs +++ b/src/utils_tests.rs @@ -1,11 +1,52 @@ #[cfg(test)] mod tests { + use clap::Parser; use std::fs::{self, File}; use std::io::{BufRead, BufReader, Seek, SeekFrom, Write}; use pretty_assertions::assert_eq; + use tokio::sync::mpsc; + use tokio_util::sync::CancellationToken; - use crate::utils::{convert_f64_to_gb, convert_runtime, convert_to_gb, was_log_rotated}; + use crate::{ + Cli, + app::{App, AppConfig, ServarrConfig}, + utils::{ + convert_f64_to_gb, convert_runtime, convert_to_gb, select_cli_configuration, was_log_rotated, + }, + }; + + #[test] + fn test_select_cli_configuration_selects_valid_named_instance() { + let selected_config = ServarrConfig { + name: Some("4K Movies".to_owned()), + ..ServarrConfig::default() + }; + let config = AppConfig { + radarr: Some(vec![ServarrConfig::default(), selected_config.clone()]), + ..AppConfig::default() + }; + let (network_tx, _network_rx) = mpsc::channel(1); + let mut app = App::new(network_tx, config.clone(), CancellationToken::new()); + let args = Cli::try_parse_from([ + "managarr", + "radarr", + "clear-blocklist", + "--servarr-name", + "4K Movies", + ]) + .unwrap(); + + select_cli_configuration( + &mut app, + &config, + args.command.as_ref().unwrap(), + args.global.servarr_name.as_deref(), + ) + .unwrap(); + + assert_eq!(app.server_tabs.get_active_config(), &Some(selected_config)); + } #[test] fn test_convert_to_gb() { diff --git a/tests/servarr_name_cli_integration_tests.rs b/tests/servarr_name_cli_integration_tests.rs new file mode 100644 index 00000000..a27e0c08 --- /dev/null +++ b/tests/servarr_name_cli_integration_tests.rs @@ -0,0 +1,261 @@ +use std::{ + fs, + path::{Path, PathBuf}, + process, str, + sync::atomic::{AtomicU64, Ordering}, +}; + +use assert_cmd::cargo::cargo_bin_cmd; +use mockito::Server; + +static NEXT_CONFIG_ID: AtomicU64 = AtomicU64::new(0); + +struct TemporaryConfig { + path: PathBuf, +} + +impl TemporaryConfig { + fn new(contents: &str) -> Self { + let path = std::env::temp_dir().join(format!( + "managarr-servarr-name-{}-{}.yaml", + process::id(), + NEXT_CONFIG_ID.fetch_add(1, Ordering::Relaxed) + )); + fs::write(&path, contents).unwrap(); + Self { path } + } + + fn path(&self) -> &Path { + &self.path + } +} + +impl Drop for TemporaryConfig { + fn drop(&mut self) { + let _ = fs::remove_file(&self.path); + } +} + +#[test] +fn named_instance_of_different_servarr_type_rejects_command() { + let mut radarr_server = Server::new(); + let radarr_mock = radarr_server + .mock("GET", "/api/v3/system/status") + .match_header("X-Api-Key", "test-token") + .with_status(200) + .with_header("content-type", "application/json") + .with_body(r#"{"version":"radarr","startTime":"2024-01-01T00:00:00Z"}"#) + .expect(0) + .create(); + let mut sonarr_server = Server::new(); + let sonarr_mock = sonarr_server + .mock("GET", "/api/v3/system/status") + .match_header("X-Api-Key", "test-token") + .with_status(200) + .with_header("content-type", "application/json") + .with_body(r#"{"version":"sonarr","startTime":"2024-01-01T00:00:00Z"}"#) + .expect(0) + .create(); + let config = TemporaryConfig::new(&format!( + "radarr:\n - name: Movies\n uri: {}\n api_token: test-token\nsonarr:\n - name: Shows\n uri: {}\n api_token: test-token\n", + radarr_server.url(), + sonarr_server.url() + )); + + let mut command = cargo_bin_cmd!("managarr"); + let assertion = command + .arg("--config-file") + .arg(config.path()) + .args([ + "--disable-spinner", + "--servarr-name", + "Shows", + "radarr", + "get", + "system-status", + ]) + .assert() + .failure(); + let stderr = str::from_utf8(&assertion.get_output().stderr).unwrap(); + + assert!( + stderr.contains("Shows"), + "CLI error did not identify the rejected instance: {stderr}" + ); + radarr_mock.assert(); + sonarr_mock.assert(); +} + +#[test] +fn same_named_servarrs_select_command_matching_instance() { + let mut radarr_server = Server::new(); + let radarr_mock = radarr_server + .mock("GET", "/api/v3/system/status") + .match_header("X-Api-Key", "test-token") + .with_status(200) + .with_header("content-type", "application/json") + .with_body(r#"{"version":"wrong-instance","startTime":"2024-01-01T00:00:00Z"}"#) + .expect(0) + .create(); + let mut sonarr_server = Server::new(); + let sonarr_mock = sonarr_server + .mock("GET", "/api/v3/system/status") + .match_header("X-Api-Key", "test-token") + .with_status(200) + .with_header("content-type", "application/json") + .with_body(r#"{"version":"expected-instance","startTime":"2024-01-01T00:00:00Z"}"#) + .expect(1) + .create(); + let config = TemporaryConfig::new(&format!( + "radarr:\n - name: Servarr\n uri: {}\n api_token: test-token\nsonarr:\n - name: Servarr\n uri: {}\n api_token: test-token\n", + radarr_server.url(), + sonarr_server.url() + )); + + let mut command = cargo_bin_cmd!("managarr"); + let assertion = command + .arg("--config-file") + .arg(config.path()) + .args([ + "--disable-spinner", + "--servarr-name", + "Servarr", + "sonarr", + "get", + "system-status", + ]) + .assert() + .success(); + let stdout = str::from_utf8(&assertion.get_output().stdout).unwrap(); + + assert!( + stdout.contains("\"version\": \"expected-instance\""), + "unexpected CLI output: {stdout}" + ); + assert!(!stdout.contains("wrong-instance")); + sonarr_mock.assert(); + radarr_mock.assert(); +} + +#[test] +fn unknown_servarr_name_fails() { + let mut radarr_server = Server::new(); + let radarr_mock = radarr_server + .mock("GET", "/api/v3/system/status") + .match_header("X-Api-Key", "test-token") + .with_status(200) + .with_header("content-type", "application/json") + .with_body(r#"{"version":"radarr","startTime":"2024-01-01T00:00:00Z"}"#) + .expect(0) + .create(); + + let config = TemporaryConfig::new(&format!( + "radarr:\n - name: Movies\n uri: {}\n api_token: test-token\n", + radarr_server.url() + )); + let mut command = cargo_bin_cmd!("managarr"); + let assertion = command + .arg("--config-file") + .arg(config.path()) + .args([ + "--servarr-name", + "Unknown", + "radarr", + "get", + "system-status", + ]) + .assert() + .failure(); + + let stderr = str::from_utf8(&assertion.get_output().stderr).unwrap(); + + assert!( + stderr.contains("A Servarr titled 'Unknown'"), + "CLI error did not identify the unknown instance: {stderr}" + ); + radarr_mock.assert(); +} + +#[test] +fn whitespace_servarr_name_trimmed() { + let mut radarr_server = Server::new(); + let radarr_mock = radarr_server + .mock("GET", "/api/v3/system/status") + .match_header("X-Api-Key", "test-token") + .with_status(200) + .with_header("content-type", "application/json") + .with_body(r#"{"version":"radarr-whitespace","startTime":"2024-01-01T00:00:00Z"}"#) + .expect(1) + .create(); + + let config = TemporaryConfig::new(&format!( + "radarr:\n - name: Movies\n uri: {}\n api_token: test-token\n", + radarr_server.url() + )); + let mut command = cargo_bin_cmd!("managarr"); + let assertion = command + .arg("--config-file") + .arg(config.path()) + .args([ + "--disable-spinner", + "--servarr-name", + " Movies ", + "radarr", + "get", + "system-status", + ]) + .assert() + .success(); + + let stdout = str::from_utf8(&assertion.get_output().stdout).unwrap(); + + assert!( + stdout.contains("\"version\": \"radarr-whitespace\""), + "unexpected CLI output: {stdout}" + ); + radarr_mock.assert(); +} + +#[test] +fn no_servarr_name_uses_first_configured_instance() { + let mut radarr_server = Server::new(); + let radarr_mock = radarr_server + .mock("GET", "/api/v3/system/status") + .match_header("X-Api-Key", "test-token") + .with_status(200) + .with_header("content-type", "application/json") + .with_body(r#"{"version":"expected-instance","startTime":"2024-01-01T00:00:00Z"}"#) + .expect(1) + .create(); + let mut radarr_server2 = Server::new(); + let radarr_mock2 = radarr_server2 + .mock("GET", "/api/v3/system/status") + .match_header("X-Api-Key", "test-token") + .with_status(200) + .with_header("content-type", "application/json") + .with_body(r#"{"version":"wrong-instance","startTime":"2024-01-01T00:00:00Z"}"#) + .expect(0) + .create(); + let config = TemporaryConfig::new(&format!( + "radarr:\n - name: Movies\n uri: {}\n api_token: test-token\n weight: 2\n - name: 4K-Movies\n uri: {}\n api_token: test-token\n weight: 1\n", + radarr_server.url(), + radarr_server2.url() + )); + + let mut command = cargo_bin_cmd!("managarr"); + let assertion = command + .arg("--config-file") + .arg(config.path()) + .args(["--disable-spinner", "radarr", "get", "system-status"]) + .assert() + .success(); + let stdout = str::from_utf8(&assertion.get_output().stdout).unwrap(); + + assert!( + stdout.contains("\"version\": \"expected-instance\""), + "unexpected CLI output: {stdout}" + ); + assert!(!stdout.contains("wrong-instance")); + radarr_mock.assert(); + radarr_mock2.assert(); +}