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
change yqswlvmlkklttlwtplymuskzpwkpqznt
commit 06a22c4f0fc9f7ecadb5a8b3a74e98f0da6a1cfb
author Alpha Chen <alpha@kejadlen.dev>
date
parent tmkmukpy
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());
+    }
 }