diff --git a/apps/desktop/src/store/tinybase/persister/human/load.ts b/apps/desktop/src/store/tinybase/persister/human/load.ts index 2b6ba85089..1d2dfab746 100644 --- a/apps/desktop/src/store/tinybase/persister/human/load.ts +++ b/apps/desktop/src/store/tinybase/persister/human/load.ts @@ -1,6 +1,6 @@ import type { HumanStorage } from "@hypr/store"; -import { cleanupOrphanFiles, loadAllEntities } from "../markdown-utils"; +import { loadAllEntities } from "../markdown-utils"; import { frontmatterToHuman } from "./utils"; const LABEL = "HumanPersister"; @@ -11,10 +11,3 @@ export async function loadAllHumans( ): Promise> { return loadAllEntities(dataDir, DIR_NAME, LABEL, frontmatterToHuman); } - -export async function cleanupOrphanHumanFiles( - dataDir: string, - validHumanIds: Set, -): Promise { - return cleanupOrphanFiles(dataDir, DIR_NAME, validHumanIds, LABEL); -} diff --git a/apps/desktop/src/store/tinybase/persister/human/persister.ts b/apps/desktop/src/store/tinybase/persister/human/persister.ts index 1e8788d9ab..ff87186b46 100644 --- a/apps/desktop/src/store/tinybase/persister/human/persister.ts +++ b/apps/desktop/src/store/tinybase/persister/human/persister.ts @@ -4,9 +4,11 @@ import type { OptionalSchemas, } from "tinybase/with-schemas"; +import { commands as folderCommands } from "@hypr/plugin-folder"; + import { createSessionDirPersister, getDataDir } from "../utils"; import { collectHumanWriteOps, type HumanCollectorResult } from "./collect"; -import { cleanupOrphanHumanFiles, loadAllHumans } from "./load"; +import { loadAllHumans } from "./load"; import { migrateHumansJsonIfNeeded } from "./migrate"; export function createHumanPersister( @@ -24,9 +26,13 @@ export function createHumanPersister( } return [{ humans }, {}] as unknown as Content; }, - postSave: async (dataDir, result) => { + postSave: async (_dataDir, result) => { const { validHumanIds } = result as HumanCollectorResult; - await cleanupOrphanHumanFiles(dataDir, validHumanIds); + await folderCommands.cleanupOrphanFiles( + "humans", + "md", + Array.from(validHumanIds), + ); }, }); } diff --git a/apps/desktop/src/store/tinybase/persister/markdown-utils.ts b/apps/desktop/src/store/tinybase/persister/markdown-utils.ts index 226f36dc85..36718444b8 100644 --- a/apps/desktop/src/store/tinybase/persister/markdown-utils.ts +++ b/apps/desktop/src/store/tinybase/persister/markdown-utils.ts @@ -91,48 +91,6 @@ export async function loadAllEntities( return result; } -export async function cleanupOrphanFiles( - dataDir: string, - dirName: string, - validIds: Set, - label: string, -): Promise { - const paths = createEntityPaths(dirName); - const entityDir = paths.getDir(dataDir); - - let entries: { name: string; isDirectory: boolean }[]; - try { - entries = await readDir(entityDir); - } catch (error) { - if (isFileNotFoundError(error)) { - return; - } - throw error; - } - - for (const entry of entries) { - if (entry.isDirectory) continue; - if (!entry.name.endsWith(".md")) continue; - - const entityId = entry.name.replace(/\.md$/, ""); - if (!isUUID(entityId)) continue; - - if (!validIds.has(entityId)) { - try { - const filePath = paths.getFilePath(dataDir, entityId); - await remove(filePath); - } catch (error) { - if (!isFileNotFoundError(error)) { - console.error( - `[${label}] Failed to remove orphan file ${entry.name}:`, - error, - ); - } - } - } - } -} - export async function migrateJsonToMarkdown( dataDir: string, jsonFilename: string, diff --git a/apps/desktop/src/store/tinybase/persister/organization/load.ts b/apps/desktop/src/store/tinybase/persister/organization/load.ts index 778dd6535a..d37b974c51 100644 --- a/apps/desktop/src/store/tinybase/persister/organization/load.ts +++ b/apps/desktop/src/store/tinybase/persister/organization/load.ts @@ -1,6 +1,6 @@ import type { OrganizationStorage } from "@hypr/store"; -import { cleanupOrphanFiles, loadAllEntities } from "../markdown-utils"; +import { loadAllEntities } from "../markdown-utils"; import { frontmatterToOrganization } from "./utils"; const LABEL = "OrganizationPersister"; @@ -11,10 +11,3 @@ export async function loadAllOrganizations( ): Promise> { return loadAllEntities(dataDir, DIR_NAME, LABEL, frontmatterToOrganization); } - -export async function cleanupOrphanOrganizationFiles( - dataDir: string, - validOrgIds: Set, -): Promise { - return cleanupOrphanFiles(dataDir, DIR_NAME, validOrgIds, LABEL); -} diff --git a/apps/desktop/src/store/tinybase/persister/organization/persister.ts b/apps/desktop/src/store/tinybase/persister/organization/persister.ts index acabb988a8..a002991be2 100644 --- a/apps/desktop/src/store/tinybase/persister/organization/persister.ts +++ b/apps/desktop/src/store/tinybase/persister/organization/persister.ts @@ -4,12 +4,14 @@ import type { OptionalSchemas, } from "tinybase/with-schemas"; +import { commands as folderCommands } from "@hypr/plugin-folder"; + import { createSessionDirPersister, getDataDir } from "../utils"; import { collectOrganizationWriteOps, type OrganizationCollectorResult, } from "./collect"; -import { cleanupOrphanOrganizationFiles, loadAllOrganizations } from "./load"; +import { loadAllOrganizations } from "./load"; import { migrateOrganizationsJsonIfNeeded } from "./migrate"; export function createOrganizationPersister( @@ -27,9 +29,13 @@ export function createOrganizationPersister( } return [{ organizations }, {}] as unknown as Content; }, - postSave: async (dataDir, result) => { + postSave: async (_dataDir, result) => { const { validOrgIds } = result as OrganizationCollectorResult; - await cleanupOrphanOrganizationFiles(dataDir, validOrgIds); + await folderCommands.cleanupOrphanFiles( + "organizations", + "md", + Array.from(validOrgIds), + ); }, }); } diff --git a/apps/desktop/src/store/tinybase/persister/prompts/load.ts b/apps/desktop/src/store/tinybase/persister/prompts/load.ts index 075c0dba83..c1b1cac924 100644 --- a/apps/desktop/src/store/tinybase/persister/prompts/load.ts +++ b/apps/desktop/src/store/tinybase/persister/prompts/load.ts @@ -1,6 +1,6 @@ import type { PromptStorage } from "@hypr/store"; -import { cleanupOrphanFiles, loadAllEntities } from "../markdown-utils"; +import { loadAllEntities } from "../markdown-utils"; import { frontmatterToPrompt } from "./utils"; const LABEL = "PromptPersister"; @@ -11,10 +11,3 @@ export async function loadAllPrompts( ): Promise> { return loadAllEntities(dataDir, DIR_NAME, LABEL, frontmatterToPrompt); } - -export async function cleanupOrphanPromptFiles( - dataDir: string, - validPromptIds: Set, -): Promise { - return cleanupOrphanFiles(dataDir, DIR_NAME, validPromptIds, LABEL); -} diff --git a/apps/desktop/src/store/tinybase/persister/prompts/persister.ts b/apps/desktop/src/store/tinybase/persister/prompts/persister.ts index 077e9daeef..59492822fe 100644 --- a/apps/desktop/src/store/tinybase/persister/prompts/persister.ts +++ b/apps/desktop/src/store/tinybase/persister/prompts/persister.ts @@ -4,9 +4,11 @@ import type { OptionalSchemas, } from "tinybase/with-schemas"; +import { commands as folderCommands } from "@hypr/plugin-folder"; + import { createSessionDirPersister, getDataDir } from "../utils"; import { collectPromptWriteOps, type PromptCollectorResult } from "./collect"; -import { cleanupOrphanPromptFiles, loadAllPrompts } from "./load"; +import { loadAllPrompts } from "./load"; import { migratePromptsJsonIfNeeded } from "./migrate"; export function createPromptPersister( @@ -24,9 +26,13 @@ export function createPromptPersister( } return [{ prompts }, {}] as unknown as Content; }, - postSave: async (dataDir, result) => { + postSave: async (_dataDir, result) => { const { validPromptIds } = result as PromptCollectorResult; - await cleanupOrphanPromptFiles(dataDir, validPromptIds); + await folderCommands.cleanupOrphanFiles( + "prompts", + "md", + Array.from(validPromptIds), + ); }, }); } diff --git a/apps/desktop/src/store/tinybase/persister/session/load.ts b/apps/desktop/src/store/tinybase/persister/session/load.ts index f6d8905bad..2297b7134c 100644 --- a/apps/desktop/src/store/tinybase/persister/session/load.ts +++ b/apps/desktop/src/store/tinybase/persister/session/load.ts @@ -1,5 +1,5 @@ import { sep } from "@tauri-apps/api/path"; -import { exists, readDir, readTextFile, remove } from "@tauri-apps/plugin-fs"; +import { exists, readDir, readTextFile } from "@tauri-apps/plugin-fs"; import type { MappingSessionParticipantStorage, @@ -8,7 +8,6 @@ import type { Tag, } from "@hypr/store"; -import { isFileNotFoundError, isUUID } from "../utils"; import type { SessionMetaJson } from "./collect"; export type LoadedData = { @@ -114,66 +113,3 @@ export async function loadAllSessionMeta(dataDir: string): Promise { return result; } - -async function collectSessionDirsRecursively( - sessionsDir: string, - currentPath: string, - result: Array<{ path: string; name: string }>, -): Promise { - const s = sep(); - const fullPath = currentPath - ? [sessionsDir, currentPath].join(s) - : sessionsDir; - - let entries: { name: string; isDirectory: boolean }[]; - try { - entries = await readDir(fullPath); - } catch { - return; - } - - for (const entry of entries) { - if (!entry.isDirectory) continue; - - const entryPath = currentPath - ? [currentPath, entry.name].join(s) - : entry.name; - const metaPath = [sessionsDir, entryPath, "_meta.json"].join(s); - const hasMetaJson = await exists(metaPath); - - if (hasMetaJson) { - result.push({ path: [sessionsDir, entryPath].join(s), name: entry.name }); - } else { - await collectSessionDirsRecursively(sessionsDir, entryPath, result); - } - } -} - -export async function cleanupOrphanSessionDirs( - dataDir: string, - validSessionIds: Set, -): Promise { - const sessionsDir = [dataDir, "sessions"].join(sep()); - const existingDirs: Array<{ path: string; name: string }> = []; - - try { - await collectSessionDirsRecursively(sessionsDir, "", existingDirs); - } catch { - return; - } - - for (const dir of existingDirs) { - if (isUUID(dir.name) && !validSessionIds.has(dir.name)) { - try { - await remove(dir.path, { recursive: true }); - } catch (e) { - if (!isFileNotFoundError(e)) { - console.error( - `[SessionPersister] Failed to remove orphan dir ${dir.path}:`, - e, - ); - } - } - } - } -} diff --git a/apps/desktop/src/store/tinybase/persister/session/persister.ts b/apps/desktop/src/store/tinybase/persister/session/persister.ts index 25e43ce4b9..7d13fd9d19 100644 --- a/apps/desktop/src/store/tinybase/persister/session/persister.ts +++ b/apps/desktop/src/store/tinybase/persister/session/persister.ts @@ -4,9 +4,11 @@ import type { OptionalSchemas, } from "tinybase/with-schemas"; +import { commands as folderCommands } from "@hypr/plugin-folder"; + import { createSessionDirPersister, getDataDir } from "../utils"; import { collectSessionWriteOps, type SessionCollectorResult } from "./collect"; -import { cleanupOrphanSessionDirs, loadAllSessionMeta } from "./load"; +import { loadAllSessionMeta } from "./load"; export function createSessionPersister( store: MergeableStore, @@ -33,9 +35,13 @@ export function createSessionPersister( return undefined; } }, - postSave: async (dataDir, result) => { + postSave: async (_dataDir, result) => { const { validSessionIds } = result as SessionCollectorResult; - await cleanupOrphanSessionDirs(dataDir, validSessionIds); + await folderCommands.cleanupOrphanDirs( + "sessions", + "_meta.json", + Array.from(validSessionIds), + ); }, }); } diff --git a/plugins/folder/build.rs b/plugins/folder/build.rs index c067cd1de5..dbaba0bf87 100644 --- a/plugins/folder/build.rs +++ b/plugins/folder/build.rs @@ -4,6 +4,8 @@ const COMMANDS: &[&str] = &[ "create_folder", "rename_folder", "delete_folder", + "cleanup_orphan_files", + "cleanup_orphan_dirs", ]; fn main() { diff --git a/plugins/folder/js/bindings.gen.ts b/plugins/folder/js/bindings.gen.ts index cdd228370b..ea7989f860 100644 --- a/plugins/folder/js/bindings.gen.ts +++ b/plugins/folder/js/bindings.gen.ts @@ -45,6 +45,22 @@ async deleteFolder(folderPath: string) : Promise> { if(e instanceof Error) throw e; else return { status: "error", error: e as any }; } +}, +async cleanupOrphanFiles(subdir: string, extension: string, validIds: string[]) : Promise> { + try { + return { status: "ok", data: await TAURI_INVOKE("plugin:folder|cleanup_orphan_files", { subdir, extension, validIds }) }; +} catch (e) { + if(e instanceof Error) throw e; + else return { status: "error", error: e as any }; +} +}, +async cleanupOrphanDirs(subdir: string, markerFile: string, validIds: string[]) : Promise> { + try { + return { status: "ok", data: await TAURI_INVOKE("plugin:folder|cleanup_orphan_dirs", { subdir, markerFile, validIds }) }; +} catch (e) { + if(e instanceof Error) throw e; + else return { status: "error", error: e as any }; +} } } diff --git a/plugins/folder/permissions/autogenerated/commands/cleanup_orphan_dirs.toml b/plugins/folder/permissions/autogenerated/commands/cleanup_orphan_dirs.toml new file mode 100644 index 0000000000..e33bb08326 --- /dev/null +++ b/plugins/folder/permissions/autogenerated/commands/cleanup_orphan_dirs.toml @@ -0,0 +1,13 @@ +# Automatically generated - DO NOT EDIT! + +"$schema" = "../../schemas/schema.json" + +[[permission]] +identifier = "allow-cleanup-orphan-dirs" +description = "Enables the cleanup_orphan_dirs command without any pre-configured scope." +commands.allow = ["cleanup_orphan_dirs"] + +[[permission]] +identifier = "deny-cleanup-orphan-dirs" +description = "Denies the cleanup_orphan_dirs command without any pre-configured scope." +commands.deny = ["cleanup_orphan_dirs"] diff --git a/plugins/folder/permissions/autogenerated/commands/cleanup_orphan_files.toml b/plugins/folder/permissions/autogenerated/commands/cleanup_orphan_files.toml new file mode 100644 index 0000000000..4ba885592b --- /dev/null +++ b/plugins/folder/permissions/autogenerated/commands/cleanup_orphan_files.toml @@ -0,0 +1,13 @@ +# Automatically generated - DO NOT EDIT! + +"$schema" = "../../schemas/schema.json" + +[[permission]] +identifier = "allow-cleanup-orphan-files" +description = "Enables the cleanup_orphan_files command without any pre-configured scope." +commands.allow = ["cleanup_orphan_files"] + +[[permission]] +identifier = "deny-cleanup-orphan-files" +description = "Denies the cleanup_orphan_files command without any pre-configured scope." +commands.deny = ["cleanup_orphan_files"] diff --git a/plugins/folder/permissions/autogenerated/reference.md b/plugins/folder/permissions/autogenerated/reference.md index 70d75fa62b..c981503911 100644 --- a/plugins/folder/permissions/autogenerated/reference.md +++ b/plugins/folder/permissions/autogenerated/reference.md @@ -9,6 +9,8 @@ Default permissions for the plugin - `allow-create-folder` - `allow-rename-folder` - `allow-delete-folder` +- `allow-cleanup-orphan-files` +- `allow-cleanup-orphan-dirs` ## Permission Table @@ -19,6 +21,58 @@ Default permissions for the plugin + + + +`folder:allow-cleanup-orphan-dirs` + + + + +Enables the cleanup_orphan_dirs command without any pre-configured scope. + + + + + + + +`folder:deny-cleanup-orphan-dirs` + + + + +Denies the cleanup_orphan_dirs command without any pre-configured scope. + + + + + + + +`folder:allow-cleanup-orphan-files` + + + + +Enables the cleanup_orphan_files command without any pre-configured scope. + + + + + + + +`folder:deny-cleanup-orphan-files` + + + + +Denies the cleanup_orphan_files command without any pre-configured scope. + + + + diff --git a/plugins/folder/permissions/default.toml b/plugins/folder/permissions/default.toml index f8b964d133..095f1e7d92 100644 --- a/plugins/folder/permissions/default.toml +++ b/plugins/folder/permissions/default.toml @@ -6,4 +6,6 @@ permissions = [ "allow-create-folder", "allow-rename-folder", "allow-delete-folder", + "allow-cleanup-orphan-files", + "allow-cleanup-orphan-dirs", ] diff --git a/plugins/folder/permissions/schemas/schema.json b/plugins/folder/permissions/schemas/schema.json index 74680ac934..791cb67dbd 100644 --- a/plugins/folder/permissions/schemas/schema.json +++ b/plugins/folder/permissions/schemas/schema.json @@ -294,6 +294,30 @@ "PermissionKind": { "type": "string", "oneOf": [ + { + "description": "Enables the cleanup_orphan_dirs command without any pre-configured scope.", + "type": "string", + "const": "allow-cleanup-orphan-dirs", + "markdownDescription": "Enables the cleanup_orphan_dirs command without any pre-configured scope." + }, + { + "description": "Denies the cleanup_orphan_dirs command without any pre-configured scope.", + "type": "string", + "const": "deny-cleanup-orphan-dirs", + "markdownDescription": "Denies the cleanup_orphan_dirs command without any pre-configured scope." + }, + { + "description": "Enables the cleanup_orphan_files command without any pre-configured scope.", + "type": "string", + "const": "allow-cleanup-orphan-files", + "markdownDescription": "Enables the cleanup_orphan_files command without any pre-configured scope." + }, + { + "description": "Denies the cleanup_orphan_files command without any pre-configured scope.", + "type": "string", + "const": "deny-cleanup-orphan-files", + "markdownDescription": "Denies the cleanup_orphan_files command without any pre-configured scope." + }, { "description": "Enables the create_folder command without any pre-configured scope.", "type": "string", @@ -355,10 +379,10 @@ "markdownDescription": "Denies the rename_folder command without any pre-configured scope." }, { - "description": "Default permissions for the plugin\n#### This default permission set includes:\n\n- `allow-list-folders`\n- `allow-move-session`\n- `allow-create-folder`\n- `allow-rename-folder`\n- `allow-delete-folder`", + "description": "Default permissions for the plugin\n#### This default permission set includes:\n\n- `allow-list-folders`\n- `allow-move-session`\n- `allow-create-folder`\n- `allow-rename-folder`\n- `allow-delete-folder`\n- `allow-cleanup-orphan-files`\n- `allow-cleanup-orphan-dirs`", "type": "string", "const": "default", - "markdownDescription": "Default permissions for the plugin\n#### This default permission set includes:\n\n- `allow-list-folders`\n- `allow-move-session`\n- `allow-create-folder`\n- `allow-rename-folder`\n- `allow-delete-folder`" + "markdownDescription": "Default permissions for the plugin\n#### This default permission set includes:\n\n- `allow-list-folders`\n- `allow-move-session`\n- `allow-create-folder`\n- `allow-rename-folder`\n- `allow-delete-folder`\n- `allow-cleanup-orphan-files`\n- `allow-cleanup-orphan-dirs`" } ] } diff --git a/plugins/folder/src/commands.rs b/plugins/folder/src/commands.rs index 5c12a0fd53..88a4ed3a20 100644 --- a/plugins/folder/src/commands.rs +++ b/plugins/folder/src/commands.rs @@ -53,3 +53,29 @@ pub(crate) async fn delete_folder( .delete_folder(&folder_path) .map_err(|e| e.to_string()) } + +#[tauri::command] +#[specta::specta] +pub(crate) async fn cleanup_orphan_files( + app: tauri::AppHandle, + subdir: String, + extension: String, + valid_ids: Vec, +) -> Result { + app.folder() + .cleanup_orphan_files(&subdir, &extension, valid_ids) + .map_err(|e| e.to_string()) +} + +#[tauri::command] +#[specta::specta] +pub(crate) async fn cleanup_orphan_dirs( + app: tauri::AppHandle, + subdir: String, + marker_file: String, + valid_ids: Vec, +) -> Result { + app.folder() + .cleanup_orphan_dirs(&subdir, &marker_file, valid_ids) + .map_err(|e| e.to_string()) +} diff --git a/plugins/folder/src/ext.rs b/plugins/folder/src/ext.rs index f88341b8b3..063643d7a7 100644 --- a/plugins/folder/src/ext.rs +++ b/plugins/folder/src/ext.rs @@ -1,10 +1,12 @@ -use std::collections::HashMap; +use std::collections::{HashMap, HashSet}; use std::path::PathBuf; use tauri_plugin_path2::Path2PluginExt; use crate::types::ListFoldersResult; -use crate::utils::{find_session_dir, scan_directory_recursive}; +use crate::utils::{ + cleanup_dirs_recursive, cleanup_files_in_dir, find_session_dir, scan_directory_recursive, +}; pub struct Folder<'a, R: tauri::Runtime, M: tauri::Manager> { manager: &'a M, @@ -12,14 +14,16 @@ pub struct Folder<'a, R: tauri::Runtime, M: tauri::Manager> { } impl<'a, R: tauri::Runtime, M: tauri::Manager> Folder<'a, R, M> { - fn sessions_dir(&self) -> Result { - let base = self - .manager + fn base_dir(&self) -> Result { + self.manager .app_handle() .path2() .base() - .map_err(|e| crate::Error::Path(e.to_string()))?; - Ok(base.join("sessions")) + .map_err(|e| crate::Error::Path(e.to_string())) + } + + fn sessions_dir(&self) -> Result { + Ok(self.base_dir()?.join("sessions")) } pub fn list_folders(&self) -> Result { @@ -128,6 +132,28 @@ impl<'a, R: tauri::Runtime, M: tauri::Manager> Folder<'a, R, M> { tracing::info!("Deleted folder: {:?}", folder); Ok(()) } + + pub fn cleanup_orphan_files( + &self, + subdir: &str, + extension: &str, + valid_ids: Vec, + ) -> Result { + let dir = self.base_dir()?.join(subdir); + let valid_set: HashSet = valid_ids.into_iter().collect(); + Ok(cleanup_files_in_dir(&dir, extension, &valid_set)?) + } + + pub fn cleanup_orphan_dirs( + &self, + subdir: &str, + marker_file: &str, + valid_ids: Vec, + ) -> Result { + let dir = self.base_dir()?.join(subdir); + let valid_set: HashSet = valid_ids.into_iter().collect(); + Ok(cleanup_dirs_recursive(&dir, marker_file, &valid_set)?) + } } pub trait FolderPluginExt { diff --git a/plugins/folder/src/lib.rs b/plugins/folder/src/lib.rs index 37b08128c8..713ebd3418 100644 --- a/plugins/folder/src/lib.rs +++ b/plugins/folder/src/lib.rs @@ -21,6 +21,8 @@ fn make_specta_builder() -> tauri_specta::Builder { commands::create_folder::, commands::rename_folder::, commands::delete_folder::, + commands::cleanup_orphan_files::, + commands::cleanup_orphan_dirs::, ]) .error_handling(tauri_specta::ErrorHandlingMode::Result) } diff --git a/plugins/folder/src/utils.rs b/plugins/folder/src/utils.rs index 8f39246b78..f13844a8a6 100644 --- a/plugins/folder/src/utils.rs +++ b/plugins/folder/src/utils.rs @@ -1,3 +1,4 @@ +use std::collections::HashSet; use std::path::{Path, PathBuf}; use uuid::Uuid; @@ -101,6 +102,116 @@ pub fn scan_directory_recursive( } } +pub fn cleanup_files_in_dir( + dir: &Path, + extension: &str, + valid_ids: &HashSet, +) -> std::io::Result { + if !dir.exists() { + return Ok(0); + } + + let mut removed = 0; + + for entry in std::fs::read_dir(dir)?.flatten() { + let path = entry.path(); + if !path.is_file() { + continue; + } + + let Some(stem) = path.file_stem().and_then(|n| n.to_str()) else { + continue; + }; + + if path.extension().and_then(|e| e.to_str()) != Some(extension) { + continue; + } + + if !is_uuid(stem) { + continue; + } + + if !valid_ids.contains(stem) { + if let Err(e) = std::fs::remove_file(&path) { + tracing::warn!("Failed to remove orphan file {:?}: {}", path, e); + } else { + tracing::debug!("Removed orphan file: {:?}", path); + removed += 1; + } + } + } + + Ok(removed) +} + +pub fn cleanup_dirs_recursive( + base_dir: &Path, + marker_file: &str, + valid_ids: &HashSet, +) -> std::io::Result { + if !base_dir.exists() { + return Ok(0); + } + + let orphans = collect_orphan_dirs(base_dir, marker_file, valid_ids); + + let mut removed = 0; + for dir in orphans { + if let Err(e) = std::fs::remove_dir_all(&dir) { + tracing::warn!("Failed to remove orphan dir {:?}: {}", dir, e); + } else { + tracing::info!("Removed orphan dir: {:?}", dir); + removed += 1; + } + } + + Ok(removed) +} + +fn collect_orphan_dirs( + base_dir: &Path, + marker_file: &str, + valid_ids: &HashSet, +) -> Vec { + let mut orphans = Vec::new(); + collect_orphan_dirs_recursive(base_dir, base_dir, marker_file, valid_ids, &mut orphans); + orphans +} + +fn collect_orphan_dirs_recursive( + base_dir: &Path, + current_dir: &Path, + marker_file: &str, + valid_ids: &HashSet, + orphans: &mut Vec, +) { + let entries = match std::fs::read_dir(current_dir) { + Ok(entries) => entries, + Err(_) => return, + }; + + for entry in entries.flatten() { + let path = entry.path(); + if !path.is_dir() { + continue; + } + + let Some(name) = path.file_name().and_then(|n| n.to_str()) else { + continue; + }; + + let has_marker = path.join(marker_file).exists(); + + if has_marker { + if is_uuid(name) && !valid_ids.contains(name) { + orphans.push(path); + } + } else if !is_uuid(name) { + collect_orphan_dirs_recursive(base_dir, &path, marker_file, valid_ids, orphans); + } + } +} + #[cfg(test)] mod tests { use super::*;