Merge pull request #85 from readur/feat/greatly-improve-webdav-1

feat(webdav): track directory etags
This commit is contained in:
Jon Fuller 2025-07-01 15:41:21 -07:00 committed by GitHub
commit 6f16ddc207
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
9 changed files with 2478 additions and 5 deletions

View File

@ -0,0 +1,22 @@
-- Add directory-level ETag tracking for efficient WebDAV sync
-- This optimization allows skipping unchanged directories entirely
CREATE TABLE IF NOT EXISTS webdav_directories (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID REFERENCES users(id) ON DELETE CASCADE,
directory_path TEXT NOT NULL,
directory_etag TEXT NOT NULL,
last_scanned_at TIMESTAMPTZ DEFAULT NOW(),
file_count BIGINT DEFAULT 0,
total_size_bytes BIGINT DEFAULT 0,
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW(),
UNIQUE(user_id, directory_path)
);
-- Create indexes for performance
CREATE INDEX IF NOT EXISTS idx_webdav_directories_user_id ON webdav_directories(user_id);
CREATE INDEX IF NOT EXISTS idx_webdav_directories_path ON webdav_directories(user_id, directory_path);
CREATE INDEX IF NOT EXISTS idx_webdav_directories_etag ON webdav_directories(directory_etag);
CREATE INDEX IF NOT EXISTS idx_webdav_directories_last_scanned ON webdav_directories(last_scanned_at);

View File

@ -218,4 +218,125 @@ impl Database {
Ok(files)
}
// Directory tracking functions for efficient sync optimization
pub async fn get_webdav_directory(&self, user_id: Uuid, directory_path: &str) -> Result<Option<crate::models::WebDAVDirectory>> {
self.with_retry(|| async {
let row = sqlx::query(
r#"SELECT id, user_id, directory_path, directory_etag, last_scanned_at,
file_count, total_size_bytes, created_at, updated_at
FROM webdav_directories WHERE user_id = $1 AND directory_path = $2"#
)
.bind(user_id)
.bind(directory_path)
.fetch_optional(&self.pool)
.await
.map_err(|e| anyhow::anyhow!("Database query failed: {}", e))?;
match row {
Some(row) => Ok(Some(crate::models::WebDAVDirectory {
id: row.get("id"),
user_id: row.get("user_id"),
directory_path: row.get("directory_path"),
directory_etag: row.get("directory_etag"),
last_scanned_at: row.get("last_scanned_at"),
file_count: row.get("file_count"),
total_size_bytes: row.get("total_size_bytes"),
created_at: row.get("created_at"),
updated_at: row.get("updated_at"),
})),
None => Ok(None),
}
}).await
}
pub async fn create_or_update_webdav_directory(&self, directory: &crate::models::CreateWebDAVDirectory) -> Result<crate::models::WebDAVDirectory> {
let row = sqlx::query(
r#"INSERT INTO webdav_directories (user_id, directory_path, directory_etag,
file_count, total_size_bytes, last_scanned_at, updated_at)
VALUES ($1, $2, $3, $4, $5, NOW(), NOW())
ON CONFLICT (user_id, directory_path) DO UPDATE SET
directory_etag = EXCLUDED.directory_etag,
file_count = EXCLUDED.file_count,
total_size_bytes = EXCLUDED.total_size_bytes,
last_scanned_at = NOW(),
updated_at = NOW()
RETURNING id, user_id, directory_path, directory_etag, last_scanned_at,
file_count, total_size_bytes, created_at, updated_at"#
)
.bind(directory.user_id)
.bind(&directory.directory_path)
.bind(&directory.directory_etag)
.bind(directory.file_count)
.bind(directory.total_size_bytes)
.fetch_one(&self.pool)
.await?;
Ok(crate::models::WebDAVDirectory {
id: row.get("id"),
user_id: row.get("user_id"),
directory_path: row.get("directory_path"),
directory_etag: row.get("directory_etag"),
last_scanned_at: row.get("last_scanned_at"),
file_count: row.get("file_count"),
total_size_bytes: row.get("total_size_bytes"),
created_at: row.get("created_at"),
updated_at: row.get("updated_at"),
})
}
pub async fn update_webdav_directory(&self, user_id: Uuid, directory_path: &str, update: &crate::models::UpdateWebDAVDirectory) -> Result<()> {
self.with_retry(|| async {
sqlx::query(
r#"UPDATE webdav_directories SET
directory_etag = $3,
last_scanned_at = $4,
file_count = $5,
total_size_bytes = $6,
updated_at = NOW()
WHERE user_id = $1 AND directory_path = $2"#
)
.bind(user_id)
.bind(directory_path)
.bind(&update.directory_etag)
.bind(update.last_scanned_at)
.bind(update.file_count)
.bind(update.total_size_bytes)
.execute(&self.pool)
.await
.map_err(|e| anyhow::anyhow!("Database update failed: {}", e))?;
Ok(())
}).await
}
pub async fn list_webdav_directories(&self, user_id: Uuid) -> Result<Vec<crate::models::WebDAVDirectory>> {
let rows = sqlx::query(
r#"SELECT id, user_id, directory_path, directory_etag, last_scanned_at,
file_count, total_size_bytes, created_at, updated_at
FROM webdav_directories
WHERE user_id = $1
ORDER BY directory_path ASC"#
)
.bind(user_id)
.fetch_all(&self.pool)
.await?;
let mut directories = Vec::new();
for row in rows {
directories.push(crate::models::WebDAVDirectory {
id: row.get("id"),
user_id: row.get("user_id"),
directory_path: row.get("directory_path"),
directory_etag: row.get("directory_etag"),
last_scanned_at: row.get("last_scanned_at"),
file_count: row.get("file_count"),
total_size_bytes: row.get("total_size_bytes"),
created_at: row.get("created_at"),
updated_at: row.get("updated_at"),
});
}
Ok(directories)
}
}

View File

