blue/crates/blue-mcp/src/handlers/delete.rs
Eric Garcia 489942cd35 feat: implement RFC 0006 (soft-delete) and RFC 0007 (branch naming)
RFC 0006 - Document Deletion Tools:
- Add soft-delete with 7-day retention before permanent deletion
- Add blue_delete, blue_restore, blue_deleted_list, blue_purge_deleted tools
- Add deleted_at column to documents table (schema v3)
- Block deletion of documents with ADR dependents
- Support dry_run, force, and permanent options

RFC 0007 - Consistent Branch Naming:
- Strip RFC number prefix from branch/worktree names
- Branch format: feature-description (not rfc/NNNN-feature-description)
- PR title format: RFC NNNN: Feature Description
- Add strip_rfc_number_prefix helper with tests

Also:
- Remove orphan .blue/repos/ and .blue/data/ directories
- Fix docs path resolution bug (spike documented)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-24 16:42:31 -05:00

344 lines
11 KiB
Rust

//! Document deletion handlers for Blue MCP
//!
//! Implements soft-delete with 7-day retention and restore capability.
use serde_json::{json, Value};
use std::fs;
use std::path::Path;
use blue_core::store::DocType;
use blue_core::ProjectState;
use crate::ServerError;
/// Check what would be deleted (dry run)
pub fn handle_delete_dry_run(
state: &ProjectState,
doc_type: DocType,
title: &str,
) -> Result<Value, ServerError> {
let doc = state
.store
.find_document(doc_type, title)
.map_err(|e| ServerError::StateLoadFailed(e.to_string()))?;
let doc_id = doc.id.unwrap();
// Check for ADR dependents
let adr_dependents = state
.store
.has_adr_dependents(doc_id)
.map_err(|e| ServerError::StateLoadFailed(e.to_string()))?;
// Check for active sessions
let active_session = state
.store
.get_active_session(&doc.title)
.map_err(|e| ServerError::StateLoadFailed(e.to_string()))?;
// Check for worktree
let worktree = state
.store
.get_worktree(doc_id)
.map_err(|e| ServerError::StateLoadFailed(e.to_string()))?;
// Find companion files
let mut companion_files = Vec::new();
if let Some(ref file_path) = doc.file_path {
let base_path = Path::new(file_path);
if let Some(stem) = base_path.file_stem() {
if let Some(parent) = base_path.parent() {
let stem_str = stem.to_string_lossy();
// Check for .plan.md, .dialogue.md
for suffix in &[".plan.md", ".dialogue.md", ".draft.md"] {
let companion = parent.join(format!("{}{}", stem_str, suffix));
if companion.exists() {
companion_files.push(companion.display().to_string());
}
}
}
}
}
let mut warnings = Vec::new();
let mut blockers = Vec::new();
// ADR dependents are permanent blockers
if !adr_dependents.is_empty() {
let adr_titles: Vec<_> = adr_dependents.iter().map(|d| d.title.clone()).collect();
blockers.push(format!(
"Has ADR dependents: {}. ADRs are permanent records and cannot be cascade-deleted.",
adr_titles.join(", ")
));
}
// Non-draft status requires force
if doc.status != "draft" {
warnings.push(format!(
"Status is '{}'. Use force=true to delete non-draft documents.",
doc.status
));
}
// Active session requires force
if let Some(session) = &active_session {
warnings.push(format!(
"Has active {} session started at {}. Use force=true to override.",
session.session_type.as_str(),
session.started_at
));
}
Ok(json!({
"dry_run": true,
"document": {
"type": doc_type.as_str(),
"title": doc.title,
"status": doc.status,
"file_path": doc.file_path,
},
"would_delete": {
"primary_file": doc.file_path,
"companion_files": companion_files,
"worktree": worktree.map(|w| w.worktree_path),
},
"blockers": blockers,
"warnings": warnings,
"can_proceed": blockers.is_empty(),
}))
}
/// Delete a document with safety checks
pub fn handle_delete(
state: &mut ProjectState,
doc_type: DocType,
title: &str,
force: bool,
permanent: bool,
) -> Result<Value, ServerError> {
// Find the document
let doc = state
.store
.find_document(doc_type, title)
.map_err(|e| ServerError::StateLoadFailed(e.to_string()))?;
let doc_id = doc.id.unwrap();
// Check for ADR dependents - this is a permanent blocker
let adr_dependents = state
.store
.has_adr_dependents(doc_id)
.map_err(|e| ServerError::StateLoadFailed(e.to_string()))?;
if !adr_dependents.is_empty() {
let adr_titles: Vec<_> = adr_dependents.iter().map(|d| d.title.clone()).collect();
return Ok(json!({
"status": "blocked",
"message": format!(
"Cannot delete {} '{}'.\n\nThis document has ADR dependents: {}.\nADRs are permanent architectural records and cannot be cascade-deleted.\n\nTo proceed:\n1. Update the ADR(s) to remove the reference, or\n2. Mark this document as 'superseded' instead of deleting",
doc_type.as_str(),
doc.title,
adr_titles.join(", ")
),
"adr_dependents": adr_titles,
}));
}
// Check status - non-draft requires force
if doc.status != "draft" && !force {
let status_msg = match doc.status.as_str() {
"accepted" => "This document has been accepted.",
"in-progress" => "This document has active work.",
"implemented" => "This document is a historical record.",
_ => "This document is not in draft status.",
};
return Ok(json!({
"status": "requires_force",
"message": format!(
"Cannot delete {} '{}'.\n\nStatus: {}\n{}\n\nUse force=true to delete anyway.",
doc_type.as_str(),
doc.title,
doc.status,
status_msg
),
"current_status": doc.status,
}));
}
// Check for active session - requires force
let active_session = state
.store
.get_active_session(&doc.title)
.map_err(|e| ServerError::StateLoadFailed(e.to_string()))?;
if active_session.is_some() && !force {
let session = active_session.unwrap();
return Ok(json!({
"status": "requires_force",
"message": format!(
"Cannot delete {} '{}'.\n\nHas active {} session started at {}.\n\nUse force=true to delete anyway, which will end the session.",
doc_type.as_str(),
doc.title,
session.session_type.as_str(),
session.started_at
),
"active_session": {
"type": session.session_type.as_str(),
"started_at": session.started_at,
},
}));
}
// End any active session
if active_session.is_some() {
let _ = state.store.end_session(&doc.title);
}
// Remove worktree if exists
let mut worktree_removed = false;
if let Ok(Some(worktree)) = state.store.get_worktree(doc_id) {
// Remove from filesystem
let worktree_path = Path::new(&worktree.worktree_path);
if worktree_path.exists() {
// Use git worktree remove
let _ = std::process::Command::new("git")
.args(["worktree", "remove", "--force", &worktree.worktree_path])
.output();
}
// Remove from database
let _ = state.store.remove_worktree(doc_id);
worktree_removed = true;
}
// Delete companion files
let mut files_deleted = Vec::new();
if let Some(ref file_path) = doc.file_path {
let base_path = Path::new(file_path);
if let Some(stem) = base_path.file_stem() {
if let Some(parent) = base_path.parent() {
let stem_str = stem.to_string_lossy();
for suffix in &[".plan.md", ".dialogue.md", ".draft.md"] {
let companion = parent.join(format!("{}{}", stem_str, suffix));
if companion.exists() {
if fs::remove_file(&companion).is_ok() {
files_deleted.push(companion.display().to_string());
}
}
}
}
}
// Delete primary file
if base_path.exists() {
if fs::remove_file(base_path).is_ok() {
files_deleted.push(file_path.clone());
}
}
}
// Soft or permanent delete
if permanent {
state
.store
.delete_document(doc_type, &doc.title)
.map_err(|e| ServerError::StateLoadFailed(e.to_string()))?;
} else {
state
.store
.soft_delete_document(doc_type, &doc.title)
.map_err(|e| ServerError::StateLoadFailed(e.to_string()))?;
}
let action = if permanent {
"permanently deleted"
} else {
"soft-deleted (recoverable for 7 days)"
};
Ok(json!({
"status": "success",
"message": format!("{} '{}' {}.", doc_type.as_str().to_uppercase(), doc.title, action),
"doc_type": doc_type.as_str(),
"title": doc.title,
"permanent": permanent,
"files_deleted": files_deleted,
"worktree_removed": worktree_removed,
"restore_command": if !permanent {
Some(format!("blue restore {} {}", doc_type.as_str(), doc.title))
} else {
None
},
}))
}
/// Restore a soft-deleted document
pub fn handle_restore(
state: &mut ProjectState,
doc_type: DocType,
title: &str,
) -> Result<Value, ServerError> {
// Check if document exists and is soft-deleted
let doc = state
.store
.get_deleted_document(doc_type, title)
.map_err(|e| ServerError::StateLoadFailed(e.to_string()))?;
// Restore the document
state
.store
.restore_document(doc_type, &doc.title)
.map_err(|e| ServerError::StateLoadFailed(e.to_string()))?;
Ok(json!({
"status": "success",
"message": format!("{} '{}' restored.", doc_type.as_str().to_uppercase(), doc.title),
"doc_type": doc_type.as_str(),
"title": doc.title,
"note": "Files were deleted and will need to be recreated if needed.",
}))
}
/// List soft-deleted documents
pub fn handle_list_deleted(
state: &ProjectState,
doc_type: Option<DocType>,
) -> Result<Value, ServerError> {
let deleted = state
.store
.list_deleted_documents(doc_type)
.map_err(|e| ServerError::StateLoadFailed(e.to_string()))?;
let docs: Vec<_> = deleted
.iter()
.map(|d| {
json!({
"type": d.doc_type.as_str(),
"title": d.title,
"status": d.status,
"deleted_at": d.deleted_at,
})
})
.collect();
Ok(json!({
"status": "success",
"count": docs.len(),
"deleted_documents": docs,
"note": "Documents are auto-purged 7 days after deletion. Use blue_restore to recover.",
}))
}
/// Purge old soft-deleted documents
pub fn handle_purge_deleted(state: &mut ProjectState, days: i64) -> Result<Value, ServerError> {
let purged = state
.store
.purge_old_deleted_documents(days)
.map_err(|e| ServerError::StateLoadFailed(e.to_string()))?;
Ok(json!({
"status": "success",
"message": format!("Purged {} documents older than {} days.", purged, days),
"purged_count": purged,
}))
}