use std::collections::{HashMap, HashSet};
use std::fs;
use std::path::{Path, PathBuf};
use jwalk::WalkDir;
use crate::audit::{AuditAction, AuditActorSource, AuditEvent, AuditService};
use crate::config::{AppConfig, Config};
use crate::db::{Database, Root};
use crate::error::{Error, Result};
const SECS_PER_DAY: i64 = 86400;
#[must_use = "expiration calculation result should be used"]
pub fn calculate_expiration(countdown_start: i64, expiration_days: u32) -> i64 {
let now = jiff::Timestamp::now();
let start_ts = jiff::Timestamp::from_second(countdown_start).unwrap_or(now);
let expiration_secs = i64::from(expiration_days) * SECS_PER_DAY;
let expiration_duration = jiff::SignedDuration::from_secs(expiration_secs);
let expires_at = start_ts.checked_add(expiration_duration).unwrap_or(now);
let duration_remaining = expires_at.duration_since(now);
duration_remaining.as_secs() / SECS_PER_DAY
}
#[must_use = "transition summary should be logged or displayed"]
pub fn transition_expired_paths(
db: &Database,
app_config: &AppConfig,
) -> Result<TransitionSummary> {
let mut expired_to_pending = 0u64;
let mut expired_to_approved = 0u64;
let mut deferred_reset = 0u64;
let now = jiff::Timestamp::now().as_second();
let roots = db.list_roots()?;
let root_configs: HashMap<i64, &Config> = roots
.iter()
.map(|r| (r.id, app_config.for_root(&r.path)))
.collect();
let conn = db.conn();
let mut stmt = conn.prepare(
"SELECT id, root_id, path, countdown_start, status, deferred_until
FROM entries
WHERE is_dir = 0 AND status IN ('tracked', 'deferred')",
)?;
let mut rows = stmt.query([])?;
let mut transitions = Vec::new();
while let Some(row) = rows.next()? {
let id: i64 = row.get(0)?;
let root_id: i64 = row.get(1)?;
let path = PathBuf::from(row.get::<_, String>(2)?);
let countdown_start: Option<i64> = row.get(3)?;
let status: String = row.get(4)?;
let deferred_until: Option<i64> = row.get(5)?;
let config = root_configs
.get(&root_id)
.copied()
.unwrap_or(&app_config.global);
if status == "deferred"
&& let Some(deferred_until_ts) = deferred_until
&& now >= deferred_until_ts
{
transitions.push((id, path, "tracked".to_string(), true));
deferred_reset += 1;
continue;
}
if status == "tracked"
&& let Some(countdown_ts) = countdown_start
{
let days_remaining = calculate_expiration(countdown_ts, config.expiration_days);
if days_remaining <= 0 {
let new_status = if config.auto_remove {
"approved"
} else {
"pending"
};
transitions.push((id, path, new_status.to_string(), false));
if config.auto_remove {
expired_to_approved += 1;
} else {
expired_to_pending += 1;
}
}
}
}
drop(rows);
drop(stmt);
let audit = AuditService::new(db);
let user = AuditService::current_user();
for (id, path, new_status, is_deferral_reset) in transitions {
tracing::trace!(
entry_id = id,
path = ?path,
new_status = %new_status,
is_deferral_reset,
"Applying scanner transition"
);
if is_deferral_reset {
conn.execute(
"UPDATE entries SET status = ?1, deferred_until = NULL, updated_at = strftime('%s', 'now') WHERE id = ?2",
(&new_status, id),
)?;
} else {
db.update_entry_status(id, &new_status)?;
}
record_transition_audit(
&audit,
&user,
id,
path.as_path(),
&new_status,
is_deferral_reset,
)?;
}
Ok(TransitionSummary {
expired_to_pending,
expired_to_approved,
deferred_reset,
})
}
#[derive(Debug, Clone, Default)]
#[must_use = "transition summary should be logged or displayed"]
#[non_exhaustive]
pub struct TransitionSummary {
pub expired_to_pending: u64,
pub expired_to_approved: u64,
pub deferred_reset: u64,
}
#[derive(Debug, Clone, Default)]
#[must_use = "refresh summary should be logged or displayed"]
#[non_exhaustive]
pub struct RefreshSummary {
pub scan: ScanSummary,
pub transitions: TransitionSummary,
}
pub async fn refresh(
db: &Database,
scanner: &Scanner,
app_config: &AppConfig,
) -> Result<RefreshSummary> {
let scan = scan_and_persist(db, scanner, app_config).await?;
let transitions = transition_expired_paths(db, app_config)?;
Ok(RefreshSummary { scan, transitions })
}
pub async fn scan_and_persist(
db: &Database,
scanner: &Scanner,
app_config: &AppConfig,
) -> Result<ScanSummary> {
let mut total_directories = 0u64;
let mut total_files = 0u64;
let mut total_size_bytes = 0u64;
let scan_timestamp = jiff::Timestamp::now().as_second();
for path in &app_config.global.tracked_paths {
db.insert_root(path)?;
}
let roots = db.list_roots()?;
for root in &roots {
let path = root.path.clone();
let is_first_scan = root.last_scanned.is_none();
tracing::info!(?path, is_first_scan, "Scanning path");
let scan_result = scanner.scan(&path).await?;
let persisted_directories =
persist_scan_result_for_root(db, root, &scan_result, scan_timestamp)?;
total_directories += persisted_directories;
total_files += scan_result.total_files;
total_size_bytes += scan_result.total_size_bytes;
}
#[allow(clippy::cast_possible_wrap)]
update_stats(
db,
total_files as i64,
total_size_bytes as i64,
scan_timestamp,
app_config,
&roots,
)?;
let audit = AuditService::new(db);
let user = AuditService::current_user();
record_scan_summary_audit(
&audit,
&user,
roots.len(),
total_directories,
total_files,
total_size_bytes,
)?;
tracing::info!(
total_directories,
total_files,
total_size_bytes,
"Scan complete"
);
Ok(ScanSummary {
total_directories,
total_files,
total_size_bytes,
})
}
fn cleanup_missing_entries(
db: &Database,
root_id: i64,
discovered_paths: &HashSet<PathBuf>,
) -> Result<()> {
let existing_entries = db.list_entries_by_root(root_id)?;
let mut missing_candidates: Vec<(i64, PathBuf)> = Vec::new();
for entry in &existing_entries {
if entry.status == "removed" {
continue;
}
if discovered_paths.contains(&entry.path) {
continue;
}
missing_candidates.push((entry.id, entry.path.clone()));
}
let mut cleaned = 0u64;
for (entry_id, path) in &missing_candidates {
if !path.exists() {
tracing::debug!(
path = ?path,
entry_id,
"Entry no longer exists on disk, marking as removed"
);
if let Err(e) = db.update_entry_status(*entry_id, "removed") {
tracing::warn!("Failed to mark missing entry as removed: {e}");
} else {
cleaned += 1;
}
}
}
if cleaned > 0 {
tracing::info!(root_id, cleaned, "Cleaned up missing entries");
}
Ok(())
}
fn persist_scan_result_for_root(
db: &Database,
root: &Root,
scan_result: &ScanResult,
scan_timestamp: i64,
) -> Result<u64> {
let root_id = root.id;
with_scan_write_transaction(db, || {
for dir_info in &scan_result.directories_found {
let parent_path = dir_info
.path
.parent()
.map_or_else(|| root.path.clone(), Path::to_path_buf);
let dir_mtime = dir_info.oldest_mtime.map(jiff::Timestamp::as_second);
#[allow(clippy::cast_possible_wrap)]
db.upsert_entry_no_return(
root_id,
&dir_info.path,
&parent_path,
true,
dir_info.size_bytes as i64,
dir_mtime,
)?;
}
for file_info in &scan_result.files_found {
let mtime_unix = file_info.mtime.map(jiff::Timestamp::as_second);
#[allow(clippy::cast_possible_wrap)]
db.upsert_entry_no_return(
root_id,
&file_info.path,
&file_info.parent_path,
false,
file_info.size_bytes as i64,
mtime_unix,
)?;
}
let ignored_updates = db.enforce_ignored_directory_inheritance(root_id)?;
tracing::debug!(
root_id,
ignored_updates,
"Applied ignored-directory inheritance during scan persistence"
);
Ok(())
})?;
cleanup_missing_entries(db, root_id, &scan_result.discovered_paths)?;
if root.last_scanned.is_none() {
let reset_count = db.reset_root_countdowns(root_id)?;
tracing::info!(
root_id,
reset_count,
"Reset countdowns for newly tracked root"
);
}
db.update_root_last_scanned(root_id, scan_timestamp)?;
u64::try_from(scan_result.directories_found.len()).map_err(|_| {
Error::Config("directory count overflow while persisting scan result".to_string())
})
}
fn with_scan_write_transaction<T>(db: &Database, f: impl FnOnce() -> Result<T>) -> Result<T> {
db.conn().execute_batch("BEGIN IMMEDIATE TRANSACTION")?;
let result = f();
match result {
Ok(value) => {
db.conn().execute_batch("COMMIT")?;
Ok(value)
}
Err(err) => {
if let Err(rollback_err) = db.conn().execute_batch("ROLLBACK") {
tracing::warn!(
error = %rollback_err,
"Rollback failed after scan write transaction error"
);
}
Err(err)
}
}
}
fn record_transition_audit(
audit: &AuditService<'_>,
user: &str,
entry_id: i64,
path: &Path,
new_status: &str,
is_deferral_reset: bool,
) -> Result<()> {
let details = if is_deferral_reset {
"Deferral period ended, reset to tracked"
} else if new_status == "approved" {
"Expired and auto-approved for removal"
} else {
"Expired, pending approval for removal"
};
audit.record_event(&AuditEvent {
user,
actor_source: AuditActorSource::Scanner,
action: AuditAction::Scan,
target_path: Some(path),
details: Some(details),
entry_id: Some(entry_id),
root_id: None,
status_before: Some(if is_deferral_reset {
"deferred"
} else {
"tracked"
}),
status_after: Some(new_status),
outcome: Some(if is_deferral_reset {
"deferred_reset"
} else {
new_status
}),
})
}
fn record_scan_summary_audit(
audit: &AuditService<'_>,
user: &str,
root_count: usize,
total_directories: u64,
total_files: u64,
total_size_bytes: u64,
) -> Result<()> {
let details = format!(
"Scanned {root_count} paths: {total_directories} directories, {total_files} files, {total_size_bytes} bytes"
);
audit.record_event(&AuditEvent {
user,
actor_source: AuditActorSource::Scanner,
action: AuditAction::Scan,
target_path: None,
details: Some(&details),
entry_id: None,
root_id: None,
status_before: None,
status_after: None,
outcome: Some("completed"),
})
}
fn update_stats(
db: &Database,
total_files: i64,
total_size_bytes: i64,
scan_timestamp: i64,
app_config: &AppConfig,
roots: &[Root],
) -> Result<()> {
let root_configs: HashMap<i64, (u32, u32)> = roots
.iter()
.map(|r| {
let cfg = app_config.for_root(&r.path);
(r.id, (cfg.expiration_days, cfg.warning_days))
})
.collect();
let mut files_within_warning = 0i64;
let mut files_overdue = 0i64;
let mut stmt = db.conn().prepare(
"SELECT root_id, countdown_start, status
FROM entries
WHERE is_dir = 0 AND countdown_start IS NOT NULL",
)?;
let mut rows = stmt.query([])?;
while let Some(row) = rows.next()? {
let root_id: i64 = row.get(0)?;
let countdown_start: i64 = row.get(1)?;
let status: String = row.get(2)?;
let (expiration_days, warning_days) = root_configs.get(&root_id).copied().unwrap_or((
app_config.global.expiration_days,
app_config.global.warning_days,
));
let days_remaining = calculate_expiration(countdown_start, expiration_days);
if status == "tracked" {
if days_remaining <= 0 {
files_overdue += 1;
} else if days_remaining <= i64::from(warning_days) {
files_within_warning += 1;
}
}
}
drop(rows);
drop(stmt);
let files_pending: i64 = db.conn().query_row(
"SELECT COUNT(*) FROM entries WHERE is_dir = 0 AND status = 'pending'",
[],
|row| row.get(0),
)?;
db.conn().execute(
"UPDATE stats SET
total_files = ?1,
total_size_bytes = ?2,
last_scan_completed = ?3,
files_within_warning = ?4,
files_pending_approval = ?5,
files_overdue = ?6
WHERE id = 1",
(
total_files,
total_size_bytes,
scan_timestamp,
files_within_warning,
files_pending,
files_overdue,
),
)?;
Ok(())
}
#[derive(Debug, Clone, Default)]
#[allow(clippy::struct_field_names)]
#[must_use = "scan summary should be logged or displayed"]
#[non_exhaustive]
pub struct ScanSummary {
pub total_directories: u64,
pub total_files: u64,
pub total_size_bytes: u64,
}
pub struct Scanner {
}
impl Scanner {
#[must_use]
pub fn new() -> Self {
Self {}
}
pub async fn scan(&self, root: &Path) -> Result<ScanResult> {
if !root.exists() {
return Err(Error::PathNotFound(root.to_path_buf()));
}
if !root.is_dir() {
return Err(Error::Filesystem {
path: root.to_path_buf(),
source: std::io::Error::new(
std::io::ErrorKind::NotADirectory,
"path is not a directory",
),
});
}
let root = root.to_path_buf();
tokio::task::spawn_blocking(move || scan_directory_tree(&root))
.await
.map_err(|e| Error::Config(format!("Scan task panicked: {e}")))
}
}
impl Default for Scanner {
fn default() -> Self {
Self::new()
}
}
fn scan_directory_tree(root: &Path) -> ScanResult {
let mut total_files = 0u64;
let mut total_size_bytes = 0u64;
let mut dir_map: HashMap<PathBuf, DirectoryAggregator> = HashMap::new();
let mut files_found: Vec<ScannedFile> = Vec::new();
let mut discovered_paths: HashSet<PathBuf> = HashSet::new();
for entry in WalkDir::new(root)
.skip_hidden(false)
.follow_links(false)
.into_iter()
.filter_map(|e| match e {
Ok(entry) => Some(entry),
Err(e) => {
tracing::warn!("Skipping entry due to error: {e}");
None
}
})
{
let path = entry.path();
let metadata = match get_metadata(&path) {
Ok(m) => m,
Err(e) => {
tracing::warn!("Failed to get metadata for {}: {e}", path.display());
continue;
}
};
if metadata.is_dir {
discovered_paths.insert(path.clone());
dir_map
.entry(path.clone())
.or_insert_with(|| DirectoryAggregator {
path: path.clone(),
size_bytes: 0,
file_count: 0,
oldest_mtime: None,
});
continue;
}
if !metadata.is_file {
continue;
}
total_files += 1;
total_size_bytes += metadata.size_bytes;
let parent_dir = path
.parent()
.map_or_else(|| root.to_path_buf(), std::path::Path::to_path_buf);
discovered_paths.insert(path.clone());
files_found.push(ScannedFile {
path: path.clone(),
parent_path: parent_dir.clone(),
size_bytes: metadata.size_bytes,
mtime: metadata.mtime,
});
let parent_agg = dir_map
.entry(parent_dir.clone())
.or_insert_with(|| DirectoryAggregator {
path: parent_dir.clone(),
size_bytes: 0,
file_count: 0,
oldest_mtime: None,
});
parent_agg.file_count += 1;
accumulate_recursive_dir_stats(
&mut dir_map,
&mut discovered_paths,
parent_dir.as_path(),
root,
metadata.size_bytes,
metadata.mtime,
);
}
let directories_found = dir_map
.into_values()
.map(|agg| DirectoryInfo {
path: agg.path,
size_bytes: agg.size_bytes,
file_count: agg.file_count,
oldest_mtime: agg.oldest_mtime,
})
.collect();
ScanResult {
total_files,
total_size_bytes,
directories_found,
files_found,
discovered_paths,
}
}
fn accumulate_recursive_dir_stats(
dir_map: &mut HashMap<PathBuf, DirectoryAggregator>,
discovered_paths: &mut HashSet<PathBuf>,
parent_dir: &Path,
root: &Path,
size_bytes: u64,
mtime: Option<jiff::Timestamp>,
) {
let mut current = Some(parent_dir);
while let Some(dir) = current {
if !dir.starts_with(root) {
break;
}
discovered_paths.insert(dir.to_path_buf());
let agg = dir_map
.entry(dir.to_path_buf())
.or_insert_with(|| DirectoryAggregator {
path: dir.to_path_buf(),
size_bytes: 0,
file_count: 0,
oldest_mtime: None,
});
agg.size_bytes += size_bytes;
if let Some(ts) = mtime {
agg.oldest_mtime = Some(match agg.oldest_mtime {
Some(existing) if ts < existing => ts,
Some(existing) => existing,
None => ts,
});
}
if dir == root {
break;
}
current = dir.parent();
}
}
struct FileMetadata {
size_bytes: u64,
mtime: Option<jiff::Timestamp>,
is_file: bool,
is_dir: bool,
}
fn get_metadata(path: &Path) -> Result<FileMetadata> {
let metadata = fs::metadata(path)?;
let is_file = metadata.is_file();
let is_dir = metadata.is_dir();
let size_bytes = metadata.len();
let mtime = metadata
.modified()
.ok()
.and_then(|systime| jiff::Timestamp::try_from(systime).ok());
Ok(FileMetadata {
size_bytes,
mtime,
is_file,
is_dir,
})
}
struct DirectoryAggregator {
path: PathBuf,
size_bytes: u64,
file_count: u64,
oldest_mtime: Option<jiff::Timestamp>,
}
#[derive(Debug, Default, Clone)]
#[must_use = "scan results should be processed"]
#[non_exhaustive]
pub struct ScanResult {
pub total_files: u64,
pub total_size_bytes: u64,
pub directories_found: Vec<DirectoryInfo>,
pub files_found: Vec<ScannedFile>,
pub discovered_paths: HashSet<PathBuf>,
}
#[derive(Debug, Clone)]
#[non_exhaustive]
pub struct ScannedFile {
pub path: PathBuf,
pub parent_path: PathBuf,
pub size_bytes: u64,
pub mtime: Option<jiff::Timestamp>,
}
#[derive(Debug, Clone)]
#[non_exhaustive]
#[allow(dead_code)]
pub struct DirectoryInfo {
pub path: PathBuf,
pub size_bytes: u64,
pub file_count: u64,
pub oldest_mtime: Option<jiff::Timestamp>,
}
#[cfg(test)]
mod tests {
use super::*;
use crate::config::{AppConfig, Config};
use crate::db::Database;
use filetime::{FileTime, set_file_mtime};
use std::collections::HashSet;
use std::fs::File;
use std::io::Write;
use tempfile::{NamedTempFile, TempDir};
fn temp_database() -> (NamedTempFile, Database) {
let temp_file = NamedTempFile::new().expect("failed to create temp file");
let db = crate::db::Database::open(temp_file.path()).expect("failed to open database");
(temp_file, db)
}
fn test_app_config(expiration_days: u32, warning_days: u32, auto_remove: bool) -> AppConfig {
AppConfig::from_global(Config {
expiration_days,
warning_days,
auto_remove,
..Config::default()
})
}
fn test_app_config_with_paths(
paths: Vec<PathBuf>,
expiration_days: u32,
warning_days: u32,
) -> AppConfig {
AppConfig::from_global(Config {
tracked_paths: paths,
expiration_days,
warning_days,
..Config::default()
})
}
fn create_test_tree() -> TempDir {
let temp_dir = TempDir::new().expect(
"failed to create temp directory for scanner test - check disk space and permissions",
);
let root = temp_dir.path();
let mut file1 = File::create(root.join("file1.txt"))
.expect("failed to create test file - check disk space and permissions");
file1
.write_all(&[0u8; 100])
.expect("failed to write test data to file - disk may be full");
file1
.sync_all()
.expect("failed to sync test file to disk - check filesystem health");
let subdir = root.join("subdir");
fs::create_dir(&subdir)
.expect("failed to create test directory - check disk space and permissions");
let mut file2 = File::create(subdir.join("file2.txt"))
.expect("failed to create test file - check disk space and permissions");
file2
.write_all(&[0u8; 200])
.expect("failed to write test data to file - disk may be full");
file2
.sync_all()
.expect("failed to sync test file to disk - check filesystem health");
let mut file3 = File::create(subdir.join("file3.txt"))
.expect("failed to create test file - check disk space and permissions");
file3
.write_all(&[0u8; 300])
.expect("failed to write test data to file - disk may be full");
file3
.sync_all()
.expect("failed to sync test file to disk - check filesystem health");
temp_dir
}
#[tokio::test]
async fn scanner_finds_all_files() {
let temp_dir = create_test_tree();
let root = temp_dir.path();
let scanner = Scanner::new();
let result = scanner.scan(root).await.expect(
"scanner should successfully scan test directory - check permissions and disk space",
);
assert_eq!(result.total_files, 3, "Expected 3 files to be found");
assert_eq!(
result.files_found.len(),
3,
"Expected file records to match total files"
);
assert_eq!(
result.total_size_bytes, 600,
"Expected total size of 600 bytes"
);
}
#[tokio::test]
async fn scanner_aggregates_by_directory() {
let temp_dir = create_test_tree();
let root = temp_dir.path();
let scanner = Scanner::new();
let result = scanner.scan(root).await.expect(
"scanner should successfully scan test directory - check permissions and disk space",
);
assert_eq!(
result.directories_found.len(),
2,
"Expected 2 directories (root and subdir)"
);
let root_agg = result
.directories_found
.iter()
.find(|d| d.path == root)
.expect("Root directory should be in results");
assert_eq!(root_agg.file_count, 1, "Root should have 1 direct file");
assert_eq!(
root_agg.size_bytes, 600,
"Root should include recursive bytes from subdirectories"
);
let subdir = root.join("subdir");
let subdir_agg = result
.directories_found
.iter()
.find(|d| d.path == subdir)
.expect("Subdir should be in results");
assert_eq!(subdir_agg.file_count, 2, "Subdir should have 2 files");
assert_eq!(
subdir_agg.size_bytes, 500,
"Subdir files should total 500 bytes"
);
}
#[tokio::test]
async fn scanner_includes_empty_directories() {
let temp_dir = create_test_tree();
let root = temp_dir.path();
let empty_dir = root.join("empty");
fs::create_dir(&empty_dir)
.expect("failed to create empty test directory - check permissions and disk space");
let scanner = Scanner::new();
let result = scanner.scan(root).await.expect(
"scanner should successfully scan test directory - check permissions and disk space",
);
let empty_agg = result
.directories_found
.iter()
.find(|d| d.path == empty_dir)
.expect("empty directory should be included in scan results");
assert_eq!(empty_agg.file_count, 0);
assert_eq!(empty_agg.size_bytes, 0);
assert!(empty_agg.oldest_mtime.is_none());
}
#[tokio::test]
async fn scanner_tracks_oldest_mtime() {
let temp_dir = create_test_tree();
let root = temp_dir.path();
let scanner = Scanner::new();
let result = scanner.scan(root).await.expect(
"scanner should successfully scan test directory - check permissions and disk space",
);
for dir_info in &result.directories_found {
assert!(
dir_info.oldest_mtime.is_some(),
"Directory {} should have oldest_mtime",
dir_info.path.display()
);
}
}
#[tokio::test]
async fn scanner_fails_on_nonexistent_path() {
let scanner = Scanner::new();
let result = scanner
.scan(Path::new("/nonexistent/path/that/does/not/exist"))
.await;
assert!(result.is_err(), "Expected error for nonexistent path");
match result.expect_err("expected error result for nonexistent path") {
Error::PathNotFound(_) => {}
e => panic!("Expected PathNotFound error, got: {e:?}"),
}
}
#[tokio::test]
async fn scanner_fails_on_file_path() {
let temp_dir = TempDir::new().expect(
"failed to create temp directory for scanner test - check disk space and permissions",
);
let file_path = temp_dir.path().join("file.txt");
File::create(&file_path)
.expect("failed to create test file - check disk space and permissions");
let scanner = Scanner::new();
let result = scanner.scan(&file_path).await;
assert!(result.is_err(), "Expected error when scanning a file");
match result.expect_err("expected error result when scanning a file path") {
Error::Filesystem { .. } => {}
e => panic!("Expected Filesystem error about directory, got: {e:?}"),
}
}
#[tokio::test]
async fn scanner_handles_empty_directory() {
let temp_dir = TempDir::new().expect(
"failed to create temp directory for scanner test - check disk space and permissions",
);
let scanner = Scanner::new();
let result = scanner.scan(temp_dir.path()).await.expect(
"scanner should successfully scan test directory - check permissions and disk space",
);
assert_eq!(result.total_files, 0, "Expected no files");
assert_eq!(result.total_size_bytes, 0, "Expected zero size");
assert_eq!(
result.directories_found.len(),
1,
"Expected root directory to be included"
);
let root_dir = result
.directories_found
.iter()
.find(|d| d.path == temp_dir.path())
.expect("Expected root directory aggregation");
assert_eq!(root_dir.size_bytes, 0);
assert_eq!(root_dir.file_count, 0);
}
#[tokio::test]
async fn scanner_resolves_symlinks() {
let temp_dir = TempDir::new().expect(
"failed to create temp directory for scanner test - check disk space and permissions",
);
let root = temp_dir.path();
let target_file = root.join("target.txt");
let mut file = File::create(&target_file)
.expect("failed to create test file - check disk space and permissions");
file.write_all(&[0u8; 150])
.expect("failed to write test data to file - disk may be full");
file.sync_all()
.expect("failed to sync test file to disk - check filesystem health");
#[cfg(unix)]
{
use std::os::unix::fs::symlink;
symlink(&target_file, root.join("link.txt")).expect(
"failed to create symlink for test - check filesystem support for symlinks",
);
}
let scanner = Scanner::new();
let result = scanner.scan(root).await.expect(
"scanner should successfully scan test directory - check permissions and disk space",
);
#[cfg(unix)]
{
assert_eq!(result.total_files, 2, "Expected 2 files (target + link)");
assert_eq!(result.total_size_bytes, 300, "Expected 300 bytes (150 * 2)");
}
#[cfg(not(unix))]
{
assert_eq!(result.total_files, 1);
assert_eq!(result.total_size_bytes, 150);
}
}
#[test]
fn cleanup_missing_entries_keeps_undiscovered_paths_if_they_still_exist() {
let temp_dir = TempDir::new().expect(
"failed to create temp directory for scanner test - check disk space and permissions",
);
let root = temp_dir.path();
let file_path = root.join("existing.txt");
File::create(&file_path)
.expect("failed to create test file - check disk space and permissions");
let (_temp, db) = temp_database();
let root_id = db.insert_root(root).expect("insert root");
db.upsert_entry_no_return(root_id, &file_path, root, false, 1, Some(1_700_000_000))
.expect("upsert entry");
let discovered_paths = HashSet::new();
cleanup_missing_entries(&db, root_id, &discovered_paths).expect("cleanup should succeed");
let entry = db
.get_entry_by_path(&file_path)
.expect("query should succeed")
.expect("entry should exist");
assert_eq!(entry.status, "tracked");
}
#[test]
fn cleanup_missing_entries_marks_nonexistent_paths_as_removed() {
let temp_dir = TempDir::new().expect(
"failed to create temp directory for scanner test - check disk space and permissions",
);
let root = temp_dir.path();
let missing_path = root.join("missing.txt");
let (_temp, db) = temp_database();
let root_id = db.insert_root(root).expect("insert root");
db.upsert_entry_no_return(root_id, &missing_path, root, false, 1, Some(1_700_000_000))
.expect("upsert entry");
let discovered_paths = HashSet::new();
cleanup_missing_entries(&db, root_id, &discovered_paths).expect("cleanup should succeed");
let entry = db
.get_entry_by_path(&missing_path)
.expect("query should succeed")
.expect("entry should exist");
assert_eq!(entry.status, "removed");
}
#[tokio::test]
#[cfg(unix)]
async fn scanner_skips_broken_symlinks_gracefully() {
use std::os::unix::fs::symlink;
let temp_dir = TempDir::new().expect(
"failed to create temp directory for scanner test - check disk space and permissions",
);
let root = temp_dir.path();
let mut file = File::create(root.join("valid.txt"))
.expect("failed to create test file - check disk space and permissions");
file.write_all(&[0u8; 100])
.expect("failed to write test data to file - disk may be full");
file.sync_all()
.expect("failed to sync test file to disk - check filesystem health");
symlink("/nonexistent/target", root.join("broken_link"))
.expect("failed to create symlink for test - check filesystem support for symlinks");
let scanner = Scanner::new();
let result = scanner.scan(root).await.expect(
"scanner should successfully scan test directory - check permissions and disk space",
);
assert_eq!(
result.total_files, 1,
"Broken symlink should not be counted"
);
assert_eq!(result.total_size_bytes, 100);
}
#[tokio::test]
async fn scanner_correctly_identifies_oldest_mtime() {
let temp_dir = TempDir::new().expect(
"failed to create temp directory for scanner test - check disk space and permissions",
);
let root = temp_dir.path();
let file1 = root.join("old.txt");
let file2 = root.join("new.txt");
File::create(&file1)
.expect("failed to create test file - check disk space and permissions")
.write_all(&[0u8; 10])
.expect("failed to write test data to file - disk may be full");
File::create(&file2)
.expect("failed to create test file - check disk space and permissions")
.write_all(&[0u8; 10])
.expect("failed to write test data to file - disk may be full");
let old_time = FileTime::from_unix_time(1_000_000_000, 0);
set_file_mtime(&file1, old_time)
.expect("failed to set file modification time for test - check filesystem support");
let scanner = Scanner::new();
let result = scanner.scan(root).await.expect(
"scanner should successfully scan test directory - check permissions and disk space",
);
let root_dir = result
.directories_found
.iter()
.find(|d| d.path == root)
.expect("Root directory should be in results");
let oldest = root_dir
.oldest_mtime
.expect("Root directory should have oldest_mtime");
let expected = jiff::Timestamp::from_second(1_000_000_000)
.expect("timestamp should be valid for test data - check time value is in valid range");
let diff_seconds = (oldest.as_second() - expected.as_second()).abs();
assert!(
diff_seconds <= 1,
"oldest_mtime should be close to the old file's mtime (expected ~{expected}, got {oldest}, diff={diff_seconds}s)"
);
}
#[tokio::test]
async fn scanner_includes_hidden_files() {
let temp_dir = TempDir::new().expect(
"failed to create temp directory for scanner test - check disk space and permissions",
);
let root = temp_dir.path();
let mut hidden = File::create(root.join(".hidden"))
.expect("failed to create test file - check disk space and permissions");
hidden
.write_all(&[0u8; 50])
.expect("failed to write test data to file - disk may be full");
hidden
.sync_all()
.expect("failed to sync test file to disk - check filesystem health");
let scanner = Scanner::new();
let result = scanner.scan(root).await.expect(
"scanner should successfully scan test directory - check permissions and disk space",
);
assert_eq!(result.total_files, 1, "Hidden file should be counted");
assert_eq!(result.total_size_bytes, 50);
}
#[test]
fn calculate_expiration_returns_positive_for_recent_files() {
let now = jiff::Timestamp::now();
let ten_days_ago = now
.checked_sub(jiff::SignedDuration::from_secs(10 * SECS_PER_DAY))
.expect("timestamp arithmetic should succeed for test data - check duration values");
let days_remaining = super::calculate_expiration(ten_days_ago.as_second(), 90);
assert!(
(79..=80).contains(&days_remaining),
"Expected ~80 days remaining, got {days_remaining}"
);
}
#[test]
fn calculate_expiration_returns_negative_for_expired_files() {
let now = jiff::Timestamp::now();
let hundred_days_ago = now
.checked_sub(jiff::SignedDuration::from_secs(100 * SECS_PER_DAY))
.expect("timestamp arithmetic should succeed for test data - check duration values");
let days_remaining = super::calculate_expiration(hundred_days_ago.as_second(), 90);
assert!(
(-11..=-9).contains(&days_remaining),
"Expected ~-10 days remaining, got {days_remaining}"
);
}
#[test]
fn calculate_expiration_returns_zero_on_expiration_day() {
let now = jiff::Timestamp::now();
let ninety_days_ago = now
.checked_sub(jiff::SignedDuration::from_secs(90 * SECS_PER_DAY))
.expect("timestamp arithmetic should succeed for test data - check duration values");
let days_remaining = super::calculate_expiration(ninety_days_ago.as_second(), 90);
assert!(
(-1..=0).contains(&days_remaining),
"Expected 0 days remaining, got {days_remaining}"
);
}
#[test]
fn calculate_expiration_handles_custom_expiration_period() {
let now = jiff::Timestamp::now();
let twenty_days_ago = now
.checked_sub(jiff::SignedDuration::from_secs(20 * SECS_PER_DAY))
.expect("timestamp arithmetic should succeed for test data - check duration values");
let days_remaining = super::calculate_expiration(twenty_days_ago.as_second(), 30);
assert!(
(9..=10).contains(&days_remaining),
"Expected ~10 days remaining, got {days_remaining}"
);
}
#[test]
fn transition_expired_paths_moves_expired_tracked_to_pending() {
let (_temp, db) = temp_database();
let now = jiff::Timestamp::now();
let hundred_days_ago = now
.checked_sub(jiff::SignedDuration::from_secs(100 * SECS_PER_DAY))
.expect("timestamp arithmetic should succeed for test data - check duration values");
let root_id = db.insert_root(Path::new("/data")).expect("insert root");
let entry_id = db
.upsert_entry(
root_id,
Path::new("/data/expired"),
Path::new("/data"),
false,
1024,
Some(hundred_days_ago.as_second()),
)
.expect("insert entry");
db.conn()
.execute(
"UPDATE entries SET countdown_start = ?1 WHERE id = ?2",
(hundred_days_ago.as_second(), entry_id),
)
.expect("failed to backdate countdown_start");
let entry_before = db
.get_entry_by_path(Path::new("/data/expired"))
.expect("failed to query entry from database - connection may be lost")
.expect(
"expected entry to exist after insert - verify database persisted data correctly",
);
assert_eq!(entry_before.status, "tracked");
let app_config = test_app_config(90, 14, false);
let summary = super::transition_expired_paths(&db, &app_config)
.expect("failed to transition expired paths - database connection may be lost");
assert_eq!(summary.expired_to_pending, 1);
assert_eq!(summary.expired_to_approved, 0);
assert_eq!(summary.deferred_reset, 0);
let entry_after = db
.get_entry_by_path(Path::new("/data/expired"))
.expect("failed to query entry from database - connection may be lost")
.expect("expected entry to exist after transition - verify scanner persisted data correctly");
assert_eq!(entry_after.status, "pending");
let audit = crate::audit::AuditService::new(&db);
let entries = audit
.list_by_path(Path::new("/data/expired"))
.expect("failed to query recent audit entries - database connection may be lost");
assert_eq!(entries.len(), 1);
assert!(
entries[0]
.details
.as_ref()
.expect("audit entry should have details field populated")
.contains("pending approval")
);
}
#[test]
fn transition_expired_paths_moves_expired_tracked_to_approved_with_auto_remove() {
let (_temp, db) = temp_database();
let now = jiff::Timestamp::now();
let hundred_days_ago = now
.checked_sub(jiff::SignedDuration::from_secs(100 * SECS_PER_DAY))
.expect("timestamp arithmetic should succeed for test data - check duration values");
let root_id = db.insert_root(Path::new("/data")).expect("insert root");
let entry_id = db
.upsert_entry(
root_id,
Path::new("/data/expired"),
Path::new("/data"),
false,
1024,
Some(hundred_days_ago.as_second()),
)
.expect("failed to insert test entry - database connection may be lost");
db.conn()
.execute(
"UPDATE entries SET countdown_start = ?1 WHERE id = ?2",
(hundred_days_ago.as_second(), entry_id),
)
.expect("failed to backdate countdown_start");
let app_config = test_app_config(90, 14, true);
let summary = super::transition_expired_paths(&db, &app_config)
.expect("failed to transition expired paths - database connection may be lost");
assert_eq!(summary.expired_to_pending, 0);
assert_eq!(summary.expired_to_approved, 1);
assert_eq!(summary.deferred_reset, 0);
let entry = db
.get_entry_by_path(Path::new("/data/expired"))
.expect("failed to query entry from database - connection may be lost")
.expect("expected entry to exist after transition - verify scanner persisted data correctly");
assert_eq!(entry.status, "approved");
}
#[test]
fn transition_expired_paths_does_not_transition_non_expired() {
let (_temp, db) = temp_database();
let now = jiff::Timestamp::now();
let ten_days_ago = now
.checked_sub(jiff::SignedDuration::from_secs(10 * SECS_PER_DAY))
.expect("timestamp arithmetic should succeed for test data - check duration values");
let root_id = db.insert_root(Path::new("/data")).expect("insert root");
db.upsert_entry(
root_id,
Path::new("/data/recent"),
Path::new("/data"),
false,
1024,
Some(ten_days_ago.as_second()),
)
.expect("failed to insert test entry - database connection may be lost");
let app_config = test_app_config(90, 14, false);
let summary = super::transition_expired_paths(&db, &app_config)
.expect("failed to transition expired paths - database connection may be lost");
assert_eq!(summary.expired_to_pending, 0);
assert_eq!(summary.expired_to_approved, 0);
assert_eq!(summary.deferred_reset, 0);
let entry = db
.get_entry_by_path(Path::new("/data/recent"))
.expect("failed to query entry from database - connection may be lost")
.expect("expected entry to exist after transition - verify scanner persisted data correctly");
assert_eq!(entry.status, "tracked");
}
#[test]
fn transition_expired_paths_resets_expired_deferral() {
let (_temp, db) = temp_database();
let now = jiff::Timestamp::now();
let yesterday = now
.checked_sub(jiff::SignedDuration::from_secs(SECS_PER_DAY))
.expect("timestamp arithmetic should succeed for test data - check duration values");
let root_id = db.insert_root(Path::new("/data")).expect("insert root");
let entry_id = db
.upsert_entry(
root_id,
Path::new("/data/deferred"),
Path::new("/data"),
false,
1024,
None,
)
.expect("failed to insert test entry - database connection may be lost");
db.conn()
.execute(
"UPDATE entries SET status = 'deferred', deferred_until = ?1 WHERE id = ?2",
(yesterday.as_second(), entry_id),
)
.expect("failed to update entry status in test - database connection may be lost");
let entry_before = db
.get_entry_by_path(Path::new("/data/deferred"))
.expect("failed to query entry from database - connection may be lost")
.expect(
"expected entry to exist after insert - verify scanner persisted data correctly",
);
assert_eq!(entry_before.status, "deferred");
assert!(entry_before.deferred_until.is_some());
let app_config = test_app_config(90, 14, false);
let summary = super::transition_expired_paths(&db, &app_config)
.expect("failed to transition expired paths - database connection may be lost");
assert_eq!(summary.expired_to_pending, 0);
assert_eq!(summary.expired_to_approved, 0);
assert_eq!(summary.deferred_reset, 1);
let entry_after = db
.get_entry_by_path(Path::new("/data/deferred"))
.expect("failed to query entry from database - connection may be lost")
.expect("expected entry to exist after transition - verify scanner persisted data correctly");
assert_eq!(entry_after.status, "tracked");
assert_eq!(entry_after.deferred_until, None);
}
#[test]
fn transition_expired_paths_does_not_reset_active_deferral() {
let (_temp, db) = temp_database();
let now = jiff::Timestamp::now();
let next_week = now
.checked_add(jiff::SignedDuration::from_secs(7 * SECS_PER_DAY))
.expect("timestamp arithmetic should succeed for test data - check duration values");
let root_id = db.insert_root(Path::new("/data")).expect("insert root");
let entry_id = db
.upsert_entry(
root_id,
Path::new("/data/deferred"),
Path::new("/data"),
false,
1024,
None,
)
.expect("failed to insert test entry - database connection may be lost");
db.conn()
.execute(
"UPDATE entries SET status = 'deferred', deferred_until = ?1 WHERE id = ?2",
(next_week.as_second(), entry_id),
)
.expect("failed to update entry status in test - database connection may be lost");
let app_config = test_app_config(90, 14, false);
let summary = super::transition_expired_paths(&db, &app_config)
.expect("failed to transition expired paths - database connection may be lost");
assert_eq!(summary.expired_to_pending, 0);
assert_eq!(summary.expired_to_approved, 0);
assert_eq!(summary.deferred_reset, 0);
let entry = db
.get_entry_by_path(Path::new("/data/deferred"))
.expect("failed to query entry from database - connection may be lost")
.expect("expected entry to exist after transition - verify scanner persisted data correctly");
assert_eq!(entry.status, "deferred");
assert_eq!(entry.deferred_until, Some(next_week.as_second()));
}
#[test]
fn transition_expired_paths_ignores_ignored_status() {
let (_temp, db) = temp_database();
let now = jiff::Timestamp::now();
let hundred_days_ago = now
.checked_sub(jiff::SignedDuration::from_secs(100 * SECS_PER_DAY))
.expect("timestamp arithmetic should succeed for test data - check duration values");
let root_id = db.insert_root(Path::new("/data")).expect("insert root");
let entry_id = db
.upsert_entry(
root_id,
Path::new("/data/ignored"),
Path::new("/data"),
false,
1024,
Some(hundred_days_ago.as_second()),
)
.expect("failed to insert test entry - database connection may be lost");
db.update_entry_status(entry_id, "ignored")
.expect("failed to update entry status - database connection may be lost");
let app_config = test_app_config(90, 14, false);
let summary = super::transition_expired_paths(&db, &app_config)
.expect("failed to transition expired paths - database connection may be lost");
assert_eq!(summary.expired_to_pending, 0);
assert_eq!(summary.expired_to_approved, 0);
assert_eq!(summary.deferred_reset, 0);
let entry = db
.get_entry_by_path(Path::new("/data/ignored"))
.expect("failed to query entry from database - connection may be lost")
.expect("expected entry to exist after transition - verify scanner persisted data correctly");
assert_eq!(entry.status, "ignored");
}
#[test]
fn transition_expired_paths_handles_multiple_entries() {
let (_temp, db) = temp_database();
let now = jiff::Timestamp::now();
let hundred_days_ago = now
.checked_sub(jiff::SignedDuration::from_secs(100 * SECS_PER_DAY))
.expect("timestamp arithmetic should succeed for test data - check duration values");
let ten_days_ago = now
.checked_sub(jiff::SignedDuration::from_secs(10 * SECS_PER_DAY))
.expect("timestamp arithmetic should succeed for test data - check duration values");
let yesterday = now
.checked_sub(jiff::SignedDuration::from_secs(SECS_PER_DAY))
.expect("timestamp arithmetic should succeed for test data - check duration values");
let root_id = db.insert_root(Path::new("/data")).expect("insert root");
let expired1_id = db
.upsert_entry(
root_id,
Path::new("/data/expired1"),
Path::new("/data"),
false,
1024,
Some(hundred_days_ago.as_second()),
)
.expect("failed to insert test entry - database connection may be lost");
let expired2_id = db
.upsert_entry(
root_id,
Path::new("/data/expired2"),
Path::new("/data"),
false,
2048,
Some(hundred_days_ago.as_second()),
)
.expect("failed to insert test entry - database connection may be lost");
db.conn()
.execute(
"UPDATE entries SET countdown_start = ?1 WHERE id IN (?2, ?3)",
(hundred_days_ago.as_second(), expired1_id, expired2_id),
)
.expect("failed to backdate countdown_start");
db.upsert_entry(
root_id,
Path::new("/data/recent"),
Path::new("/data"),
false,
512,
Some(ten_days_ago.as_second()),
)
.expect("failed to insert test entry - database connection may be lost");
let deferred_id = db
.upsert_entry(
root_id,
Path::new("/data/deferred"),
Path::new("/data"),
false,
256,
None,
)
.expect("failed to insert test entry - database connection may be lost");
db.conn()
.execute(
"UPDATE entries SET status = 'deferred', deferred_until = ?1 WHERE id = ?2",
(yesterday.as_second(), deferred_id),
)
.expect("failed to update entry status in test - database connection may be lost");
let app_config = test_app_config(90, 14, false);
let summary = super::transition_expired_paths(&db, &app_config)
.expect("failed to transition expired paths - database connection may be lost");
assert_eq!(summary.expired_to_pending, 2);
assert_eq!(summary.expired_to_approved, 0);
assert_eq!(summary.deferred_reset, 1);
assert_eq!(
db.get_entry_by_path(Path::new("/data/expired1"))
.expect("failed to query entry from database - connection may be lost")
.expect("expected entry to exist after transition - verify scanner persisted data correctly")
.status,
"pending"
);
assert_eq!(
db.get_entry_by_path(Path::new("/data/expired2"))
.expect("failed to query entry from database - connection may be lost")
.expect("expected entry to exist after transition - verify scanner persisted data correctly")
.status,
"pending"
);
assert_eq!(
db.get_entry_by_path(Path::new("/data/recent"))
.expect("failed to query entry from database - connection may be lost")
.expect("expected entry to exist after transition - verify scanner persisted data correctly")
.status,
"tracked"
);
assert_eq!(
db.get_entry_by_path(Path::new("/data/deferred"))
.expect("failed to query entry from database - connection may be lost")
.expect("expected entry to exist after transition - verify scanner persisted data correctly")
.status,
"tracked"
);
}
#[test]
fn transition_expired_paths_handles_entry_without_mtime() {
let (_temp, db) = temp_database();
let root_id = db.insert_root(Path::new("/data")).expect("insert root");
db.upsert_entry(
root_id,
Path::new("/data/no_mtime"),
Path::new("/data"),
false,
0,
None,
)
.expect("failed to insert test entry - database connection may be lost");
let app_config = test_app_config(90, 14, false);
let summary = super::transition_expired_paths(&db, &app_config)
.expect("failed to transition expired paths - database connection may be lost");
assert_eq!(summary.expired_to_pending, 0);
assert_eq!(summary.expired_to_approved, 0);
assert_eq!(summary.deferred_reset, 0);
let entry = db
.get_entry_by_path(Path::new("/data/no_mtime"))
.expect("failed to query entry from database - connection may be lost")
.expect("expected entry to exist after transition - verify scanner persisted data correctly");
assert_eq!(entry.status, "tracked");
}
#[test]
fn transition_expired_paths_ignores_directories() {
let (_temp, db) = temp_database();
let root_id = db.insert_root(Path::new("/data")).expect("insert root");
db.upsert_entry(
root_id,
Path::new("/data/subdir"),
Path::new("/data"),
true,
0,
None,
)
.expect("failed to insert test entry - database connection may be lost");
let app_config = test_app_config(90, 14, false);
let summary = super::transition_expired_paths(&db, &app_config)
.expect("failed to transition expired paths - database connection may be lost");
assert_eq!(summary.expired_to_pending, 0);
assert_eq!(summary.expired_to_approved, 0);
assert_eq!(summary.deferred_reset, 0);
}
#[test]
fn transition_expired_paths_ignores_pending_approved_removed_blocked() {
let (_temp, db) = temp_database();
let now = jiff::Timestamp::now();
let hundred_days_ago = now
.checked_sub(jiff::SignedDuration::from_secs(100 * SECS_PER_DAY))
.expect("timestamp arithmetic should succeed for test data - check duration values");
let root_id = db.insert_root(Path::new("/data")).expect("insert root");
for (path, status) in [
("/data/pending", "pending"),
("/data/approved", "approved"),
("/data/removed", "removed"),
("/data/blocked", "blocked"),
] {
let entry_id = db
.upsert_entry(
root_id,
Path::new(path),
Path::new("/data"),
false,
1024,
Some(hundred_days_ago.as_second()),
)
.expect("failed to insert test entry - database connection may be lost");
db.update_entry_status(entry_id, status)
.expect("failed to update entry status - database connection may be lost");
}
let app_config = test_app_config(90, 14, false);
let summary = super::transition_expired_paths(&db, &app_config)
.expect("failed to transition expired paths - database connection may be lost");
assert_eq!(summary.expired_to_pending, 0);
assert_eq!(summary.expired_to_approved, 0);
assert_eq!(summary.deferred_reset, 0);
for (path, expected_status) in [
("/data/pending", "pending"),
("/data/approved", "approved"),
("/data/removed", "removed"),
("/data/blocked", "blocked"),
] {
let entry = db.get_entry_by_path(Path::new(path)).expect("failed to query entry from database - connection may be lost").expect("expected entry to exist after transition - verify scanner persisted data correctly");
assert_eq!(
entry.status, expected_status,
"Status for {path} should be unchanged"
);
}
}
#[test]
fn transition_expired_paths_handles_deferred_with_null_deferred_until() {
let (_temp, db) = temp_database();
let root_id = db.insert_root(Path::new("/data")).expect("insert root");
let entry_id = db
.upsert_entry(
root_id,
Path::new("/data/deferred-null"),
Path::new("/data"),
false,
1024,
None,
)
.expect("failed to insert test entry - database connection may be lost");
db.conn()
.execute(
"UPDATE entries SET status = 'deferred' WHERE id = ?1",
(entry_id,),
)
.expect("failed to update entry status in test - database connection may be lost");
let app_config = test_app_config(90, 14, false);
let summary = super::transition_expired_paths(&db, &app_config)
.expect("failed to transition expired paths - database connection may be lost");
assert_eq!(summary.deferred_reset, 0);
let entry = db
.get_entry_by_path(Path::new("/data/deferred-null"))
.expect("failed to query entry from database - connection may be lost")
.expect("expected entry to exist after transition - verify scanner persisted data correctly");
assert_eq!(entry.status, "deferred");
}
#[test]
fn transition_expired_paths_handles_empty_database() {
let (_temp, db) = temp_database();
let app_config = test_app_config(90, 14, false);
let summary = super::transition_expired_paths(&db, &app_config)
.expect("failed to transition expired paths - database connection may be lost");
assert_eq!(summary.expired_to_pending, 0);
assert_eq!(summary.expired_to_approved, 0);
assert_eq!(summary.deferred_reset, 0);
}
#[tokio::test]
async fn scan_and_persist_creates_root_and_entries() {
let temp_dir = TempDir::new().expect(
"failed to create temp directory for scanner test - check disk space and permissions",
);
let root = temp_dir.path();
let project_dir = root.join("project");
fs::create_dir(&project_dir)
.expect("failed to create test directory - check disk space and permissions");
let mut file1 = File::create(project_dir.join("file1.txt"))
.expect("failed to create test file - check disk space and permissions");
file1
.write_all(&[0u8; 100])
.expect("failed to write test data to file - disk may be full");
file1
.sync_all()
.expect("failed to sync test file to disk - check filesystem health");
let db_path = root.join("test.db");
let db = Database::open(&db_path)
.expect("failed to open test database - check permissions and disk space");
let scanner = Scanner::new();
let app_config = test_app_config_with_paths(vec![project_dir.clone()], 90, 14);
let summary = scan_and_persist(&db, &scanner, &app_config)
.await
.expect("failed to scan and persist - check permissions and database connection");
assert_eq!(summary.total_directories, 1);
assert_eq!(summary.total_files, 1);
assert_eq!(summary.total_size_bytes, 100);
let roots = db.list_roots().expect("failed to list roots");
assert_eq!(roots.len(), 1);
assert_eq!(roots[0].path, project_dir);
assert!(roots[0].last_scanned.is_some());
let dir_entry = db
.get_entry_by_path(&project_dir)
.expect("failed to query entry from database - connection may be lost")
.expect("expected directory entry to exist after scan");
assert!(dir_entry.is_dir);
assert_eq!(dir_entry.size_bytes, 100);
assert_eq!(dir_entry.status, "tracked");
let file_path = project_dir.join("file1.txt");
let file_entry = db
.get_entry_by_path(&file_path)
.expect("failed to query entry from database - connection may be lost")
.expect("expected file entry to exist after scan");
assert!(!file_entry.is_dir);
assert_eq!(file_entry.size_bytes, 100);
assert!(file_entry.mtime.is_some());
assert!(file_entry.tracked_since.is_some());
assert_eq!(file_entry.status, "tracked");
}
#[tokio::test]
async fn scan_and_persist_creates_file_entries() {
let temp_dir = TempDir::new().expect(
"failed to create temp directory for scanner test - check disk space and permissions",
);
let root = temp_dir.path();
let project_dir = root.join("project");
fs::create_dir(&project_dir)
.expect("failed to create test directory - check disk space and permissions");
File::create(project_dir.join("a.txt"))
.expect("failed to create test file - check disk space and permissions")
.write_all(&[0u8; 10])
.expect("failed to write test data to file - disk may be full");
File::create(project_dir.join("b.txt"))
.expect("failed to create test file - check disk space and permissions")
.write_all(&[0u8; 20])
.expect("failed to write test data to file - disk may be full");
let db_path = root.join("test.db");
let db = Database::open(&db_path)
.expect("failed to open test database - check permissions and disk space");
let scanner = Scanner::new();
let app_config = test_app_config_with_paths(vec![project_dir.clone()], 90, 14);
let _summary = scan_and_persist(&db, &scanner, &app_config)
.await
.expect("failed to scan and persist - check permissions and database connection");
let entries = db
.list_entries_by_parent(&project_dir)
.expect("failed to list entries from database - connection may be lost");
assert_eq!(entries.len(), 2, "Expected 2 file entries");
assert!(entries[0].path.ends_with("a.txt"));
assert_eq!(entries[0].size_bytes, 10);
assert!(!entries[0].is_dir);
assert!(entries[1].path.ends_with("b.txt"));
assert_eq!(entries[1].size_bytes, 20);
assert!(!entries[1].is_dir);
}
#[tokio::test]
async fn scan_and_persist_sets_recursive_directory_sizes() {
let temp_dir = TempDir::new().expect(
"failed to create temp directory for scanner test - check disk space and permissions",
);
let root = temp_dir.path();
let project_dir = root.join("project");
let nested_dir = project_dir.join("nested");
fs::create_dir(&project_dir)
.expect("failed to create project directory - check disk space and permissions");
fs::create_dir(&nested_dir)
.expect("failed to create nested directory - check disk space and permissions");
File::create(nested_dir.join("a.txt"))
.expect("failed to create nested file - check disk space and permissions")
.write_all(&[0u8; 10])
.expect("failed to write test data to nested file - disk may be full");
File::create(nested_dir.join("b.txt"))
.expect("failed to create nested file - check disk space and permissions")
.write_all(&[0u8; 20])
.expect("failed to write test data to nested file - disk may be full");
let db_path = root.join("test.db");
let db = Database::open(&db_path)
.expect("failed to open test database - check permissions and disk space");
let scanner = Scanner::new();
let app_config = test_app_config_with_paths(vec![project_dir.clone()], 90, 14);
let _summary = scan_and_persist(&db, &scanner, &app_config)
.await
.expect("failed to scan and persist - check permissions and database connection");
let project_entry = db
.get_entry_by_path(&project_dir)
.expect("failed to query project entry")
.expect("project directory should be persisted");
let nested_entry = db
.get_entry_by_path(&nested_dir)
.expect("failed to query nested entry")
.expect("nested directory should be persisted");
assert!(project_entry.is_dir);
assert!(nested_entry.is_dir);
assert_eq!(project_entry.size_bytes, 30);
assert_eq!(nested_entry.size_bytes, 30);
}
#[tokio::test]
async fn scan_and_persist_persists_empty_subdirectories() {
let temp_dir = TempDir::new().expect(
"failed to create temp directory for scanner test - check disk space and permissions",
);
let root = temp_dir.path();
let project_dir = root.join("project");
let empty_dir = project_dir.join("empty");
fs::create_dir(&project_dir)
.expect("failed to create project directory - check disk space and permissions");
fs::create_dir(&empty_dir)
.expect("failed to create empty directory - check disk space and permissions");
let db_path = root.join("test.db");
let db = Database::open(&db_path)
.expect("failed to open test database - check permissions and disk space");
let scanner = Scanner::new();
let app_config = test_app_config_with_paths(vec![project_dir.clone()], 90, 14);
let _summary = scan_and_persist(&db, &scanner, &app_config)
.await
.expect("failed to scan and persist - check permissions and database connection");
let empty_entry = db
.get_entry_by_path(&empty_dir)
.expect("failed to query empty directory entry")
.expect("empty directory should be persisted");
assert!(empty_entry.is_dir);
assert_eq!(empty_entry.size_bytes, 0);
}
#[tokio::test]
async fn scan_and_persist_new_files_in_ignored_directory_stay_ignored() {
let temp_dir = TempDir::new().expect(
"failed to create temp directory for scanner test - check disk space and permissions",
);
let root = temp_dir.path();
let project_dir = root.join("project");
fs::create_dir(&project_dir)
.expect("failed to create project directory - check disk space and permissions");
let db_path = root.join("test.db");
let db = Database::open(&db_path)
.expect("failed to open test database - check permissions and disk space");
let scanner = Scanner::new();
let app_config = test_app_config_with_paths(vec![project_dir.clone()], 90, 14);
let _summary = scan_and_persist(&db, &scanner, &app_config)
.await
.expect("failed to scan and persist - check permissions and database connection");
let project_entry = db
.get_entry_by_path(&project_dir)
.expect("failed to query project directory entry")
.expect("project directory should be persisted");
db.update_entry_status(project_entry.id, "ignored")
.expect("failed to mark directory ignored");
let child_path = project_dir.join("new.txt");
File::create(&child_path)
.expect("failed to create child file - check permissions and disk space")
.write_all(&[0u8; 10])
.expect("failed to write child file - disk may be full");
let _summary = scan_and_persist(&db, &scanner, &app_config)
.await
.expect("failed to rescan and persist - check permissions and database connection");
let child_entry = db
.get_entry_by_path(&child_path)
.expect("failed to query child entry")
.expect("child file should be persisted");
assert_eq!(child_entry.status, "ignored");
}
#[tokio::test]
async fn scan_and_persist_updates_stats_table() {
let temp_dir = TempDir::new().expect(
"failed to create temp directory for scanner test - check disk space and permissions",
);
let root = temp_dir.path();
let project_dir = root.join("project");
fs::create_dir(&project_dir)
.expect("failed to create test directory - check disk space and permissions");
File::create(project_dir.join("file.txt"))
.expect("failed to create test file - check disk space and permissions")
.write_all(&[0u8; 500])
.expect("failed to write test data to file - disk may be full");
let db_path = root.join("test.db");
let db = Database::open(&db_path)
.expect("failed to open test database - check permissions and disk space");
let scanner = Scanner::new();
let app_config = test_app_config_with_paths(vec![project_dir], 90, 14);
let _summary = scan_and_persist(&db, &scanner, &app_config)
.await
.expect("failed to scan and persist - check permissions and database connection");
let stats = db
.get_stats()
.expect("failed to query stats from database - connection may be lost");
assert_eq!(stats.total_files, 1, "Expected 1 tracked file");
assert_eq!(stats.total_size_bytes, 500, "Expected 500 bytes total");
assert!(
stats.last_scan_completed.is_some(),
"Expected last_scan_completed to be set"
);
}
#[tokio::test]
async fn scan_and_persist_records_audit_entry() {
use crate::audit::AuditService;
let temp_dir = TempDir::new().expect(
"failed to create temp directory for scanner test - check disk space and permissions",
);
let root = temp_dir.path();
let project_dir = root.join("project");
fs::create_dir(&project_dir)
.expect("failed to create test directory - check disk space and permissions");
File::create(project_dir.join("file.txt"))
.expect("failed to create test file - check disk space and permissions")
.write_all(&[0u8; 100])
.expect("failed to write test data to file - disk may be full");
let db_path = root.join("test.db");
let db = Database::open(&db_path)
.expect("failed to open test database - check permissions and disk space");
let scanner = Scanner::new();
let app_config = test_app_config_with_paths(vec![project_dir], 90, 14);
let _summary = scan_and_persist(&db, &scanner, &app_config)
.await
.expect("failed to scan and persist - check permissions and database connection");
let audit = AuditService::new(&db);
let entries = audit
.list_recent(10)
.expect("failed to query recent audit entries - database connection may be lost");
assert_eq!(entries.len(), 1, "Expected 1 audit entry");
assert_eq!(entries[0].action, "scan");
assert!(entries[0].details.is_some());
assert!(
entries[0]
.details
.as_ref()
.expect("audit entry should have details field populated")
.contains("1 directories"),
"Expected details to mention directories"
);
}
#[tokio::test]
async fn scan_and_persist_handles_multiple_paths() {
let temp_dir = TempDir::new().expect(
"failed to create temp directory for scanner test - check disk space and permissions",
);
let root = temp_dir.path();
let dir1 = root.join("project1");
let dir2 = root.join("project2");
fs::create_dir(&dir1)
.expect("failed to create test directory - check disk space and permissions");
fs::create_dir(&dir2)
.expect("failed to create test directory - check disk space and permissions");
File::create(dir1.join("file1.txt"))
.expect("failed to create test file - check disk space and permissions")
.write_all(&[0u8; 100])
.expect("failed to write test data to file - disk may be full");
File::create(dir2.join("file2.txt"))
.expect("failed to create test file - check disk space and permissions")
.write_all(&[0u8; 200])
.expect("failed to write test data to file - disk may be full");
let db_path = root.join("test.db");
let db = Database::open(&db_path)
.expect("failed to open test database - check permissions and disk space");
let scanner = Scanner::new();
let app_config = test_app_config_with_paths(vec![dir1.clone(), dir2.clone()], 90, 14);
let summary = scan_and_persist(&db, &scanner, &app_config)
.await
.expect("failed to scan and persist - check permissions and database connection");
assert_eq!(summary.total_directories, 2);
assert_eq!(summary.total_files, 2);
assert_eq!(summary.total_size_bytes, 300);
let roots = db.list_roots().expect("failed to list roots");
assert_eq!(roots.len(), 2);
assert!(
db.get_entry_by_path(&dir1)
.expect("failed to query entry from database - connection may be lost")
.is_some()
);
assert!(
db.get_entry_by_path(&dir2)
.expect("failed to query entry from database - connection may be lost")
.is_some()
);
}
#[tokio::test]
async fn scan_and_persist_upserts_existing_entries() {
let temp_dir = TempDir::new().expect(
"failed to create temp directory for scanner test - check disk space and permissions",
);
let root = temp_dir.path();
let project_dir = root.join("project");
fs::create_dir(&project_dir)
.expect("failed to create test directory - check disk space and permissions");
File::create(project_dir.join("file1.txt"))
.expect("failed to create test file - check disk space and permissions")
.write_all(&[0u8; 100])
.expect("failed to write test data to file - disk may be full");
let db_path = root.join("test.db");
let db = Database::open(&db_path)
.expect("failed to open test database - check permissions and disk space");
let scanner = Scanner::new();
let app_config = test_app_config_with_paths(vec![project_dir.clone()], 90, 14);
let _summary = scan_and_persist(&db, &scanner, &app_config)
.await
.expect("failed to scan and persist - check permissions and database connection");
let file_path = project_dir.join("file1.txt");
let entry = db
.get_entry_by_path(&file_path)
.expect("failed to query entry from database - connection may be lost")
.expect("expected entry to exist after scan");
db.update_entry_status(entry.id, "approved")
.expect("failed to update entry status - database connection may be lost");
File::create(project_dir.join("file2.txt"))
.expect("failed to create test file - check disk space and permissions")
.write_all(&[0u8; 50])
.expect("failed to write test data to file - disk may be full");
let _summary = scan_and_persist(&db, &scanner, &app_config)
.await
.expect("failed to scan and persist - check permissions and database connection");
let updated_entry = db
.get_entry_by_path(&file_path)
.expect("failed to query entry from database - connection may be lost")
.expect("expected entry to exist after scan");
assert_eq!(updated_entry.id, entry.id, "ID should not change");
assert_eq!(
updated_entry.status, "approved",
"Status should be preserved"
);
}
#[tokio::test]
async fn stats_update_calculates_files_within_warning() {
let temp_dir = TempDir::new().expect(
"failed to create temp directory for scanner test - check disk space and permissions",
);
let root = temp_dir.path();
let warning_dir = root.join("warning");
fs::create_dir(&warning_dir)
.expect("failed to create test directory - check disk space and permissions");
File::create(warning_dir.join("old.txt"))
.expect("failed to create test file - check disk space and permissions")
.write_all(&[0u8; 100])
.expect("failed to write test data to file - disk may be full");
let db_path = root.join("test.db");
let db = Database::open(&db_path)
.expect("failed to open test database - check permissions and disk space");
let scanner = Scanner::new();
let app_config = test_app_config_with_paths(vec![warning_dir.clone()], 90, 14);
let _summary = scan_and_persist(&db, &scanner, &app_config)
.await
.expect("failed to scan and persist - check permissions and database connection");
let now = jiff::Timestamp::now();
let eighty_days_ago = now
.checked_sub(jiff::SignedDuration::from_secs(80 * SECS_PER_DAY))
.expect("timestamp arithmetic should succeed");
db.conn()
.execute(
"UPDATE entries SET countdown_start = ?1 WHERE is_dir = 0",
(eighty_days_ago.as_second(),),
)
.expect("failed to update countdown_start for test");
let _summary = scan_and_persist(&db, &scanner, &app_config)
.await
.expect("failed to scan and persist - check permissions and database connection");
let stats = db
.get_stats()
.expect("failed to query stats from database - connection may be lost");
assert_eq!(stats.total_files, 1);
assert_eq!(
stats.files_within_warning, 1,
"File tracked for 80 days should be in warning period (10 days remaining, within 14-day warning)"
);
assert_eq!(stats.files_pending_approval, 0);
assert_eq!(stats.files_overdue, 0);
}
#[tokio::test]
async fn stats_update_calculates_files_pending_approval() {
let temp_dir = TempDir::new().expect(
"failed to create temp directory for scanner test - check disk space and permissions",
);
let root = temp_dir.path();
let pending_dir = root.join("pending");
fs::create_dir(&pending_dir)
.expect("failed to create test directory - check disk space and permissions");
File::create(pending_dir.join("file.txt"))
.expect("failed to create test file - check disk space and permissions")
.write_all(&[0u8; 50])
.expect("failed to write test data to file - disk may be full");
let db_path = root.join("test.db");
let db = Database::open(&db_path)
.expect("failed to open test database - check permissions and disk space");
let scanner = Scanner::new();
let app_config = test_app_config_with_paths(vec![pending_dir.clone()], 90, 14);
let _summary = scan_and_persist(&db, &scanner, &app_config)
.await
.expect("failed to scan and persist - check permissions and database connection");
let file_path = pending_dir.join("file.txt");
let entry = db
.get_entry_by_path(&file_path)
.expect("failed to query entry from database - connection may be lost")
.expect("expected entry to exist after scan");
db.update_entry_status(entry.id, "pending")
.expect("failed to update entry status - database connection may be lost");
let _summary = scan_and_persist(&db, &scanner, &app_config)
.await
.expect("failed to scan and persist - check permissions and database connection");
let stats = db
.get_stats()
.expect("failed to query stats from database - connection may be lost");
assert_eq!(stats.files_pending_approval, 1);
}
#[tokio::test]
async fn stats_update_calculates_files_overdue() {
let temp_dir = TempDir::new().expect(
"failed to create temp directory for scanner test - check disk space and permissions",
);
let root = temp_dir.path();
let overdue_dir = root.join("overdue");
fs::create_dir(&overdue_dir)
.expect("failed to create test directory - check disk space and permissions");
File::create(overdue_dir.join("file.txt"))
.expect("failed to create test file - check disk space and permissions")
.write_all(&[0u8; 100])
.expect("failed to write test data to file - disk may be full");
let db_path = root.join("test.db");
let db = Database::open(&db_path)
.expect("failed to open test database - check permissions and disk space");
let scanner = Scanner::new();
let app_config = test_app_config_with_paths(vec![overdue_dir.clone()], 90, 14);
let _summary = scan_and_persist(&db, &scanner, &app_config)
.await
.expect("failed to scan and persist - check permissions and database connection");
let now = jiff::Timestamp::now();
let hundred_days_ago = now
.checked_sub(jiff::SignedDuration::from_secs(100 * SECS_PER_DAY))
.expect("timestamp arithmetic should succeed");
db.conn()
.execute(
"UPDATE entries SET countdown_start = ?1 WHERE is_dir = 0",
(hundred_days_ago.as_second(),),
)
.expect("failed to update countdown_start for test");
let _summary = scan_and_persist(&db, &scanner, &app_config)
.await
.expect("failed to scan and persist - check permissions and database connection");
let stats = db
.get_stats()
.expect("failed to query stats from database - connection may be lost");
assert_eq!(stats.total_files, 1);
assert_eq!(
stats.files_overdue, 1,
"File tracked for 100 days should be overdue"
);
assert_eq!(stats.files_pending_approval, 0);
assert_eq!(stats.files_within_warning, 0);
}
#[tokio::test]
async fn stats_update_handles_mixed_scenarios() {
let temp_dir = TempDir::new().expect(
"failed to create temp directory for scanner test - check disk space and permissions",
);
let root = temp_dir.path();
let now = jiff::Timestamp::now();
let safe_dir = root.join("safe");
fs::create_dir(&safe_dir)
.expect("failed to create test directory - check disk space and permissions");
File::create(safe_dir.join("recent.txt"))
.expect("failed to create test file - check disk space and permissions")
.write_all(&[0u8; 50])
.expect("failed to write test data to file - disk may be full");
let warning_dir = root.join("warning");
fs::create_dir(&warning_dir)
.expect("failed to create test directory - check disk space and permissions");
File::create(warning_dir.join("warning.txt"))
.expect("failed to create test file - check disk space and permissions")
.write_all(&[0u8; 50])
.expect("failed to write test data to file - disk may be full");
let eighty_days_ago = now
.checked_sub(jiff::SignedDuration::from_secs(80 * SECS_PER_DAY))
.expect("timestamp arithmetic should succeed for test data - check duration values");
set_file_mtime(
warning_dir.join("warning.txt"),
FileTime::from_unix_time(eighty_days_ago.as_second(), 0),
)
.expect("failed to set file modification time for test - check filesystem support");
let overdue_dir = root.join("overdue");
fs::create_dir(&overdue_dir)
.expect("failed to create test directory - check disk space and permissions");
File::create(overdue_dir.join("overdue.txt"))
.expect("failed to create test file - check disk space and permissions")
.write_all(&[0u8; 50])
.expect("failed to write test data to file - disk may be full");
let hundred_days_ago = now
.checked_sub(jiff::SignedDuration::from_secs(100 * SECS_PER_DAY))
.expect("timestamp arithmetic should succeed for test data - check duration values");
set_file_mtime(
overdue_dir.join("overdue.txt"),
FileTime::from_unix_time(hundred_days_ago.as_second(), 0),
)
.expect("failed to set file modification time for test - check filesystem support");
let db_path = root.join("test.db");
let db = Database::open(&db_path)
.expect("failed to open test database - check permissions and disk space");
let scanner = Scanner::new();
let app_config = test_app_config_with_paths(
vec![safe_dir.clone(), warning_dir.clone(), overdue_dir.clone()],
90,
14,
);
let _summary = scan_and_persist(&db, &scanner, &app_config)
.await
.expect("failed to scan and persist - check permissions and database connection");
let overdue_file_path = overdue_dir.join("overdue.txt");
db.conn()
.execute(
"UPDATE entries SET countdown_start = ?1 WHERE path = ?2",
(
hundred_days_ago.as_second(),
overdue_file_path.to_string_lossy().as_ref(),
),
)
.expect("failed to backdate countdown_start");
let warning_file_path = warning_dir.join("warning.txt");
let entry = db
.get_entry_by_path(&warning_file_path)
.expect("failed to query entry from database - connection may be lost")
.expect("expected entry to exist after scan");
db.update_entry_status(entry.id, "pending")
.expect("failed to update entry status - database connection may be lost");
let _summary = scan_and_persist(&db, &scanner, &app_config)
.await
.expect("failed to scan and persist - check permissions and database connection");
let stats = db
.get_stats()
.expect("failed to query stats from database - connection may be lost");
assert_eq!(stats.total_files, 3);
assert_eq!(stats.files_overdue, 1, "One overdue file");
assert_eq!(stats.files_pending_approval, 1, "One pending file");
assert_eq!(
stats.files_within_warning, 0,
"Warning file was marked pending"
);
}
#[tokio::test]
async fn stats_update_excludes_ignored_from_overdue_count() {
let temp_dir = TempDir::new().expect(
"failed to create temp directory for scanner test - check disk space and permissions",
);
let root = temp_dir.path();
let ignored_dir = root.join("ignored");
fs::create_dir(&ignored_dir)
.expect("failed to create test directory - check disk space and permissions");
File::create(ignored_dir.join("old.txt"))
.expect("failed to create test file - check disk space and permissions")
.write_all(&[0u8; 50])
.expect("failed to write test data to file - disk may be full");
let now = jiff::Timestamp::now();
let hundred_days_ago = now
.checked_sub(jiff::SignedDuration::from_secs(100 * SECS_PER_DAY))
.expect("timestamp arithmetic should succeed for test data - check duration values");
set_file_mtime(
ignored_dir.join("old.txt"),
FileTime::from_unix_time(hundred_days_ago.as_second(), 0),
)
.expect("failed to set file modification time for test - check filesystem support");
let db_path = root.join("test.db");
let db = Database::open(&db_path)
.expect("failed to open test database - check permissions and disk space");
let scanner = Scanner::new();
let app_config = test_app_config_with_paths(vec![ignored_dir.clone()], 90, 14);
let _summary = scan_and_persist(&db, &scanner, &app_config)
.await
.expect("failed to scan and persist - check permissions and database connection");
let file_path = ignored_dir.join("old.txt");
let entry = db
.get_entry_by_path(&file_path)
.expect("failed to query entry from database - connection may be lost")
.expect("expected entry to exist after scan");
db.update_entry_status(entry.id, "ignored")
.expect("failed to update entry status - database connection may be lost");
let _summary = scan_and_persist(&db, &scanner, &app_config)
.await
.expect("failed to scan and persist - check permissions and database connection");
let stats = db
.get_stats()
.expect("failed to query stats from database - connection may be lost");
assert_eq!(
stats.files_overdue, 0,
"Ignored files should not be counted as overdue"
);
}
#[tokio::test]
async fn stats_update_custom_expiration_warning_periods() {
let temp_dir = TempDir::new().expect("failed to create temp directory for test - check disk space and system temp directory permissions");
let root = temp_dir.path();
let dir = root.join("test");
fs::create_dir(&dir).expect("failed to create test directory - check disk space and write permissions on temp directory");
File::create(dir.join("file.txt"))
.expect("failed to create test file - check disk space and permissions")
.write_all(&[0u8; 50])
.expect("failed to write test data - disk may be full or readonly");
let db_path = root.join("test.db");
let db = Database::open(&db_path)
.expect("failed to initialize database - check disk space and SQLite is functioning");
let scanner = Scanner::new();
let app_config = test_app_config_with_paths(vec![dir.clone()], 30, 7);
let _summary = scan_and_persist(&db, &scanner, &app_config)
.await
.expect("scan_and_persist failed - check file permissions and database connection");
let now = jiff::Timestamp::now();
let twentyfive_days_ago = now
.checked_sub(jiff::SignedDuration::from_secs(25 * SECS_PER_DAY))
.expect("timestamp arithmetic overflow");
db.conn()
.execute(
"UPDATE entries SET countdown_start = ?1 WHERE is_dir = 0",
(twentyfive_days_ago.as_second(),),
)
.expect("failed to update countdown_start for test");
let _summary = scan_and_persist(&db, &scanner, &app_config)
.await
.expect("scan_and_persist failed - check file permissions and database connection");
let stats = db.get_stats().expect(
"failed to query stats from database - connection may be lost or stats table corrupted",
);
assert_eq!(stats.total_files, 1);
assert_eq!(
stats.files_within_warning, 1,
"With 30-day expiration and 7-day warning, file tracked for 25 days (5 days remaining) should be in warning"
);
assert_eq!(stats.files_overdue, 0);
}
#[tokio::test]
async fn stats_update_handles_entries_without_mtime() {
let temp_dir = TempDir::new().expect("failed to create temp directory for test - check disk space and system temp directory permissions");
let root = temp_dir.path();
let empty_dir = root.join("empty");
fs::create_dir(&empty_dir).expect(
"failed to create empty test directory - check disk space and write permissions",
);
let db_path = root.join("test.db");
let db = Database::open(&db_path)
.expect("failed to initialize database - check disk space and SQLite is functioning");
let scanner = Scanner::new();
let app_config = test_app_config_with_paths(vec![empty_dir], 90, 14);
let _summary = scan_and_persist(&db, &scanner, &app_config)
.await
.expect("scan_and_persist failed on empty directory - check permissions and database connection");
let stats = db.get_stats().expect(
"failed to query stats from database - connection may be lost or stats table corrupted",
);
assert_eq!(stats.total_files, 0);
assert_eq!(stats.files_within_warning, 0);
assert_eq!(stats.files_overdue, 0);
}
#[tokio::test]
async fn stats_update_sets_last_scan_completed_timestamp() {
let temp_dir = TempDir::new().expect("failed to create temp directory for test - check disk space and system temp directory permissions");
let root = temp_dir.path();
let dir = root.join("test");
fs::create_dir(&dir)
.expect("failed to create test directory - check disk space and write permissions");
File::create(dir.join("file.txt"))
.expect("failed to create test file - check disk space and permissions")
.write_all(&[0u8; 50])
.expect("failed to write test data - disk may be full or readonly");
let before_scan = jiff::Timestamp::now().as_second();
let db_path = root.join("test.db");
let db = Database::open(&db_path)
.expect("failed to initialize database - check disk space and SQLite is functioning");
let scanner = Scanner::new();
let app_config = test_app_config_with_paths(vec![dir], 90, 14);
let _summary = scan_and_persist(&db, &scanner, &app_config)
.await
.expect("scan_and_persist failed - check file permissions and database connection");
let after_scan = jiff::Timestamp::now().as_second();
let stats = db.get_stats().expect(
"failed to query stats from database - connection may be lost or stats table corrupted",
);
assert!(
stats.last_scan_completed.is_some(),
"last_scan_completed should be set"
);
let last_scan = stats.last_scan_completed.expect("last_scan_completed should be Some after scan, but was None - check scan_and_persist updates stats correctly");
assert!(
last_scan >= before_scan && last_scan <= after_scan,
"last_scan_completed ({last_scan}) should be between {before_scan} and {after_scan}"
);
}
#[tokio::test]
async fn scan_sets_tracked_since_on_first_insert() {
let temp_dir = TempDir::new()
.expect("failed to create temp directory - check disk space and permissions");
let root = temp_dir.path();
let project_dir = root.join("project");
fs::create_dir(&project_dir)
.expect("failed to create test directory - check disk space and permissions");
let file_path = project_dir.join("old_file.txt");
let mut file = File::create(&file_path)
.expect("failed to create test file - check disk space and permissions");
file.write_all(b"test content")
.expect("failed to write test data - disk may be full");
file.sync_all()
.expect("failed to sync file - check filesystem health");
let hundred_days_ago = jiff::Timestamp::now()
.checked_sub(jiff::SignedDuration::from_secs(100 * SECS_PER_DAY))
.expect("timestamp arithmetic failed");
#[cfg(unix)]
{
filetime::set_file_mtime(
&file_path,
filetime::FileTime::from_unix_time(hundred_days_ago.as_second(), 0),
)
.expect("failed to set file mtime - check permissions");
}
let db_path = root.join("test.db");
let db = Database::open(&db_path).expect("failed to open database - check permissions");
let scanner = Scanner::new();
let app_config = test_app_config_with_paths(vec![project_dir.clone()], 90, 14);
let before_scan = jiff::Timestamp::now().as_second();
let _ = scan_and_persist(&db, &scanner, &app_config)
.await
.expect("scan failed - check permissions");
let after_scan = jiff::Timestamp::now().as_second();
let entry = db
.get_entry_by_path(&file_path)
.expect("failed to query entry")
.expect("entry should exist");
let tracked_since = entry
.tracked_since
.expect("tracked_since should be set on first insert");
assert!(
tracked_since >= before_scan && tracked_since <= after_scan,
"tracked_since should be current time, not old mtime"
);
#[cfg(unix)]
{
assert_eq!(
entry.mtime,
Some(hundred_days_ago.as_second()),
"mtime should preserve file's actual modification time"
);
}
}
#[tokio::test]
async fn scan_preserves_tracked_since_on_update() {
let temp_dir = TempDir::new()
.expect("failed to create temp directory - check disk space and permissions");
let root = temp_dir.path();
let project_dir = root.join("project");
fs::create_dir(&project_dir)
.expect("failed to create test directory - check disk space and permissions");
let file_path = project_dir.join("file.txt");
let mut file = File::create(&file_path)
.expect("failed to create test file - check disk space and permissions");
file.write_all(b"initial content")
.expect("failed to write test data - disk may be full");
file.sync_all()
.expect("failed to sync file - check filesystem health");
let db_path = root.join("test.db");
let db = Database::open(&db_path).expect("failed to open database - check permissions");
let scanner = Scanner::new();
let app_config = test_app_config_with_paths(vec![project_dir.clone()], 90, 14);
let _ = scan_and_persist(&db, &scanner, &app_config)
.await
.expect("first scan failed");
let entry_before = db
.get_entry_by_path(&file_path)
.expect("failed to query entry")
.expect("entry should exist");
let tracked_since_original = entry_before
.tracked_since
.expect("tracked_since should be set after first scan");
std::thread::sleep(std::time::Duration::from_secs(1));
let mut file = fs::OpenOptions::new()
.write(true)
.truncate(true)
.open(&file_path)
.expect("failed to reopen file for modification");
file.write_all(b"updated content with more bytes")
.expect("failed to write updated content");
file.sync_all().expect("failed to sync updated file");
let _summary = scan_and_persist(&db, &scanner, &app_config)
.await
.expect("second scan failed");
let entry_after = db
.get_entry_by_path(&file_path)
.expect("failed to query entry after second scan")
.expect("entry should exist");
assert_eq!(
entry_after.tracked_since,
Some(tracked_since_original),
"tracked_since should be preserved on file updates"
);
assert_ne!(
entry_after.mtime, entry_before.mtime,
"mtime should be updated on file modification"
);
assert_ne!(
entry_after.size_bytes, entry_before.size_bytes,
"size_bytes should be updated on file modification"
);
}
#[tokio::test]
async fn expiration_uses_effective_timestamp() {
let temp_dir = TempDir::new()
.expect("failed to create temp directory - check disk space and permissions");
let root = temp_dir.path();
let project_dir = root.join("project");
fs::create_dir(&project_dir)
.expect("failed to create test directory - check disk space and permissions");
let file_path = project_dir.join("old_file.txt");
let mut file = File::create(&file_path)
.expect("failed to create test file - check disk space and permissions");
file.write_all(b"old file")
.expect("failed to write test data - disk may be full");
file.sync_all()
.expect("failed to sync file - check filesystem health");
let two_hundred_days_ago = jiff::Timestamp::now()
.checked_sub(jiff::SignedDuration::from_secs(200 * SECS_PER_DAY))
.expect("timestamp arithmetic failed");
#[cfg(unix)]
{
filetime::set_file_mtime(
&file_path,
filetime::FileTime::from_unix_time(two_hundred_days_ago.as_second(), 0),
)
.expect("failed to set file mtime - check permissions");
}
let db_path = root.join("test.db");
let db = Database::open(&db_path).expect("failed to open database - check permissions");
let scanner = Scanner::new();
let app_config = test_app_config_with_paths(vec![project_dir.clone()], 90, 14);
let _summary = scan_and_persist(&db, &scanner, &app_config)
.await
.expect("scan failed - check permissions");
let entry = db
.get_entry_by_path(&file_path)
.expect("failed to query entry")
.expect("entry should exist");
let mtime = entry.mtime.expect("mtime should be set");
let tracked_since = entry.tracked_since.expect("tracked_since should be set");
let effective_mtime = std::cmp::max(mtime, tracked_since);
let days_remaining = calculate_expiration(effective_mtime, 90);
#[cfg(unix)]
{
assert!(
(89..=90).contains(&days_remaining),
"newly tracked old file should have full expiration period ({days_remaining} days remaining)"
);
}
#[cfg(not(unix))]
{
assert!(
days_remaining > 0,
"newly tracked file should not be overdue"
);
}
}
}