feat: add tags back
Re-add tags to the data model with tags and task_tags tables.
CLI commands: tag add/remove/list. Show tags in task show output.
Support --tag filter on task list. Tags are global, not per-backlog.
Qualify TASK_COLUMNS with table name for JOIN compatibility.
change wzsmlwuzolvzmqpvortkonztvvukzwyr
commit 6331fb91cdbd7bc697e486030a9eac82bd1222d7
author Alpha Chen <alpha@kejadlen.dev>
date
parent zywmuyqx
diff --git a/migrations/007_add_tags.sql b/migrations/007_add_tags.sql
new file mode 100644
index 0000000..3050796
--- /dev/null
+++ b/migrations/007_add_tags.sql
@@ -0,0 +1,10 @@
+CREATE TABLE IF NOT EXISTS tags (
+    id INTEGER PRIMARY KEY AUTOINCREMENT,
+    name TEXT NOT NULL UNIQUE
+);
+
+CREATE TABLE IF NOT EXISTS task_tags (
+    task_id INTEGER NOT NULL REFERENCES tasks(id) ON DELETE CASCADE,
+    tag_id INTEGER NOT NULL REFERENCES tags(id) ON DELETE CASCADE,
+    PRIMARY KEY (task_id, tag_id)
+);
diff --git a/src/bin/ranger/commands/backlog.rs b/src/bin/ranger/commands/backlog.rs
index 086244b..7ff4f84 100644
--- a/src/bin/ranger/commands/backlog.rs
+++ b/src/bin/ranger/commands/backlog.rs
@@ -97,10 +97,21 @@ pub async fn run(pool: &SqlitePool, command: BacklogCommands, json: bool) -> Res
                     if !tasks.is_empty() {
                         println!("\n[{}]", state);
                         for t in &tasks {
+                            let tags = ops::tag::list_for_task(&mut conn, t.id).await?;
+                            let tag_str = if tags.is_empty() {
+                                String::new()
+                            } else {
+                                let names: Vec<String> = tags
+                                    .iter()
+                                    .map(|tg| format!("\x1b[36m#{}\x1b[0m", tg.name))
+                                    .collect();
+                                format!(" {}", names.join(" "))
+                            };
                             println!(
-                                "  {} {}",
+                                "  {} {}{}",
                                 output::format_key_from_map(&t.key, &prefixes),
-                                t.title
+                                t.title,
+                                tag_str
                             );
                         }
                     }
diff --git a/src/bin/ranger/commands/mod.rs b/src/bin/ranger/commands/mod.rs
index 5c55ef4..18d6800 100644
--- a/src/bin/ranger/commands/mod.rs
+++ b/src/bin/ranger/commands/mod.rs
@@ -1,4 +1,5 @@
 pub mod backlog;
 pub mod comment;
 pub mod serve;
+pub mod tag;
 pub mod task;
