use crate::config::{get_watch_path, shared_inbox_path, shared_processed_path};
use std::fs;
use std::io;
use std::path::{Path, PathBuf};
use std::time::{SystemTime, UNIX_EPOCH};
pub fn is_cross_device_link_error(error: &io::Error) -> bool {
matches!(error.raw_os_error(), Some(18) | Some(17))
}
pub fn move_file_with_fallback_impl<F>(
source: &Path,
destination: &Path,
rename_op: F,
) -> io::Result<()>
where
F: FnOnce(&Path, &Path) -> io::Result<()>,
{
if let Some(parent) = destination.parent() {
fs::create_dir_all(parent)?;
}
match rename_op(source, destination) {
Ok(()) => Ok(()),
Err(error) if is_cross_device_link_error(&error) => {
fs::copy(source, destination)?;
fs::remove_file(source)?;
Ok(())
}
Err(error) => Err(error),
}
}
pub fn move_file_with_fallback(source: &Path, destination: &Path) -> io::Result<()> {
move_file_with_fallback_impl(source, destination, |src, dst| fs::rename(src, dst))
}
pub fn processed_watch_destination(path: &Path) -> Option<PathBuf> {
if let Some(shared_inbox) = shared_inbox_path() {
if path.parent() == Some(shared_inbox.as_path()) {
let processed = shared_processed_path()?;
let file_name = path.file_name()?;
return Some(processed.join(file_name));
}
}
let (_, processed_path) = get_watch_path()?;
let file_name = path.file_name()?;
Some(processed_path.join(file_name))
}
fn unique_relay_destination(source: &Path, destination_dir: &Path) -> io::Result<PathBuf> {
let file_name = source.file_name().ok_or_else(|| {
io::Error::new(
io::ErrorKind::InvalidInput,
"Relay source file has no file name",
)
})?;
let candidate = destination_dir.join(file_name);
if !candidate.exists() {
return Ok(candidate);
}
let now_ms = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap_or_default()
.as_millis();
let stem = source
.file_stem()
.and_then(|value| value.to_str())
.unwrap_or("relay");
let extension = source.extension().and_then(|value| value.to_str());
let renamed = match extension {
Some(ext) => format!("{stem}-{now_ms}.{ext}"),
None => format!("{stem}-{now_ms}"),
};
Ok(destination_dir.join(renamed))
}
pub fn relay_watch_file_to_shared_inbox(path: &Path) -> io::Result<PathBuf> {
let inbox = shared_inbox_path()
.ok_or_else(|| io::Error::new(io::ErrorKind::NotFound, "Shared inbox path unavailable"))?;
fs::create_dir_all(&inbox)?;
let destination = unique_relay_destination(path, &inbox)?;
move_file_with_fallback(path, &destination)?;
Ok(destination)
}
pub fn archive_watch_file(path: &Path, fallback_extension: &str) -> io::Result<PathBuf> {
if let Some(destination) = processed_watch_destination(path) {
if move_file_with_fallback(path, &destination).is_ok() {
return Ok(destination);
}
}
let mut fallback_path = path.to_path_buf();
fallback_path.set_extension(fallback_extension);
fs::rename(path, &fallback_path)?;
Ok(fallback_path)
}
#[cfg(test)]
mod tests {
use super::{
archive_watch_file, is_cross_device_link_error, move_file_with_fallback_impl,
relay_watch_file_to_shared_inbox,
};
use crate::config::{clear_shared_config_state_for_tests, set_app_paths_override_for_tests};
use std::fs;
fn shared_env_guard() -> &'static std::sync::Mutex<()> {
crate::config::shared_env_guard_for_tests()
}
#[test]
fn cross_device_link_detection_accepts_windows_and_unix_codes() {
assert!(is_cross_device_link_error(
&std::io::Error::from_raw_os_error(18)
));
assert!(is_cross_device_link_error(
&std::io::Error::from_raw_os_error(17)
));
}
#[test]
fn move_file_with_fallback_copies_when_rename_crosses_devices() {
let dir = tempfile::tempdir().expect("create tempdir");
let source = dir.path().join("source.txt");
let destination = dir.path().join("nested").join("destination.txt");
fs::write(&source, "sample payload").expect("write source file");
move_file_with_fallback_impl(&source, &destination, |_src, _dst| {
Err(std::io::Error::from_raw_os_error(17))
})
.expect("fallback move should succeed");
assert!(!source.exists());
assert_eq!(
fs::read_to_string(&destination).expect("read copied destination"),
"sample payload"
);
}
#[test]
fn archive_watch_file_falls_back_to_local_rename_when_processed_dir_is_unavailable() {
let _guard = shared_env_guard().lock().unwrap();
let dir = tempfile::tempdir().expect("create tempdir");
let config_dir = dir.path().join("config");
let data_dir = dir.path().join("data");
set_app_paths_override_for_tests(Some((config_dir, data_dir.clone())));
fs::create_dir_all(&data_dir).expect("create data dir");
fs::write(data_dir.join("processed_files"), "block directory creation")
.expect("write processed path blocker");
let source = dir.path().join("sample.control");
fs::write(&source, "content").expect("write source");
let archived = archive_watch_file(&source, "control.done").expect("archive watch file");
assert_eq!(
archived.extension().and_then(|ext| ext.to_str()),
Some("done")
);
set_app_paths_override_for_tests(None);
}
#[test]
fn relay_watch_file_to_shared_inbox_moves_file() {
let _guard = shared_env_guard().lock().unwrap();
let dir = tempfile::tempdir().expect("create tempdir");
let source = dir.path().join("sample.control");
let shared_root = dir.path().join("shared-root");
let effective_root = shared_root.join("superseedr-config");
let original_shared_dir = std::env::var_os("SUPERSEEDR_SHARED_CONFIG_DIR");
fs::write(&source, "content").expect("write source");
std::env::set_var("SUPERSEEDR_SHARED_CONFIG_DIR", &shared_root);
clear_shared_config_state_for_tests();
let relayed = relay_watch_file_to_shared_inbox(&source).expect("relay watch file");
assert!(!source.exists());
assert!(relayed.starts_with(effective_root.join("inbox")));
assert_eq!(
fs::read_to_string(&relayed).expect("read relayed file"),
"content"
);
if let Some(value) = original_shared_dir {
std::env::set_var("SUPERSEEDR_SHARED_CONFIG_DIR", value);
} else {
std::env::remove_var("SUPERSEEDR_SHARED_CONFIG_DIR");
}
clear_shared_config_state_for_tests();
}
}