Compare commits

...
Author SHA1 Message Date
Dark-Alex-17 0f230a1a4f fix: never panic when the log directory is unwritable; disable file logging instead [#67]
Check / stable / fmt (push) Failing after 26s
Check / beta / clippy (push) Failing after 1m10s
Check / stable / clippy (push) Failing after 1m10s
Check / nightly / doc (push) Successful in 1m0s
Check / 1.95.0 / check (push) Successful in 1m7s
Check / dockerfile / msrv (push) Successful in 5s
Test Suite / ubuntu / beta (push) Successful in 2m15s
Test Suite / ubuntu / stable (push) Successful in 2m14s
Test Suite / ubuntu / stable / coverage (push) Failing after 1m57s
Test Suite / macos-latest / stable (push) Canceled after 0s
Test Suite / windows-latest / stable (push) Canceled after 0s
2026-10-12 11:16:11 -06:00
Dark-Alex-17 f6071f73b5 Merge branch 'main' of github.com:Dark-Alex-17/managarr
Check / stable / fmt (push) Failing after 25s
Check / beta / clippy (push) Failing after 1m15s
Check / stable / clippy (push) Failing after 1m11s
Check / nightly / doc (push) Successful in 1m3s
Check / 1.95.0 / check (push) Successful in 1m10s
Check / dockerfile / msrv (push) Successful in 5s
Test Suite / ubuntu / beta (push) Successful in 2m16s
Test Suite / ubuntu / stable (push) Successful in 2m17s
Test Suite / ubuntu / stable / coverage (push) Failing after 2m0s
Test Suite / macos-latest / stable (push) Canceled after 0s
Test Suite / windows-latest / stable (push) Canceled after 0s
2026-10-01 14:45:21 -06:00
Dark-Alex-17 11524bb232 feat: all CLI-based errors now come out in the same JSON format to further improve scripting and LLM integration
Check / stable / fmt (push) Failing after 25s
Check / beta / clippy (push) Failing after 1m15s
Check / stable / clippy (push) Failing after 1m12s
Check / nightly / doc (push) Successful in 1m2s
Check / 1.95.0 / check (push) Successful in 1m8s
Check / dockerfile / msrv (push) Successful in 5s
Test Suite / ubuntu / beta (push) Successful in 2m21s
Test Suite / ubuntu / stable (push) Successful in 2m20s
Test Suite / ubuntu / stable / coverage (push) Failing after 1m59s
Test Suite / macos-latest / stable (push) Canceled after 0s
Test Suite / windows-latest / stable (push) Canceled after 0s
2026-09-30 22:02:46 -06:00
github-actions[bot] 72ace0f2d5 chore: bump Cargo.toml to 0.8.0 2026-09-30 17:57:08 +00:00
github-actions[bot] 9375e04ebf bump: version 0.7.3 → 0.8.0 [skip ci] 2026-09-30 17:57:06 +00:00
Dark-Alex-17 7a79e1c05e ci: Added a dockerfile check to ensure the dockerfile MSRV matches Cargo.toml
Check / stable / fmt (push) Failing after 32s
Check / beta / clippy (push) Failing after 1m20s
Check / stable / clippy (push) Failing after 1m19s
Check / nightly / doc (push) Successful in 1m4s
Check / 1.95.0 / check (push) Successful in 1m8s
Check / dockerfile / msrv (push) Successful in 5s
Test Suite / ubuntu / beta (push) Successful in 2m24s
Test Suite / ubuntu / stable (push) Successful in 2m28s
Test Suite / ubuntu / stable / coverage (push) Failing after 2m22s
Test Suite / macos-latest / stable (push) Canceled after 0s
Test Suite / windows-latest / stable (push) Canceled after 0s
2026-09-30 11:40:46 -06:00
Alex Clarke 06e504732d Merge pull request #65 from Dark-Alex-17/readarr-support
Readarr support
2026-09-30 10:41:44 -06:00
10 changed files with 865 additions and 46 deletions

No files matched your search

+27
View File
@@ -88,3 +88,30 @@ jobs:
- name: cargo +1.95.0 check
run: cargo check
dockerfile-msrv:
# The Dockerfile's pinned Rust image is only built at release time, so a
# rust-version bump that misses it fails silently until the next release.
# Catch the drift on every push instead.
name: dockerfile / msrv
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v5
- name: Check Dockerfile Rust image against Cargo.toml rust-version
run: |
set -euo pipefail
msrv=$(sed -nE 's/^rust-version = "([0-9.]+)"$/\1/p' Cargo.toml)
image_tag=$(sed -nE 's/^FROM rust:([0-9.]+) AS builder$/\1/p' Dockerfile)
: "${msrv:?failed to extract rust-version from Cargo.toml}"
: "${image_tag:?failed to extract the rust image tag from the Dockerfile}"
# The rust image tag pins major.minor, so compare on major.minor only
msrv_mm=$(echo "$msrv" | cut -d. -f1,2)
image_mm=$(echo "$image_tag" | cut -d. -f1,2)
if [ "$(printf '%s\n' "$msrv_mm" "$image_mm" | sort -V | head -n1)" != "$msrv_mm" ]; then
echo "Dockerfile builds with rust:$image_tag, but Cargo.toml declares rust-version = \"$msrv\"."
echo "Cargo refuses to build under an older toolchain, so the release Docker publish step will fail."
echo "Update the FROM rust:* line in the Dockerfile to at least $msrv_mm."
exit 1
fi
echo "OK: Dockerfile rust:$image_tag satisfies rust-version $msrv"
+141
View File
@@ -5,6 +5,147 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## v0.8.0 (2026-09-30)
### Feat
- show sizes under a gigabyte in megabytes
- **lidarr**: highlight tracks whose album is downloading or awaiting import
- **readarr**: show the author overview in the add author prompt
- **radarr**: render the movie overview popup at the large size
- **radarr**: make the collection movie overview popup scrollable
- **sonarr**: add a scrollable overview popup to series details
- **lidarr**: add a scrollable overview popup to artist details
- **readarr**: add a scrollable overview popup to author details
- **readarr**: add the indexers UI
- **readarr**: add the indexers handlers
- **readarr**: add the system UI
- **readarr**: add the system handlers
- **readarr**: add the root folders UI
- **readarr**: add the add root folder handler
- **readarr**: add the root folders handler
- **readarr**: add the history UI
- **readarr**: add the downloads UI
- **readarr**: add the blocklist UI
- **readarr**: add the history handler
- **readarr**: add the downloads handler
- **readarr**: add the blocklist handler
- **readarr**: add the delete book UI
- **readarr**: add the delete author UI
- **readarr**: add the edit author UI
- **readarr**: add the add author UI
- **readarr**: add the delete book handler
- **readarr**: add the delete author handler
- **readarr**: add the edit author handler
- **readarr**: add the add author handler
- **readarr**: render the edition details popup
- **readarr**: render the book details popup
- **readarr**: reach the book file info tab from the book details tabs
- **readarr**: scroll and dismiss the edition details popup
- **readarr**: handle the book details tabs and open the edition details popup
- **readarr**: render the author details view
- **readarr**: handle key events for the author details view
- **readarr**: handle key events for the authors library table
- **readarr**: fetch data on tick for the active block
- **readarr**: edit the shared indexer settings and expose the command on the CLI
- **readarr**: fetch the shared indexer settings and expose them on the CLI
- **readarr**: test all indexers and expose the command on the CLI
- **readarr**: test an indexer and expose the command on the CLI
- **readarr**: delete an indexer and expose the command on the CLI
- **readarr**: edit an indexer and expose the command on the CLI
- **readarr**: fetch all indexers and expose them on the CLI
- **readarr**: clear the blocklist and expose the command on the CLI
- **readarr**: delete an item from the blocklist and expose the command on the CLI
- **readarr**: fetch the blocklist and expose it on the CLI
- **readarr**: manually download a release and expose the command on the CLI
- **readarr**: fetch releases for an author and expose them on the CLI
- **readarr**: fetch releases for a book and expose them on the CLI
- **readarr**: mark a history item as failed and expose the command on the CLI
- **readarr**: fetch Readarr history and expose it on the CLI
- **readarr**: refresh Readarr downloads and expose the command on the CLI
- **readarr**: delete a download and expose the command on the CLI
- **readarr**: fetch Readarr downloads and expose them on the CLI
- **readarr**: delete a book file and expose the command on the CLI
- **readarr**: delete a book and expose the command on the CLI
- **readarr**: toggle book monitoring and expose the command on the CLI
- **readarr**: trigger an automatic book search and expose the command on the CLI
- **readarr**: fetch Readarr book history and expose it on the CLI
- **readarr**: fetch Readarr book files and expose them on the CLI
- **readarr**: fetch Readarr book editions and expose them on the CLI
- **readarr**: fetch Readarr book details and expose them on the CLI
- **readarr**: fetch Readarr books and expose them on the CLI
- **readarr**: fetch Readarr author history and expose it on the CLI
- **readarr**: refresh all Readarr authors and expose the command on the CLI
- **readarr**: refresh and rescan a Readarr author and expose the command on the CLI
- **readarr**: trigger automatic author searches and expose the command on the CLI
- **readarr**: toggle Readarr author monitoring and expose the command on the CLI
- **readarr**: delete Readarr authors and expose the command on the CLI
- **readarr**: edit Readarr authors and expose the command on the CLI
- **readarr**: add authors to Readarr and expose the command on the CLI
- **readarr**: search for new Readarr authors and expose the command on the CLI
- **readarr**: fetch Readarr author details and expose them on the CLI
- **readarr**: fetch Readarr authors and expose them on the CLI
- **readarr**: delete Readarr root folders and expose the command on the CLI
- **readarr**: add Readarr root folders and expose the command on the CLI
- **readarr**: delete Readarr tags and expose the command on the CLI
- **readarr**: add Readarr tags and expose the command on the CLI
- **readarr**: fetch Readarr root folders and expose them on the CLI
- **readarr**: fetch Readarr tags and expose them on the CLI
- **readarr**: fetch Readarr metadata profiles and expose them on the CLI
- **readarr**: fetch Readarr quality profiles and expose them on the CLI
- **readarr**: fetch Readarr queued events and expose them on the CLI
- **readarr**: trigger Readarr tasks and expose the command on the CLI
- **readarr**: fetch Readarr tasks and expose them on the CLI
- **readarr**: fetch Readarr logs and expose them on the CLI
- **readarr**: fetch Readarr updates and expose them on the CLI
- **readarr**: fetch the Readarr security config and expose it on the CLI
- **readarr**: fetch the Readarr host config and expose it on the CLI
- **readarr**: fetch Readarr disk space and expose it on the CLI
- **readarr**: fetch the Readarr system status and expose it on the CLI
- **readarr**: add the Readarr health check network call
- scaffold Readarr support across the state, app, handler, UI, and CLI layers
- updated gitignore to not include coyote memory or workspace config
### Fix
- **readarr**: send an empty images list when adding an author
- **readarr**: accept release searches wrapped in a filter summary envelope
- **readarr**: tolerate book payloads that omit the grabbed flag
- **radarr**: render the movie overview one size down so the collection details stay visible behind it
- **app/mod**: use correct api for tab when starting on non default tab
- passes servarr-name global switch to both the tui and cli
- **radarr**: model the movie status as an enum
- page up and down stop at the table edges instead of wrapping
- **radarr**: serialize the credit type under the same key it is read from
- **radarr**: model the download status as a DownloadStatus enum
- fall back to the default when a servarr returns an unknown enum value
- show N/A instead of zeros when a season, album or book has no statistics
- stop paging up or down on a single row table from crashing
- keep history readable when an event type is unrecognised
- **readarr**: attach editions when toggling book monitoring
- **sonarr**: stop marking seasons fully downloaded when no episodes are known
- keep a space between overview words that span a line break
- parse the log level from the unscrolled log text
- **radarr**: show a loading block while the updates popup refreshes
- show confirmation prompt clues on delete prompts
- drop redundant references in format arguments
- **readarr**: stop a long author overview hiding the other fields
- **ui**: render detail popup labels in bold
- **readarr**: stop appending stray colons to the edition overview
- **readarr**: move the book file delete to the File tab
- **readarr**: render the update all authors confirmation prompt
- **readarr**: drop the always-empty author type column from the authors table
- tolerate update records that omit the changes list
- address reviewer feedback — rename scroll field to bool, relocate static, drop env var
- revert draw gating; replace frame-count scroll with wall-clock timer
- address reviewer feedback — regex OnceLock and restore tick rate
- reduce TUI idle CPU and improve server error banners
### Refactor
- share one DownloadStatus enum across the servarr models
- move shared servarr types into servarr_models
## v0.7.3 (2026-06-25)
### Feat
Generated
+7 -7
View File
@@ -1879,7 +1879,7 @@ dependencies = [
[[package]]
name = "managarr"
version = "0.7.3"
version = "0.8.0"
dependencies = [
"anyhow",
"assert_cmd",
@@ -2568,9 +2568,9 @@ dependencies = [
[[package]]
name = "quinn-proto"
version = "0.11.18"
version = "0.11.19"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a9746dbde176634f4f2f1faf2404e30a31b2bc1e9cafb5329c95d8177a18c9fc"
checksum = "0e750cca55fe4f0439a15d0bb529da9651e79993e8e72c61a899a36d462befbe"
dependencies = [
"aws-lc-rs",
"bytes",
@@ -2591,9 +2591,9 @@ dependencies = [
[[package]]
name = "quinn-udp"
version = "0.5.15"
version = "0.5.16"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "35a133f956daabe89a61a685c2649f13d82d5aa4bd5d12d1277e1072a21c0694"
checksum = "af66907df18639dcf4db56ca65490cabc4b27a97dbadd96f2926cca73298f016"
dependencies = [
"cfg_aliases",
"libc",
@@ -4538,9 +4538,9 @@ dependencies = [
[[package]]
name = "yoke-derive"
version = "0.8.3"
version = "0.8.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "33811428bee40dbceb6d545e95754741d17a6aef9a4849f0fd62e2ba4f412a78"
checksum = "ec8ebde2db3681e8c9980cc27822030e68752690ddfa9473e739aeb4dbde6d71"
dependencies = [
"proc-macro2",
"quote",
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "managarr"
version = "0.7.3"
version = "0.8.0"
authors = ["Alex Clarke <alex.j.tusa@gmail.com>"]
description = "A TUI and CLI to manage your Servarrs"
keywords = ["managarr", "ratatui", "dashboard", "servarr", "tui"]
+33 -3
View File
@@ -1,4 +1,5 @@
use std::fmt::Debug;
use std::error::Error;
use std::fmt::{self, Debug};
use std::sync::{Arc, OnceLock};
use anyhow::{Result, anyhow};
@@ -156,8 +157,22 @@ impl<'a, 'b> Network<'a, 'b> {
let error_body = format_error_body(&response_body);
error!("Request failed. Received {status} response code with body: {response_body}");
self.app.lock().await.handle_error(anyhow!("Request failed. Received {status} response code with body: {error_body}"));
Err(anyhow!("Request failed. Received {status} response code with body: {error_body}"))
let message = format!("Request failed. Received {status} response code with body: {error_body}");
let body = if response_body.is_empty() {
None
} else {
Some(
serde_json::from_str::<Value>(&response_body)
.unwrap_or(Value::String(response_body)),
)
};
let network_error = NetworkError {
message,
status: status.as_u16(),
body,
};
self.app.lock().await.handle_error(network_error.clone().into());
Err(network_error.into())
}
}
Err(e) => {
@@ -304,6 +319,21 @@ fn format_error_body(response_body: &str) -> String {
re.replace_all(&body, " ").to_string()
}
#[derive(Clone, Debug)]
pub struct NetworkError {
pub message: String,
pub status: u16,
pub body: Option<Value>,
}
impl fmt::Display for NetworkError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "{}", self.message)
}
}
impl Error for NetworkError {}
#[derive(Clone, Copy, Debug, Display, PartialEq, Eq)]
pub enum RequestMethod {
Get,
+102 -2
View File
@@ -10,7 +10,7 @@ mod tests {
use reqwest::header::HeaderMap;
use rstest::rstest;
use serde::{Deserialize, Serialize};
use serde_json::json;
use serde_json::{Value, json};
use tokio::sync::{Mutex, mpsc};
use tokio_util::sync::CancellationToken;
@@ -22,7 +22,9 @@ mod tests {
use crate::network::network_tests::test_utils::test_network;
use crate::network::radarr_network::RadarrEvent;
use crate::network::sonarr_network::SonarrEvent;
use crate::network::{Network, NetworkEvent, NetworkTrait, RequestMethod, RequestProps};
use crate::network::{
Network, NetworkError, NetworkEvent, NetworkTrait, RequestMethod, RequestProps,
};
#[tokio::test]
async fn test_handle_network_event_radarr_event() {
@@ -398,6 +400,104 @@ mod tests {
);
}
#[tokio::test]
async fn test_handle_request_non_success_code_downcasts_to_network_error_with_json_body() {
let (async_server, app_arc, server) = mock_api(RequestMethod::Get, 404, true).await;
let mut network = test_network(&app_arc);
let resp = network
.handle_request::<(), Test>(
RequestProps {
uri: format!("{}/test", server.url()),
method: RequestMethod::Get,
body: None,
api_token: "test1234".to_owned(),
ignore_status_code: false,
custom_headers: HeaderMap::new(),
},
|_, _| (),
)
.await;
async_server.assert_async().await;
let network_error = resp.unwrap_err().downcast::<NetworkError>().unwrap();
assert_str_eq!(
network_error.message,
r#"Request failed. Received 404 Not Found response code with body: { "value": "Test" }"#
);
assert_eq!(network_error.status, 404);
assert_some_eq_x!(network_error.body.as_ref(), &json!({ "value": "Test" }));
}
#[tokio::test]
async fn test_handle_request_non_success_code_downcasts_to_network_error_with_string_body() {
let mut server = Server::new_async().await;
let async_server = server
.mock("GET", "/test")
.match_header("X-Api-Key", "test1234")
.with_status(503)
.with_body("Service Unavailable")
.create_async()
.await;
let app_arc = Arc::new(Mutex::new(App::test_default()));
let mut network = test_network(&app_arc);
let resp = network
.handle_request::<(), Test>(
RequestProps {
uri: format!("{}/test", server.url()),
method: RequestMethod::Get,
body: None,
api_token: "test1234".to_owned(),
ignore_status_code: false,
custom_headers: HeaderMap::new(),
},
|_, _| (),
)
.await;
async_server.assert_async().await;
let network_error = resp.unwrap_err().downcast::<NetworkError>().unwrap();
assert_str_eq!(
network_error.message,
"Request failed. Received 503 Service Unavailable response code with body: Service Unavailable"
);
assert_eq!(network_error.status, 503);
assert_some_eq_x!(
network_error.body.as_ref(),
&Value::String("Service Unavailable".to_owned())
);
}
#[tokio::test]
async fn test_handle_request_non_success_code_downcasts_to_network_error_with_empty_body() {
let (async_server, app_arc, server) = mock_api(RequestMethod::Post, 404, false).await;
let mut network = test_network(&app_arc);
let resp = network
.handle_request::<(), Test>(
RequestProps {
uri: format!("{}/test", server.url()),
method: RequestMethod::Post,
body: None,
api_token: "test1234".to_owned(),
ignore_status_code: false,
custom_headers: HeaderMap::new(),
},
|_, _| (),
)
.await;
async_server.assert_async().await;
let network_error = resp.unwrap_err().downcast::<NetworkError>().unwrap();
assert_str_eq!(
network_error.message,
r#"Request failed. Received 404 Not Found response code with body: "#
);
assert_eq!(network_error.status, 404);
assert_none!(network_error.body);
}
#[rstest]
#[tokio::test]
async fn test_call_api(
+106 -26
View File
@@ -2,11 +2,11 @@ use std::fs::{self, File};
use std::io::{BufRead, BufReader, Seek, SeekFrom};
use std::path::PathBuf;
use std::process;
use std::sync::Arc;
use std::sync::{Arc, Once};
use std::time::Duration;
use anyhow::Result;
use anyhow::{Context, anyhow};
use anyhow::{Context, Error, anyhow};
use colored::Colorize;
use indicatif::{ProgressBar, ProgressStyle};
use log::{LevelFilter, error};
@@ -18,13 +18,15 @@ use log4rs::config::{Appender, Root};
use log4rs::encode::pattern::PatternEncoder;
use regex::Regex;
use reqwest::{Certificate, Client};
use serde::Serialize;
use serde_json::Value;
use tokio::sync::Mutex;
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::network::{Network, NetworkError};
use crate::ui::theme::ThemeDefinitionsWrapper;
#[cfg(test)]
@@ -32,52 +34,92 @@ use crate::ui::theme::ThemeDefinitionsWrapper;
mod utils_tests;
pub fn get_log_path() -> PathBuf {
let mut log_path = if cfg!(target_os = "linux") {
let preferred = if cfg!(target_os = "linux") {
dirs_next::cache_dir().unwrap_or_else(|| PathBuf::from("~/.cache"))
} else if cfg!(target_os = "macos") {
dirs_next::home_dir().unwrap().join("Library/Logs")
dirs_next::home_dir()
.map(|h| h.join("Library/Logs"))
.unwrap_or_else(std::env::temp_dir)
} else {
dirs_next::data_local_dir().unwrap_or_else(|| PathBuf::from("C:\\Logs"))
};
}
.join("managarr");
log_path.push("managarr");
resolve_log_dir(preferred).join("managarr.log")
}
if let Err(e) = fs::create_dir_all(&log_path) {
eprintln!("Failed to create log directory: {e:?}");
fn resolve_log_dir(preferred: PathBuf) -> PathBuf {
static WARN_ONCE: Once = Once::new();
if let Err(e) = fs::create_dir_all(&preferred) {
WARN_ONCE.call_once(|| {
eprintln!(
"Failed to create log directory {}: {e}; file logging will be disabled",
preferred.display()
);
});
}
log_path.push("managarr.log");
log_path
preferred
}
pub fn init_logging_config() -> log4rs::Config {
let log_path = get_log_path();
build_logging_config(get_log_path())
}
fn build_logging_config(log_path: PathBuf) -> log4rs::Config {
let archive_pattern = log_path
.with_file_name("managarr.{}.log")
.to_string_lossy()
.into_owned();
let trigger = SizeTrigger::new(10 * 1024 * 1024);
let roller = FixedWindowRoller::builder()
.build(&archive_pattern, 3)
.expect("Failed to build log roller");
let roller = match FixedWindowRoller::builder().build(&archive_pattern, 3) {
Ok(roller) => roller,
Err(e) => {
eprintln!(
"Failed to build log roller for {archive_pattern}: {e}; file logging will be disabled"
);
return disabled_logging_config();
}
};
let policy = CompoundPolicy::new(Box::new(trigger), Box::new(roller));
let logfile = RollingFileAppender::builder()
let logfile = match RollingFileAppender::builder()
.encoder(Box::new(PatternEncoder::new(
"{d(%Y-%m-%d %H:%M:%S%.3f)(utc)} <{i}> [{l}] {f}:{L} - {m}{n}",
)))
.build(log_path, Box::new(policy))
.expect("Failed to build rolling file appender");
.build(&log_path, Box::new(policy))
{
Ok(logfile) => logfile,
Err(e) => {
eprintln!(
"Failed to build rolling file appender for {}: {e}; file logging will be disabled",
log_path.display()
);
return disabled_logging_config();
}
};
log4rs::Config::builder()
match log4rs::Config::builder()
.appender(Appender::builder().build("logfile", Box::new(logfile)))
.build(
Root::builder()
.appender("logfile")
.build(LevelFilter::Debug),
)
.unwrap()
) {
Ok(config) => config,
Err(e) => {
eprintln!("Failed to build logging config: {e}; file logging will be disabled");
disabled_logging_config()
}
}
}
fn disabled_logging_config() -> log4rs::Config {
log4rs::Config::builder()
.build(Root::builder().build(LevelFilter::Debug))
.expect("appender-free logging config is infallibly valid")
}
pub fn format_size(bytes: i64, precision: usize) -> String {
@@ -100,7 +142,12 @@ pub fn convert_runtime(runtime: i64) -> (i64, i64) {
pub async fn tail_logs(no_color: bool) -> Result<()> {
let re = Regex::new(r"^(?P<timestamp>\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}\.\d{3})\s+<(?P<opid>[^\s>]+)>\s+\[(?P<level>[A-Z]+)]\s+(?P<logger>[^:]+):(?P<line>\d+)\s+-\s+(?P<message>.*)$").unwrap();
let file_path = get_log_path();
let file = File::open(&file_path).expect("Cannot open file");
let file = File::open(&file_path).with_context(|| {
format!(
"Unable to open log file {}; file logging may be disabled",
file_path.display()
)
})?;
let mut reader = BufReader::new(file);
reader
@@ -282,6 +329,36 @@ pub(super) fn render_spinner() -> ProgressBar {
pb
}
#[derive(Serialize)]
struct CliError<'a> {
error: CliErrorDetails<'a>,
}
#[derive(Serialize)]
struct CliErrorDetails<'a> {
message: String,
status: Option<u16>,
body: Option<&'a Value>,
}
fn format_cli_error(error: &Error) -> String {
let details = match error.downcast_ref::<NetworkError>() {
Some(network_error) => CliErrorDetails {
message: network_error.message.clone(),
status: Some(network_error.status),
body: network_error.body.as_ref(),
},
None => CliErrorDetails {
message: error.to_string(),
status: None,
body: None,
},
};
serde_json::to_string_pretty(&CliError { error: details })
.expect("CLI error details are infallibly serializable")
}
pub(super) async fn start_cli_with_spinner(
config: AppConfig,
reqwest_client: Client,
@@ -295,7 +372,8 @@ pub(super) async fn start_cli_with_spinner(
let mut app = app.lock().await;
app.cli_mode = true;
select_cli_configuration(&mut app, &config, &command, servarr_name).unwrap_or_else(|error| {
log_and_print_error(error.to_string());
error!("{error}");
eprintln!("{}", format_cli_error(&error));
process::exit(1);
});
}
@@ -309,7 +387,7 @@ pub(super) async fn start_cli_with_spinner(
}
Err(e) => {
pb.finish();
eprintln!("error: {}", e.to_string().red());
eprintln!("{}", format_cli_error(&e));
process::exit(1);
}
}
@@ -328,7 +406,8 @@ pub(super) async fn start_cli_no_spinner(
let mut app = app.lock().await;
app.cli_mode = true;
select_cli_configuration(&mut app, &config, &command, servarr_name).unwrap_or_else(|error| {
log_and_print_error(error.to_string());
error!("{error}");
eprintln!("{}", format_cli_error(&error));
process::exit(1);
});
}
@@ -339,7 +418,7 @@ pub(super) async fn start_cli_no_spinner(
println!("{output}");
}
Err(e) => {
eprintln!("error: {}", e.to_string().red());
eprintln!("{}", format_cli_error(&e));
process::exit(1);
}
}
@@ -360,6 +439,7 @@ pub fn select_cli_configuration(
(Route::Radarr(..), Command::Radarr(_))
| (Route::Sonarr(..), Command::Sonarr(_))
| (Route::Lidarr(..), Command::Lidarr(_))
| (Route::Readarr(..), Command::Readarr(_))
)
});
app.server_tabs.index = selected_index.ok_or_else(|| {
+199 -1
View File
@@ -1,6 +1,8 @@
#[cfg(test)]
mod tests {
use anyhow::{Error, anyhow};
use clap::Parser;
use serde_json::json;
use std::fs::{self, File};
use std::io::{BufRead, BufReader, Seek, SeekFrom, Write};
@@ -11,7 +13,11 @@ mod tests {
use crate::{
Cli,
app::{App, AppConfig, ServarrConfig},
utils::{convert_runtime, format_size, select_cli_configuration, was_log_rotated},
network::NetworkError,
utils::{
build_logging_config, convert_runtime, format_cli_error, format_size, resolve_log_dir,
select_cli_configuration, was_log_rotated,
},
};
#[test]
@@ -64,6 +70,123 @@ mod tests {
assert_eq!(minutes, 34);
}
#[test]
fn test_format_cli_error_network_error_with_json_body() {
let error = Error::new(NetworkError {
message: "Request failed. Received 404 Not Found response code with body: Not Found"
.to_owned(),
status: 404,
body: Some(json!({ "status": 404, "title": "Not Found" })),
});
let output = format_cli_error(&error);
assert_str_eq!(
output,
r#"{
"error": {
"message": "Request failed. Received 404 Not Found response code with body: Not Found",
"status": 404,
"body": {
"status": 404,
"title": "Not Found"
}
}
}"#
);
}
#[test]
fn test_format_cli_error_network_error_with_string_body() {
let error = Error::new(NetworkError {
message:
"Request failed. Received 503 Service Unavailable response code with body: Service Unavailable"
.to_owned(),
status: 503,
body: Some(json!("Service Unavailable")),
});
let output = format_cli_error(&error);
assert_str_eq!(
output,
r#"{
"error": {
"message": "Request failed. Received 503 Service Unavailable response code with body: Service Unavailable",
"status": 503,
"body": "Service Unavailable"
}
}"#
);
}
#[test]
fn test_format_cli_error_network_error_with_null_body() {
let error = Error::new(NetworkError {
message: "Request failed. Received 404 Not Found response code with body: ".to_owned(),
status: 404,
body: None,
});
let output = format_cli_error(&error);
assert_str_eq!(
output,
r#"{
"error": {
"message": "Request failed. Received 404 Not Found response code with body: ",
"status": 404,
"body": null
}
}"#
);
}
#[test]
fn test_format_cli_error_network_error_wrapped_in_context() {
let error = Error::new(NetworkError {
message: "Request failed. Received 404 Not Found response code with body: Not Found"
.to_owned(),
status: 404,
body: Some(json!({ "status": 404, "title": "Not Found" })),
})
.context("failed to fetch system status");
let output = format_cli_error(&error);
assert_str_eq!(
output,
r#"{
"error": {
"message": "Request failed. Received 404 Not Found response code with body: Not Found",
"status": 404,
"body": {
"status": 404,
"title": "Not Found"
}
}
}"#
);
}
#[test]
fn test_format_cli_error_plain_anyhow_error() {
let error = anyhow!("Failed to send request. connection refused ");
let output = format_cli_error(&error);
assert_str_eq!(
output,
r#"{
"error": {
"message": "Failed to send request. connection refused ",
"status": null,
"body": null
}
}"#
);
}
#[test]
fn test_was_log_rotated_returns_false_when_file_has_not_rotated() {
let path = std::env::temp_dir().join("managarr_test_no_rotation.log");
@@ -114,4 +237,79 @@ mod tests {
fs::remove_file(&path).unwrap();
}
#[test]
fn test_resolve_log_dir_returns_preferred_dir_when_creatable() {
let preferred = std::env::temp_dir().join(format!(
"managarr_test_preferred_log_dir_{}",
std::process::id()
));
let _ = fs::remove_dir_all(&preferred);
let resolved = resolve_log_dir(preferred.clone());
assert_eq!(resolved, preferred);
assert!(preferred.is_dir());
fs::remove_dir_all(&preferred).unwrap();
}
#[test]
fn test_resolve_log_dir_keeps_preferred_dir_and_disables_file_logging_when_uncreatable() {
let blocker = std::env::temp_dir().join(format!(
"managarr_test_log_dir_blocker_{}",
std::process::id()
));
let _ = fs::remove_file(&blocker);
fs::write(&blocker, "not a directory").unwrap();
let preferred = blocker.join("logs");
let resolved = resolve_log_dir(preferred.clone());
assert_eq!(resolved, preferred);
assert!(!resolved.exists());
let config = build_logging_config(resolved.join("managarr.log"));
assert!(config.appenders().is_empty());
assert!(config.root().appenders().is_empty());
fs::remove_file(&blocker).unwrap();
}
#[test]
fn test_build_logging_config_uses_logfile_appender_when_log_path_is_writable() {
let log_dir = std::env::temp_dir().join(format!(
"managarr_test_logging_config_{}",
std::process::id()
));
let _ = fs::remove_dir_all(&log_dir);
fs::create_dir_all(&log_dir).unwrap();
let config = build_logging_config(log_dir.join("managarr.log"));
assert_eq!(config.appenders().len(), 1);
assert_str_eq!(config.appenders()[0].name(), "logfile");
assert_eq!(config.root().appenders(), ["logfile"]);
drop(config);
fs::remove_dir_all(&log_dir).unwrap();
}
#[test]
fn test_build_logging_config_disables_logging_when_appender_is_unbuildable() {
let blocker = std::env::temp_dir().join(format!(
"managarr_test_appender_blocker_{}",
std::process::id()
));
let _ = fs::remove_file(&blocker);
fs::write(&blocker, "not a directory").unwrap();
let config = build_logging_config(blocker.join("logs").join("managarr.log"));
assert!(config.appenders().is_empty());
assert!(config.root().appenders().is_empty());
fs::remove_file(&blocker).unwrap();
}
}
+65 -6
View File
@@ -5,7 +5,7 @@ use std::{
sync::atomic::{AtomicU64, Ordering},
};
use assert_cmd::cargo::cargo_bin_cmd;
use assert_cmd::{Command, cargo::cargo_bin_cmd};
use mockito::Server;
static NEXT_CONFIG_ID: AtomicU64 = AtomicU64::new(0);
@@ -36,6 +36,14 @@ impl Drop for TemporaryConfig {
}
}
fn managarr_command() -> Command {
let home = std::env::temp_dir().join(format!("managarr-servarr-name-home-{}", process::id()));
fs::create_dir_all(&home).unwrap();
let mut command = cargo_bin_cmd!("managarr");
command.env("HOME", &home).env("XDG_CACHE_HOME", &home);
command
}
#[test]
fn named_instance_of_different_servarr_type_rejects_command() {
let mut radarr_server = Server::new();
@@ -62,7 +70,7 @@ fn named_instance_of_different_servarr_type_rejects_command() {
sonarr_server.url()
));
let mut command = cargo_bin_cmd!("managarr");
let mut command = managarr_command();
let assertion = command
.arg("--config-file")
.arg(config.path())
@@ -86,6 +94,57 @@ fn named_instance_of_different_servarr_type_rejects_command() {
sonarr_mock.assert();
}
#[test]
fn named_readarr_instance_selected_for_readarr_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":"wrong-instance","startTime":"2024-01-01T00:00:00Z"}"#)
.expect(0)
.create();
let mut readarr_server = Server::new();
let readarr_mock = readarr_server
.mock("GET", "/api/v1/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\nreadarr:\n - name: Servarr\n uri: {}\n api_token: test-token\n",
radarr_server.url(),
readarr_server.url()
));
let mut command = managarr_command();
let assertion = command
.arg("--config-file")
.arg(config.path())
.args([
"--disable-spinner",
"--servarr-name",
"Servarr",
"readarr",
"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"));
readarr_mock.assert();
radarr_mock.assert();
}
#[test]
fn same_named_servarrs_select_command_matching_instance() {
let mut radarr_server = Server::new();
@@ -112,7 +171,7 @@ fn same_named_servarrs_select_command_matching_instance() {
sonarr_server.url()
));
let mut command = cargo_bin_cmd!("managarr");
let mut command = managarr_command();
let assertion = command
.arg("--config-file")
.arg(config.path())
@@ -153,7 +212,7 @@ fn unknown_servarr_name_fails() {
"radarr:\n - name: Movies\n uri: {}\n api_token: test-token\n",
radarr_server.url()
));
let mut command = cargo_bin_cmd!("managarr");
let mut command = managarr_command();
let assertion = command
.arg("--config-file")
.arg(config.path())
@@ -192,7 +251,7 @@ fn whitespace_servarr_name_trimmed() {
"radarr:\n - name: Movies\n uri: {}\n api_token: test-token\n",
radarr_server.url()
));
let mut command = cargo_bin_cmd!("managarr");
let mut command = managarr_command();
let assertion = command
.arg("--config-file")
.arg(config.path())
@@ -242,7 +301,7 @@ fn no_servarr_name_uses_first_configured_instance() {
radarr_server2.url()
));
let mut command = cargo_bin_cmd!("managarr");
let mut command = managarr_command();
let assertion = command
.arg("--config-file")
.arg(config.path())
@@ -0,0 +1,184 @@
#![cfg(unix)]
use std::{fs, path::PathBuf, process, str};
#[cfg(target_os = "linux")]
use assert_cmd::Command;
use assert_cmd::cargo::cargo_bin_cmd;
struct ProbeEnv {
base: PathBuf,
}
impl ProbeEnv {
fn new(test_name: &str) -> Self {
let base = std::env::temp_dir().join(format!(
"managarr_unwritable_log_dir_{test_name}_{}",
process::id()
));
let _ = fs::remove_dir_all(&base);
fs::create_dir_all(base.join("home")).unwrap();
fs::create_dir_all(base.join("fresh_tmp")).unwrap();
fs::write(base.join("blockfile"), "not a directory").unwrap();
fs::write(
base.join("config.yml"),
"radarr:\n - name: Movies\n host: 127.0.0.1\n port: 7878\n api_token: test-token\n",
)
.unwrap();
Self { base }
}
#[cfg(target_os = "linux")]
fn blocked_cache_dir(&self) -> PathBuf {
self.base.join("blockfile").join("cache")
}
#[cfg(target_os = "linux")]
fn command(&self) -> Command {
let mut command = cargo_bin_cmd!("managarr");
command
.env_clear()
.env("PATH", std::env::var_os("PATH").unwrap())
.env("HOME", self.base.join("home"))
.env("XDG_CACHE_HOME", self.blocked_cache_dir())
.env("TMPDIR", self.base.join("fresh_tmp"))
.arg("--config-file")
.arg(self.base.join("config.yml"));
command
}
}
impl Drop for ProbeEnv {
fn drop(&mut self) {
let _ = fs::remove_dir_all(&self.base);
}
}
#[cfg(target_os = "linux")]
#[test]
fn unwritable_log_dir_warns_and_cli_command_still_succeeds() {
let env = ProbeEnv::new("completions");
let expected_log_dir = env.blocked_cache_dir().join("managarr");
let mut command = env.command();
let assertion = command.args(["completions", "bash"]).assert().success();
let output = assertion.get_output();
let stderr = str::from_utf8(&output.stderr).unwrap();
assert!(
!output.stdout.is_empty(),
"completions produced no stdout despite exiting successfully"
);
assert!(
stderr.contains(&format!(
"Failed to create log directory {}",
expected_log_dir.display()
)),
"stderr did not warn about the uncreatable log directory: {stderr}"
);
assert!(
stderr.contains("file logging will be disabled"),
"stderr did not announce that file logging is disabled: {stderr}"
);
assert!(
!stderr.contains("panicked"),
"binary panicked instead of degrading gracefully: {stderr}"
);
assert!(
fs::read_dir(env.base.join("fresh_tmp")).unwrap().count() == 0,
"log output fell back to a temp directory despite the no-fallback ruling"
);
}
#[cfg(target_os = "linux")]
#[test]
fn tail_logs_with_unopenable_log_file_returns_error_without_panicking() {
let env = ProbeEnv::new("tail_logs");
let expected_log_file = env
.blocked_cache_dir()
.join("managarr")
.join("managarr.log");
let mut command = env.command();
let assertion = command
.arg("tail-logs")
.timeout(std::time::Duration::from_secs(30))
.assert()
.failure();
let stderr = str::from_utf8(&assertion.get_output().stderr).unwrap();
assert!(
stderr.contains(&format!(
"Unable to open log file {}",
expected_log_file.display()
)),
"tail-logs error did not name the log file: {stderr}"
);
assert_eq!(
stderr.matches("Failed to create log directory").count(),
1,
"the create-dir warning must print exactly once per process: {stderr}"
);
assert!(
stderr.contains("file logging may be disabled"),
"tail-logs error did not mention that file logging may be disabled: {stderr}"
);
assert!(
!stderr.contains("panicked"),
"tail-logs panicked instead of returning an error: {stderr}"
);
}
#[cfg(target_os = "linux")]
#[test]
fn writable_cache_dir_creates_log_file_without_warnings() {
let env = ProbeEnv::new("happy_path");
let cache_dir = env.base.join("home").join("cache");
fs::create_dir_all(&cache_dir).unwrap();
let mut command = env.command();
let assertion = command
.env("XDG_CACHE_HOME", &cache_dir)
.args(["completions", "bash"])
.assert()
.success();
let stderr = str::from_utf8(&assertion.get_output().stderr).unwrap();
assert!(
stderr.is_empty(),
"happy path emitted unexpected stderr: {stderr}"
);
assert!(
cache_dir.join("managarr").join("managarr.log").is_file(),
"logfile appender did not create managarr.log in the writable cache dir"
);
}
#[test]
fn unset_home_does_not_panic_on_startup() {
let env = ProbeEnv::new("unset_home");
let mut command = cargo_bin_cmd!("managarr");
let assertion = command
.env_clear()
.env("PATH", std::env::var_os("PATH").unwrap())
.env("TMPDIR", env.base.join("fresh_tmp"))
.current_dir(&env.base)
.arg("--config-file")
.arg(env.base.join("config.yml"))
.args(["completions", "bash"])
.assert()
.success();
let output = assertion.get_output();
let stderr = str::from_utf8(&output.stderr).unwrap();
assert!(
!output.stdout.is_empty(),
"completions produced no stdout despite exiting successfully"
);
assert!(
!stderr.contains("panicked"),
"binary panicked instead of degrading gracefully: {stderr}"
);
}