Scope tag list to current backlog by default
tag list now shows only tags used by tasks in the current backlog
(unless no default backlog is set). Added --all flag to restore
cross-backlog behavior. New list_for_backlog query joins through
task_tags and tasks to filter by backlog_id.

Assisted-by: GLM-5.1 via pi
change tmkmukpyuwsmxxkvzllpsrqqrumlkpvz
commit ea934aae5a2c054362058449282549276ffb1137
author Alpha Chen <alpha@kejadlen.dev>
date
parent mwmqlkzy
diff --git a/src/bin/ranger/commands/tag.rs b/src/bin/ranger/commands/tag.rs
index 401e260..525f963 100644
--- a/src/bin/ranger/commands/tag.rs
+++ b/src/bin/ranger/commands/tag.rs
@@ -25,7 +25,11 @@ pub enum TagCommands {
     },
     /// List all tags
     #[command(visible_alias = "ls")]
-    List,
+    List {
+        /// Show tags from all backlogs (default: current backlog only)
+        #[arg(long)]
+        all: bool,
+    },
 }
 
 pub async fn run(pool: &SqlitePool, command: TagCommands, json: bool) -> Result<(), RangerError> {
@@ -47,8 +51,12 @@ pub async fn run(pool: &SqlitePool, command: TagCommands, json: bool) -> Result<
                 println!("Removed tag {} from {}", tag, task);
             }
         }
-        TagCommands::List => {
-            let tags = ops::tag::list_all(&mut conn).await?;
+        TagCommands::List { all } => {
+            let tags = if all || backlog_scope.is_none() {
+                ops::tag::list_all(&mut conn).await?
+            } else {
+                ops::tag::list_for_backlog(&mut conn, backlog_scope.unwrap()).await?
+            };
             output::print_list(&tags, json, |t| println!("{}", t.name));
         }
     }
diff --git a/src/ops/tag.rs b/src/ops/tag.rs
index 0ab1537..de973f7 100644
--- a/src/ops/tag.rs
+++ b/src/ops/tag.rs
@@ -83,6 +83,22 @@ pub async fn list_all(conn: &mut SqliteConnection) -> Result<Vec<Tag>, RangerErr
     )
 }
 
+/// List tags used by tasks in a specific backlog.
+pub async fn list_for_backlog(
+    conn: &mut SqliteConnection,
+    backlog_id: i64,
+) -> Result<Vec<Tag>, RangerError> {
+    Ok(sqlx::query_as::<_, Tag>(
+        "SELECT DISTINCT t.id, t.name FROM tags t \
+         JOIN task_tags tt ON t.id = tt.tag_id \
+         JOIN tasks tk ON tt.task_id = tk.id \
+         WHERE tk.backlog_id = ? ORDER BY t.name",
+    )
+    .bind(backlog_id)
+    .fetch_all(&mut *conn)
+    .await?)
+}
+
 #[cfg(test)]
 mod tests {
     use super::*;
@@ -110,6 +126,39 @@ mod tests {
         (pool, task.id, dir)
     }
 
+    async fn setup_two_backlogs() -> (sqlx::SqlitePool, i64, i64, i64, i64, tempfile::TempDir) {
+        let dir = tempfile::tempdir().unwrap();
+        let pool = crate::db::connect(&dir.path().join("test.db"))
+            .await
+            .unwrap();
+        let mut conn = pool.acquire().await.unwrap();
+        let bl1 = ops::backlog::create(&mut conn, "Alpha").await.unwrap();
+        let bl2 = ops::backlog::create(&mut conn, "Beta").await.unwrap();
+        let t1 = ops::task::create(
+            &mut conn,
+            CreateTask {
+                title: "Alpha task",
+                backlog_id: bl1.id,
+                state: None,
+                description: None,
+            },
+        )
+        .await
+        .unwrap();
+        let t2 = ops::task::create(
+            &mut conn,
+            CreateTask {
+                title: "Beta task",
+                backlog_id: bl2.id,
+                state: None,
+                description: None,
+            },
+        )
+        .await
+        .unwrap();
+        (pool, bl1.id, bl2.id, t1.id, t2.id, dir)
+    }
+
     #[tokio::test]
     async fn add_and_list_tags() {
         let (pool, task_id, _dir) = setup().await;
@@ -201,4 +250,39 @@ mod tests {
         assert_eq!(tags1[0].name, "shared");
         assert_eq!(tags2[0].name, "shared");
     }
+
+    #[tokio::test]
+    async fn list_for_backlog_scopes_to_backlog() {
+        let (pool, bl1_id, bl2_id, t1_id, t2_id, _dir) = setup_two_backlogs().await;
+        let mut conn = pool.acquire().await.unwrap();
+
+        add(&mut conn, t1_id, "frontend").await.unwrap();
+        add(&mut conn, t2_id, "backend").await.unwrap();
+        // Shared tag used in both backlogs
+        add(&mut conn, t1_id, "urgent").await.unwrap();
+        add(&mut conn, t2_id, "urgent").await.unwrap();
+
+        let alpha_tags = list_for_backlog(&mut conn, bl1_id).await.unwrap();
+        assert_eq!(alpha_tags.len(), 2);
+        let alpha_names: Vec<&str> = alpha_tags.iter().map(|t| t.name.as_str()).collect();
+        assert!(alpha_names.contains(&"frontend"));
+        assert!(alpha_names.contains(&"urgent"));
+        assert!(!alpha_names.contains(&"backend"));
+
+        let beta_tags = list_for_backlog(&mut conn, bl2_id).await.unwrap();
+        assert_eq!(beta_tags.len(), 2);
+        let beta_names: Vec<&str> = beta_tags.iter().map(|t| t.name.as_str()).collect();
+        assert!(beta_names.contains(&"backend"));
+        assert!(beta_names.contains(&"urgent"));
+        assert!(!beta_names.contains(&"frontend"));
+    }
+
+    #[tokio::test]
+    async fn list_for_backlog_empty_backlog() {
+        let (pool, bl1_id, _bl2_id, _t1_id, _t2_id, _dir) = setup_two_backlogs().await;
+        let mut conn = pool.acquire().await.unwrap();
+
+        let tags = list_for_backlog(&mut conn, bl1_id).await.unwrap();
+        assert!(tags.is_empty());
+    }
 }