diff --git a/src/bin/ranger/commands/tag.rs b/src/bin/ranger/commands/tag.rs
new file mode 100644
index 0000000..c1ceb06
--- /dev/null
+++ b/src/bin/ranger/commands/tag.rs
@@ -0,0 +1,56 @@
+use clap::Subcommand;
+use color_eyre::eyre::Result;
+use ranger::db::SqlitePool;
+use ranger::ops;
+
+use super::task::default_backlog_id;
+use crate::output;
+
+#[derive(Subcommand)]
+pub enum TagCommands {
+    /// Add a tag to a task
+    Add {
+        /// Task key or prefix
+        task: String,
+        /// Tag name
+        tag: String,
+    },
+    /// Remove a tag from a task
+    #[command(visible_alias = "rm")]
+    Remove {
+        /// Task key or prefix
+        task: String,
+        /// Tag name
+        tag: String,
+    },
+    /// List all tags
+    #[command(visible_alias = "ls")]
+    List,
+}
+
+pub async fn run(pool: &SqlitePool, command: TagCommands, json: bool) -> Result<()> {
+    let backlog_scope = default_backlog_id(pool).await;
+    let mut conn = pool.acquire().await?;
+
+    match command {
+        TagCommands::Add { task, tag } => {
+            let t = ops::task::get_by_key_prefix(&mut conn, &task, backlog_scope).await?;
+            let created = ops::tag::add(&mut conn, t.id, &tag).await?;
+            output::print(&created, json, |tg| {
+                println!("Tagged {} with {}", task, tg.name)
+            });
+        }
+        TagCommands::Remove { task, tag } => {
+            let t = ops::task::get_by_key_prefix(&mut conn, &task, backlog_scope).await?;
+            ops::tag::remove(&mut conn, t.id, &tag).await?;
+            if !json {
+                println!("Removed tag {} from {}", tag, task);
+            }
+        }
+        TagCommands::List => {
+            let tags = ops::tag::list_all(&mut conn).await?;
+            output::print_list(&tags, json, |t| println!("{}", t.name));
+        }
+    }
+    Ok(())
+}
diff --git a/src/bin/ranger/commands/task.rs b/src/bin/ranger/commands/task.rs
index 5cf05b8..c172cbb 100644
--- a/src/bin/ranger/commands/task.rs
+++ b/src/bin/ranger/commands/task.rs
@@ -93,6 +93,9 @@ pub enum TaskCommands {
         /// Filter by state
         #[arg(long)]
         state: Option<String>,
+        /// Filter by tag
+        #[arg(long)]
+        tag: Option<String>,
         /// Include archived tasks
         #[arg(long)]
         archived: bool,
@@ -213,12 +216,14 @@ pub async fn run(pool: &SqlitePool, command: TaskCommands, json: bool) -> Result
         TaskCommands::List {
             backlog,
             state,
+            tag,
             archived,
         } => {
             let mut conn = pool.acquire().await?;
             let filter = ListFilter {
                 state: state.map(|s| s.parse::<State>()).transpose()?,
                 include_archived: archived,
+                tag,
             };
 
             if let Some(backlog_name) = &backlog {
@@ -248,11 +253,13 @@ pub async fn run(pool: &SqlitePool, command: TaskCommands, json: bool) -> Result
             let mut conn = pool.acquire().await?;
             let task = ops::task::get_by_key_prefix(&mut conn, &key, backlog_scope).await?;
             let comments = ops::comment::list(&mut conn, task.id).await?;
+            let tags = ops::tag::list_for_task(&mut conn, task.id).await?;
 
             if json {
                 let detail = serde_json::json!({
                     "task": task,
                     "comments": comments,
+                    "tags": tags,
                 });
                 println!("{}", serde_json::to_string_pretty(&detail).unwrap());
             } else {
@@ -260,6 +267,10 @@ pub async fn run(pool: &SqlitePool, command: TaskCommands, json: bool) -> Result
                 let prefixes = key::unique_prefix_lengths(&all_keys);
 
                 print_task_detail(&task, &prefixes);
+                if !tags.is_empty() {
+                    let tag_names: Vec<&str> = tags.iter().map(|t| t.name.as_str()).collect();
+                    println!("Tags:    {}", tag_names.join(", "));
+                }
                 if !comments.is_empty() {
                     println!();
                     for c in &comments {
diff --git a/src/bin/ranger/main.rs b/src/bin/ranger/main.rs
index eb990ca..2087e56 100644
--- a/src/bin/ranger/main.rs
+++ b/src/bin/ranger/main.rs
@@ -41,6 +41,12 @@ enum Commands {
         #[command(subcommand)]
         command: commands::comment::CommentCommands,
     },
+    /// Manage tags
+    #[command(visible_alias = "g")]
+    Tag {
+        #[command(subcommand)]
+        command: commands::tag::TagCommands,
+    },
     /// Generate shell completions
     Completions {
         /// Shell to generate completions for
@@ -96,6 +102,9 @@ async fn main() -> color_eyre::Result<()> {
         Some(Commands::Comment { command }) => {
             commands::comment::run(&pool, command, cli.json).await?;
         }
+        Some(Commands::Tag { command }) => {
+            commands::tag::run(&pool, command, cli.json).await?;
+        }
         Some(Commands::Completions { .. }) => unreachable!(),
         Some(Commands::Serve { port, backlog }) => {
             commands::serve::run(&pool, port, backlog).await?;
diff --git a/src/db.rs b/src/db.rs
index 6ec8125..ab346a2 100644
--- a/src/db.rs
+++ b/src/db.rs
@@ -51,9 +51,9 @@ mod tests {
         assert!(table_names.contains(&"backlogs".to_string()));
         assert!(table_names.contains(&"tasks".to_string()));
         assert!(table_names.contains(&"comments".to_string()));
+        assert!(table_names.contains(&"tags".to_string()));
+        assert!(table_names.contains(&"task_tags".to_string()));
         assert!(!table_names.contains(&"blockers".to_string()));
-        assert!(!table_names.contains(&"tags".to_string()));
-        assert!(!table_names.contains(&"task_tags".to_string()));
         assert!(!table_names.contains(&"backlog_tasks".to_string()));
     }
 }
diff --git a/src/models.rs b/src/models.rs
index 38aa54e..9f5bef5 100644
--- a/src/models.rs
+++ b/src/models.rs
@@ -101,6 +101,12 @@ pub struct Task {
     pub updated_at: Timestamp,
 }
 
+#[derive(Debug, Clone, Serialize, Deserialize, FromRow)]
+pub struct Tag {
+    pub id: i64,
+    pub name: String,
+}
+
 #[derive(Debug, Clone, Serialize, Deserialize, FromRow)]
 pub struct Comment {
     pub id: i64,
diff --git a/src/ops/mod.rs b/src/ops/mod.rs
index e0afbd2..c746aa9 100644
--- a/src/ops/mod.rs
+++ b/src/ops/mod.rs
@@ -1,3 +1,4 @@
 pub mod backlog;
 pub mod comment;
+pub mod tag;
 pub mod task;
diff --git a/src/ops/tag.rs b/src/ops/tag.rs
new file mode 100644
index 0000000..fe19d31
--- /dev/null
+++ b/src/ops/tag.rs
@@ -0,0 +1,206 @@
+use crate::error::RangerError;
+use crate::models::Tag;
+use sqlx::SqliteConnection;
+
+/// Get or create a tag by name. Returns the existing tag if it already exists.
+pub async fn get_or_create(conn: &mut SqliteConnection, name: &str) -> Result<Tag, RangerError> {
+    if let Some(tag) = sqlx::query_as::<_, Tag>("SELECT id, name FROM tags WHERE name = ?")
+        .bind(name)
+        .fetch_optional(&mut *conn)
+        .await?
+    {
+        return Ok(tag);
+    }
+
+    sqlx::query("INSERT INTO tags (name) VALUES (?)")
+        .bind(name)
+        .execute(&mut *conn)
+        .await?;
+
+    Ok(
+        sqlx::query_as::<_, Tag>("SELECT id, name FROM tags WHERE name = ?")
+            .bind(name)
+            .fetch_one(&mut *conn)
+            .await?,
+    )
+}
+
+/// Add a tag to a task. Creates the tag if it doesn't exist. No-op if already tagged.
+pub async fn add(
+    conn: &mut SqliteConnection,
+    task_id: i64,
+    name: &str,
+) -> Result<Tag, RangerError> {
+    let tag = get_or_create(conn, name).await?;
+
+    sqlx::query("INSERT OR IGNORE INTO task_tags (task_id, tag_id) VALUES (?, ?)")
+        .bind(task_id)
+        .bind(tag.id)
+        .execute(&mut *conn)
+        .await?;
+
+    Ok(tag)
+}
+
+/// Remove a tag from a task.
+pub async fn remove(
+    conn: &mut SqliteConnection,
+    task_id: i64,
+    name: &str,
+) -> Result<(), RangerError> {
+    sqlx::query(
+        "DELETE FROM task_tags WHERE task_id = ? AND tag_id = (SELECT id FROM tags WHERE name = ?)",
+    )
+    .bind(task_id)
+    .bind(name)
+    .execute(&mut *conn)
+    .await?;
+
+    Ok(())
+}
+
+/// List all tags for a task.
+pub async fn list_for_task(
+    conn: &mut SqliteConnection,
+    task_id: i64,
+) -> Result<Vec<Tag>, RangerError> {
+    Ok(sqlx::query_as::<_, Tag>(
+        "SELECT t.id, t.name FROM tags t \
+         JOIN task_tags tt ON t.id = tt.tag_id \
+         WHERE tt.task_id = ? ORDER BY t.name",
+    )
+    .bind(task_id)
+    .fetch_all(&mut *conn)
+    .await?)
+}
+
+/// List all tags (globally).
+pub async fn list_all(conn: &mut SqliteConnection) -> Result<Vec<Tag>, RangerError> {
+    Ok(
+        sqlx::query_as::<_, Tag>("SELECT id, name FROM tags ORDER BY name")
+            .fetch_all(&mut *conn)
+            .await?,
+    )
+}
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+    use crate::ops;
+    use crate::ops::task::CreateTask;
+
+    async fn setup() -> (sqlx::SqlitePool, 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 backlog = ops::backlog::create(&mut conn, "Test").await.unwrap();
+        let task = ops::task::create(
+            &mut conn,
+            CreateTask {
+                title: "Test task",
+                backlog_id: backlog.id,
+                state: None,
+                parent_id: None,
+                description: None,
+            },
+        )
+        .await
+        .unwrap();
+        (pool, task.id, dir)
+    }
+
+    #[tokio::test]
+    async fn add_and_list_tags() {
+        let (pool, task_id, _dir) = setup().await;
+        let mut conn = pool.acquire().await.unwrap();
+
+        add(&mut conn, task_id, "frontend").await.unwrap();
+        add(&mut conn, task_id, "bug").await.unwrap();
+
+        let tags = list_for_task(&mut conn, task_id).await.unwrap();
+        assert_eq!(tags.len(), 2);
+        assert_eq!(tags[0].name, "bug");
+        assert_eq!(tags[1].name, "frontend");
+    }
+
+    #[tokio::test]
+    async fn add_duplicate_tag_is_noop() {
+        let (pool, task_id, _dir) = setup().await;
+        let mut conn = pool.acquire().await.unwrap();
+
+        add(&mut conn, task_id, "bug").await.unwrap();
+        add(&mut conn, task_id, "bug").await.unwrap();
+
+        let tags = list_for_task(&mut conn, task_id).await.unwrap();
+        assert_eq!(tags.len(), 1);
+    }
+
+    #[tokio::test]
+    async fn remove_tag() {
+        let (pool, task_id, _dir) = setup().await;
+        let mut conn = pool.acquire().await.unwrap();
+
+        add(&mut conn, task_id, "bug").await.unwrap();
+        remove(&mut conn, task_id, "bug").await.unwrap();
+
+        let tags = list_for_task(&mut conn, task_id).await.unwrap();
+        assert!(tags.is_empty());
+    }
+
+    #[tokio::test]
+    async fn remove_nonexistent_tag_is_noop() {
+        let (pool, task_id, _dir) = setup().await;
+        let mut conn = pool.acquire().await.unwrap();
+
+        remove(&mut conn, task_id, "nonexistent").await.unwrap();
+    }
+
+    #[tokio::test]
+    async fn list_all_tags() {
+        let (pool, task_id, _dir) = setup().await;
+        let mut conn = pool.acquire().await.unwrap();
+
+        add(&mut conn, task_id, "frontend").await.unwrap();
+        add(&mut conn, task_id, "bug").await.unwrap();
+
+        let all = list_all(&mut conn).await.unwrap();
+        assert_eq!(all.len(), 2);
+        assert_eq!(all[0].name, "bug");
+        assert_eq!(all[1].name, "frontend");
+    }
+
+    #[tokio::test]
+    async fn tags_shared_across_tasks() {
+        let (pool, task_id, _dir) = setup().await;
+        let mut conn = pool.acquire().await.unwrap();
+
+        let backlog = ops::backlog::get_by_name(&mut conn, "Test").await.unwrap();
+        let task2 = ops::task::create(
+            &mut conn,
+            CreateTask {
+                title: "Second task",
+                backlog_id: backlog.id,
+                state: None,
+                parent_id: None,
+                description: None,
+            },
+        )
+        .await
+        .unwrap();
+
+        add(&mut conn, task_id, "shared").await.unwrap();
+        add(&mut conn, task2.id, "shared").await.unwrap();
+
+        let all = list_all(&mut conn).await.unwrap();
+        assert_eq!(all.len(), 1); // One tag, used by two tasks
+
+        let tags1 = list_for_task(&mut conn, task_id).await.unwrap();
+        let tags2 = list_for_task(&mut conn, task2.id).await.unwrap();
+        assert_eq!(tags1.len(), 1);
+        assert_eq!(tags2.len(), 1);
+        assert_eq!(tags1[0].name, "shared");
+        assert_eq!(tags2[0].name, "shared");
+    }
+}
diff --git a/src/ops/task.rs b/src/ops/task.rs
index e0d0f04..b1ac632 100644
--- a/src/ops/task.rs
+++ b/src/ops/task.rs
@@ -4,7 +4,7 @@ use crate::models::{State, Task};
 use crate::position;
 use sqlx::sqlite::SqliteConnection;
 
-const TASK_COLUMNS: &str = "id, key, backlog_id, parent_id, title, description, state, position, archived, created_at, updated_at";
+const TASK_COLUMNS: &str = "tasks.id, tasks.key, tasks.backlog_id, tasks.parent_id, tasks.title, tasks.description, tasks.state, tasks.position, tasks.archived, tasks.created_at, tasks.updated_at";
 
 pub struct CreateTask<'a> {
     pub title: &'a str,
@@ -56,6 +56,7 @@ pub async fn create(
 pub struct ListFilter {
     pub state: Option<State>,
     pub include_archived: bool,
+    pub tag: Option<String>,
 }
 
 pub async fn list(
@@ -68,27 +69,38 @@ pub async fn list(
     } else {
         " AND archived = 0"
     };
+    let tag_join = if filter.tag.is_some() {
+        " JOIN task_tags tt ON tasks.id = tt.task_id \
+         JOIN tags tg ON tt.tag_id = tg.id AND tg.name = ?"
+    } else {
+        ""
+    };
+
     let tasks = if let Some(state) = &filter.state {
         let query = format!(
-            "SELECT {TASK_COLUMNS} FROM tasks \
+            "SELECT {TASK_COLUMNS} FROM tasks{tag_join} \
              WHERE backlog_id = ? AND state = ?{archived_clause} \
              ORDER BY position"
         );
-        sqlx::query_as::<_, Task>(&query)
-            .bind(backlog_id)
+        let mut q = sqlx::query_as::<_, Task>(&query);
+        if let Some(tag) = &filter.tag {
+            q = q.bind(tag);
+        }
+        q.bind(backlog_id)
             .bind(state.as_str())
             .fetch_all(&mut *conn)
             .await?
     } else {
         let query = format!(
-            "SELECT {TASK_COLUMNS} FROM tasks \
+            "SELECT {TASK_COLUMNS} FROM tasks{tag_join} \
              WHERE backlog_id = ?{archived_clause} \
              ORDER BY position"
         );
-        sqlx::query_as::<_, Task>(&query)
-            .bind(backlog_id)
-            .fetch_all(&mut *conn)
-            .await?
+        let mut q = sqlx::query_as::<_, Task>(&query);
+        if let Some(tag) = &filter.tag {
+            q = q.bind(tag);
+        }
+        q.bind(backlog_id).fetch_all(&mut *conn).await?
     };
     Ok(tasks)
 }
@@ -1989,4 +2001,51 @@ mod tests {
             .await
             .unwrap();
     }
+
+    #[tokio::test]
+    async fn list_tasks_with_tag_and_state_filter() {
+        let pool = test_pool().await;
+        let mut conn = pool.acquire().await.unwrap();
+        let bl = backlog::create(&mut conn, "Test").await.unwrap();
+        let t1 = create(
+            &mut conn,
+            CreateTask {
+                title: "Tagged queued",
+                backlog_id: bl.id,
+                state: Some(State::Queued),
+                parent_id: None,
+                description: None,
+            },
+        )
+        .await
+        .unwrap();
+        create(
+            &mut conn,
+            CreateTask {
+                title: "Untagged queued",
+                backlog_id: bl.id,
+                state: Some(State::Queued),
+                parent_id: None,
+                description: None,
+            },
+        )
+        .await
+        .unwrap();
+
+        crate::ops::tag::add(&mut conn, t1.id, "bug").await.unwrap();
+
+        let results = list(
+            &mut conn,
+            bl.id,
+            &ListFilter {
+                state: Some(State::Queued),
+                tag: Some("bug".to_string()),
+                ..Default::default()
+            },
+        )
+        .await
+        .unwrap();
+        assert_eq!(results.len(), 1);
+        assert_eq!(results[0].title, "Tagged queued");
+    }
 }
diff --git a/tests/cli.rs b/tests/cli.rs
index f7b27ce..843980a 100644
--- a/tests/cli.rs
+++ b/tests/cli.rs
@@ -341,6 +341,86 @@ fn full_workflow() {
         "JSON should exclude done without --done"
     );
 
+    // --- Tags ---
+
+    // Add a tag to a task
+    let output = ranger(db_path)
+        .args(["tag", "add", &t1_key[..4], "bug"])
+        .output()
+        .unwrap();
+    assert!(output.status.success());
+    let stdout = String::from_utf8(output.stdout).unwrap();
+    assert!(stdout.contains("bug"));
+
+    // Add another tag
+    ranger(db_path)
+        .args(["tag", "add", &t1_key[..4], "frontend"])
+        .output()
+        .unwrap();
+
+    // Show task includes tags
+    let output = ranger(db_path)
+        .args(["task", "show", &t1_key[..4]])
+        .output()
+        .unwrap();
+    assert!(output.status.success());
+    let stdout = String::from_utf8(output.stdout).unwrap();
+    assert!(stdout.contains("Tags:"));
+    assert!(stdout.contains("bug"));
+    assert!(stdout.contains("frontend"));
+
+    // Show task JSON includes tags
+    let output = ranger(db_path)
+        .args(["task", "show", &t1_key[..4], "--json"])
+        .output()
+        .unwrap();
+    assert!(output.status.success());
+    let detail: serde_json::Value = serde_json::from_slice(&output.stdout).unwrap();
+    assert!(detail["tags"].is_array());
+    assert_eq!(detail["tags"].as_array().unwrap().len(), 2);
+
+    // List all tags
+    let output = ranger(db_path).args(["tag", "list"]).output().unwrap();
+    assert!(output.status.success());
+    let stdout = String::from_utf8(output.stdout).unwrap();
+    assert!(stdout.contains("bug"));
+    assert!(stdout.contains("frontend"));
+
+    // Filter tasks by tag
+    let output = ranger(db_path)
+        .args(["task", "list", "--tag", "bug"])
+        .output()
+        .unwrap();
+    let stderr = String::from_utf8(output.stderr.clone()).unwrap();
+    assert!(output.status.success(), "tag filter failed: {stderr}");
+    let stdout = String::from_utf8(output.stdout).unwrap();
+    assert!(stdout.contains("First task"));
+
+    // Filter by tag that no task has
+    let output = ranger(db_path)
+        .args(["task", "list", "--tag", "nonexistent"])
+        .output()
+        .unwrap();
+    assert!(output.status.success());
+    let stdout = String::from_utf8(output.stdout).unwrap();
+    assert!(stdout.is_empty() || !stdout.contains("First task"));
+
+    // Remove a tag
+    let output = ranger(db_path)
+        .args(["tag", "remove", &t1_key[..4], "bug"])
+        .output()
+        .unwrap();
+    assert!(output.status.success());
+
+    // Verify tag removed
+    let output = ranger(db_path)
+        .args(["task", "show", &t1_key[..4]])
+        .output()
+        .unwrap();
+    let stdout = String::from_utf8(output.stdout).unwrap();
+    assert!(!stdout.contains("bug"));
+    assert!(stdout.contains("frontend"));
+
     // Shell completions (no DB needed, but pass one anyway for the helper)
     for shell in ["bash", "zsh", "fish", "elvish", "powershell"] {
         let output = ranger(db_path)