From e8e3376327858f92a04be2340bcd37f6ffc9de24 Mon Sep 17 00:00:00 2001 From: Alex Clarke Date: Wed, 26 Aug 2026 10:21:55 -0600 Subject: [PATCH] feat(readarr): delete Readarr tags and expose the command on the CLI --- src/cli/readarr/delete_command_handler.rs | 67 +++++++++++ .../readarr/delete_command_handler_tests.rs | 111 ++++++++++++++++++ src/cli/readarr/mod.rs | 12 ++ src/network/readarr_network/mod.rs | 26 +++- .../readarr_network/readarr_network_tests.rs | 36 ++++++ 5 files changed, 251 insertions(+), 1 deletion(-) create mode 100644 src/cli/readarr/delete_command_handler.rs create mode 100644 src/cli/readarr/delete_command_handler_tests.rs diff --git a/src/cli/readarr/delete_command_handler.rs b/src/cli/readarr/delete_command_handler.rs new file mode 100644 index 00000000..cb483c90 --- /dev/null +++ b/src/cli/readarr/delete_command_handler.rs @@ -0,0 +1,67 @@ +use std::sync::Arc; + +use anyhow::Result; +use clap::Subcommand; +use tokio::sync::Mutex; + +use super::ReadarrCommand; +use crate::{ + app::App, + cli::{CliCommandHandler, Command}, + network::{NetworkTrait, readarr_network::ReadarrEvent}, +}; + +#[cfg(test)] +#[path = "delete_command_handler_tests.rs"] +mod delete_command_handler_tests; + +#[derive(Debug, Clone, PartialEq, Eq, Subcommand)] +pub enum ReadarrDeleteCommand { + #[command(about = "Delete the tag with the specified ID")] + Tag { + #[arg(long, help = "The ID of the tag to delete", required = true)] + tag_id: i64, + }, +} + +impl From for Command { + fn from(value: ReadarrDeleteCommand) -> Self { + Command::Readarr(ReadarrCommand::Delete(value)) + } +} + +pub(super) struct ReadarrDeleteCommandHandler<'a, 'b> { + _app: &'a Arc>>, + command: ReadarrDeleteCommand, + network: &'a mut dyn NetworkTrait, +} + +impl<'a, 'b> CliCommandHandler<'a, 'b, ReadarrDeleteCommand> + for ReadarrDeleteCommandHandler<'a, 'b> +{ + fn with( + app: &'a Arc>>, + command: ReadarrDeleteCommand, + network: &'a mut dyn NetworkTrait, + ) -> Self { + ReadarrDeleteCommandHandler { + _app: app, + command, + network, + } + } + + async fn handle(self) -> Result { + let result = match self.command { + ReadarrDeleteCommand::Tag { tag_id } => { + let resp = self + .network + .handle_network_event(ReadarrEvent::DeleteTag(tag_id).into()) + .await?; + serde_json::to_string_pretty(&resp)? + } + }; + + Ok(result) + } +} diff --git a/src/cli/readarr/delete_command_handler_tests.rs b/src/cli/readarr/delete_command_handler_tests.rs new file mode 100644 index 00000000..a1ccd3d1 --- /dev/null +++ b/src/cli/readarr/delete_command_handler_tests.rs @@ -0,0 +1,111 @@ +#[cfg(test)] +mod tests { + use clap::{CommandFactory, Parser, error::ErrorKind}; + + use crate::{ + Cli, + cli::{ + Command, + readarr::{ReadarrCommand, delete_command_handler::ReadarrDeleteCommand}, + }, + }; + use pretty_assertions::assert_eq; + + #[test] + fn test_readarr_delete_command_from() { + let command = ReadarrDeleteCommand::Tag { tag_id: 1 }; + + let result = Command::from(command.clone()); + + assert_eq!(result, Command::Readarr(ReadarrCommand::Delete(command))); + } + + mod cli { + use super::*; + use pretty_assertions::assert_eq; + + #[test] + fn test_delete_tag_requires_arguments() { + let result = Cli::command().try_get_matches_from(["managarr", "readarr", "delete", "tag"]); + + assert_err!(&result); + assert_eq!( + result.unwrap_err().kind(), + ErrorKind::MissingRequiredArgument + ); + } + + #[test] + fn test_delete_tag_tag_id_requires_a_number() { + let result = Cli::command().try_get_matches_from([ + "managarr", + "readarr", + "delete", + "tag", + "--tag-id", + "not_a_number", + ]); + + assert_err!(&result); + assert_eq!(result.unwrap_err().kind(), ErrorKind::ValueValidation); + } + + #[test] + fn test_delete_tag_success() { + let expected_args = ReadarrDeleteCommand::Tag { tag_id: 1 }; + + let result = Cli::try_parse_from(["managarr", "readarr", "delete", "tag", "--tag-id", "1"]); + + assert_ok!(&result); + + let Some(Command::Readarr(ReadarrCommand::Delete(delete_command))) = result.unwrap().command + else { + panic!("Unexpected command type"); + }; + assert_eq!(delete_command, expected_args); + } + } + + mod handler { + use std::sync::Arc; + + use mockall::predicate::eq; + use serde_json::json; + use tokio::sync::Mutex; + + use crate::cli::CliCommandHandler; + use crate::cli::readarr::delete_command_handler::{ + ReadarrDeleteCommand, ReadarrDeleteCommandHandler, + }; + use crate::models::Serdeable; + use crate::models::readarr_models::ReadarrSerdeable; + use crate::network::readarr_network::ReadarrEvent; + use crate::{ + app::App, + network::{MockNetworkTrait, NetworkEvent}, + }; + + #[tokio::test] + async fn test_handle_delete_tag_command() { + let mut mock_network = MockNetworkTrait::new(); + mock_network + .expect_handle_network_event() + .with(eq::(ReadarrEvent::DeleteTag(1).into())) + .times(1) + .returning(|_| { + Ok(Serdeable::Readarr(ReadarrSerdeable::Value( + json!({"testResponse": "response"}), + ))) + }); + let app_arc = Arc::new(Mutex::new(App::test_default())); + let delete_tag_command = ReadarrDeleteCommand::Tag { tag_id: 1 }; + + let result = + ReadarrDeleteCommandHandler::with(&app_arc, delete_tag_command, &mut mock_network) + .handle() + .await; + + assert_ok!(&result); + } + } +} diff --git a/src/cli/readarr/mod.rs b/src/cli/readarr/mod.rs index e6f421a1..3d62bfc8 100644 --- a/src/cli/readarr/mod.rs +++ b/src/cli/readarr/mod.rs @@ -3,6 +3,7 @@ use std::sync::Arc; use add_command_handler::{ReadarrAddCommand, ReadarrAddCommandHandler}; use anyhow::Result; use clap::Subcommand; +use delete_command_handler::{ReadarrDeleteCommand, ReadarrDeleteCommandHandler}; use get_command_handler::{ReadarrGetCommand, ReadarrGetCommandHandler}; use list_command_handler::{ReadarrListCommand, ReadarrListCommandHandler}; use tokio::sync::Mutex; @@ -13,6 +14,7 @@ use crate::network::readarr_network::ReadarrEvent; use crate::{app::App, network::NetworkTrait}; mod add_command_handler; +mod delete_command_handler; mod get_command_handler; mod list_command_handler; @@ -27,6 +29,11 @@ pub enum ReadarrCommand { about = "Commands to add or create new resources within your Readarr instance" )] Add(ReadarrAddCommand), + #[command( + subcommand, + about = "Commands to delete resources from your Readarr instance" + )] + Delete(ReadarrDeleteCommand), #[command( subcommand, about = "Commands to fetch details of the resources in your Readarr instance" @@ -81,6 +88,11 @@ impl<'a, 'b> CliCommandHandler<'a, 'b, ReadarrCommand> for ReadarrCliHandler<'a, .handle() .await? } + ReadarrCommand::Delete(delete_command) => { + ReadarrDeleteCommandHandler::with(self.app, delete_command, self.network) + .handle() + .await? + } ReadarrCommand::Get(get_command) => { ReadarrGetCommandHandler::with(self.app, get_command, self.network) .handle() diff --git a/src/network/readarr_network/mod.rs b/src/network/readarr_network/mod.rs index 7846c42b..af59fe92 100644 --- a/src/network/readarr_network/mod.rs +++ b/src/network/readarr_network/mod.rs @@ -17,6 +17,7 @@ mod readarr_network_tests; #[derive(Debug, Eq, PartialEq, Clone)] pub enum ReadarrEvent { AddTag(String), + DeleteTag(i64), GetDiskSpace, GetHostConfig, GetLogs(u64), @@ -46,7 +47,7 @@ impl NetworkResource for ReadarrEvent { ReadarrEvent::GetRootFolders => "/rootfolder", ReadarrEvent::GetStatus => "/system/status", ReadarrEvent::GetTasks => "/system/task", - ReadarrEvent::AddTag(_) | ReadarrEvent::GetTags => "/tag", + ReadarrEvent::AddTag(_) | ReadarrEvent::DeleteTag(_) | ReadarrEvent::GetTags => "/tag", ReadarrEvent::GetUpdates => "/update", } } @@ -65,6 +66,10 @@ impl Network<'_, '_> { ) -> Result { match readarr_event { ReadarrEvent::AddTag(tag) => self.add_readarr_tag(tag).await.map(ReadarrSerdeable::from), + ReadarrEvent::DeleteTag(tag_id) => self + .delete_readarr_tag(tag_id) + .await + .map(ReadarrSerdeable::from), ReadarrEvent::GetDiskSpace => self .get_readarr_diskspace() .await @@ -201,4 +206,23 @@ impl Network<'_, '_> { }) .await } + + async fn delete_readarr_tag(&mut self, id: i64) -> Result<()> { + info!("Deleting Readarr tag with ID: {id}"); + let event = ReadarrEvent::DeleteTag(id); + + let request_props = self + .request_props_from( + event, + RequestMethod::Delete, + None::<()>, + Some(format!("/{id}")), + None, + ) + .await; + + self + .handle_request::<(), ()>(request_props, |_, _| ()) + .await + } } diff --git a/src/network/readarr_network/readarr_network_tests.rs b/src/network/readarr_network/readarr_network_tests.rs index d224694d..c20b8d92 100644 --- a/src/network/readarr_network/readarr_network_tests.rs +++ b/src/network/readarr_network/readarr_network_tests.rs @@ -23,6 +23,7 @@ mod tests { #[case(ReadarrEvent::GetStatus, "/system/status")] #[case(ReadarrEvent::GetTasks, "/system/task")] #[case(ReadarrEvent::AddTag(String::new()), "/tag")] + #[case(ReadarrEvent::DeleteTag(1), "/tag")] #[case(ReadarrEvent::GetTags, "/tag")] #[case(ReadarrEvent::GetUpdates, "/update")] fn test_resource(#[case] event: ReadarrEvent, #[case] expected_uri: &str) { @@ -410,4 +411,39 @@ mod tests { assert_err!(result); assert_eq!(app.lock().await.data.readarr_data.tags_map, seeded_map); } + + #[tokio::test] + async fn test_handle_delete_readarr_tag_event() { + let (mock, app, _server) = MockServarrApi::delete() + .path("/1") + .build_for(ReadarrEvent::DeleteTag(1)) + .await; + app.lock().await.server_tabs.set_index(3); + let mut network = test_network(&app); + + let result = network + .handle_readarr_event(ReadarrEvent::DeleteTag(1)) + .await; + + mock.assert_async().await; + assert_ok!(result); + } + + #[tokio::test] + async fn test_handle_delete_readarr_tag_event_failure() { + let (mock, app, _server) = MockServarrApi::delete() + .path("/1") + .status(500) + .build_for(ReadarrEvent::DeleteTag(1)) + .await; + app.lock().await.server_tabs.set_index(3); + let mut network = test_network(&app); + + let result = network + .handle_readarr_event(ReadarrEvent::DeleteTag(1)) + .await; + + mock.assert_async().await; + assert_err!(result); + } }