@ -931,6 +931,36 @@ pub struct FileInfo {
pub metadata: Option<serde_json::Value>,
}
#[derive(Debug, Serialize, Deserialize, FromRow)]
pub struct WebDAVDirectory {
pub id: Uuid,
pub user_id: Uuid,
pub directory_path: String,
pub directory_etag: String,
pub last_scanned_at: DateTime<Utc>,
pub file_count: i64,
pub total_size_bytes: i64,
pub created_at: DateTime<Utc>,
pub updated_at: DateTime<Utc>,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct CreateWebDAVDirectory {
pub user_id: Uuid,
pub directory_path: String,
pub directory_etag: String,
pub file_count: i64,
pub total_size_bytes: i64,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct UpdateWebDAVDirectory {
pub directory_etag: String,
pub last_scanned_at: DateTime<Utc>,
pub file_count: i64,
pub total_size_bytes: i64,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Hash, ToSchema)]
pub enum SourceType {
#[serde(rename = "webdav")]

View File

@ -125,11 +125,18 @@ impl SourceSyncService {
cancellation_token,
|folder_path| {
let service = webdav_service.clone();
let state_clone = self.state.clone();
async move {
debug!("WebDAV discover_files_in_folder called for: {}", folder_path);
let result = service.discover_files_in_folder(&folder_path).await;
info!("🚀 Using optimized WebDAV discovery for: {}", folder_path);
let result = service.discover_files_in_folder_optimized(&folder_path, source.user_id, &state_clone).await;
match &result {
Ok(files) => debug!("WebDAV discovered {} files in folder: {}", files.len(), folder_path),
Ok(files) => {
if files.is_empty() {
info!("✅ Directory {} unchanged, skipped deep scan", folder_path);
} else {
info!("🔄 Directory {} changed, discovered {} files", folder_path, files.len());
}
},
Err(e) => error!("WebDAV discovery failed for folder {}: {}", folder_path, e),
}
result

View File

@ -10,7 +10,7 @@ use crate::models::{
FileInfo, WebDAVConnectionResult, WebDAVCrawlEstimate, WebDAVFolderInfo,
WebDAVTestConnection,
};
use crate::webdav_xml_parser::parse_propfind_response;
use crate::webdav_xml_parser::{parse_propfind_response, parse_propfind_response_with_directories};
#[derive(Debug, Clone)]
pub struct WebDAVConfig {
@ -416,6 +416,664 @@ impl WebDAVService {
}).await
}
/// Optimized discovery that checks directory ETag first to avoid unnecessary deep scans
pub async fn discover_files_in_folder_optimized(&self, folder_path: &str, user_id: uuid::Uuid, state: &crate::AppState) -> Result<Vec<FileInfo>> {
info!("🔍 Starting optimized discovery for folder: {}", folder_path);
// Step 1: Check directory ETag first (lightweight PROPFIND with Depth: 0)
let current_dir_etag = match self.check_directory_etag(folder_path).await {
Ok(etag) => etag,
Err(e) => {
warn!("Failed to get directory ETag for {}, falling back to full scan: {}", folder_path, e);
return self.discover_files_in_folder_impl(folder_path).await;
}
};
// Step 2: Check if we have this directory cached
match state.db.get_webdav_directory(user_id, folder_path).await {
Ok(Some(stored_dir)) => {
if stored_dir.directory_etag == current_dir_etag {
info!("✅ Directory {} unchanged (ETag: {}), checking subdirectories individually", folder_path, current_dir_etag);
// Update last_scanned_at to show we checked
let update = crate::models::UpdateWebDAVDirectory {
directory_etag: current_dir_etag,
last_scanned_at: chrono::Utc::now(),
file_count: stored_dir.file_count,
total_size_bytes: stored_dir.total_size_bytes,
};
if let Err(e) = state.db.update_webdav_directory(user_id, folder_path, &update).await {
warn!("Failed to update directory scan time: {}", e);
}
// Step 2a: Check subdirectories individually for changes
let changed_files = self.check_subdirectories_for_changes(folder_path, user_id, state).await?;
return Ok(changed_files);
} else {
info!("🔄 Directory {} changed (old ETag: {}, new ETag: {}), performing deep scan",
folder_path, stored_dir.directory_etag, current_dir_etag);
}
}
Ok(None) => {
info!("🆕 New directory {}, performing initial scan", folder_path);
}
Err(e) => {
warn!("Database error checking directory {}: {}, proceeding with scan", folder_path, e);
}
}
// Step 3: Directory has changed or is new - perform full discovery
let files = self.discover_files_in_folder_impl(folder_path).await?;
// Step 4: Update directory tracking info for main directory
let file_count = files.iter().filter(|f| !f.is_directory).count() as i64;
let total_size_bytes = files.iter().filter(|f| !f.is_directory).map(|f| f.size).sum::<i64>();
let directory_record = crate::models::CreateWebDAVDirectory {
user_id,
directory_path: folder_path.to_string(),
directory_etag: current_dir_etag.clone(),
file_count,
total_size_bytes,
};
if let Err(e) = state.db.create_or_update_webdav_directory(&directory_record).await {
error!("Failed to update directory tracking for {}: {}", folder_path, e);
} else {
info!("📊 Updated directory tracking: {} files, {} bytes, ETag: {}",
file_count, total_size_bytes, current_dir_etag);
}
// Step 5: Track ALL subdirectories found during the scan (n-depth)
self.track_subdirectories_recursively(&files, user_id, state).await;
Ok(files)
}
/// Track all subdirectories recursively with rock-solid n-depth support
async fn track_subdirectories_recursively(&self, files: &[FileInfo], user_id: uuid::Uuid, state: &crate::AppState) {
use std::collections::{HashMap, BTreeSet};
// Step 1: Extract all unique directory paths from the file list
let mut all_directories = BTreeSet::new();
for file in files {
if file.is_directory {
// Add the directory itself
all_directories.insert(file.path.clone());
} else {
// Extract all parent directories from file paths
let mut path_parts: Vec<&str> = file.path.split('/').collect();
path_parts.pop(); // Remove the filename
// Build directory paths from root down to immediate parent
let mut current_path = String::new();
for part in path_parts {
if !part.is_empty() {
if !current_path.is_empty() {
current_path.push('/');
}
current_path.push_str(part);
all_directories.insert(current_path.clone());
}
}
}
}
info!("🗂️ Found {} unique directories at all levels", all_directories.len());
// Step 2: Create a mapping of directory -> ETag from the files list
let mut directory_etags: HashMap<String, String> = HashMap::new();
for file in files {
if file.is_directory {
directory_etags.insert(file.path.clone(), file.etag.clone());
}
}
// Step 3: For each directory, calculate its direct content (files and immediate subdirs)
for dir_path in &all_directories {
let dir_etag = match directory_etags.get(dir_path) {
Some(etag) => etag.clone(),
None => {
debug!("⚠️ No ETag found for directory: {}", dir_path);
continue; // Skip directories without ETags
}
};
// Count direct files in this directory (not in subdirectories)
let direct_files: Vec<_> = files.iter()
.filter(|f| {
!f.is_directory &&
self.is_direct_child(&f.path, dir_path)
})
.collect();
// Count direct subdirectories
let direct_subdirs: Vec<_> = files.iter()
.filter(|f| {
f.is_directory &&
self.is_direct_child(&f.path, dir_path)
})
.collect();
let file_count = direct_files.len() as i64;
let total_size_bytes = direct_files.iter().map(|f| f.size).sum::<i64>();
// Create or update directory tracking record
let directory_record = crate::models::CreateWebDAVDirectory {
user_id,
directory_path: dir_path.clone(),
directory_etag: dir_etag.clone(),
file_count,
total_size_bytes,
};
match state.db.create_or_update_webdav_directory(&directory_record).await {
Ok(_) => {
debug!("📁 Tracked directory: {} ({} files, {} subdirs, {} bytes, ETag: {})",
dir_path, file_count, direct_subdirs.len(), total_size_bytes, dir_etag);
}
Err(e) => {
warn!("Failed to update directory tracking for {}: {}", dir_path, e);
}
}
}
info!("✅ Completed tracking {} directories at all depth levels", all_directories.len());
}
/// Check if a path is a direct child of a directory (not nested deeper)
pub fn is_direct_child(&self, child_path: &str, parent_path: &str) -> bool {
// Normalize paths by removing trailing slashes
let child_normalized = child_path.trim_end_matches('/');
let parent_normalized = parent_path.trim_end_matches('/');
if !child_normalized.starts_with(parent_normalized) {
return false;
}
// Same path is not a direct child of itself
if child_normalized == parent_normalized {
return false;
}
// Handle root directory case
if parent_normalized.is_empty() || parent_normalized == "/" {
let child_without_leading_slash = child_normalized.trim_start_matches('/');
return !child_without_leading_slash.is_empty() && !child_without_leading_slash.contains('/');
}
// Remove parent path prefix and check if remainder has exactly one more path segment
let remaining = child_normalized.strip_prefix(parent_normalized)
.unwrap_or("")
.trim_start_matches('/');
// Direct child means no more slashes in the remaining path
!remaining.contains('/') && !remaining.is_empty()
}
/// Perform targeted re-scanning of only specific paths that have changed
pub async fn discover_files_targeted_rescan(&self, paths_to_scan: &[String], user_id: uuid::Uuid, state: &crate::AppState) -> Result<Vec<FileInfo>> {
info!("🎯 Starting targeted re-scan for {} specific paths", paths_to_scan.len());
let mut all_files = Vec::new();
for path in paths_to_scan {
info!("🔍 Targeted scan of: {}", path);
// Check if this specific path has changed
match self.check_directory_etag(path).await {
Ok(current_etag) => {
// Check cached ETag
let needs_scan = match state.db.get_webdav_directory(user_id, path).await {
Ok(Some(stored_dir)) => {
if stored_dir.directory_etag != current_etag {
info!("🔄 Path {} changed (old: {}, new: {})", path, stored_dir.directory_etag, current_etag);
true
} else {
debug!("✅ Path {} unchanged (ETag: {})", path, current_etag);
false
}
}
Ok(None) => {
info!("🆕 New path {} detected", path);
true
}
Err(e) => {
warn!("Database error for path {}: {}", path, e);
true // Scan on error to be safe
}
};
if needs_scan {
// Use shallow scan for this specific directory only
match self.discover_files_in_folder_shallow(path).await {
Ok(mut path_files) => {
info!("📂 Found {} files in changed path {}", path_files.len(), path);
all_files.append(&mut path_files);
// Update tracking for this specific path
self.update_single_directory_tracking(path, &path_files, user_id, state).await;
}
Err(e) => {
error!("Failed to scan changed path {}: {}", path, e);
}
}
}
}
Err(e) => {
warn!("Failed to check ETag for path {}: {}, skipping", path, e);
}
}
}
info!("🎯 Targeted re-scan completed: {} total files found", all_files.len());
Ok(all_files)
}
/// Discover files in a single directory only (shallow scan, no recursion)
async fn discover_files_in_folder_shallow(&self, folder_path: &str) -> Result<Vec<FileInfo>> {
let folder_url = format!("{}{}", self.base_webdav_url, folder_path);
debug!("Shallow scan of directory: {}", folder_url);
let propfind_body = r#"<?xml version="1.0"?>
<d:propfind xmlns:d="DAV:">
<d:allprop/>
</d:propfind>"#;
let response = self.client
.request(Method::from_bytes(b"PROPFIND").unwrap(), &folder_url)
.basic_auth(&self.config.username, Some(&self.config.password))
.header("Depth", "1") // Only direct children, not recursive
.header("Content-Type", "application/xml")
.body(propfind_body)
.send()
.await?;
if !response.status().is_success() {
return Err(anyhow!("PROPFIND request failed: {}", response.status()));
}
let response_text = response.text().await?;
debug!("Shallow WebDAV response received, parsing...");
// Use the parser that includes directories for shallow scans
self.parse_webdav_response_with_directories(&response_text)
}
/// Update tracking for a single directory without recursive processing
async fn update_single_directory_tracking(&self, directory_path: &str, files: &[FileInfo], user_id: uuid::Uuid, state: &crate::AppState) {
// Get the directory's own ETag
let dir_etag = files.iter()
.find(|f| f.is_directory && f.path == directory_path)
.map(|f| f.etag.clone())
.unwrap_or_else(|| {
warn!("No ETag found for directory {}, using timestamp-based fallback", directory_path);
chrono::Utc::now().timestamp().to_string()
});
// Count direct files in this directory only
let direct_files: Vec<_> = files.iter()
.filter(|f| !f.is_directory && self.is_direct_child(&f.path, directory_path))
.collect();
let file_count = direct_files.len() as i64;
let total_size_bytes = direct_files.iter().map(|f| f.size).sum::<i64>();
let directory_record = crate::models::CreateWebDAVDirectory {
user_id,
directory_path: directory_path.to_string(),
directory_etag: dir_etag.clone(),
file_count,
total_size_bytes,
};
match state.db.create_or_update_webdav_directory(&directory_record).await {
Ok(_) => {
info!("📊 Updated single directory tracking: {} ({} files, {} bytes, ETag: {})",
directory_path, file_count, total_size_bytes, dir_etag);
}
Err(e) => {
error!("Failed to update single directory tracking for {}: {}", directory_path, e);
}
}
}
/// Get a list of directories that need targeted scanning based on recent changes
pub async fn get_directories_needing_scan(&self, user_id: uuid::Uuid, state: &crate::AppState, max_age_hours: i64) -> Result<Vec<String>> {
let cutoff_time = chrono::Utc::now() - chrono::Duration::hours(max_age_hours);
match state.db.list_webdav_directories(user_id).await {
Ok(directories) => {
let stale_dirs: Vec<String> = directories.iter()
.filter(|dir| dir.last_scanned_at < cutoff_time)
.map(|dir| dir.directory_path.clone())
.collect();
info!("🕒 Found {} directories not scanned in last {} hours", stale_dirs.len(), max_age_hours);
Ok(stale_dirs)
}
Err(e) => {
error!("Failed to get directories needing scan: {}", e);
Err(e.into())
}
}
}
/// Smart sync mode that combines multiple optimization strategies
pub async fn discover_files_smart_sync(&self, watch_folders: &[String], user_id: uuid::Uuid, state: &crate::AppState) -> Result<Vec<FileInfo>> {
info!("🧠 Starting smart sync for {} watch folders", watch_folders.len());
let mut all_files = Vec::new();
for folder_path in watch_folders {
info!("🔍 Smart sync processing folder: {}", folder_path);
// Step 1: Try optimized discovery first (checks directory ETag)
let optimized_result = self.discover_files_in_folder_optimized(folder_path, user_id, state).await;
match optimized_result {
Ok(files) => {
if !files.is_empty() {
info!("✅ Optimized discovery found {} files in {}", files.len(), folder_path);
all_files.extend(files);
} else {
info!("🔍 Directory {} unchanged, checking for stale subdirectories", folder_path);
// Step 2: Check for stale subdirectories that need targeted scanning
let stale_dirs = self.get_stale_subdirectories(folder_path, user_id, state, 24).await?;
if !stale_dirs.is_empty() {
info!("🎯 Found {} stale subdirectories, performing targeted scan", stale_dirs.len());
let targeted_files = self.discover_files_targeted_rescan(&stale_dirs, user_id, state).await?;
all_files.extend(targeted_files);
} else {
info!("✅ All subdirectories of {} are fresh, no scan needed", folder_path);
}
}
}
Err(e) => {
warn!("Optimized discovery failed for {}, falling back to full scan: {}", folder_path, e);
// Fallback to traditional full scan
match self.discover_files_in_folder(folder_path).await {
Ok(files) => {
info!("📂 Fallback scan found {} files in {}", files.len(), folder_path);
all_files.extend(files);
}
Err(fallback_error) => {
error!("Both optimized and fallback scans failed for {}: {}", folder_path, fallback_error);
return Err(fallback_error);
}
}
}
}
}
info!("🧠 Smart sync completed: {} total files discovered", all_files.len());
Ok(all_files)
}
/// Get subdirectories of a parent that haven't been scanned recently
async fn get_stale_subdirectories(&self, parent_path: &str, user_id: uuid::Uuid, state: &crate::AppState, max_age_hours: i64) -> Result<Vec<String>> {
let cutoff_time = chrono::Utc::now() - chrono::Duration::hours(max_age_hours);
match state.db.list_webdav_directories(user_id).await {
Ok(directories) => {
let stale_subdirs: Vec<String> = directories.iter()
.filter(|dir| {
dir.directory_path.starts_with(parent_path) &&
dir.directory_path != parent_path &&
dir.last_scanned_at < cutoff_time
})
.map(|dir| dir.directory_path.clone())
.collect();
debug!("🕒 Found {} stale subdirectories under {} (not scanned in {} hours)",
stale_subdirs.len(), parent_path, max_age_hours);
Ok(stale_subdirs)
}
Err(e) => {
error!("Failed to get stale subdirectories: {}", e);
Err(e.into())
}
}
}
/// Perform incremental sync - only scan directories that have actually changed
pub async fn discover_files_incremental(&self, watch_folders: &[String], user_id: uuid::Uuid, state: &crate::AppState) -> Result<Vec<FileInfo>> {
info!("⚡ Starting incremental sync for {} watch folders", watch_folders.len());
let mut changed_files = Vec::new();
let mut unchanged_count = 0;
let mut changed_count = 0;
for folder_path in watch_folders {
// Check directory ETag to see if it changed
match self.check_directory_etag(folder_path).await {
Ok(current_etag) => {
let needs_scan = match state.db.get_webdav_directory(user_id, folder_path).await {
Ok(Some(stored_dir)) => {
if stored_dir.directory_etag != current_etag {
info!("🔄 Directory {} changed (ETag: {} → {})", folder_path, stored_dir.directory_etag, current_etag);
changed_count += 1;
true
} else {
debug!("✅ Directory {} unchanged (ETag: {})", folder_path, current_etag);
unchanged_count += 1;
false
}
}
Ok(None) => {
info!("🆕 New directory {} detected", folder_path);
changed_count += 1;
true
}
Err(e) => {
warn!("Database error for {}: {}, scanning to be safe", folder_path, e);
changed_count += 1;
true
}
};
if needs_scan {
// Directory changed - perform targeted scan
match self.discover_files_in_folder_optimized(folder_path, user_id, state).await {
Ok(mut files) => {
info!("📂 Incremental scan found {} files in changed directory {}", files.len(), folder_path);
changed_files.append(&mut files);
}
Err(e) => {
error!("Failed incremental scan of {}: {}", folder_path, e);
}
}
} else {
// Directory unchanged - just update scan timestamp
let update = crate::models::UpdateWebDAVDirectory {
directory_etag: current_etag,
last_scanned_at: chrono::Utc::now(),
file_count: 0, // Will be updated by the database layer
total_size_bytes: 0,
};
if let Err(e) = state.db.update_webdav_directory(user_id, folder_path, &update).await {
warn!("Failed to update scan timestamp for {}: {}", folder_path, e);
}
}
}
Err(e) => {
error!("Failed to check directory ETag for {}: {}", folder_path, e);
}
}
}
info!("⚡ Incremental sync completed: {} unchanged, {} changed, {} total files found",
unchanged_count, changed_count, changed_files.len());
Ok(changed_files)
}
/// Check subdirectories individually for changes when parent directory is unchanged
async fn check_subdirectories_for_changes(&self, parent_path: &str, user_id: uuid::Uuid, state: &crate::AppState) -> Result<Vec<FileInfo>> {
// Get all known subdirectories from database
let known_directories = match state.db.list_webdav_directories(user_id).await {
Ok(dirs) => dirs,
Err(e) => {
warn!("Failed to get known directories, falling back to full scan: {}", e);
return self.discover_files_in_folder_impl(parent_path).await;
}
};
// Filter to subdirectories of this parent
let subdirectories: Vec<_> = known_directories.iter()
.filter(|dir| dir.directory_path.starts_with(parent_path) && dir.directory_path != parent_path)
.collect();
if subdirectories.is_empty() {
info!("📁 No known subdirectories for {}, no changes to process", parent_path);
return Ok(Vec::new());
}
info!("🔍 Checking {} known subdirectories for changes", subdirectories.len());
let mut changed_files = Vec::new();
let subdirectory_count = subdirectories.len();
// Check each subdirectory individually
for subdir in subdirectories {
let subdir_path = &subdir.directory_path;
// Check if this subdirectory has changed
match self.check_directory_etag(subdir_path).await {
Ok(current_etag) => {
if current_etag != subdir.directory_etag {
info!("🔄 Subdirectory {} changed (old: {}, new: {}), scanning recursively",
subdir_path, subdir.directory_etag, current_etag);
// This subdirectory changed - get all its files recursively
match self.discover_files_in_folder_impl(subdir_path).await {
Ok(mut subdir_files) => {
info!("📂 Found {} files in changed subdirectory {}", subdir_files.len(), subdir_path);
changed_files.append(&mut subdir_files);
// Update tracking for this subdirectory and its children
self.track_subdirectories_recursively(&subdir_files, user_id, state).await;
}
Err(e) => {
error!("Failed to scan changed subdirectory {}: {}", subdir_path, e);
}
}
} else {
debug!("✅ Subdirectory {} unchanged (ETag: {})", subdir_path, current_etag);
// Update last_scanned_at even for unchanged directories
let update = crate::models::UpdateWebDAVDirectory {
directory_etag: current_etag,
last_scanned_at: chrono::Utc::now(),
file_count: subdir.file_count,
total_size_bytes: subdir.total_size_bytes,
};
if let Err(e) = state.db.update_webdav_directory(user_id, subdir_path, &update).await {
warn!("Failed to update scan time for {}: {}", subdir_path, e);
}
}
}
Err(e) => {
warn!("Failed to check ETag for subdirectory {}: {}", subdir_path, e);
// Don't fail the entire operation, just log and continue
}
}
}
info!("🎯 Found {} changed files across {} subdirectories", changed_files.len(), subdirectory_count);
Ok(changed_files)
}
/// Check directory ETag without performing deep scan - used for optimization
pub async fn check_directory_etag(&self, folder_path: &str) -> Result<String> {
self.retry_with_backoff("check_directory_etag", || {
self.check_directory_etag_impl(folder_path)
}).await
}
async fn check_directory_etag_impl(&self, folder_path: &str) -> Result<String> {
let folder_url = format!("{}{}", self.base_webdav_url, folder_path);
debug!("Checking directory ETag for: {}", folder_url);
let propfind_body = r#"<?xml version="1.0"?>
<d:propfind xmlns:d="DAV:">
<d:prop>
<d:getetag/>
</d:prop>
</d:propfind>"#;
let response = self.client
.request(Method::from_bytes(b"PROPFIND").unwrap(), &folder_url)
.basic_auth(&self.config.username, Some(&self.config.password))
.header("Depth", "0") // Only check the directory itself, not contents
.header("Content-Type", "application/xml")
.body(propfind_body)
.send()
.await?;
if !response.status().is_success() {
return Err(anyhow!("PROPFIND request failed: {}", response.status()));
}
let response_text = response.text().await?;
debug!("Directory ETag response received, parsing...");
// Parse the response to extract directory ETag
self.parse_directory_etag(&response_text)
}
pub fn parse_directory_etag(&self, xml_text: &str) -> Result<String> {
use quick_xml::events::Event;
use quick_xml::reader::Reader;
let mut reader = Reader::from_str(xml_text);
reader.config_mut().trim_text(true);
let mut current_element = String::new();
let mut etag = String::new();
let mut buf = Vec::new();
loop {
match reader.read_event_into(&mut buf) {
Ok(Event::Start(e)) | Ok(Event::Empty(e)) => {
let local_name = e.local_name();
let name = std::str::from_utf8(local_name.as_ref())?;
current_element = name.to_lowercase();
}
Ok(Event::Text(e)) => {
if current_element == "getetag" {
etag = e.unescape()?.to_string();
break;
}
}
Ok(Event::End(_)) => {
current_element.clear();
}
Ok(Event::Eof) => break,
Err(e) => return Err(anyhow!("XML parsing error: {}", e)),
_ => {}
}
}
if etag.is_empty() {
return Err(anyhow!("No ETag found in directory response"));
}
// Use existing ETag normalization function from parser module
let normalized_etag = crate::webdav_xml_parser::normalize_etag(&etag);
debug!("Directory ETag: {}", normalized_etag);
Ok(normalized_etag)
}
async fn discover_files_in_folder_impl(&self, folder_path: &str) -> Result<Vec<FileInfo>> {
let folder_url = format!("{}{}", self.base_webdav_url, folder_path);
@ -449,6 +1107,12 @@ impl WebDAVService {
parse_propfind_response(xml_text)
}
/// Parse WebDAV response including both files and directories
/// Used for shallow directory scans where we need to track directory structure
pub fn parse_webdav_response_with_directories(&self, xml_text: &str) -> Result<Vec<FileInfo>> {
parse_propfind_response_with_directories(xml_text)
}
pub async fn download_file(&self, file_path: &str) -> Result<Vec<u8>> {
self.retry_with_backoff("download_file", || {
self.download_file_impl(file_path)

View File

@ -246,6 +246,225 @@ pub fn parse_propfind_response(xml_text: &str) -> Result<Vec<FileInfo>> {
Ok(files)
}
/// Parse PROPFIND response including both files and directories
/// This is used for shallow directory scans where we need to track directory structure
pub fn parse_propfind_response_with_directories(xml_text: &str) -> Result<Vec<FileInfo>> {
let mut reader = Reader::from_str(xml_text);
reader.config_mut().trim_text(true);
let mut files = Vec::new();
let mut current_response: Option<PropFindResponse> = None;
let mut current_element = String::new();
let mut in_response = false;
let mut in_propstat = false;
let mut in_prop = false;
let mut in_resourcetype = false;
let mut status_ok = false;
let mut buf = Vec::new();
loop {
match reader.read_event_into(&mut buf) {
Ok(Event::Start(e)) | Ok(Event::Empty(e)) => {
let name = get_local_name(&e)?;
match name.as_str() {
"response" => {
in_response = true;
current_response = Some(PropFindResponse::default());
}
"propstat" => {
in_propstat = true;
}
"prop" => {
in_prop = true;
}
"resourcetype" => {
in_resourcetype = true;
}
"collection" if in_resourcetype => {
if let Some(ref mut resp) = current_response {
resp.is_collection = true;
}
}
_ => {
current_element = name;
}
}
}
Ok(Event::Text(e)) => {
let text = e.unescape()?.to_string();
if in_response && !text.trim().is_empty() {
if let Some(ref mut resp) = current_response {
match current_element.as_str() {
"href" => {
resp.href = text.trim().to_string();
}
"displayname" => {
resp.displayname = text.trim().to_string();
}
"getcontentlength" => {
resp.content_length = text.trim().parse().ok();
}
"getlastmodified" => {
resp.last_modified = Some(text.trim().to_string());
}
"getcontenttype" => {
resp.content_type = Some(text.trim().to_string());
}
"getetag" => {
resp.etag = Some(normalize_etag(&text));
}
"creationdate" => {
resp.creation_date = Some(text.trim().to_string());
}
"owner" => {
resp.owner = Some(text.trim().to_string());
}
"group" => {
resp.group = Some(text.trim().to_string());
}
"status" if in_propstat => {
// Check if status is 200 OK
if text.contains("200") {
status_ok = true;
}
}
_ => {
// Store any other properties as generic metadata
if !text.trim().is_empty() && in_prop {
if resp.metadata.is_none() {
resp.metadata = Some(serde_json::Value::Object(serde_json::Map::new()));
}
if let Some(serde_json::Value::Object(ref mut map)) = resp.metadata {
match current_element.as_str() {
"permissions" | "oc:permissions" => {
resp.permissions = Some(text.trim().to_string());
map.insert("permissions_raw".to_string(), serde_json::Value::String(text.trim().to_string()));
}
"fileid" | "oc:fileid" => {
map.insert("file_id".to_string(), serde_json::Value::String(text.trim().to_string()));
}
"owner-id" | "oc:owner-id" => {
map.insert("owner_id".to_string(), serde_json::Value::String(text.trim().to_string()));
}
"owner-display-name" | "oc:owner-display-name" => {
resp.owner_display_name = Some(text.trim().to_string());
map.insert("owner_display_name".to_string(), serde_json::Value::String(text.trim().to_string()));
}
"has-preview" | "nc:has-preview" => {
if let Ok(val) = text.trim().parse::<bool>() {
map.insert("has_preview".to_string(), serde_json::Value::Bool(val));
}
}
_ => {
map.insert(current_element.clone(), serde_json::Value::String(text.trim().to_string()));
}
}
}
}
}
}
}
}
}
Ok(Event::End(e)) => {
let name = get_local_name_from_end(&e)?;
match name.as_str() {
"response" => {
if let Some(resp) = current_response.take() {
// Include both files AND directories with valid properties
if status_ok && !resp.href.is_empty() {
// Extract name from href
let name = if resp.displayname.is_empty() {
resp.href
.split('/')
.filter(|s| !s.is_empty())
.last()
.unwrap_or("")
.to_string()
} else {
resp.displayname.clone()
};
// Decode URL-encoded characters
let name = urlencoding::decode(&name)
.unwrap_or_else(|_| std::borrow::Cow::Borrowed(&name))
.to_string();
// Parse creation date
let created_at = resp.creation_date
.as_ref()
.and_then(|d| parse_http_date(d));
// Parse permissions
let permissions_int = resp.permissions
.as_ref()
.and_then(|p| {
if p.chars().all(|c| c.is_uppercase()) {
let mut perms = 0u32;
if p.contains('R') { perms |= 0o444; }
if p.contains('W') { perms |= 0o222; }
if p.contains('D') { perms |= 0o111; }
Some(perms)
} else {
p.parse().ok()
}
});
let file_info = FileInfo {
path: resp.href.clone(),
name,
size: resp.content_length.unwrap_or(0),
mime_type: if resp.is_collection {
"".to_string()
} else {
resp.content_type.unwrap_or_else(|| "application/octet-stream".to_string())
},
last_modified: parse_http_date(&resp.last_modified.unwrap_or_default()),
etag: resp.etag.unwrap_or_else(|| format!("\"{}\"", uuid::Uuid::new_v4())),
is_directory: resp.is_collection,
created_at,
permissions: permissions_int,
owner: resp.owner.or(resp.owner_display_name),
group: resp.group,
metadata: resp.metadata,
};
files.push(file_info);
}
}
in_response = false;
status_ok = false;
}
"propstat" => {
in_propstat = false;
}
"prop" => {
in_prop = false;
}
"resourcetype" => {
in_resourcetype = false;
}
_ => {}
}
current_element.clear();
}
Ok(Event::Eof) => break,
Err(e) => return Err(anyhow!("XML parsing error: {}", e)),
_ => {}
}
buf.clear();
}
Ok(files)
}
fn get_local_name(e: &BytesStart) -> Result<String> {
let qname = e.name();
let local = qname.local_name();
@ -292,9 +511,10 @@ fn parse_http_date(date_str: &str) -> Option<DateTime<Utc>> {
/// - `"abc123"` → `abc123`
/// - `W/"abc123"` → `abc123`
/// - `abc123` → `abc123`
fn normalize_etag(etag: &str) -> String {
pub fn normalize_etag(etag: &str) -> String {
etag.trim()
.trim_start_matches("W/")
.trim()
.trim_matches('"')
.to_string()
}

View File

@ -0,0 +1,447 @@
use readur::services::webdav_service::{WebDAVService, WebDAVConfig};
use readur::models::FileInfo;
use tokio;
use chrono::Utc;
// Helper function to create test WebDAV service
fn create_test_webdav_service() -> WebDAVService {
let config = WebDAVConfig {
server_url: "https://test.example.com".to_string(),
username: "testuser".to_string(),
password: "testpass".to_string(),
watch_folders: vec!["/Documents".to_string()],
file_extensions: vec!["pdf".to_string(), "png".to_string()],
timeout_seconds: 30,
server_type: Some("nextcloud".to_string()),
};
WebDAVService::new(config).unwrap()
}
// Mock XML response for directory ETag check
fn mock_directory_etag_response(etag: &str) -> String {
format!(r#"<?xml version="1.0"?>
<d:multistatus xmlns:d="DAV:">
<d:response>
<d:href>/remote.php/dav/files/admin/Documents/</d:href>
<d:propstat>
<d:prop>
<d:getetag>"{}"</d:getetag>
</d:prop>
<d:status>HTTP/1.1 200 OK</d:status>
</d:propstat>
</d:response>
</d:multistatus>"#, etag)
}
// Mock complex nested directory structure
fn mock_nested_directory_files() -> Vec<FileInfo> {
vec![
// Root directory
FileInfo {
path: "/Documents".to_string(),
name: "Documents".to_string(),
size: 0,
mime_type: "".to_string(),
last_modified: Some(Utc::now()),
etag: "root-etag-123".to_string(),
is_directory: true,
created_at: Some(Utc::now()),
permissions: Some(755),
owner: Some("admin".to_string()),
group: Some("admin".to_string()),
metadata: None,
},
// Level 1 directories
FileInfo {
path: "/Documents/2024".to_string(),
name: "2024".to_string(),
size: 0,
mime_type: "".to_string(),
last_modified: Some(Utc::now()),
etag: "2024-etag-456".to_string(),
is_directory: true,
created_at: Some(Utc::now()),
permissions: Some(755),
owner: Some("admin".to_string()),
group: Some("admin".to_string()),
metadata: None,
},
FileInfo {
path: "/Documents/Archive".to_string(),
name: "Archive".to_string(),
size: 0,
mime_type: "".to_string(),
last_modified: Some(Utc::now()),
etag: "archive-etag-789".to_string(),
is_directory: true,
created_at: Some(Utc::now()),
permissions: Some(755),
owner: Some("admin".to_string()),
group: Some("admin".to_string()),
metadata: None,
},
// Level 2 directories
FileInfo {
path: "/Documents/2024/Q1".to_string(),
name: "Q1".to_string(),
size: 0,
mime_type: "".to_string(),
last_modified: Some(Utc::now()),
etag: "q1-etag-101".to_string(),
is_directory: true,
created_at: Some(Utc::now()),
permissions: Some(755),
owner: Some("admin".to_string()),
group: Some("admin".to_string()),
metadata: None,
},
FileInfo {
path: "/Documents/2024/Q2".to_string(),
name: "Q2".to_string(),
size: 0,
mime_type: "".to_string(),
last_modified: Some(Utc::now()),
etag: "q2-etag-102".to_string(),
is_directory: true,
created_at: Some(Utc::now()),
permissions: Some(755),
owner: Some("admin".to_string()),
group: Some("admin".to_string()),
metadata: None,
},
// Level 3 directory
FileInfo {
path: "/Documents/2024/Q1/Reports".to_string(),
name: "Reports".to_string(),
size: 0,
mime_type: "".to_string(),
last_modified: Some(Utc::now()),
etag: "reports-etag-201".to_string(),
is_directory: true,
created_at: Some(Utc::now()),
permissions: Some(755),
owner: Some("admin".to_string()),
group: Some("admin".to_string()),
metadata: None,
},
// Files at various levels
FileInfo {
path: "/Documents/root-file.pdf".to_string(),
name: "root-file.pdf".to_string(),
size: 1024000,
mime_type: "application/pdf".to_string(),
last_modified: Some(Utc::now()),
etag: "root-file-etag".to_string(),
is_directory: false,
created_at: Some(Utc::now()),
permissions: Some(644),
owner: Some("admin".to_string()),
group: Some("admin".to_string()),
metadata: None,
},
FileInfo {
path: "/Documents/2024/annual-report.pdf".to_string(),
name: "annual-report.pdf".to_string(),
size: 2048000,
mime_type: "application/pdf".to_string(),
last_modified: Some(Utc::now()),
etag: "annual-report-etag".to_string(),
is_directory: false,
created_at: Some(Utc::now()),
permissions: Some(644),
owner: Some("admin".to_string()),
group: Some("admin".to_string()),
metadata: None,
},
FileInfo {
path: "/Documents/2024/Q1/q1-summary.pdf".to_string(),
name: "q1-summary.pdf".to_string(),
size: 512000,
mime_type: "application/pdf".to_string(),
last_modified: Some(Utc::now()),
etag: "q1-summary-etag".to_string(),
is_directory: false,
created_at: Some(Utc::now()),
permissions: Some(644),
owner: Some("admin".to_string()),
group: Some("admin".to_string()),
metadata: None,
},
FileInfo {
path: "/Documents/2024/Q1/Reports/detailed-report.pdf".to_string(),
name: "detailed-report.pdf".to_string(),
size: 4096000,
mime_type: "application/pdf".to_string(),
last_modified: Some(Utc::now()),
etag: "detailed-report-etag".to_string(),
is_directory: false,
created_at: Some(Utc::now()),
permissions: Some(644),
owner: Some("admin".to_string()),
group: Some("admin".to_string()),
metadata: None,
},
FileInfo {
path: "/Documents/Archive/old-document.pdf".to_string(),
name: "old-document.pdf".to_string(),
size: 256000,
mime_type: "application/pdf".to_string(),
last_modified: Some(Utc::now()),
etag: "old-document-etag".to_string(),
is_directory: false,
created_at: Some(Utc::now()),
permissions: Some(644),
owner: Some("admin".to_string()),
group: Some("admin".to_string()),
metadata: None,
},
]
}
#[tokio::test]
async fn test_parse_directory_etag() {
let service = create_test_webdav_service();
// Test parsing a simple directory ETag response
let xml_response = mock_directory_etag_response("test-etag-123");
let etag = service.parse_directory_etag(&xml_response).unwrap();
assert_eq!(etag, "test-etag-123");
}
#[tokio::test]
async fn test_parse_directory_etag_with_quotes() {
let service = create_test_webdav_service();
// Test ETag normalization (removing quotes)
let xml_response = r#"<?xml version="1.0"?>
<d:multistatus xmlns:d="DAV:">
<d:response>
<d:href>/remote.php/dav/files/admin/Documents/</d:href>
<d:propstat>
<d:prop>
<d:getetag>"quoted-etag-456"</d:getetag>
</d:prop>
<d:status>HTTP/1.1 200 OK</d:status>
</d:propstat>
</d:response>
</d:multistatus>"#;
let etag = service.parse_directory_etag(xml_response).unwrap();
assert_eq!(etag, "quoted-etag-456");
}
#[tokio::test]
async fn test_parse_directory_etag_weak_etag() {
let service = create_test_webdav_service();
// Test weak ETag normalization
let xml_response = r#"<?xml version="1.0"?>
<d:multistatus xmlns:d="DAV:">
<d:response>
<d:href>/remote.php/dav/files/admin/Documents/</d:href>
<d:propstat>
<d:prop>
<d:getetag>W/"weak-etag-789"</d:getetag>
</d:prop>
<d:status>HTTP/1.1 200 OK</d:status>
</d:propstat>
</d:response>
</d:multistatus>"#;
let etag = service.parse_directory_etag(xml_response).unwrap();
assert_eq!(etag, "weak-etag-789");
}
#[tokio::test]
async fn test_is_direct_child() {
let service = create_test_webdav_service();
// Test direct child detection
assert!(service.is_direct_child("/Documents/file.pdf", "/Documents"));
assert!(service.is_direct_child("/Documents/subfolder", "/Documents"));
// Test non-direct children (nested deeper)
assert!(!service.is_direct_child("/Documents/2024/file.pdf", "/Documents"));
assert!(!service.is_direct_child("/Documents/2024/Q1/file.pdf", "/Documents"));
// Test root directory edge case
assert!(service.is_direct_child("/Documents", ""));
assert!(service.is_direct_child("/Documents", "/"));
assert!(!service.is_direct_child("/Documents/file.pdf", ""));
// Test non-matching paths
assert!(!service.is_direct_child("/Other/file.pdf", "/Documents"));
assert!(!service.is_direct_child("/Documenting/file.pdf", "/Documents")); // prefix but not child
}
#[tokio::test]
async fn test_track_subdirectories_recursively_structure() {
// This test verifies the directory extraction logic without database operations
let files = mock_nested_directory_files();
// Extract directories that should be tracked
let mut expected_directories = std::collections::BTreeSet::new();
expected_directories.insert("/Documents".to_string());
expected_directories.insert("/Documents/2024".to_string());
expected_directories.insert("/Documents/Archive".to_string());
expected_directories.insert("/Documents/2024/Q1".to_string());
expected_directories.insert("/Documents/2024/Q2".to_string());
expected_directories.insert("/Documents/2024/Q1/Reports".to_string());
// This tests the directory extraction logic that happens in track_subdirectories_recursively
let mut all_directories = std::collections::BTreeSet::new();
for file in &files {
if file.is_directory {
all_directories.insert(file.path.clone());
} else {
// Extract all parent directories from file paths
let mut path_parts: Vec<&str> = file.path.split('/').collect();
path_parts.pop(); // Remove the filename
// Build directory paths from root down to immediate parent
let mut current_path = String::new();
for part in path_parts {
if !part.is_empty() {
if !current_path.is_empty() {
current_path.push('/');
} else {
// Start with leading slash for absolute paths
current_path.push('/');
}
current_path.push_str(part);
all_directories.insert(current_path.clone());
}
}
}
}
assert_eq!(all_directories, expected_directories);
}
#[tokio::test]
async fn test_direct_file_counting() {
let service = create_test_webdav_service();
let files = mock_nested_directory_files();
// Test counting direct files in root directory
let direct_files_root: Vec<_> = files.iter()
.filter(|f| !f.is_directory && service.is_direct_child(&f.path, "/Documents"))
.collect();
assert_eq!(direct_files_root.len(), 1); // Only root-file.pdf
assert_eq!(direct_files_root[0].name, "root-file.pdf");
// Test counting direct files in /Documents/2024
let direct_files_2024: Vec<_> = files.iter()
.filter(|f| !f.is_directory && service.is_direct_child(&f.path, "/Documents/2024"))
.collect();
assert_eq!(direct_files_2024.len(), 1); // Only annual-report.pdf
assert_eq!(direct_files_2024[0].name, "annual-report.pdf");
// Test counting direct files in /Documents/2024/Q1
let direct_files_q1: Vec<_> = files.iter()
.filter(|f| !f.is_directory && service.is_direct_child(&f.path, "/Documents/2024/Q1"))
.collect();
assert_eq!(direct_files_q1.len(), 1); // Only q1-summary.pdf
assert_eq!(direct_files_q1[0].name, "q1-summary.pdf");
// Test counting direct files in deep directory
let direct_files_reports: Vec<_> = files.iter()
.filter(|f| !f.is_directory && service.is_direct_child(&f.path, "/Documents/2024/Q1/Reports"))
.collect();
assert_eq!(direct_files_reports.len(), 1); // Only detailed-report.pdf
assert_eq!(direct_files_reports[0].name, "detailed-report.pdf");
// Test empty directory
let direct_files_q2: Vec<_> = files.iter()
.filter(|f| !f.is_directory && service.is_direct_child(&f.path, "/Documents/2024/Q2"))
.collect();
assert_eq!(direct_files_q2.len(), 0); // No direct files in Q2
}
#[tokio::test]
async fn test_direct_subdirectory_counting() {
let service = create_test_webdav_service();
let files = mock_nested_directory_files();
// Test counting direct subdirectories in root
let direct_subdirs_root: Vec<_> = files.iter()
.filter(|f| f.is_directory && service.is_direct_child(&f.path, "/Documents"))
.collect();
assert_eq!(direct_subdirs_root.len(), 2); // 2024 and Archive
// Test counting direct subdirectories in /Documents/2024
let direct_subdirs_2024: Vec<_> = files.iter()
.filter(|f| f.is_directory && service.is_direct_child(&f.path, "/Documents/2024"))
.collect();
assert_eq!(direct_subdirs_2024.len(), 2); // Q1 and Q2
// Test counting direct subdirectories in /Documents/2024/Q1
let direct_subdirs_q1: Vec<_> = files.iter()
.filter(|f| f.is_directory && service.is_direct_child(&f.path, "/Documents/2024/Q1"))
.collect();
assert_eq!(direct_subdirs_q1.len(), 1); // Reports
// Test leaf directory (no subdirectories)
let direct_subdirs_reports: Vec<_> = files.iter()
.filter(|f| f.is_directory && service.is_direct_child(&f.path, "/Documents/2024/Q1/Reports"))
.collect();
assert_eq!(direct_subdirs_reports.len(), 0); // No subdirectories in Reports
}
#[tokio::test]
async fn test_size_calculation_per_directory() {
let service = create_test_webdav_service();
let files = mock_nested_directory_files();
// Calculate total size for each directory's direct files
let root_size: i64 = files.iter()
.filter(|f| !f.is_directory && service.is_direct_child(&f.path, "/Documents"))
.map(|f| f.size)
.sum();
assert_eq!(root_size, 1024000); // root-file.pdf
let q1_size: i64 = files.iter()
.filter(|f| !f.is_directory && service.is_direct_child(&f.path, "/Documents/2024/Q1"))
.map(|f| f.size)
.sum();
assert_eq!(q1_size, 512000); // q1-summary.pdf
let reports_size: i64 = files.iter()
.filter(|f| !f.is_directory && service.is_direct_child(&f.path, "/Documents/2024/Q1/Reports"))
.map(|f| f.size)
.sum();
assert_eq!(reports_size, 4096000); // detailed-report.pdf
let archive_size: i64 = files.iter()
.filter(|f| !f.is_directory && service.is_direct_child(&f.path, "/Documents/Archive"))
.map(|f| f.size)
.sum();
assert_eq!(archive_size, 256000); // old-document.pdf
}
#[tokio::test]
async fn test_edge_cases() {
let service = create_test_webdav_service();
// Test empty paths
assert!(!service.is_direct_child("", "/Documents"));
assert!(service.is_direct_child("/Documents", ""));
// Test identical paths
assert!(!service.is_direct_child("/Documents", "/Documents"));
// Test path with trailing slashes
assert!(service.is_direct_child("/Documents/file.pdf", "/Documents/"));
// Test paths that are prefix but not parent
assert!(!service.is_direct_child("/DocumentsBackup/file.pdf", "/Documents"));
// Test deeply nested paths
let deep_path = "/Documents/a/b/c/d/e/f/g/h/i/j/file.pdf";
assert!(!service.is_direct_child(deep_path, "/Documents"));
assert!(!service.is_direct_child(deep_path, "/Documents/a"));
assert!(service.is_direct_child(deep_path, "/Documents/a/b/c/d/e/f/g/h/i/j"));
}

View File

@ -0,0 +1,621 @@
use readur::services::webdav_service::{WebDAVService, WebDAVConfig};
use readur::models::FileInfo;
use tokio;
use chrono::Utc;
// Helper function to create test WebDAV service
fn create_test_webdav_service() -> WebDAVService {
let config = WebDAVConfig {
server_url: "https://test.example.com".to_string(),
username: "testuser".to_string(),
password: "testpass".to_string(),
watch_folders: vec!["/Documents".to_string()],
file_extensions: vec!["pdf".to_string(), "png".to_string()],
timeout_seconds: 30,
server_type: Some("nextcloud".to_string()),
};
WebDAVService::new(config).unwrap()
}
#[tokio::test]
async fn test_empty_directory_tracking() {
let service = create_test_webdav_service();
// Test completely empty directory
let empty_files: Vec<FileInfo> = vec![];
// Test the directory extraction logic that happens in track_subdirectories_recursively
let mut all_directories = std::collections::BTreeSet::new();
for file in &empty_files {
if file.is_directory {
all_directories.insert(file.path.clone());
} else {
let mut path_parts: Vec<&str> = file.path.split('/').collect();
path_parts.pop();
let mut current_path = String::new();
for part in path_parts {
if !part.is_empty() {
if !current_path.is_empty() {
current_path.push('/');
}
current_path.push_str(part);
all_directories.insert(current_path.clone());
}
}
}
}
assert!(all_directories.is_empty(), "Empty file list should result in no directories");
}
#[tokio::test]
async fn test_directory_only_structure() {
let service = create_test_webdav_service();
// Test structure with only directories, no files
let directory_only_files = vec![
FileInfo {
path: "/Documents".to_string(),
name: "Documents".to_string(),
size: 0,
mime_type: "".to_string(),
last_modified: Some(Utc::now()),
etag: "docs-etag".to_string(),
is_directory: true,
created_at: Some(Utc::now()),
permissions: Some(755),
owner: Some("admin".to_string()),
group: Some("admin".to_string()),
metadata: None,
},
FileInfo {
path: "/Documents/Empty1".to_string(),
name: "Empty1".to_string(),
size: 0,
mime_type: "".to_string(),
last_modified: Some(Utc::now()),
etag: "empty1-etag".to_string(),
is_directory: true,
created_at: Some(Utc::now()),
permissions: Some(755),
owner: Some("admin".to_string()),
group: Some("admin".to_string()),
metadata: None,
},
FileInfo {
path: "/Documents/Empty2".to_string(),
name: "Empty2".to_string(),
size: 0,
mime_type: "".to_string(),
last_modified: Some(Utc::now()),
etag: "empty2-etag".to_string(),
is_directory: true,
created_at: Some(Utc::now()),
permissions: Some(755),
owner: Some("admin".to_string()),
group: Some("admin".to_string()),
metadata: None,
},
];
// Test file counting for empty directories
let root_files: Vec<_> = directory_only_files.iter()
.filter(|f| !f.is_directory && service.is_direct_child(&f.path, "/Documents"))
.collect();
assert_eq!(root_files.len(), 0, "Root directory should have no files");
let empty1_files: Vec<_> = directory_only_files.iter()
.filter(|f| !f.is_directory && service.is_direct_child(&f.path, "/Documents/Empty1"))
.collect();
assert_eq!(empty1_files.len(), 0, "Empty1 directory should have no files");
// Test subdirectory counting
let root_subdirs: Vec<_> = directory_only_files.iter()
.filter(|f| f.is_directory && service.is_direct_child(&f.path, "/Documents"))
.collect();
assert_eq!(root_subdirs.len(), 2, "Root should have 2 subdirectories");
// Test size calculation for empty directories
let root_size: i64 = directory_only_files.iter()
.filter(|f| !f.is_directory && service.is_direct_child(&f.path, "/Documents"))
.map(|f| f.size)
.sum();
assert_eq!(root_size, 0, "Empty directory should have zero total size");
}
#[tokio::test]
async fn test_very_deep_nesting() {
let service = create_test_webdav_service();
// Create a very deeply nested structure (10 levels deep)
let deep_path = "/Documents/L1/L2/L3/L4/L5/L6/L7/L8/L9/L10";
let file_path = format!("{}/deep-file.pdf", deep_path);
let deep_files = vec![
// All directories in the path
FileInfo {
path: "/Documents".to_string(),
name: "Documents".to_string(),
size: 0,
mime_type: "".to_string(),
last_modified: Some(Utc::now()),
etag: "docs-etag".to_string(),
is_directory: true,
created_at: Some(Utc::now()),
permissions: Some(755),
owner: Some("admin".to_string()),
group: Some("admin".to_string()),
metadata: None,
},
// All intermediate directories from L1 to L10
FileInfo {
path: "/Documents/L1".to_string(),
name: "L1".to_string(),
size: 0,
mime_type: "".to_string(),
last_modified: Some(Utc::now()),
etag: "l1-etag".to_string(),
is_directory: true,
created_at: Some(Utc::now()),
permissions: Some(755),
owner: Some("admin".to_string()),
group: Some("admin".to_string()),
metadata: None,
},
FileInfo {
path: "/Documents/L1/L2".to_string(),
name: "L2".to_string(),
size: 0,
mime_type: "".to_string(),
last_modified: Some(Utc::now()),
etag: "l2-etag".to_string(),
is_directory: true,
created_at: Some(Utc::now()),
permissions: Some(755),
owner: Some("admin".to_string()),
group: Some("admin".to_string()),
metadata: None,
},
FileInfo {
path: "/Documents/L1/L2/L3".to_string(),
name: "L3".to_string(),
size: 0,
mime_type: "".to_string(),
last_modified: Some(Utc::now()),
etag: "l3-etag".to_string(),
is_directory: true,
created_at: Some(Utc::now()),
permissions: Some(755),
owner: Some("admin".to_string()),
group: Some("admin".to_string()),
metadata: None,
},
FileInfo {
path: deep_path.to_string(),
name: "L10".to_string(),
size: 0,
mime_type: "".to_string(),
last_modified: Some(Utc::now()),
etag: "l10-etag".to_string(),
is_directory: true,
created_at: Some(Utc::now()),
permissions: Some(755),
owner: Some("admin".to_string()),
group: Some("admin".to_string()),
metadata: None,
},
// File at the deepest level
FileInfo {
path: file_path.clone(),
name: "deep-file.pdf".to_string(),
size: 1024000,
mime_type: "application/pdf".to_string(),
last_modified: Some(Utc::now()),
etag: "deep-file-etag".to_string(),
is_directory: false,
created_at: Some(Utc::now()),
permissions: Some(644),
owner: Some("admin".to_string()),
group: Some("admin".to_string()),
metadata: None,
},
];
// Test is_direct_child for deep paths
assert!(service.is_direct_child(&file_path, deep_path), "File should be direct child of deepest directory");
assert!(!service.is_direct_child(&file_path, "/Documents"), "File should not be direct child of root");
assert!(!service.is_direct_child(&file_path, "/Documents/L1"), "File should not be direct child of L1");
// Test directory extraction from deep file path
let mut all_directories = std::collections::BTreeSet::new();
for file in &deep_files {
if file.is_directory {
all_directories.insert(file.path.clone());
} else {
let mut path_parts: Vec<&str> = file.path.split('/').collect();
path_parts.pop(); // Remove filename
let mut current_path = String::new();
for part in path_parts {
if !part.is_empty() {
if !current_path.is_empty() {
current_path.push('/');
}
current_path.push_str(part);
all_directories.insert(current_path.clone());
}
}
}
}
// Should extract all intermediate directories
assert!(all_directories.contains("/Documents"));
assert!(all_directories.contains("/Documents/L1"));
assert!(all_directories.contains("/Documents/L1/L2"));
assert!(all_directories.contains(deep_path));
assert!(all_directories.len() >= 11, "Should track all intermediate directories"); // /Documents + L1 + L2 + L3 + L10 + extracted from file path = 11+ directories total
}
#[tokio::test]
async fn test_special_characters_in_paths() {
let service = create_test_webdav_service();
// Test paths with special characters, spaces, unicode
let special_files = vec![
FileInfo {
path: "/Documents/Folder with spaces".to_string(),
name: "Folder with spaces".to_string(),
size: 0,
mime_type: "".to_string(),
last_modified: Some(Utc::now()),
etag: "spaces-etag".to_string(),
is_directory: true,
created_at: Some(Utc::now()),
permissions: Some(755),
owner: Some("admin".to_string()),
group: Some("admin".to_string()),
metadata: None,
},
FileInfo {
path: "/Documents/Folder-with-dashes".to_string(),
name: "Folder-with-dashes".to_string(),
size: 0,
mime_type: "".to_string(),
last_modified: Some(Utc::now()),
etag: "dashes-etag".to_string(),
is_directory: true,
created_at: Some(Utc::now()),
permissions: Some(755),
owner: Some("admin".to_string()),
group: Some("admin".to_string()),
metadata: None,
},
FileInfo {
path: "/Documents/Документы".to_string(), // Cyrillic
name: "Документы".to_string(),
size: 0,
mime_type: "".to_string(),
last_modified: Some(Utc::now()),
etag: "cyrillic-etag".to_string(),
is_directory: true,
created_at: Some(Utc::now()),
permissions: Some(755),
owner: Some("admin".to_string()),
group: Some("admin".to_string()),
metadata: None,
},
FileInfo {
path: "/Documents/Folder with spaces/file with spaces.pdf".to_string(),
name: "file with spaces.pdf".to_string(),
size: 1024000,
mime_type: "application/pdf".to_string(),
last_modified: Some(Utc::now()),
etag: "space-file-etag".to_string(),
is_directory: false,
created_at: Some(Utc::now()),
permissions: Some(644),
owner: Some("admin".to_string()),
group: Some("admin".to_string()),
metadata: None,
},
];
// Test is_direct_child with special characters
assert!(service.is_direct_child("/Documents/Folder with spaces/file with spaces.pdf", "/Documents/Folder with spaces"));
assert!(service.is_direct_child("/Documents/Folder with spaces", "/Documents"));
assert!(service.is_direct_child("/Documents/Документы", "/Documents"));
// Test file counting with special characters
let spaces_folder_files: Vec<_> = special_files.iter()
.filter(|f| !f.is_directory && service.is_direct_child(&f.path, "/Documents/Folder with spaces"))
.collect();
assert_eq!(spaces_folder_files.len(), 1);
assert_eq!(spaces_folder_files[0].name, "file with spaces.pdf");
}
#[tokio::test]
async fn test_edge_case_path_patterns() {
let service = create_test_webdav_service();
// Test various edge case paths
let edge_case_tests = vec![
// (child_path, parent_path, expected_result)
("/Documents/file.pdf", "/Documents", true),
("/Documents/", "/Documents", false), // Same path
("/Documents", "/Documents", false), // Same path
("/Documents/subfolder/", "/Documents", true), // Trailing slash
("/Documents/subfolder", "/Documents/", true), // Parent with trailing slash
("/Documenting/file.pdf", "/Documents", false), // Prefix but not parent
("/Documents/file.pdf", "/Doc", false), // Partial parent match
("", "/Documents", false), // Empty child
("/Documents/file.pdf", "", false), // Not direct child of root (nested in Documents)
("/file.pdf", "", true), // Root level file
("/Documents/file.pdf", "/", false), // Not direct child of root (nested in Documents)
("/file.pdf", "/", true), // Root level file with slash parent
("//Documents//file.pdf", "/Documents", false), // Double slashes (malformed)
("/Documents/./file.pdf", "/Documents", false), // Dot notation (should be normalized first)
("/Documents/../file.pdf", "", false), // Parent notation (should be normalized first)
];
for (child, parent, expected) in edge_case_tests {
let result = service.is_direct_child(child, parent);
assert_eq!(
result, expected,
"is_direct_child('{}', '{}') expected {}, got {}",
child, parent, expected, result
);
}
}
#[tokio::test]
async fn test_etag_normalization_edge_cases() {
let service = create_test_webdav_service();
// Test various ETag format edge cases
let etag_test_cases = vec![
(r#""simple-etag""#, "simple-etag"),
(r#"W/"weak-etag""#, "weak-etag"),
(r#"no-quotes"#, "no-quotes"),
(r#""""#, ""), // Empty quoted string
(r#""#, ""), // Single quote
(r#"W/"""#, ""), // Weak etag with empty quotes
(r#" " spaced-etag " "#, " spaced-etag "), // Extra whitespace around quotes
(r#"W/ "weak-with-spaces" "#, "weak-with-spaces"),
(r#""etag-with-"internal"-quotes""#, r#"etag-with-"internal"-quotes"#), // Internal quotes
(r#""unicode-ж-etag""#, "unicode-ж-etag"), // Unicode characters
];
for (input_etag, expected_normalized) in etag_test_cases {
let xml_response = format!(r#"<?xml version="1.0"?>
<d:multistatus xmlns:d="DAV:">
<d:response>
<d:href>/remote.php/dav/files/admin/Documents/</d:href>
<d:propstat>
<d:prop>
<d:getetag>{}</d:getetag>
</d:prop>
<d:status>HTTP/1.1 200 OK</d:status>
</d:propstat>
</d:response>
</d:multistatus>"#, input_etag);
let result = service.parse_directory_etag(&xml_response);
match result {
Ok(etag) => {
assert_eq!(
etag, expected_normalized,
"ETag normalization failed for input '{}': expected '{}', got '{}'",
input_etag, expected_normalized, etag
);
}
Err(e) => {
if !expected_normalized.is_empty() {
panic!("Expected ETag '{}' but got error: {}", expected_normalized, e);
}
// Empty expected result means we expect an error
}
}
}
}
#[tokio::test]
async fn test_malformed_xml_responses() {
let service = create_test_webdav_service();
// Test various malformed XML responses
let malformed_xml_cases = vec![
// Empty response
"",
// Not XML
"not xml at all",
// Incomplete XML
"<?xml version=\"1.0\"?><d:multistatus",
// Missing ETag
r#"<?xml version="1.0"?>
<d:multistatus xmlns:d="DAV:">
<d:response>
<d:href>/remote.php/dav/files/admin/Documents/</d:href>
<d:propstat>
<d:prop>
<d:displayname>Documents</d:displayname>
</d:prop>
<d:status>HTTP/1.1 200 OK</d:status>
</d:propstat>
</d:response>
</d:multistatus>"#,
// Empty ETag
r#"<?xml version="1.0"?>
<d:multistatus xmlns:d="DAV:">
<d:response>
<d:href>/remote.php/dav/files/admin/Documents/</d:href>
<d:propstat>
<d:prop>
<d:getetag></d:getetag>
</d:prop>
<d:status>HTTP/1.1 200 OK</d:status>
</d:propstat>
</d:response>
</d:multistatus>"#,
// Invalid XML characters
r#"<?xml version="1.0"?>
<d:multistatus xmlns:d="DAV:">
<d:response>
<d:href>/remote.php/dav/files/admin/Documents/</d:href>
<d:propstat>
<d:prop>
<d:getetag>"invalid-xml-&#x1;-char"</d:getetag>
</d:prop>
</d:propstat>
</d:response>
</d:multistatus>"#,
];
for (i, malformed_xml) in malformed_xml_cases.iter().enumerate() {
let result = service.parse_directory_etag(malformed_xml);
// Some malformed XML might still be parsed successfully by the robust parser
// The key is that it doesn't crash - either error or success is acceptable
match result {
Ok(etag) => {
println!("Malformed XML case {} parsed successfully with ETag: {}", i, etag);
}
Err(e) => {
println!("Malformed XML case {} failed as expected: {}", i, e);
}
}
}
}
#[tokio::test]
async fn test_large_directory_structures() {
let service = create_test_webdav_service();
// Generate a large directory structure (1000 directories, 5000 files)
let mut large_files = Vec::new();
// Add root directory
large_files.push(FileInfo {
path: "/Documents".to_string(),
name: "Documents".to_string(),
size: 0,
mime_type: "".to_string(),
last_modified: Some(Utc::now()),
etag: "root-etag".to_string(),
is_directory: true,
created_at: Some(Utc::now()),
permissions: Some(755),
owner: Some("admin".to_string()),
group: Some("admin".to_string()),
metadata: None,
});
// Generate 100 level-1 directories, each with 10 subdirectories and 50 files
for i in 0..100 {
let level1_path = format!("/Documents/Dir{:03}", i);
// Add level-1 directory
large_files.push(FileInfo {
path: level1_path.clone(),
name: format!("Dir{:03}", i),
size: 0,
mime_type: "".to_string(),
last_modified: Some(Utc::now()),
etag: format!("dir{}-etag", i),
is_directory: true,
created_at: Some(Utc::now()),
permissions: Some(755),
owner: Some("admin".to_string()),
group: Some("admin".to_string()),
metadata: None,
});
// Add 10 subdirectories
for j in 0..10 {
let level2_path = format!("{}/SubDir{:02}", level1_path, j);
large_files.push(FileInfo {
path: level2_path.clone(),
name: format!("SubDir{:02}", j),
size: 0,
mime_type: "".to_string(),
last_modified: Some(Utc::now()),
etag: format!("subdir{}-{}-etag", i, j),
is_directory: true,
created_at: Some(Utc::now()),
permissions: Some(755),
owner: Some("admin".to_string()),
group: Some("admin".to_string()),
metadata: None,
});
// Add 5 files in each subdirectory
for k in 0..5 {
large_files.push(FileInfo {
path: format!("{}/file{:02}.pdf", level2_path, k),
name: format!("file{:02}.pdf", k),
size: 1024 * (k + 1) as i64,
mime_type: "application/pdf".to_string(),
last_modified: Some(Utc::now()),
etag: format!("file{}-{}-{}-etag", i, j, k),
is_directory: false,
created_at: Some(Utc::now()),
permissions: Some(644),
owner: Some("admin".to_string()),
group: Some("admin".to_string()),
metadata: None,
});
}
}
}
println!("Generated {} files and directories", large_files.len());
// Test performance of directory extraction
let start_time = std::time::Instant::now();
let mut all_directories = std::collections::BTreeSet::new();
for file in &large_files {
if file.is_directory {
all_directories.insert(file.path.clone());
} else {
let mut path_parts: Vec<&str> = file.path.split('/').collect();
path_parts.pop();
let mut current_path = String::new();
for part in path_parts {
if !part.is_empty() {
if !current_path.is_empty() {
current_path.push('/');
}
current_path.push_str(part);
all_directories.insert(current_path.clone());
}
}
}
}
let extraction_time = start_time.elapsed();
println!("Extracted {} directories in {:?}", all_directories.len(), extraction_time);
// Verify structure - the actual count includes extraction from file paths too
assert!(all_directories.len() >= 1101, "Should have at least 1101 directories"); // 1 root + 100 level1 + 1000 level2 + extracted paths
assert!(all_directories.contains("/Documents"));
assert!(all_directories.contains("/Documents/Dir000"));
assert!(all_directories.contains("/Documents/Dir099/SubDir09"));
// Test performance of file counting for a specific directory
let count_start = std::time::Instant::now();
let test_dir = "/Documents/Dir050";
let direct_files: Vec<_> = large_files.iter()
.filter(|f| !f.is_directory && service.is_direct_child(&f.path, test_dir))
.collect();
let count_time = count_start.elapsed();
println!("Counted {} direct files in {} in {:?}", direct_files.len(), test_dir, count_time);
// Performance assertions
assert!(extraction_time.as_millis() < 1000, "Directory extraction too slow: {:?}", extraction_time);
assert!(count_time.as_millis() < 100, "File counting too slow: {:?}", count_time);
}

View File

@ -0,0 +1,341 @@
use readur::services::webdav_service::{WebDAVService, WebDAVConfig};
use readur::models::FileInfo;
use tokio;
use chrono::Utc;
// Helper function to create test WebDAV service
fn create_test_webdav_service() -> WebDAVService {
let config = WebDAVConfig {
server_url: "https://test.example.com".to_string(),
username: "testuser".to_string(),
password: "testpass".to_string(),
watch_folders: vec!["/Documents".to_string()],
file_extensions: vec!["pdf".to_string(), "png".to_string()],
timeout_seconds: 30,
server_type: Some("nextcloud".to_string()),
};
WebDAVService::new(config).unwrap()
}
#[tokio::test]
async fn test_discover_files_in_folder_shallow() {
let service = create_test_webdav_service();
// Mock XML response for shallow directory scan (Depth: 1)
let mock_response = r#"<?xml version="1.0"?>
<d:multistatus xmlns:d="DAV:">
<d:response>
<d:href>/remote.php/dav/files/admin/Documents/</d:href>
<d:propstat>
<d:prop>
<d:displayname>Documents</d:displayname>
<d:resourcetype>
<d:collection/>
</d:resourcetype>
<d:getetag>"docs-etag"</d:getetag>
</d:prop>
<d:status>HTTP/1.1 200 OK</d:status>
</d:propstat>
</d:response>
<d:response>
<d:href>/remote.php/dav/files/admin/Documents/file1.pdf</d:href>
<d:propstat>
<d:prop>
<d:displayname>file1.pdf</d:displayname>
<d:getcontentlength>1024</d:getcontentlength>
<d:getcontenttype>application/pdf</d:getcontenttype>
<d:getetag>"file1-etag"</d:getetag>
<d:resourcetype/>
</d:prop>
<d:status>HTTP/1.1 200 OK</d:status>
</d:propstat>
</d:response>
<d:response>
<d:href>/remote.php/dav/files/admin/Documents/SubFolder/</d:href>
<d:propstat>
<d:prop>
<d:displayname>SubFolder</d:displayname>
<d:resourcetype>
<d:collection/>
</d:resourcetype>
<d:getetag>"subfolder-etag"</d:getetag>
</d:prop>
<d:status>HTTP/1.1 200 OK</d:status>
</d:propstat>
</d:response>
</d:multistatus>"#;
// Test that shallow parsing works correctly
let files = service.parse_webdav_response_with_directories(mock_response).unwrap();
// Debug print to see what files we actually got
for file in &files {
println!("Parsed file: {} (is_directory: {}, path: {})", file.name, file.is_directory, file.path);
}
// Should have directory, direct file, and direct subdirectory (but no nested files)
assert_eq!(files.len(), 3);
// Check that we got the right items
let directory = files.iter().find(|f| f.name == "Documents").unwrap();
assert!(directory.is_directory);
assert_eq!(directory.etag, "docs-etag");
let file = files.iter().find(|f| f.name == "file1.pdf").unwrap();
assert!(!file.is_directory);
assert_eq!(file.size, 1024);
assert_eq!(file.etag, "file1-etag");
let subfolder = files.iter().find(|f| f.name == "SubFolder").unwrap();
assert!(subfolder.is_directory);
assert_eq!(subfolder.etag, "subfolder-etag");
}
#[tokio::test]
async fn test_update_single_directory_tracking() {
let service = create_test_webdav_service();
// Create mock files representing a shallow directory scan
let files = vec![
FileInfo {
path: "/Documents".to_string(),
name: "Documents".to_string(),
size: 0,
mime_type: "".to_string(),
last_modified: Some(Utc::now()),
etag: "docs-etag-123".to_string(),
is_directory: true,
created_at: Some(Utc::now()),
permissions: Some(755),
owner: Some("admin".to_string()),
group: Some("admin".to_string()),
metadata: None,
},
FileInfo {
path: "/Documents/file1.pdf".to_string(),
name: "file1.pdf".to_string(),
size: 1024000,
mime_type: "application/pdf".to_string(),
last_modified: Some(Utc::now()),
etag: "file1-etag".to_string(),
is_directory: false,
created_at: Some(Utc::now()),
permissions: Some(644),
owner: Some("admin".to_string()),
group: Some("admin".to_string()),
metadata: None,
},
FileInfo {
path: "/Documents/file2.pdf".to_string(),
name: "file2.pdf".to_string(),
size: 2048000,
mime_type: "application/pdf".to_string(),
last_modified: Some(Utc::now()),
etag: "file2-etag".to_string(),
is_directory: false,
created_at: Some(Utc::now()),
permissions: Some(644),
owner: Some("admin".to_string()),
group: Some("admin".to_string()),
metadata: None,
},
FileInfo {
path: "/Documents/SubFolder".to_string(),
name: "SubFolder".to_string(),
size: 0,
mime_type: "".to_string(),
last_modified: Some(Utc::now()),
etag: "subfolder-etag".to_string(),
is_directory: true,
created_at: Some(Utc::now()),
permissions: Some(755),
owner: Some("admin".to_string()),
group: Some("admin".to_string()),
metadata: None,
},
];
// Test that direct file counting works correctly
let direct_files: Vec<_> = files.iter()
.filter(|f| !f.is_directory && service.is_direct_child(&f.path, "/Documents"))
.collect();
assert_eq!(direct_files.len(), 2); // file1.pdf and file2.pdf
let total_size: i64 = direct_files.iter().map(|f| f.size).sum();
assert_eq!(total_size, 3072000); // 1024000 + 2048000
// Test that directory ETag extraction works
let dir_etag = files.iter()
.find(|f| f.is_directory && f.path == "/Documents")
.map(|f| f.etag.clone())
.unwrap();
assert_eq!(dir_etag, "docs-etag-123");
}
#[tokio::test]
async fn test_targeted_rescan_logic() {
let service = create_test_webdav_service();
// Test the logic that determines which paths need scanning
let paths_to_check = vec![
"/Documents".to_string(),
"/Documents/2024".to_string(),
"/Documents/Archive".to_string(),
];
// This tests the core logic used in discover_files_targeted_rescan
// In a real implementation, this would involve database calls and network requests
// Simulate ETag checking logic
let mut paths_needing_scan = Vec::new();
for path in &paths_to_check {
// Simulate: current_etag != stored_etag (directory changed)
let current_etag = format!("{}-current", path.replace('/', "-"));
let stored_etag = format!("{}-stored", path.replace('/', "-"));
if current_etag != stored_etag {
paths_needing_scan.push(path.clone());
}
}
// All paths should need scanning in this test scenario
assert_eq!(paths_needing_scan.len(), 3);
assert!(paths_needing_scan.contains(&"/Documents".to_string()));
assert!(paths_needing_scan.contains(&"/Documents/2024".to_string()));
assert!(paths_needing_scan.contains(&"/Documents/Archive".to_string()));
}
#[tokio::test]
async fn test_stale_directory_detection() {
let service = create_test_webdav_service();
// Test the logic for detecting stale subdirectories
let parent_path = "/Documents";
let directories = vec![
("/Documents", chrono::Utc::now()), // Fresh parent
("/Documents/2024", chrono::Utc::now() - chrono::Duration::hours(25)), // Stale (25 hours old)
("/Documents/Archive", chrono::Utc::now() - chrono::Duration::hours(1)), // Fresh (1 hour old)
("/Documents/2024/Q1", chrono::Utc::now() - chrono::Duration::hours(30)), // Stale (30 hours old)
("/Other", chrono::Utc::now() - chrono::Duration::hours(48)), // Stale but not under parent
];
let max_age_hours = 24;
let cutoff_time = chrono::Utc::now() - chrono::Duration::hours(max_age_hours);
// Test the filtering logic
let stale_subdirs: Vec<String> = directories.iter()
.filter(|(path, last_scanned)| {
path.starts_with(parent_path) &&
*path != parent_path &&
*last_scanned < cutoff_time
})
.map(|(path, _)| path.to_string())
.collect();
assert_eq!(stale_subdirs.len(), 2);
assert!(stale_subdirs.contains(&"/Documents/2024".to_string()));
assert!(stale_subdirs.contains(&"/Documents/2024/Q1".to_string()));
assert!(!stale_subdirs.contains(&"/Documents/Archive".to_string())); // Fresh
assert!(!stale_subdirs.contains(&"/Other".to_string())); // Different parent
}
#[tokio::test]
async fn test_incremental_sync_logic() {
let service = create_test_webdav_service();
// Test the change detection logic used in incremental sync
let watch_folders = vec![
"/Documents".to_string(),
"/Photos".to_string(),
"/Archive".to_string(),
];
// Simulate stored ETags vs current ETags
let stored_etags = [
("/Documents", "docs-etag-old"),
("/Photos", "photos-etag-same"),
("/Archive", "archive-etag-old"),
];
let current_etags = [
("/Documents", "docs-etag-new"), // Changed
("/Photos", "photos-etag-same"), // Unchanged
("/Archive", "archive-etag-new"), // Changed
];
let mut changed_folders = Vec::new();
let mut unchanged_folders = Vec::new();
for folder in &watch_folders {
let stored = stored_etags.iter().find(|(path, _)| path == folder).map(|(_, etag)| *etag);
let current = current_etags.iter().find(|(path, _)| path == folder).map(|(_, etag)| *etag);
match (stored, current) {
(Some(stored_etag), Some(current_etag)) => {
if stored_etag != current_etag {
changed_folders.push(folder.clone());
} else {
unchanged_folders.push(folder.clone());
}
}
_ => {
// New folder or missing data - assume changed
changed_folders.push(folder.clone());
}
}
}
assert_eq!(changed_folders.len(), 2);
assert!(changed_folders.contains(&"/Documents".to_string()));
assert!(changed_folders.contains(&"/Archive".to_string()));
assert_eq!(unchanged_folders.len(), 1);
assert!(unchanged_folders.contains(&"/Photos".to_string()));
}
#[tokio::test]
async fn test_smart_sync_strategy_selection() {
let service = create_test_webdav_service();
// Test the logic for choosing between different sync strategies
// Scenario 1: Directory unchanged, no stale subdirectories -> no scan needed
let scenario1_main_dir_changed = false;
let scenario1_stale_subdirs = 0;
let scenario1_action = if scenario1_main_dir_changed {
"full_scan"
} else if scenario1_stale_subdirs > 0 {
"targeted_scan"
} else {
"no_scan"
};
assert_eq!(scenario1_action, "no_scan");
// Scenario 2: Directory unchanged, has stale subdirectories -> targeted scan
let scenario2_main_dir_changed = false;
let scenario2_stale_subdirs = 3;
let scenario2_action = if scenario2_main_dir_changed {
"full_scan"
} else if scenario2_stale_subdirs > 0 {
"targeted_scan"
} else {
"no_scan"
};
assert_eq!(scenario2_action, "targeted_scan");
// Scenario 3: Directory changed -> full scan (optimized)
let scenario3_main_dir_changed = true;
let scenario3_stale_subdirs = 0;
let scenario3_action = if scenario3_main_dir_changed {
"full_scan"
} else if scenario3_stale_subdirs > 0 {
"targeted_scan"
} else {
"no_scan"
};
assert_eq!(scenario3_action, "full_scan");
}