feat: auto-purge notes trashed 30+ days ago on app launch
Implement silent background cleanup of notes that have been in trash for more than 30 days, fulfilling the promise already shown in the Trash view UI. Safety model (all 5 checks must pass per file): - _trashed: true in frontmatter - _trashed_at present and parseable as date - Date strictly >30 days ago - File exists on disk - File path inside vault root Uses trash::delete (OS trash) with fs::remove_file fallback. Triggers on app launch and window focus (max once/hour). Audit log at .laputa/purge.log. Dry-run mode for testing. Also fixes pre-commit hook to read CodeScene thresholds from .codescene-thresholds instead of hardcoded values, matching the ratchet mechanism documented in CLAUDE.md. ADR-0042 documents the safety model. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -16,10 +16,15 @@ pub mod vault_list;
|
||||
use std::process::Child;
|
||||
#[cfg(desktop)]
|
||||
use std::sync::Mutex;
|
||||
#[cfg(desktop)]
|
||||
use std::time::Instant;
|
||||
|
||||
#[cfg(desktop)]
|
||||
struct WsBridgeChild(Mutex<Option<Child>>);
|
||||
|
||||
#[cfg(desktop)]
|
||||
struct LastPurgeTime(Mutex<Instant>);
|
||||
|
||||
#[cfg(desktop)]
|
||||
fn log_startup_result(label: &str, result: Result<usize, String>) {
|
||||
match result {
|
||||
@@ -81,6 +86,8 @@ pub fn run() {
|
||||
|
||||
#[cfg(desktop)]
|
||||
let builder = builder.manage(WsBridgeChild(Mutex::new(None)));
|
||||
#[cfg(desktop)]
|
||||
let builder = builder.manage(LastPurgeTime(Mutex::new(Instant::now())));
|
||||
|
||||
builder
|
||||
.setup(|app| {
|
||||
@@ -189,13 +196,39 @@ pub fn run() {
|
||||
#[cfg(desktop)]
|
||||
{
|
||||
use tauri::Manager;
|
||||
if let tauri::RunEvent::Exit = _event {
|
||||
let state: tauri::State<'_, WsBridgeChild> = _app_handle.state();
|
||||
let mut guard = state.0.lock().unwrap();
|
||||
if let Some(ref mut child) = *guard {
|
||||
let _ = child.kill();
|
||||
log::info!("ws-bridge child process killed on exit");
|
||||
match _event {
|
||||
tauri::RunEvent::Exit => {
|
||||
let state: tauri::State<'_, WsBridgeChild> = _app_handle.state();
|
||||
let mut guard = state.0.lock().unwrap();
|
||||
if let Some(ref mut child) = *guard {
|
||||
let _ = child.kill();
|
||||
log::info!("ws-bridge child process killed on exit");
|
||||
}
|
||||
}
|
||||
tauri::RunEvent::WindowEvent {
|
||||
event: tauri::WindowEvent::Focused(true),
|
||||
..
|
||||
} => {
|
||||
let state: tauri::State<'_, LastPurgeTime> = _app_handle.state();
|
||||
let mut last = state.0.lock().unwrap();
|
||||
if last.elapsed() >= std::time::Duration::from_secs(3600) {
|
||||
*last = Instant::now();
|
||||
drop(last);
|
||||
std::thread::spawn(|| {
|
||||
let vault_path = dirs::home_dir()
|
||||
.map(|h| h.join("Laputa"))
|
||||
.unwrap_or_default();
|
||||
if vault_path.is_dir() {
|
||||
log_startup_result(
|
||||
"Purged trashed files on focus",
|
||||
vault::purge_trash(vault_path.to_str().unwrap_or_default())
|
||||
.map(|d| d.len()),
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
use gray_matter::engine::YAML;
|
||||
use gray_matter::Matter;
|
||||
use std::fs;
|
||||
use std::path::Path;
|
||||
use std::io::Write as IoWrite;
|
||||
use std::path::{Path, PathBuf};
|
||||
use walkdir::WalkDir;
|
||||
|
||||
/// Check if a file path points to a markdown file.
|
||||
@@ -31,20 +32,99 @@ fn parse_trashed_date(date_str: &str) -> Option<chrono::NaiveDate> {
|
||||
chrono::NaiveDate::parse_from_str(date_part, "%Y-%m-%d").ok()
|
||||
}
|
||||
|
||||
/// Delete a file and log the result. Returns the path string if successful.
|
||||
/// Check if `_trashed` (or aliases) is set to a truthy value in gray_matter data.
|
||||
fn is_trashed_flag_set(data: &Option<gray_matter::Pod>) -> bool {
|
||||
let gray_matter::Pod::Hash(ref map) = data.as_ref().unwrap_or(&gray_matter::Pod::Null) else {
|
||||
return false;
|
||||
};
|
||||
let Some(pod) = map
|
||||
.get("_trashed")
|
||||
.or_else(|| map.get("Trashed"))
|
||||
.or_else(|| map.get("trashed"))
|
||||
else {
|
||||
return false;
|
||||
};
|
||||
match pod {
|
||||
gray_matter::Pod::Boolean(b) => *b,
|
||||
gray_matter::Pod::String(s) => {
|
||||
matches!(s.to_ascii_lowercase().as_str(), "yes" | "true" | "1")
|
||||
}
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
|
||||
/// Check whether a file path is strictly inside the given vault root.
|
||||
fn is_inside_vault(file_path: &Path, vault_root: &Path) -> bool {
|
||||
match (file_path.canonicalize(), vault_root.canonicalize()) {
|
||||
(Ok(fp), Ok(vr)) => fp.starts_with(&vr),
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
|
||||
/// Move a file to OS trash. Falls back to fs::remove_file if OS trash fails.
|
||||
fn trash_or_remove(path: &Path) -> Result<(), String> {
|
||||
match trash::delete(path) {
|
||||
Ok(()) => Ok(()),
|
||||
Err(trash_err) => {
|
||||
log::warn!(
|
||||
"OS trash failed for {}: {} — falling back to fs::remove_file",
|
||||
path.display(),
|
||||
trash_err
|
||||
);
|
||||
fs::remove_file(path)
|
||||
.map_err(|e| format!("Failed to delete {}: {}", path.display(), e))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Delete a file (move to OS trash) and log the result. Returns the path string if successful.
|
||||
fn try_purge_file(path: &Path) -> Option<String> {
|
||||
match fs::remove_file(path) {
|
||||
match trash_or_remove(path) {
|
||||
Ok(()) => {
|
||||
log::info!("Purged trashed file: {}", path.display());
|
||||
Some(path.to_string_lossy().to_string())
|
||||
}
|
||||
Err(e) => {
|
||||
log::warn!("Failed to delete {}: {}", path.display(), e);
|
||||
log::warn!("Failed to purge {}: {}", path.display(), e);
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Append a purge run summary to `.laputa/purge.log`.
|
||||
fn write_purge_log(vault_path: &Path, checked: usize, purged: &[String], dry_run: bool) {
|
||||
let log_dir = vault_path.join(".laputa");
|
||||
if fs::create_dir_all(&log_dir).is_err() {
|
||||
log::warn!("Could not create .laputa/ directory for purge log");
|
||||
return;
|
||||
}
|
||||
let log_path = log_dir.join("purge.log");
|
||||
let mut file = match fs::OpenOptions::new()
|
||||
.create(true)
|
||||
.append(true)
|
||||
.open(&log_path)
|
||||
{
|
||||
Ok(f) => f,
|
||||
Err(e) => {
|
||||
log::warn!("Could not open purge.log: {}", e);
|
||||
return;
|
||||
}
|
||||
};
|
||||
let now = chrono::Utc::now().format("%Y-%m-%dT%H:%M:%SZ");
|
||||
let mode = if dry_run { " [DRY-RUN]" } else { "" };
|
||||
let _ = writeln!(
|
||||
file,
|
||||
"[{}]{} checked={}, purged={}",
|
||||
now,
|
||||
mode,
|
||||
checked,
|
||||
purged.len()
|
||||
);
|
||||
for path in purged {
|
||||
let _ = writeln!(file, " - {}", path);
|
||||
}
|
||||
}
|
||||
|
||||
/// Permanently delete a single note file.
|
||||
/// Returns the deleted path on success, or an error if the file doesn't exist.
|
||||
pub fn delete_note(path: &str) -> Result<String, String> {
|
||||
@@ -124,54 +204,137 @@ pub fn empty_trash(vault_path: &str) -> Result<Vec<String>, String> {
|
||||
));
|
||||
}
|
||||
|
||||
let deleted: Vec<String> = WalkDir::new(vault)
|
||||
let mut deleted = Vec::new();
|
||||
for entry in WalkDir::new(vault)
|
||||
.follow_links(true)
|
||||
.into_iter()
|
||||
.filter_map(|e| e.ok())
|
||||
.filter(|e| is_markdown_file(e.path()))
|
||||
.filter(|e| is_file_trashed(e.path()))
|
||||
.filter_map(|entry| try_purge_file(entry.path()))
|
||||
.collect();
|
||||
{
|
||||
if let Some(p) = try_purge_file(entry.path()) {
|
||||
deleted.push(p);
|
||||
}
|
||||
}
|
||||
|
||||
Ok(deleted)
|
||||
}
|
||||
|
||||
/// Scan all markdown files in the vault and delete those where
|
||||
/// `Trashed at` frontmatter is more than 30 days ago.
|
||||
/// Returns the list of deleted file paths.
|
||||
pub fn purge_trash(vault_path: &str) -> Result<Vec<String>, String> {
|
||||
let vault = Path::new(vault_path);
|
||||
if !vault.exists() || !vault.is_dir() {
|
||||
/// `_trashed_at` frontmatter is more than 30 days ago.
|
||||
///
|
||||
/// Safety checks enforced per file (all must pass):
|
||||
/// 1. `_trashed: true` present in frontmatter
|
||||
/// 2. `_trashed_at` present and parseable as a date
|
||||
/// 3. Date is strictly more than 30 days ago
|
||||
/// 4. File exists on disk
|
||||
/// 5. File path is inside the vault root
|
||||
///
|
||||
/// When `dry_run` is true, no files are deleted — only the list of candidates is returned.
|
||||
/// Returns the list of purged (or would-be-purged) file paths.
|
||||
pub fn purge_old_trash(vault_path: &Path, dry_run: bool) -> Result<Vec<PathBuf>, String> {
|
||||
if !vault_path.exists() || !vault_path.is_dir() {
|
||||
return Err(format!(
|
||||
"Vault path does not exist or is not a directory: {}",
|
||||
vault_path
|
||||
vault_path.display()
|
||||
));
|
||||
}
|
||||
|
||||
let today = chrono::Utc::now().date_naive();
|
||||
let matter = Matter::<YAML>::new();
|
||||
let max_age_days = 30;
|
||||
let mut checked = 0usize;
|
||||
let mut purged: Vec<String> = Vec::new();
|
||||
let mut purged_paths: Vec<PathBuf> = Vec::new();
|
||||
|
||||
let deleted: Vec<String> = WalkDir::new(vault)
|
||||
for entry in WalkDir::new(vault_path)
|
||||
.follow_links(true)
|
||||
.into_iter()
|
||||
.filter_map(|e| e.ok())
|
||||
.filter(|e| is_markdown_file(e.path()))
|
||||
.filter_map(|entry| {
|
||||
let content = fs::read_to_string(entry.path()).ok()?;
|
||||
let parsed = matter.parse(&content);
|
||||
let date_str = extract_trashed_at_string(&parsed.data)?;
|
||||
let trashed_date = parse_trashed_date(&date_str)?;
|
||||
let age = today.signed_duration_since(trashed_date);
|
||||
if age.num_days() > max_age_days {
|
||||
try_purge_file(entry.path())
|
||||
} else {
|
||||
None
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
{
|
||||
let path = entry.path();
|
||||
|
||||
Ok(deleted)
|
||||
// Safety check 4: file exists
|
||||
if !path.exists() {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Safety check 5: file is inside vault root
|
||||
if !is_inside_vault(path, vault_path) {
|
||||
log::warn!("Skipping file outside vault: {}", path.display());
|
||||
continue;
|
||||
}
|
||||
|
||||
let content = match fs::read_to_string(path) {
|
||||
Ok(c) => c,
|
||||
Err(_) => continue,
|
||||
};
|
||||
let parsed = matter.parse(&content);
|
||||
|
||||
// Safety check 1: _trashed: true
|
||||
if !is_trashed_flag_set(&parsed.data) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Safety check 2: _trashed_at present and parseable
|
||||
let date_str = match extract_trashed_at_string(&parsed.data) {
|
||||
Some(s) => s,
|
||||
None => {
|
||||
log::warn!(
|
||||
"Trashed file missing _trashed_at, skipping: {}",
|
||||
path.display()
|
||||
);
|
||||
continue;
|
||||
}
|
||||
};
|
||||
let trashed_date = match parse_trashed_date(&date_str) {
|
||||
Some(d) => d,
|
||||
None => {
|
||||
log::warn!(
|
||||
"Unparseable _trashed_at '{}', skipping: {}",
|
||||
date_str,
|
||||
path.display()
|
||||
);
|
||||
continue;
|
||||
}
|
||||
};
|
||||
|
||||
// Safety check 3: strictly more than 30 days old
|
||||
let age = today.signed_duration_since(trashed_date);
|
||||
if age.num_days() <= max_age_days {
|
||||
continue;
|
||||
}
|
||||
|
||||
checked += 1;
|
||||
|
||||
if dry_run {
|
||||
log::info!("[DRY-RUN] Would purge: {} (trashed {} days ago)", path.display(), age.num_days());
|
||||
purged.push(path.to_string_lossy().to_string());
|
||||
purged_paths.push(path.to_path_buf());
|
||||
} else if let Some(p) = try_purge_file(path) {
|
||||
purged.push(p);
|
||||
purged_paths.push(path.to_path_buf());
|
||||
}
|
||||
}
|
||||
|
||||
write_purge_log(vault_path, checked, &purged, dry_run);
|
||||
|
||||
Ok(purged_paths)
|
||||
}
|
||||
|
||||
/// Legacy wrapper that calls purge_old_trash with dry_run=false and returns string paths.
|
||||
/// Used by the Tauri command and startup tasks.
|
||||
pub fn purge_trash(vault_path: &str) -> Result<Vec<String>, String> {
|
||||
let vault = Path::new(vault_path);
|
||||
purge_old_trash(vault, false)
|
||||
.map(|paths| {
|
||||
paths
|
||||
.into_iter()
|
||||
.map(|p| p.to_string_lossy().to_string())
|
||||
.collect()
|
||||
})
|
||||
.map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
@@ -211,118 +374,188 @@ mod tests {
|
||||
assert!(result.unwrap_err().contains("does not exist"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_purge_trash_deletes_old_trashed_files() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
// File trashed 60 days ago — should be deleted
|
||||
create_test_file(
|
||||
dir.path(),
|
||||
"old-trash.md",
|
||||
"---\nTrashed at: \"2025-01-01\"\n---\n# Old Trash\n",
|
||||
);
|
||||
// File trashed recently — should be kept
|
||||
let recent = chrono::Utc::now()
|
||||
.date_naive()
|
||||
fn old_date(days_ago: i64) -> String {
|
||||
(chrono::Utc::now().date_naive() - chrono::Duration::days(days_ago))
|
||||
.format("%Y-%m-%d")
|
||||
.to_string();
|
||||
create_test_file(
|
||||
dir.path(),
|
||||
"recent-trash.md",
|
||||
&format!("---\nTrashed at: \"{}\"\n---\n# Recent Trash\n", recent),
|
||||
);
|
||||
// File without trashed_at — should be kept
|
||||
create_test_file(
|
||||
dir.path(),
|
||||
"normal.md",
|
||||
"---\ntype: Note\n---\n# Normal Note\n",
|
||||
);
|
||||
.to_string()
|
||||
}
|
||||
|
||||
let deleted = purge_trash(dir.path().to_str().unwrap()).unwrap();
|
||||
assert_eq!(deleted.len(), 1);
|
||||
assert!(deleted[0].contains("old-trash.md"));
|
||||
// Verify old file is actually gone
|
||||
assert!(!dir.path().join("old-trash.md").exists());
|
||||
// Verify other files still exist
|
||||
assert!(dir.path().join("recent-trash.md").exists());
|
||||
fn trashed_content(date: &str) -> String {
|
||||
format!(
|
||||
"---\n_trashed: true\n_trashed_at: \"{}\"\n---\n# Trashed\n",
|
||||
date
|
||||
)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_purge_old_trash_deletes_old_trashed_files() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
create_test_file(dir.path(), "old.md", &trashed_content("2025-01-01"));
|
||||
create_test_file(dir.path(), "recent.md", &trashed_content(&old_date(5)));
|
||||
create_test_file(dir.path(), "normal.md", "---\ntype: Note\n---\n# Normal\n");
|
||||
|
||||
let purged = purge_old_trash(dir.path(), false).unwrap();
|
||||
assert_eq!(purged.len(), 1);
|
||||
assert!(!dir.path().join("old.md").exists());
|
||||
assert!(dir.path().join("recent.md").exists());
|
||||
assert!(dir.path().join("normal.md").exists());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_purge_trash_supports_datetime_format() {
|
||||
fn test_purge_old_trash_supports_datetime_format() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
create_test_file(
|
||||
dir.path(),
|
||||
"datetime-trash.md",
|
||||
"---\nTrashed at: \"2025-01-01T10:30:00Z\"\n---\n# Datetime Trash\n",
|
||||
"dt.md",
|
||||
"---\n_trashed: true\n_trashed_at: \"2025-01-01T10:30:00Z\"\n---\n# DT\n",
|
||||
);
|
||||
|
||||
let deleted = purge_trash(dir.path().to_str().unwrap()).unwrap();
|
||||
assert_eq!(deleted.len(), 1);
|
||||
assert!(deleted[0].contains("datetime-trash.md"));
|
||||
let purged = purge_old_trash(dir.path(), false).unwrap();
|
||||
assert_eq!(purged.len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_purge_trash_empty_vault() {
|
||||
fn test_purge_old_trash_empty_vault() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let deleted = purge_trash(dir.path().to_str().unwrap()).unwrap();
|
||||
assert!(deleted.is_empty());
|
||||
let purged = purge_old_trash(dir.path(), false).unwrap();
|
||||
assert!(purged.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_purge_trash_nonexistent_path() {
|
||||
let result = purge_trash("/nonexistent/path/that/does/not/exist");
|
||||
fn test_purge_old_trash_nonexistent_path() {
|
||||
let result = purge_old_trash(Path::new("/nonexistent/path"), false);
|
||||
assert!(result.is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_purge_trash_exactly_30_days_not_deleted() {
|
||||
fn test_purge_old_trash_exactly_30_days_not_deleted() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let thirty_days_ago = (chrono::Utc::now().date_naive() - chrono::Duration::days(30))
|
||||
.format("%Y-%m-%d")
|
||||
.to_string();
|
||||
create_test_file(
|
||||
dir.path(),
|
||||
"borderline.md",
|
||||
&format!(
|
||||
"---\nTrashed at: \"{}\"\n---\n# Borderline\n",
|
||||
thirty_days_ago
|
||||
),
|
||||
);
|
||||
create_test_file(dir.path(), "borderline.md", &trashed_content(&old_date(30)));
|
||||
|
||||
let deleted = purge_trash(dir.path().to_str().unwrap()).unwrap();
|
||||
assert!(deleted.is_empty());
|
||||
let purged = purge_old_trash(dir.path(), false).unwrap();
|
||||
assert!(purged.is_empty());
|
||||
assert!(dir.path().join("borderline.md").exists());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_purge_trash_31_days_deleted() {
|
||||
fn test_purge_old_trash_31_days_deleted() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let thirty_one_days_ago = (chrono::Utc::now().date_naive() - chrono::Duration::days(31))
|
||||
.format("%Y-%m-%d")
|
||||
.to_string();
|
||||
create_test_file(
|
||||
dir.path(),
|
||||
"expired.md",
|
||||
&format!(
|
||||
"---\nTrashed at: \"{}\"\n---\n# Expired\n",
|
||||
thirty_one_days_ago
|
||||
),
|
||||
);
|
||||
create_test_file(dir.path(), "expired.md", &trashed_content(&old_date(31)));
|
||||
|
||||
let deleted = purge_trash(dir.path().to_str().unwrap()).unwrap();
|
||||
assert_eq!(deleted.len(), 1);
|
||||
let purged = purge_old_trash(dir.path(), false).unwrap();
|
||||
assert_eq!(purged.len(), 1);
|
||||
assert!(!dir.path().join("expired.md").exists());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_purge_trash_nested_directories() {
|
||||
fn test_purge_old_trash_nested_directories() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
create_test_file(dir.path(), "sub/deep/old.md", &trashed_content("2025-01-01"));
|
||||
|
||||
let purged = purge_old_trash(dir.path(), false).unwrap();
|
||||
assert_eq!(purged.len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_purge_old_trash_dry_run_does_not_delete() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
create_test_file(dir.path(), "old.md", &trashed_content("2025-01-01"));
|
||||
|
||||
let purged = purge_old_trash(dir.path(), true).unwrap();
|
||||
assert_eq!(purged.len(), 1);
|
||||
assert!(dir.path().join("old.md").exists(), "dry-run must not delete files");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_purge_old_trash_missing_trashed_flag_skips() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
// Has _trashed_at but no _trashed: true
|
||||
create_test_file(
|
||||
dir.path(),
|
||||
"no-flag.md",
|
||||
"---\n_trashed_at: \"2025-01-01\"\n---\n# No flag\n",
|
||||
);
|
||||
|
||||
let purged = purge_old_trash(dir.path(), false).unwrap();
|
||||
assert!(purged.is_empty());
|
||||
assert!(dir.path().join("no-flag.md").exists());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_purge_old_trash_missing_trashed_at_skips() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
// Has _trashed: true but no _trashed_at
|
||||
create_test_file(
|
||||
dir.path(),
|
||||
"no-date.md",
|
||||
"---\n_trashed: true\n---\n# No date\n",
|
||||
);
|
||||
|
||||
let purged = purge_old_trash(dir.path(), false).unwrap();
|
||||
assert!(purged.is_empty());
|
||||
assert!(dir.path().join("no-date.md").exists());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_purge_old_trash_unparseable_date_skips() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
create_test_file(
|
||||
dir.path(),
|
||||
"sub/deep/old.md",
|
||||
"---\nTrashed at: \"2025-01-01\"\n---\n# Deep Old\n",
|
||||
"bad-date.md",
|
||||
"---\n_trashed: true\n_trashed_at: \"not-a-date\"\n---\n# Bad date\n",
|
||||
);
|
||||
|
||||
let purged = purge_old_trash(dir.path(), false).unwrap();
|
||||
assert!(purged.is_empty());
|
||||
assert!(dir.path().join("bad-date.md").exists());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_purge_old_trash_trashed_false_skips() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
create_test_file(
|
||||
dir.path(),
|
||||
"not-trashed.md",
|
||||
"---\n_trashed: false\n_trashed_at: \"2025-01-01\"\n---\n# Not trashed\n",
|
||||
);
|
||||
|
||||
let purged = purge_old_trash(dir.path(), false).unwrap();
|
||||
assert!(purged.is_empty());
|
||||
assert!(dir.path().join("not-trashed.md").exists());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_purge_old_trash_legacy_field_names() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
create_test_file(
|
||||
dir.path(),
|
||||
"legacy.md",
|
||||
"---\nTrashed: true\nTrashed at: \"2025-01-01\"\n---\n# Legacy\n",
|
||||
);
|
||||
|
||||
let purged = purge_old_trash(dir.path(), false).unwrap();
|
||||
assert_eq!(purged.len(), 1);
|
||||
assert!(!dir.path().join("legacy.md").exists());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_purge_old_trash_writes_purge_log() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
create_test_file(dir.path(), "old.md", &trashed_content("2025-01-01"));
|
||||
|
||||
let _ = purge_old_trash(dir.path(), false).unwrap();
|
||||
let log_path = dir.path().join(".laputa/purge.log");
|
||||
assert!(log_path.exists(), "purge.log must be created");
|
||||
let log_content = fs::read_to_string(&log_path).unwrap();
|
||||
assert!(log_content.contains("purged=1"));
|
||||
assert!(log_content.contains("old.md"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_purge_old_trash_wrapper_returns_strings() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
create_test_file(dir.path(), "old.md", &trashed_content("2025-01-01"));
|
||||
|
||||
let deleted = purge_trash(dir.path().to_str().unwrap()).unwrap();
|
||||
assert_eq!(deleted.len(), 1);
|
||||
assert!(deleted[0].contains("old.md"));
|
||||
|
||||
Reference in New Issue
Block a user