use std::collections::BTreeMap;
use std::fs;
use std::path::{Path, PathBuf};
use std::time::SystemTime;
use walkdir::WalkDir;
use crate::error::{FastSyncError, Result, io_context};
use crate::i18n::tr_path;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum EntryKind {
File,
Directory,
Symlink,
}
impl EntryKind {
pub fn as_str(self) -> &'static str {
match self {
Self::File => "file",
Self::Directory => "directory",
Self::Symlink => "symlink",
}
}
}
#[derive(Debug, Clone)]
pub struct FileEntry {
pub relative_path: PathBuf,
pub absolute_path: PathBuf,
pub kind: EntryKind,
pub len: u64,
pub modified: Option<SystemTime>,
pub readonly: bool,
#[cfg(unix)]
pub mode: u32,
}
impl FileEntry {
pub fn is_file(&self) -> bool {
self.kind == EntryKind::File
}
pub fn is_dir(&self) -> bool {
self.kind == EntryKind::Directory
}
}
#[derive(Debug, Clone, Default)]
pub struct Snapshot {
pub entries: BTreeMap<PathBuf, FileEntry>,
}
impl Snapshot {
pub fn get(&self, path: &Path) -> Option<&FileEntry> {
self.entries.get(path)
}
}
pub fn scan_directory(root: &Path, follow_symlinks: bool) -> Result<Snapshot> {
if !root.is_dir() {
return Err(FastSyncError::InvalidSource(root.to_path_buf()));
}
scan_existing_directory(root, follow_symlinks)
}
pub fn scan_optional_directory(root: &Path, follow_symlinks: bool) -> Result<Snapshot> {
if !root.exists() {
return Ok(Snapshot::default());
}
if !root.is_dir() {
return Err(FastSyncError::InvalidTarget(root.to_path_buf()));
}
scan_existing_directory(root, follow_symlinks)
}
fn scan_existing_directory(root: &Path, follow_symlinks: bool) -> Result<Snapshot> {
let mut snapshot = Snapshot::default();
for entry in WalkDir::new(root)
.follow_links(follow_symlinks)
.min_depth(1)
.into_iter()
{
let entry = entry?;
let absolute_path = entry.path().to_path_buf();
let relative_path = absolute_path
.strip_prefix(root)
.map_err(|_| FastSyncError::PathOutsideRoot {
path: absolute_path.clone(),
})?
.to_path_buf();
let metadata = if follow_symlinks {
io_context(
tr_path("io.read_metadata", absolute_path.display()),
fs::metadata(&absolute_path),
)?
} else {
io_context(
tr_path("io.read_symlink_metadata", absolute_path.display()),
fs::symlink_metadata(&absolute_path),
)?
};
let file_type = metadata.file_type();
let kind = if file_type.is_file() {
EntryKind::File
} else if file_type.is_dir() {
EntryKind::Directory
} else if file_type.is_symlink() {
EntryKind::Symlink
} else {
return Err(FastSyncError::UnsupportedEntry(relative_path));
};
let modified = metadata.modified().ok();
let readonly = metadata.permissions().readonly();
#[cfg(unix)]
let mode = {
use std::os::unix::fs::PermissionsExt;
metadata.permissions().mode()
};
snapshot.entries.insert(
relative_path.clone(),
FileEntry {
relative_path,
absolute_path,
kind,
len: metadata.len(),
modified,
readonly,
#[cfg(unix)]
mode,
},
);
}
Ok(snapshot)
}
#[cfg(test)]
mod tests {
use std::fs;
use std::path::Path;
use tempfile::tempdir;
use super::*;
#[test]
fn optional_missing_directory_returns_empty_snapshot()
-> std::result::Result<(), Box<dyn std::error::Error>> {
let root = tempdir()?;
let missing = root.path().join("missing");
let snapshot = scan_optional_directory(&missing, false)?;
assert!(snapshot.entries.is_empty());
Ok(())
}
#[test]
fn scan_directory_records_nested_relative_paths()
-> std::result::Result<(), Box<dyn std::error::Error>> {
let root = tempdir()?;
fs::create_dir(root.path().join("nested"))?;
fs::write(root.path().join("nested").join("a.txt"), "hello")?;
let snapshot = scan_directory(root.path(), false)?;
assert!(
snapshot
.get(Path::new("nested"))
.is_some_and(FileEntry::is_dir)
);
assert!(
snapshot
.get(Path::new("nested/a.txt"))
.is_some_and(FileEntry::is_file)
);
Ok(())
}
#[cfg(unix)]
#[test]
fn scan_preserves_symlink_kind_when_not_following()
-> std::result::Result<(), Box<dyn std::error::Error>> {
let root = tempdir()?;
fs::write(root.path().join("target.txt"), "hello")?;
std::os::unix::fs::symlink("target.txt", root.path().join("link.txt"))?;
let snapshot = scan_directory(root.path(), false)?;
let link = snapshot.get(Path::new("link.txt")).expect("link entry");
assert_eq!(link.kind, EntryKind::Symlink);
Ok(())
}
#[cfg(unix)]
#[test]
fn scan_follows_symlink_when_enabled() -> std::result::Result<(), Box<dyn std::error::Error>> {
let root = tempdir()?;
fs::write(root.path().join("target.txt"), "hello")?;
std::os::unix::fs::symlink("target.txt", root.path().join("link.txt"))?;
let snapshot = scan_directory(root.path(), true)?;
let link = snapshot.get(Path::new("link.txt")).expect("link entry");
assert_eq!(link.kind, EntryKind::File);
Ok(())
}
}