diff --git a/src/cli.rs b/src/cli.rs index 8b08754..2dc8cd0 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -89,6 +89,11 @@ enum Command { Doctor { #[arg(long, help = "Emit machine-readable JSON")] json: bool, + #[arg( + long, + help = "Drop persistent legacy blob tags; periodic GC reclaims orphaned bytes" + )] + drop_legacy_tags: bool, }, } @@ -231,10 +236,24 @@ async fn run_app_command(command: Command) -> anyhow::Result<()> { return run_folder_command(command).await; } match command { - Command::Doctor { json } => println!( - "{}", - crate::ipc::DaemonClient::connect()?.doctor(json).await? - ), + Command::Doctor { + json, + drop_legacy_tags, + } => { + if drop_legacy_tags { + println!( + "{}", + crate::ipc::DaemonClient::connect()? + .drop_legacy_tags(json) + .await? + ); + } else { + println!( + "{}", + crate::ipc::DaemonClient::connect()?.doctor(json).await? + ); + } + } Command::Completions { .. } => unreachable!("handled before opening Appa"), Command::Service { .. } => unreachable!("service commands are handled before opening Appa"), Command::Daemon { .. } => unreachable!("daemon commands are handled before opening Appa"), diff --git a/src/cli/tests.rs b/src/cli/tests.rs index caad8e6..ad6d5c8 100644 --- a/src/cli/tests.rs +++ b/src/cli/tests.rs @@ -1,6 +1,28 @@ use std::io::Cursor; -use super::{resolve_ticket, resolve_ticket_from}; +use clap::Parser; + +use super::{Command, CommandLine, resolve_ticket, resolve_ticket_from}; + +#[test] +fn parses_explicit_legacy_tag_cleanup() { + let command_line = + CommandLine::try_parse_from(["appa", "doctor", "--drop-legacy-tags", "--json"]) + .expect("command should parse"); + + assert!(matches!( + command_line.command, + Command::Doctor { + json: true, + drop_legacy_tags: true, + } + )); +} + +#[test] +fn rejects_the_misleading_gc_flag() { + assert!(CommandLine::try_parse_from(["appa", "doctor", "--gc"]).is_err()); +} #[test] fn rejects_ambiguous_ticket_input() { diff --git a/src/daemon.rs b/src/daemon.rs index 4144efd..2f3c905 100644 --- a/src/daemon.rs +++ b/src/daemon.rs @@ -152,6 +152,9 @@ async fn handle_command( service_members(service, &folder_path).map(Response::Text) } Command::Doctor { json } => service_doctor(service, json).map(Response::Text), + Command::DropLegacyTags { json } => service_drop_legacy_tags(sync_runner, json) + .await + .map(Response::Text), }; result.unwrap_or_else(|error| Response::Error(error.to_string())) } @@ -295,6 +298,29 @@ fn service_doctor(service: &AppaService, json: bool) -> anyhow::Result { )) } +async fn service_drop_legacy_tags(sync_runner: &SyncRunner, json: bool) -> anyhow::Result { + let swept = sync_runner.node().sweep_legacy_tags().await?; + Ok(format_legacy_tag_cleanup(swept, json)) +} + +fn format_legacy_tag_cleanup(swept: u64, json: bool) -> String { + if json { + return serde_json::json!({ + "dropped_legacy_tag_count": swept, + "garbage_collection": "scheduled", + }) + .to_string(); + } + + if swept == 0 { + return "No persistent legacy blob tags found. Orphaned bytes will be reclaimed by periodic garbage collection.\n".to_owned(); + } + + format!( + "Dropped {swept} persistent legacy blob tag(s). Orphaned bytes will be reclaimed by periodic garbage collection.\n" + ) +} + fn format_status(status: &crate::app::FolderStatus) -> String { let last_successful_sync = status .last_successful_sync @@ -396,3 +422,25 @@ fn remove_stale_socket(socket_path: &Path) -> anyhow::Result<()> { } } } + +#[cfg(test)] +mod tests { + use super::format_legacy_tag_cleanup; + + #[test] + fn reports_cleanup_as_json_when_requested() { + let report = format_legacy_tag_cleanup(3, true); + let report: serde_json::Value = serde_json::from_str(&report).expect("valid JSON"); + + assert_eq!(report["dropped_legacy_tag_count"], 3); + assert_eq!(report["garbage_collection"], "scheduled"); + } + + #[test] + fn reports_when_no_legacy_tags_were_found() { + let report = format_legacy_tag_cleanup(0, false); + + assert!(report.contains("No persistent legacy blob tags found")); + assert!(report.contains("periodic garbage collection")); + } +} diff --git a/src/ipc.rs b/src/ipc.rs index 82670d9..94b36a0 100644 --- a/src/ipc.rs +++ b/src/ipc.rs @@ -78,6 +78,10 @@ pub(crate) enum Command { Doctor { json: bool, }, + /// Drop persistent legacy blob tags; periodic GC reclaims orphaned bytes. + DropLegacyTags { + json: bool, + }, } #[derive(Debug, Deserialize, Serialize)] @@ -220,6 +224,10 @@ impl DaemonClient { self.request_text(Command::Doctor { json }).await } + pub async fn drop_legacy_tags(&self, json: bool) -> anyhow::Result { + self.request_text(Command::DropLegacyTags { json }).await + } + async fn expect_success(&self, command: Command) -> anyhow::Result<()> { match self.request(command).await? { Response::Success => Ok(()),