use crate::config::FileCollisionAction;
use crate::error::{Error, PostProcessError, Result};
use std::path::{Path, PathBuf};
const MAX_RENAME_ATTEMPTS: u32 = 9999;
pub fn get_unique_path(path: &Path, action: FileCollisionAction) -> Result<PathBuf> {
match action {
FileCollisionAction::Overwrite => {
Ok(path.to_path_buf())
}
FileCollisionAction::Skip => {
if path.exists() {
return Err(Error::PostProcess(PostProcessError::FileCollision {
path: path.to_path_buf(),
reason: "File already exists and collision action is Skip".to_string(),
}));
}
Ok(path.to_path_buf())
}
FileCollisionAction::Rename => {
if !path.exists() {
return Ok(path.to_path_buf());
}
let stem = path.file_stem().and_then(|s| s.to_str()).ok_or_else(|| {
Error::PostProcess(PostProcessError::InvalidPath {
path: path.to_path_buf(),
reason: "Cannot extract file stem".to_string(),
})
})?;
let extension = path.extension().and_then(|e| e.to_str());
let parent = path.parent().ok_or_else(|| {
Error::PostProcess(PostProcessError::InvalidPath {
path: path.to_path_buf(),
reason: "Cannot extract parent directory".to_string(),
})
})?;
for i in 1..=MAX_RENAME_ATTEMPTS {
let new_name = match extension {
Some(ext) => format!("{} ({}).{}", stem, i, ext),
None => format!("{} ({})", stem, i),
};
let new_path = parent.join(new_name);
if !new_path.exists() {
return Ok(new_path);
}
}
Err(Error::PostProcess(PostProcessError::FileCollision {
path: path.to_path_buf(),
reason: "Could not find unique filename after 9999 attempts".to_string(),
}))
}
}
}
#[must_use]
pub fn is_sample(path: &Path) -> bool {
let name = path
.file_name()
.and_then(|n| n.to_str())
.unwrap_or("")
.to_lowercase();
const SAMPLE_PATTERNS: &[&str] = &[
"sample", "samples", "subs", "proof", "proofs", "cover", "covers", "eac3to",
];
if SAMPLE_PATTERNS.iter().any(|&pattern| name == pattern) {
return true;
}
if name.contains("sample") {
return true;
}
false
}
pub fn extract_filename_from_response(response: &reqwest::Response, url: &str) -> String {
if let Some(content_disposition) = response.headers().get("content-disposition")
&& let Ok(value) = content_disposition.to_str()
{
for part in value.split(';') {
let part = part.trim();
if part.starts_with("filename=") {
let filename = part
.trim_start_matches("filename=")
.trim_matches('"')
.to_string();
if let Some(stem) = std::path::Path::new(&filename).file_stem()
&& let Some(stem_str) = stem.to_str()
{
return stem_str.to_string();
}
return filename;
} else if part.starts_with("filename*=") {
let filename = part.trim_start_matches("filename*=");
if let Some(idx) = filename.rfind('\'') {
let encoded = &filename[idx + 1..];
if let Ok(decoded) = urlencoding::decode(encoded) {
if let Some(stem) = std::path::Path::new(decoded.as_ref()).file_stem()
&& let Some(stem_str) = stem.to_str()
{
return stem_str.to_string();
}
return decoded.to_string();
}
}
}
}
}
if let Ok(parsed_url) = url::Url::parse(url)
&& let Some(mut segments) = parsed_url.path_segments()
&& let Some(last_segment) = segments.next_back()
&& !last_segment.is_empty()
{
if let Some(stem) = std::path::Path::new(last_segment).file_stem()
&& let Some(stem_str) = stem.to_str()
{
return stem_str.to_string();
}
return last_segment.to_string();
}
"download".to_string()
}
pub fn get_available_space(path: &Path) -> std::io::Result<u64> {
#[cfg(unix)]
{
use std::ffi::CString;
use std::os::unix::ffi::OsStrExt;
let c_path = CString::new(path.as_os_str().as_bytes())
.map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidInput, e))?;
unsafe {
let mut stat: libc::statvfs = std::mem::zeroed();
if libc::statvfs(c_path.as_ptr(), &mut stat) != 0 {
return Err(std::io::Error::last_os_error());
}
let available_bytes = stat.f_bavail.saturating_mul(stat.f_frsize);
Ok(available_bytes)
}
}
#[cfg(windows)]
{
use std::os::windows::ffi::OsStrExt;
use winapi::um::fileapi::GetDiskFreeSpaceExW;
let wide_path: Vec<u16> = path
.as_os_str()
.encode_wide()
.chain(std::iter::once(0)) .collect();
unsafe {
let mut free_bytes_available: u64 = 0;
let mut _total_bytes: u64 = 0;
let mut _total_free_bytes: u64 = 0;
if GetDiskFreeSpaceExW(
wide_path.as_ptr(),
&mut free_bytes_available as *mut u64 as *mut _,
&mut _total_bytes as *mut u64 as *mut _,
&mut _total_free_bytes as *mut u64 as *mut _,
) == 0
{
return Err(std::io::Error::last_os_error());
}
Ok(free_bytes_available)
}
}
#[cfg(not(any(unix, windows)))]
{
Err(std::io::Error::new(
std::io::ErrorKind::Unsupported,
"Disk space checking is not supported on this platform",
))
}
}
#[allow(clippy::unwrap_used, clippy::expect_used)]
#[cfg(test)]
mod tests {
use super::*;
use std::fs;
use tempfile::TempDir;
use wiremock::MockServer;
use wiremock::matchers::{method, path};
use wiremock::{Mock, ResponseTemplate};
#[test]
fn test_get_unique_path_nonexistent_file() {
let temp_dir = TempDir::new().unwrap();
let path = temp_dir.path().join("test.txt");
assert_eq!(
get_unique_path(&path, FileCollisionAction::Rename).unwrap(),
path
);
assert_eq!(
get_unique_path(&path, FileCollisionAction::Overwrite).unwrap(),
path
);
assert_eq!(
get_unique_path(&path, FileCollisionAction::Skip).unwrap(),
path
);
}
#[test]
fn test_get_unique_path_rename_with_extension() {
let temp_dir = TempDir::new().unwrap();
let path = temp_dir.path().join("test.txt");
fs::write(&path, "original").unwrap();
let unique = get_unique_path(&path, FileCollisionAction::Rename).unwrap();
assert_eq!(unique, temp_dir.path().join("test (1).txt"));
fs::write(&unique, "first rename").unwrap();
let unique2 = get_unique_path(&path, FileCollisionAction::Rename).unwrap();
assert_eq!(unique2, temp_dir.path().join("test (2).txt"));
}
#[test]
fn test_get_unique_path_rename_without_extension() {
let temp_dir = TempDir::new().unwrap();
let path = temp_dir.path().join("test");
fs::write(&path, "original").unwrap();
let unique = get_unique_path(&path, FileCollisionAction::Rename).unwrap();
assert_eq!(unique, temp_dir.path().join("test (1)"));
}
#[test]
fn test_get_unique_path_overwrite() {
let temp_dir = TempDir::new().unwrap();
let path = temp_dir.path().join("test.txt");
fs::write(&path, "original").unwrap();
let result = get_unique_path(&path, FileCollisionAction::Overwrite).unwrap();
assert_eq!(result, path);
}
#[test]
fn test_get_unique_path_skip_existing() {
let temp_dir = TempDir::new().unwrap();
let path = temp_dir.path().join("test.txt");
fs::write(&path, "original").unwrap();
let result = get_unique_path(&path, FileCollisionAction::Skip);
assert!(result.is_err());
match result {
Err(Error::PostProcess(PostProcessError::FileCollision { path: p, reason: _ })) => {
assert_eq!(p, path);
}
_ => panic!("Expected FileCollision error"),
}
}
#[test]
fn test_get_unique_path_multiple_dots() {
let temp_dir = TempDir::new().unwrap();
let path = temp_dir.path().join("test.tar.gz");
fs::write(&path, "original").unwrap();
let unique = get_unique_path(&path, FileCollisionAction::Rename).unwrap();
assert_eq!(unique, temp_dir.path().join("test.tar (1).gz"));
}
#[test]
fn test_get_unique_path_sequential() {
let temp_dir = TempDir::new().unwrap();
let path = temp_dir.path().join("test.txt");
fs::write(&path, "original").unwrap();
fs::write(temp_dir.path().join("test (1).txt"), "first").unwrap();
fs::write(temp_dir.path().join("test (2).txt"), "second").unwrap();
let unique = get_unique_path(&path, FileCollisionAction::Rename).unwrap();
assert_eq!(unique, temp_dir.path().join("test (3).txt"));
}
#[test]
fn test_is_sample_folder_exact_match() {
assert!(is_sample(Path::new("/downloads/Movie/Sample")));
assert!(is_sample(Path::new("/downloads/Movie/sample")));
assert!(is_sample(Path::new("/downloads/Movie/SAMPLE")));
assert!(is_sample(Path::new("/downloads/Movie/Samples")));
assert!(is_sample(Path::new("/downloads/Movie/Subs")));
assert!(is_sample(Path::new("/downloads/Movie/Proof")));
assert!(is_sample(Path::new("/downloads/Movie/Cover")));
}
#[test]
fn test_is_sample_file_with_sample_in_name() {
assert!(is_sample(Path::new("/downloads/movie-sample.mkv")));
assert!(is_sample(Path::new("/downloads/sample.avi")));
assert!(is_sample(Path::new("/downloads/movie.sample.mp4")));
assert!(is_sample(Path::new("/downloads/SAMPLE.MKV")));
assert!(is_sample(Path::new("/downloads/Movie-SAMPLE-Scene.mkv")));
}
#[test]
fn test_is_sample_not_sample() {
assert!(!is_sample(Path::new("/downloads/Movie/movie.mkv")));
assert!(!is_sample(Path::new("/downloads/Movie/Video")));
assert!(!is_sample(Path::new("/downloads/Movie/Season 01")));
assert!(!is_sample(Path::new("/downloads/Movie/extras")));
assert!(!is_sample(Path::new("/downloads/Movie.2020.1080p.mkv")));
}
#[test]
fn test_is_sample_edge_cases() {
assert!(!is_sample(Path::new("/downloads/sampling-documentary.mkv")));
assert!(!is_sample(Path::new("/downloads/examples/movie.mkv")));
assert!(is_sample(Path::new("/downloads/resampled-audio.mkv")));
assert!(!is_sample(Path::new("/downloads/Movie.2020.mkv")));
assert!(!is_sample(Path::new("")));
assert!(!is_sample(Path::new(".mkv")));
}
#[test]
fn test_get_available_space_valid_path() {
let temp_dir = TempDir::new().unwrap();
let available = get_available_space(temp_dir.path()).unwrap();
assert!(available > 0, "Available space should be greater than 0");
assert!(
available < 1_000_000_000_000_000,
"Available space seems unreasonably large"
);
}
#[test]
fn test_get_available_space_nonexistent_path() {
let result = get_available_space(Path::new("/nonexistent/path/that/should/not/exist"));
assert!(result.is_err(), "Should return error for nonexistent path");
}
#[test]
fn test_get_available_space_current_dir() {
let available = get_available_space(Path::new(".")).unwrap();
assert!(
available > 0,
"Current directory should have available space"
);
}
async fn mock_response(
path_str: &str,
template: ResponseTemplate,
) -> (reqwest::Response, String) {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path(path_str))
.respond_with(template)
.mount(&server)
.await;
let url = format!("{}{}", server.uri(), path_str);
let resp = reqwest::get(&url).await.unwrap();
(resp, url)
}
#[tokio::test]
async fn extract_filename_from_content_disposition_quoted() {
let (resp, url) = mock_response(
"/download/123",
ResponseTemplate::new(200).insert_header(
"Content-Disposition",
r#"attachment; filename="Movie.2024.1080p.nzb""#,
),
)
.await;
let name = extract_filename_from_response(&resp, &url);
assert_eq!(
name, "Movie.2024.1080p",
"should strip .nzb extension and return stem"
);
}
#[tokio::test]
async fn extract_filename_from_content_disposition_unquoted() {
let (resp, url) = mock_response(
"/download/456",
ResponseTemplate::new(200)
.insert_header("Content-Disposition", "attachment; filename=report.pdf"),
)
.await;
let name = extract_filename_from_response(&resp, &url);
assert_eq!(
name, "report",
"should strip .pdf extension from unquoted filename"
);
}
#[tokio::test]
async fn extract_filename_from_rfc5987_encoded_header() {
let (resp, url) = mock_response(
"/download/789",
ResponseTemplate::new(200).insert_header(
"Content-Disposition",
"attachment; filename*=UTF-8''file%20name%20with%20spaces.nzb",
),
)
.await;
let name = extract_filename_from_response(&resp, &url);
assert_eq!(
name, "file name with spaces",
"should URL-decode RFC 5987 filename and strip extension"
);
}
#[tokio::test]
async fn extract_filename_falls_back_to_url_path_without_header() {
let (resp, url) = mock_response("/files/Movie.2024.nzb", ResponseTemplate::new(200)).await;
let name = extract_filename_from_response(&resp, &url);
assert_eq!(
name, "Movie.2024",
"without Content-Disposition, should use URL path stem"
);
}
#[tokio::test]
async fn extract_filename_falls_back_to_download_when_no_useful_url() {
let (resp, _url) = mock_response("/", ResponseTemplate::new(200)).await;
let name = extract_filename_from_response(&resp, "http://example.com/");
assert_eq!(
name, "download",
"should return 'download' when URL has no useful filename"
);
}
#[tokio::test]
async fn extract_filename_with_multiple_dots_keeps_all_but_last_extension() {
let (resp, url) = mock_response("/Movie.2024.720p.nzb", ResponseTemplate::new(200)).await;
let name = extract_filename_from_response(&resp, &url);
assert_eq!(
name, "Movie.2024.720p",
"file_stem strips only the last extension"
);
}
#[tokio::test]
async fn extract_filename_content_disposition_takes_priority_over_url() {
let (resp, url) = mock_response(
"/api/v1/nzb/download/generic-id",
ResponseTemplate::new(200).insert_header(
"Content-Disposition",
r#"attachment; filename="Real.Movie.Name.nzb""#,
),
)
.await;
let name = extract_filename_from_response(&resp, &url);
assert_eq!(
name, "Real.Movie.Name",
"Content-Disposition filename should take priority over URL path"
);
}
#[tokio::test]
async fn extract_filename_no_extension_returns_full_filename() {
let (resp, url) = mock_response(
"/download/123",
ResponseTemplate::new(200)
.insert_header("Content-Disposition", r#"attachment; filename="README""#),
)
.await;
let name = extract_filename_from_response(&resp, &url);
assert_eq!(
name, "README",
"filename without extension should return the full name"
);
}
#[tokio::test]
async fn extract_filename_from_invalid_url_falls_back_to_download() {
let (resp, _url) = mock_response("/test", ResponseTemplate::new(200)).await;
let name = extract_filename_from_response(&resp, "not a url at all");
assert_eq!(
name, "download",
"unparseable URL should fall back to 'download'"
);
}
#[cfg(unix)]
#[test]
fn get_unique_path_rename_on_untraversable_directory_returns_original_path() {
use std::os::unix::fs::PermissionsExt;
let temp_dir = TempDir::new().unwrap();
let restricted_dir = temp_dir.path().join("noperm");
fs::create_dir(&restricted_dir).unwrap();
let file_path = restricted_dir.join("existing.txt");
fs::write(&file_path, "data").unwrap();
fs::set_permissions(&restricted_dir, fs::Permissions::from_mode(0o000)).unwrap();
struct RestorePerms<'a>(&'a std::path::Path);
impl Drop for RestorePerms<'_> {
fn drop(&mut self) {
let _ = fs::set_permissions(self.0, fs::Permissions::from_mode(0o755));
}
}
let _guard = RestorePerms(&restricted_dir);
let result = get_unique_path(&file_path, FileCollisionAction::Rename).unwrap();
assert_eq!(
result, file_path,
"with no directory traverse permission, exists() returns false, \
so Rename returns the original path as if the file doesn't exist"
);
}
#[cfg(unix)]
#[test]
fn get_unique_path_skip_on_untraversable_directory_succeeds_because_file_appears_absent() {
use std::os::unix::fs::PermissionsExt;
let temp_dir = TempDir::new().unwrap();
let restricted_dir = temp_dir.path().join("noperm_skip");
fs::create_dir(&restricted_dir).unwrap();
let file_path = restricted_dir.join("existing.txt");
fs::write(&file_path, "data").unwrap();
fs::set_permissions(&restricted_dir, fs::Permissions::from_mode(0o000)).unwrap();
struct RestorePerms<'a>(&'a std::path::Path);
impl Drop for RestorePerms<'_> {
fn drop(&mut self) {
let _ = fs::set_permissions(self.0, fs::Permissions::from_mode(0o755));
}
}
let _guard = RestorePerms(&restricted_dir);
let result = get_unique_path(&file_path, FileCollisionAction::Skip).unwrap();
assert_eq!(
result, file_path,
"Skip returns Ok(path) because exists() is false — the permission error \
is invisible to get_unique_path"
);
}
}