use std::path::{Path, PathBuf};
use std::time::Duration;
use serde::Deserialize;
use super::{Labels, Observation, Outcome, RcloneCommand, RcloneLogSummary, StreamHooks};
#[derive(Default)]
pub(crate) struct Copy {
pub source: String,
pub destination: String,
pub log_path: PathBuf,
pub transfers: usize,
pub checkers: usize,
pub tpslimit: Option<usize>,
pub files_from: Option<PathBuf>,
pub job: &'static str,
pub step: &'static str,
pub upload_metrics: bool,
pub hooks: StreamHooks,
}
impl RcloneCommand for Copy {
type Output = RcloneLogSummary;
fn subcommand(&self) -> &'static str {
"copy"
}
fn args(&self) -> Vec<String> {
let mut args = vec![self.source.clone(), self.destination.clone()];
args.push("--transfers".to_string());
args.push(self.transfers.to_string());
args.push("--checkers".to_string());
args.push(self.checkers.to_string());
if let Some(tpslimit) = self.tpslimit {
args.push("--tpslimit".to_string());
args.push(tpslimit.to_string());
}
if let Some(files_from) = &self.files_from {
args.push("--files-from-raw".to_string());
args.push(files_from.display().to_string());
}
args.extend(
[
"--fast-list",
"--buffer-size",
"32M",
"--multi-thread-streams",
"4",
"--multi-thread-cutoff",
"256M",
]
.into_iter()
.map(String::from),
);
args
}
fn log_path(&self) -> Option<&Path> {
Some(&self.log_path)
}
fn observe(&self) -> Observation {
Observation::Streamed(self.hooks.clone())
}
fn labels(&self) -> Labels {
Labels {
job: self.job,
step: self.step,
success_outcome: "transferred",
upload_metrics: self.upload_metrics,
}
}
fn finish(&self, outcome: Outcome) -> Result<RcloneLogSummary, String> {
if !outcome.success {
return Err(format!(
"rclone copy exited with status {} ({} file(s) transferred, {} error(s)); see {} for details{}",
outcome
.exit_code
.map(|code| code.to_string())
.unwrap_or_else(|| "unknown (terminated by signal)".to_string()),
outcome.log.transferred,
outcome.log.errors,
self.log_path.display(),
if outcome.stderr.trim().is_empty() {
String::new()
} else {
format!(" -- rclone stderr: {}", outcome.stderr.trim())
},
));
}
Ok(outcome.log)
}
}
#[derive(Default)]
pub(crate) struct Purge {
pub source: String,
pub log_path: PathBuf,
pub checkers: usize,
pub job: &'static str,
pub step: &'static str,
}
impl RcloneCommand for Purge {
type Output = RcloneLogSummary;
fn subcommand(&self) -> &'static str {
"purge"
}
fn args(&self) -> Vec<String> {
vec![
self.source.clone(),
"--checkers".to_string(),
self.checkers.to_string(),
"--fast-list".to_string(),
]
}
fn log_path(&self) -> Option<&Path> {
Some(&self.log_path)
}
fn observe(&self) -> Observation {
Observation::Streamed(StreamHooks::default())
}
fn labels(&self) -> Labels {
Labels {
job: self.job,
step: self.step,
success_outcome: "deleted",
upload_metrics: false,
}
}
fn finish(&self, outcome: Outcome) -> Result<RcloneLogSummary, String> {
if !outcome.success {
return Err(format!(
"rclone purge exited with status {} ({} object(s) deleted, {} error(s)); see {} for details{}",
outcome
.exit_code
.map(|code| code.to_string())
.unwrap_or_else(|| "unknown (terminated by signal)".to_string()),
outcome.log.deletes,
outcome.log.errors,
self.log_path.display(),
if outcome.stderr.trim().is_empty() {
String::new()
} else {
format!(" -- rclone stderr: {}", outcome.stderr.trim())
},
));
}
Ok(outcome.log)
}
}
#[derive(Debug, Deserialize, Clone)]
pub(crate) struct LsjsonEntry {
#[serde(rename = "Name", default)]
pub name: String,
#[serde(rename = "Path", default)]
pub path: String,
#[serde(rename = "Size", default)]
pub size: u64,
}
#[derive(Default)]
pub(crate) struct Lsjson {
pub path: String,
pub recursive: bool,
pub include_extensions: Vec<String>,
pub missing_ok: bool,
pub job: &'static str,
}
impl RcloneCommand for Lsjson {
type Output = Vec<LsjsonEntry>;
fn subcommand(&self) -> &'static str {
"lsjson"
}
fn args(&self) -> Vec<String> {
let mut args = vec![
"--files-only".to_string(),
"--no-modtime".to_string(),
"--no-mimetype".to_string(),
];
if self.recursive {
args.push("--recursive".to_string());
}
if !self.include_extensions.is_empty() {
for extension in &self.include_extensions {
args.push("--include".to_string());
args.push(format!("*.{extension}"));
}
args.push("--ignore-case".to_string());
}
args.push(self.path.clone());
args
}
fn log_path(&self) -> Option<&Path> {
None
}
fn observe(&self) -> Observation {
Observation::OneShot
}
fn labels(&self) -> Labels {
Labels {
job: self.job,
step: "list",
success_outcome: "listed",
upload_metrics: false,
}
}
fn finish(&self, outcome: Outcome) -> Result<Vec<LsjsonEntry>, String> {
if !outcome.success {
if self.missing_ok
&& outcome
.stderr
.to_ascii_lowercase()
.contains("directory not found")
{
return Ok(Vec::new());
}
return Err(format!(
"rclone lsjson {} exited with status {}: {}",
self.path,
outcome
.exit_code
.map(|code| code.to_string())
.unwrap_or_else(|| "unknown (terminated by signal)".to_string()),
outcome.stderr.trim(),
));
}
serde_json::from_slice(&outcome.stdout).map_err(|err| {
format!(
"failed to parse rclone lsjson output for {}: {err}",
self.path
)
})
}
fn retry(&self) -> Option<(usize, Duration)> {
Some((3, Duration::from_secs(2)))
}
}
#[derive(Default)]
pub(crate) struct CopyTo {
pub source: String,
pub destination: String,
pub log_path: PathBuf,
pub job: &'static str,
pub step: &'static str,
pub emit_upload_metrics: bool,
pub bytes: u64,
}
impl RcloneCommand for CopyTo {
type Output = ();
fn subcommand(&self) -> &'static str {
"copyto"
}
fn args(&self) -> Vec<String> {
vec![self.source.clone(), self.destination.clone()]
}
fn log_path(&self) -> Option<&Path> {
Some(&self.log_path)
}
fn observe(&self) -> Observation {
Observation::OneShot
}
fn labels(&self) -> Labels {
Labels {
job: self.job,
step: self.step,
success_outcome: "transferred",
upload_metrics: false,
}
}
fn finish(&self, outcome: Outcome) -> Result<(), String> {
use crate::observability::metrics::NO_BUCKET;
if self.emit_upload_metrics {
if outcome.success {
::metrics::counter!(
"pigeon_upload_bytes_total",
"pigeon_job" => self.job,
"instance" => crate::observability::instance(),
"destination_bucket" => NO_BUCKET,
)
.increment(self.bytes);
}
::metrics::counter!(
"pigeon_upload_outcomes_total",
"pigeon_job" => self.job,
"outcome" => if outcome.success { "uploaded" } else { "failed" },
"instance" => crate::observability::instance(),
"destination_bucket" => NO_BUCKET,
)
.increment(1);
}
if !outcome.success {
return Err(format!(
"rclone copyto {} -> {} exited with status {}; see {} for details{}",
self.source,
self.destination,
outcome
.exit_code
.map(|code| code.to_string())
.unwrap_or_else(|| "unknown (terminated by signal)".to_string()),
self.log_path.display(),
if outcome.stderr.trim().is_empty() {
String::new()
} else {
format!(" -- rclone stderr: {}", outcome.stderr.trim())
},
));
}
Ok(())
}
fn retry(&self) -> Option<(usize, Duration)> {
Some((4, Duration::from_secs(30)))
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn lsjson_emits_one_include_per_extension_and_a_single_ignore_case() {
let args = (Lsjson {
path: "source:jpg/".to_string(),
recursive: true,
include_extensions: vec!["jpg".to_string(), "jpeg".to_string()],
missing_ok: false,
job: "transform",
})
.args();
let includes: Vec<&String> = args
.iter()
.zip(args.iter().skip(1))
.filter(|(flag, _)| *flag == "--include")
.map(|(_, pattern)| pattern)
.collect();
assert_eq!(includes, vec!["*.jpg", "*.jpeg"]);
assert_eq!(args.iter().filter(|arg| *arg == "--ignore-case").count(), 1);
assert!(args.contains(&"--recursive".to_string()));
assert_eq!(args.last().unwrap(), "source:jpg/");
}
#[test]
fn lsjson_emits_no_include_or_ignore_case_when_unfiltered() {
let args = (Lsjson {
path: "destination:jpg/".to_string(),
job: "transform",
..Default::default()
})
.args();
assert!(!args.contains(&"--include".to_string()));
assert!(!args.contains(&"--ignore-case".to_string()));
assert!(!args.contains(&"--recursive".to_string()));
assert!(args.contains(&"--files-only".to_string()));
}
fn missing_directory_outcome() -> Outcome {
Outcome {
exit_code: Some(1),
success: false,
stdout: b"[\n]\n".to_vec(),
stderr: "2026/10/12 21:39:09 ERROR : error listing: directory not found".to_string(),
log: RcloneLogSummary::default(),
}
}
#[test]
fn lsjson_finish_treats_a_missing_path_as_empty_only_when_missing_ok() {
let forgiving = (Lsjson {
path: "destination:jpg/".to_string(),
missing_ok: true,
job: "transform",
..Default::default()
})
.finish(missing_directory_outcome())
.unwrap();
assert!(forgiving.is_empty());
let err = (Lsjson {
path: "source:jpg/".to_string(),
missing_ok: false,
job: "transform",
..Default::default()
})
.finish(missing_directory_outcome())
.unwrap_err();
assert!(err.contains("source:jpg/"), "unexpected error: {err}");
assert!(
err.contains("directory not found"),
"the real rclone stderr must survive into the error: {err}"
);
}
#[test]
fn lsjson_finish_propagates_any_other_failure_even_when_missing_ok() {
let outcome = Outcome {
exit_code: Some(1),
success: false,
stdout: Vec::new(),
stderr: "SignatureDoesNotMatch".to_string(),
log: RcloneLogSummary::default(),
};
let err = (Lsjson {
path: "destination:jpg/".to_string(),
missing_ok: true,
job: "transform",
..Default::default()
})
.finish(outcome)
.unwrap_err();
assert!(err.contains("SignatureDoesNotMatch"), "unexpected: {err}");
}
}