use std::collections::HashSet;
use std::time::Duration;
use serde::Deserialize;
use crate::core::retry::retry_with_backoff;
const LSJSON_RETRIES: usize = 3;
const LSJSON_RETRY_BACKOFF: Duration = Duration::from_secs(2);
#[derive(Debug, Deserialize)]
struct LsjsonEntry {
#[serde(rename = "Name")]
name: String,
}
#[derive(Debug, Deserialize)]
struct LsjsonPathEntry {
#[serde(rename = "Path")]
path: String,
}
pub(crate) async fn list_existing_filenames(
destination_path: &str,
) -> Result<HashSet<String>, String> {
retry_with_backoff(LSJSON_RETRIES, LSJSON_RETRY_BACKOFF, || {
run_lsjson(destination_path)
})
.await
}
pub(crate) async fn list_source_relative_paths(
source_path: &str,
extension: &str,
) -> Result<Vec<String>, String> {
retry_with_backoff(LSJSON_RETRIES, LSJSON_RETRY_BACKOFF, || {
run_source_lsjson(source_path, extension)
})
.await
}
async fn run_lsjson(destination_path: &str) -> Result<HashSet<String>, String> {
let output = tokio::process::Command::new("rclone")
.arg("lsjson")
.args(["--files-only", "--no-modtime", "--no-mimetype"])
.arg(destination_path)
.output()
.await
.map_err(|err| format!("failed to run rclone lsjson: {err}"))?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
if stderr.to_ascii_lowercase().contains("directory not found") {
return Ok(HashSet::new());
}
return Err(format!(
"rclone lsjson {destination_path} exited with status {}: {}",
output
.status
.code()
.map(|code| code.to_string())
.unwrap_or_else(|| "unknown (terminated by signal)".to_string()),
stderr.trim(),
));
}
let entries: Vec<LsjsonEntry> = serde_json::from_slice(&output.stdout).map_err(|err| {
format!("failed to parse rclone lsjson output for {destination_path}: {err}")
})?;
Ok(entries.into_iter().map(|entry| entry.name).collect())
}
async fn run_source_lsjson(source_path: &str, extension: &str) -> Result<Vec<String>, String> {
let output = tokio::process::Command::new("rclone")
.arg("lsjson")
.args([
"--recursive",
"--files-only",
"--no-modtime",
"--no-mimetype",
])
.arg("--include")
.arg(format!("*.{extension}"))
.arg("--ignore-case")
.arg(source_path)
.output()
.await
.map_err(|err| format!("failed to run rclone lsjson: {err}"))?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
if stderr.to_ascii_lowercase().contains("directory not found") {
return Ok(Vec::new());
}
return Err(format!(
"rclone lsjson {source_path} exited with status {}: {}",
output
.status
.code()
.map(|code| code.to_string())
.unwrap_or_else(|| "unknown (terminated by signal)".to_string()),
stderr.trim(),
));
}
let entries: Vec<LsjsonPathEntry> = serde_json::from_slice(&output.stdout)
.map_err(|err| format!("failed to parse rclone lsjson output for {source_path}: {err}"))?;
Ok(entries.into_iter().map(|entry| entry.path).collect())
}
#[cfg(test)]
mod tests {
use super::*;
async fn check_rclone_available() -> Result<(), String> {
tokio::process::Command::new("rclone")
.arg("version")
.output()
.await
.map_err(|_| "rclone not found on PATH".to_string())?;
Ok(())
}
#[tokio::test]
async fn list_existing_filenames_lists_files_present_in_a_local_directory() {
if check_rclone_available().await.is_err() {
eprintln!("skipping: rclone not found on PATH");
return;
}
let dir = tempfile::tempdir().unwrap();
std::fs::write(
dir.path()
.join("2021-08-20_Delivery_Status_Notification_(Failure)_1.png"),
b"png bytes",
)
.unwrap();
std::fs::write(dir.path().join("plain.jpg"), b"jpeg bytes").unwrap();
let names = list_existing_filenames(dir.path().to_str().unwrap())
.await
.unwrap();
assert!(names.contains("2021-08-20_Delivery_Status_Notification_(Failure)_1.png"));
assert!(names.contains("plain.jpg"));
assert_eq!(names.len(), 2);
}
#[tokio::test]
async fn list_existing_filenames_returns_empty_for_a_destination_that_does_not_exist_yet() {
if check_rclone_available().await.is_err() {
eprintln!("skipping: rclone not found on PATH");
return;
}
let dir = tempfile::tempdir().unwrap();
let missing = dir.path().join("does-not-exist-yet");
let names = list_existing_filenames(missing.to_str().unwrap())
.await
.unwrap();
assert!(names.is_empty());
}
#[tokio::test]
async fn list_source_relative_paths_lists_nested_files_matching_the_extension() {
if check_rclone_available().await.is_err() {
eprintln!("skipping: rclone not found on PATH");
return;
}
let dir = tempfile::tempdir().unwrap();
std::fs::create_dir_all(dir.path().join("screenshots")).unwrap();
std::fs::write(dir.path().join("screenshots/img.png"), b"png bytes").unwrap();
std::fs::write(dir.path().join("notes.txt"), b"not a png").unwrap();
let paths = list_source_relative_paths(dir.path().to_str().unwrap(), "png")
.await
.unwrap();
assert_eq!(paths, vec!["screenshots/img.png".to_string()]);
}
#[tokio::test]
async fn list_source_relative_paths_returns_empty_for_a_source_that_does_not_exist_yet() {
if check_rclone_available().await.is_err() {
eprintln!("skipping: rclone not found on PATH");
return;
}
let dir = tempfile::tempdir().unwrap();
let missing = dir.path().join("does-not-exist-yet");
let paths = list_source_relative_paths(missing.to_str().unwrap(), "png")
.await
.unwrap();
assert!(paths.is_empty());
}
}