use std::collections::HashSet;
use std::time::Duration;
use serde::Deserialize;
use crate::core::retry::retry_with_backoff;
const DESTINATION_LISTING_RETRIES: usize = 3;
const DESTINATION_LISTING_RETRY_BACKOFF: Duration = Duration::from_secs(2);
#[derive(Debug, Deserialize)]
struct LsjsonEntry {
#[serde(rename = "Name")]
name: String,
}
pub(crate) async fn list_existing_filenames(
destination_path: &str,
) -> Result<HashSet<String>, String> {
retry_with_backoff(
DESTINATION_LISTING_RETRIES,
DESTINATION_LISTING_RETRY_BACKOFF,
|| run_lsjson(destination_path),
)
.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())
}
#[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());
}
}