diff --git a/src/basic_memory/api/routers/project_router.py b/src/basic_memory/api/routers/project_router.py index 4e97a3e6..c2bff207 100644 --- a/src/basic_memory/api/routers/project_router.py +++ b/src/basic_memory/api/routers/project_router.py @@ -1,7 +1,7 @@ """Router for project management.""" import os -from fastapi import APIRouter, HTTPException, Path, Body, BackgroundTasks, Response +from fastapi import APIRouter, HTTPException, Path, Body, BackgroundTasks, Response, Query from typing import Optional from loguru import logger @@ -247,11 +247,15 @@ async def add_project( async def remove_project( project_service: ProjectServiceDep, name: str = Path(..., description="Name of the project to remove"), + delete_notes: bool = Query( + False, description="If True, delete project directory from filesystem" + ), ) -> ProjectStatusResponse: """Remove a project from configuration and database. Args: name: The name of the project to remove + delete_notes: If True, delete the project directory from the filesystem Returns: Response confirming the project was removed @@ -276,7 +280,7 @@ async def remove_project( detail += "This is the only project in your configuration." raise HTTPException(status_code=400, detail=detail) - await project_service.remove_project(name) + await project_service.remove_project(name, delete_notes=delete_notes) return ProjectStatusResponse( message=f"Project '{name}' removed successfully", diff --git a/src/basic_memory/cli/commands/project.py b/src/basic_memory/cli/commands/project.py index 3a6a0363..e8672130 100644 --- a/src/basic_memory/cli/commands/project.py +++ b/src/basic_memory/cli/commands/project.py @@ -119,13 +119,18 @@ def add_project( @project_app.command("remove") def remove_project( name: str = typer.Argument(..., help="Name of the project to remove"), + delete_notes: bool = typer.Option( + False, "--delete-notes", help="Delete project files from disk" + ), ) -> None: """Remove a project.""" async def _remove_project(): async with get_client() as client: project_permalink = generate_permalink(name) - response = await call_delete(client, f"/projects/{project_permalink}") + response = await call_delete( + client, f"/projects/{project_permalink}?delete_notes={delete_notes}" + ) return ProjectStatusResponse.model_validate(response.json()) try: @@ -135,8 +140,9 @@ def remove_project( console.print(f"[red]Error removing project: {str(e)}[/red]") raise typer.Exit(1) - # Show this message regardless of method used - console.print("[yellow]Note: The project files have not been deleted from disk.[/yellow]") + # Show this message only if files were not deleted + if not delete_notes: + console.print("[yellow]Note: The project files have not been deleted from disk.[/yellow]") @project_app.command("default") diff --git a/src/basic_memory/services/project_service.py b/src/basic_memory/services/project_service.py index 7986e849..aa1d9336 100644 --- a/src/basic_memory/services/project_service.py +++ b/src/basic_memory/services/project_service.py @@ -1,7 +1,9 @@ """Project management service for Basic Memory.""" +import asyncio import json import os +import shutil from datetime import datetime from pathlib import Path from typing import Dict, Optional, Sequence @@ -219,11 +221,12 @@ class ProjectService: logger.info(f"Project '{name}' added at {resolved_path}") - async def remove_project(self, name: str) -> None: + async def remove_project(self, name: str, delete_notes: bool = False) -> None: """Remove a project from configuration and database. Args: name: The name of the project to remove + delete_notes: If True, delete the project directory from filesystem Raises: ValueError: If the project doesn't exist or is the default project @@ -231,16 +234,33 @@ class ProjectService: if not self.repository: # pragma: no cover raise ValueError("Repository is required for remove_project") + # Get project path before removing from config + project = await self.get_project(name) + project_path = project.path if project else None + # First remove from config (this will validate the project exists and is not default) self.config_manager.remove_project(name) # Then remove from database using robust lookup - project = await self.get_project(name) if project: await self.repository.delete(project.id) logger.info(f"Project '{name}' removed from configuration and database") + # Optionally delete the project directory + if delete_notes and project_path: + try: + path_obj = Path(project_path) + if path_obj.exists() and path_obj.is_dir(): + await asyncio.to_thread(shutil.rmtree, project_path) + logger.info(f"Deleted project directory: {project_path}") + else: + logger.warning( + f"Project directory not found or not a directory: {project_path}" + ) + except Exception as e: + logger.warning(f"Failed to delete project directory {project_path}: {e}") + async def set_default_project(self, name: str) -> None: """Set the default project in configuration and database. diff --git a/tests/api/test_project_router.py b/tests/api/test_project_router.py index bc018adf..c19f5a4e 100644 --- a/tests/api/test_project_router.py +++ b/tests/api/test_project_router.py @@ -635,3 +635,86 @@ async def test_create_project_fails_different_path(test_config, client, project_ await project_service.remove_project(test_project_name) except Exception: pass + + +@pytest.mark.asyncio +async def test_remove_project_with_delete_notes_false(test_config, client, project_service): + """Test that removing a project with delete_notes=False leaves directory intact.""" + # Create a test project with actual directory + test_project_name = "test-remove-keep-files" + with tempfile.TemporaryDirectory() as temp_dir: + test_path = Path(temp_dir) / "test-project" + test_path.mkdir() + test_file = test_path / "test.md" + test_file.write_text("# Test Note") + + await project_service.add_project(test_project_name, str(test_path)) + + # Remove the project without deleting files (default) + response = await client.delete(f"/projects/{test_project_name}") + + # Verify response + assert response.status_code == 200 + data = response.json() + assert data["status"] == "success" + + # Verify project is removed from config/db + removed_project = await project_service.get_project(test_project_name) + assert removed_project is None + + # Verify directory still exists + assert test_path.exists() + assert test_file.exists() + + +@pytest.mark.asyncio +async def test_remove_project_with_delete_notes_true(test_config, client, project_service): + """Test that removing a project with delete_notes=True deletes the directory.""" + # Create a test project with actual directory + test_project_name = "test-remove-delete-files" + with tempfile.TemporaryDirectory() as temp_dir: + test_path = Path(temp_dir) / "test-project" + test_path.mkdir() + test_file = test_path / "test.md" + test_file.write_text("# Test Note") + + await project_service.add_project(test_project_name, str(test_path)) + + # Remove the project with delete_notes=True + response = await client.delete(f"/projects/{test_project_name}?delete_notes=true") + + # Verify response + assert response.status_code == 200 + data = response.json() + assert data["status"] == "success" + + # Verify project is removed from config/db + removed_project = await project_service.get_project(test_project_name) + assert removed_project is None + + # Verify directory is deleted + assert not test_path.exists() + + +@pytest.mark.asyncio +async def test_remove_project_delete_notes_nonexistent_directory( + test_config, client, project_service +): + """Test that removing a project with delete_notes=True handles missing directory gracefully.""" + # Create a project pointing to a non-existent path + test_project_name = "test-remove-missing-dir" + test_path = "/tmp/this-directory-does-not-exist-12345" + + await project_service.add_project(test_project_name, test_path) + + # Remove the project with delete_notes=True (should not fail even if dir doesn't exist) + response = await client.delete(f"/projects/{test_project_name}?delete_notes=true") + + # Should succeed + assert response.status_code == 200 + data = response.json() + assert data["status"] == "success" + + # Verify project is removed + removed_project = await project_service.get_project(test_project_name) + assert removed_project is None diff --git a/tests/services/test_project_service.py b/tests/services/test_project_service.py index 8e564346..4b1cc8f3 100644 --- a/tests/services/test_project_service.py +++ b/tests/services/test_project_service.py @@ -1250,3 +1250,108 @@ async def test_synchronize_projects_removes_db_only_projects(project_service: Pr db_project = await project_service.repository.get_by_name(test_project_name) if db_project: await project_service.repository.delete(db_project.id) + + +@pytest.mark.asyncio +async def test_remove_project_with_delete_notes_false(project_service: ProjectService): + """Test that remove_project with delete_notes=False keeps directory intact.""" + test_project_name = f"test-remove-keep-{os.urandom(4).hex()}" + with tempfile.TemporaryDirectory() as temp_dir: + test_root = Path(temp_dir) + test_project_path = test_root / "test-project" + test_project_path.mkdir() + test_file = test_project_path / "test.md" + test_file.write_text("# Test Note") + + try: + # Add project + await project_service.add_project(test_project_name, str(test_project_path)) + + # Verify project exists + assert test_project_name in project_service.projects + assert test_project_path.exists() + assert test_file.exists() + + # Remove project without deleting notes (default behavior) + await project_service.remove_project(test_project_name, delete_notes=False) + + # Verify project is removed from config/db + assert test_project_name not in project_service.projects + db_project = await project_service.repository.get_by_name(test_project_name) + assert db_project is None + + # Verify directory and files still exist + assert test_project_path.exists() + assert test_file.exists() + + finally: + # Cleanup happens automatically with temp_dir context manager + pass + + +@pytest.mark.asyncio +async def test_remove_project_with_delete_notes_true(project_service: ProjectService): + """Test that remove_project with delete_notes=True deletes directory.""" + test_project_name = f"test-remove-delete-{os.urandom(4).hex()}" + with tempfile.TemporaryDirectory() as temp_dir: + test_root = Path(temp_dir) + test_project_path = test_root / "test-project" + test_project_path.mkdir() + test_file = test_project_path / "test.md" + test_file.write_text("# Test Note") + + try: + # Add project + await project_service.add_project(test_project_name, str(test_project_path)) + + # Verify project exists + assert test_project_name in project_service.projects + assert test_project_path.exists() + assert test_file.exists() + + # Remove project with delete_notes=True + await project_service.remove_project(test_project_name, delete_notes=True) + + # Verify project is removed from config/db + assert test_project_name not in project_service.projects + db_project = await project_service.repository.get_by_name(test_project_name) + assert db_project is None + + # Verify directory and files are deleted + assert not test_project_path.exists() + + finally: + # Cleanup happens automatically with temp_dir context manager + pass + + +@pytest.mark.asyncio +async def test_remove_project_delete_notes_missing_directory(project_service: ProjectService): + """Test that remove_project with delete_notes=True handles missing directory gracefully.""" + test_project_name = f"test-remove-missing-{os.urandom(4).hex()}" + test_project_path = f"/tmp/nonexistent-directory-{os.urandom(8).hex()}" + + try: + # Add project pointing to non-existent path + await project_service.add_project(test_project_name, test_project_path) + + # Verify project exists in config/db + assert test_project_name in project_service.projects + db_project = await project_service.repository.get_by_name(test_project_name) + assert db_project is not None + + # Remove project with delete_notes=True (should not fail even if dir doesn't exist) + await project_service.remove_project(test_project_name, delete_notes=True) + + # Verify project is removed from config/db + assert test_project_name not in project_service.projects + db_project = await project_service.repository.get_by_name(test_project_name) + assert db_project is None + + finally: + # Ensure cleanup + if test_project_name in project_service.projects: + try: + project_service.config_manager.remove_project(test_project_name) + except Exception: + pass