Add tag prune command to remove unused tags
Tags are created implicitly and never cleaned up. ranger tag prune
deletes orphaned tags (no task_tags rows). Defaults to dry run; pass
--apply to actually delete. Also fixes a clippy warning in the List
branch by using if-let instead of unwrap after is_none check.
Assisted-by: GLM-5.1 via pi
diff --git a/src/bin/ranger/commands/tag.rs b/src/bin/ranger/commands/tag.rs
index 525f963..faf86a5 100644
--- a/src/bin/ranger/commands/tag.rs
+++ b/src/bin/ranger/commands/tag.rs
@@ -30,6 +30,12 @@ pub enum TagCommands {
#[arg(long)]
all: bool,
},
+ /// Remove unused tags (no associated tasks)
+ Prune {
+ /// Actually delete the tags (default: dry run)
+ #[arg(long)]
+ apply: bool,
+ },
}
pub async fn run(pool: &SqlitePool, command: TagCommands, json: bool) -> Result<(), RangerError> {
@@ -52,13 +58,26 @@ pub async fn run(pool: &SqlitePool, command: TagCommands, json: bool) -> Result<
}
}
TagCommands::List { all } => {
- let tags = if all || backlog_scope.is_none() {
+ let tags = if all {
ops::tag::list_all(&mut conn).await?
+ } else if let Some(bl_id) = backlog_scope {
+ ops::tag::list_for_backlog(&mut conn, bl_id).await?
} else {
- ops::tag::list_for_backlog(&mut conn, backlog_scope.unwrap()).await?
+ ops::tag::list_all(&mut conn).await?
};
output::print_list(&tags, json, |t| println!("{}", t.name));
}
+ TagCommands::Prune { apply } => {
+ let pruned = ops::tag::prune(&mut conn, !apply).await?;
+ if pruned.is_empty() {
+ if !json {
+ println!("No unused tags to remove.");
+ }
+ } else {
+ let label = if apply { "Removed" } else { "Would remove" };
+ output::print_list(&pruned, json, |t| println!("{}: {}", label, t.name));
+ }
+ }
}
Ok(())
}
diff --git a/src/ops/tag.rs b/src/ops/tag.rs
index de973f7..b47f6a4 100644
--- a/src/ops/tag.rs
+++ b/src/ops/tag.rs
@@ -83,6 +83,24 @@ pub async fn list_all(conn: &mut SqliteConnection) -> Result<Vec<Tag>, RangerErr
)
}
+/// Delete tags with no associated tasks. Returns the removed tags.
+/// If dry_run is true, returns what would be removed without deleting.
+pub async fn prune(conn: &mut SqliteConnection, dry_run: bool) -> Result<Vec<Tag>, RangerError> {
+ let orphaned = sqlx::query_as::<_, Tag>(
+ "SELECT id, name FROM tags WHERE id NOT IN (SELECT tag_id FROM task_tags) ORDER BY name",
+ )
+ .fetch_all(&mut *conn)
+ .await?;
+
+ if !dry_run {
+ sqlx::query("DELETE FROM tags WHERE id NOT IN (SELECT tag_id FROM task_tags)")
+ .execute(&mut *conn)
+ .await?;
+ }
+
+ Ok(orphaned)
+}
+
/// List tags used by tasks in a specific backlog.
pub async fn list_for_backlog(
conn: &mut SqliteConnection,
@@ -285,4 +303,55 @@ mod tests {
let tags = list_for_backlog(&mut conn, bl1_id).await.unwrap();
assert!(tags.is_empty());
}
+
+ #[tokio::test]
+ async fn prune_removes_orphaned_tags() {
+ let (pool, task_id, _dir) = setup().await;
+ let mut conn = pool.acquire().await.unwrap();
+
+ add(&mut conn, task_id, "used").await.unwrap();
+ add(&mut conn, task_id, "also-used").await.unwrap();
+ // Create an orphan by adding then removing
+ add(&mut conn, task_id, "orphan").await.unwrap();
+ remove(&mut conn, task_id, "orphan").await.unwrap();
+
+ let pruned = prune(&mut conn, false).await.unwrap();
+ assert_eq!(pruned.len(), 1);
+ assert_eq!(pruned[0].name, "orphan");
+
+ let all = list_all(&mut conn).await.unwrap();
+ assert_eq!(all.len(), 2);
+ let names: Vec<&str> = all.iter().map(|t| t.name.as_str()).collect();
+ assert!(names.contains(&"used"));
+ assert!(names.contains(&"also-used"));
+ }
+
+ #[tokio::test]
+ async fn prune_dry_run_does_not_delete() {
+ let (pool, task_id, _dir) = setup().await;
+ let mut conn = pool.acquire().await.unwrap();
+
+ add(&mut conn, task_id, "orphan").await.unwrap();
+ remove(&mut conn, task_id, "orphan").await.unwrap();
+
+ let pruned = prune(&mut conn, true).await.unwrap();
+ assert_eq!(pruned.len(), 1);
+ assert_eq!(pruned[0].name, "orphan");
+
+ // Tag still exists after dry run
+ let all = list_all(&mut conn).await.unwrap();
+ assert_eq!(all.len(), 1);
+ assert_eq!(all[0].name, "orphan");
+ }
+
+ #[tokio::test]
+ async fn prune_no_orphans_is_empty() {
+ let (pool, task_id, _dir) = setup().await;
+ let mut conn = pool.acquire().await.unwrap();
+
+ add(&mut conn, task_id, "used").await.unwrap();
+
+ let pruned = prune(&mut conn, false).await.unwrap();
+ assert!(pruned.is_empty());
+ }
}