Remove tags and blockers
Unused features adding complexity without value. Drops the
tags, task_tags, and blockers tables plus all associated ops,
CLI commands, models, and test coverage.

Assisted-by: Claude Opus 4.6 via pi
change nmrnmqlquoqpxnqkyuvkpzzxukpuskoz
commit f11a607fc896e5b52e13ba7343e26ea6cac1468c
author Alpha Chen <alpha@kejadlen.dev>
date
parent ruvtrymx
diff --git a/migrations/005_drop_tags_blockers.sql b/migrations/005_drop_tags_blockers.sql
new file mode 100644
index 0000000..15bb21b
--- /dev/null
+++ b/migrations/005_drop_tags_blockers.sql
@@ -0,0 +1,3 @@
+DROP TABLE IF EXISTS task_tags;
+DROP TABLE IF EXISTS tags;
+DROP TABLE IF EXISTS blockers;
diff --git a/src/bin/ranger/commands/blocker.rs b/src/bin/ranger/commands/blocker.rs
deleted file mode 100644
index 38781c9..0000000
--- a/src/bin/ranger/commands/blocker.rs
+++ /dev/null
@@ -1,62 +0,0 @@
-use clap::Subcommand;
-use color_eyre::eyre::Result;
-use ranger::db::SqlitePool;
-use ranger::key;
-use ranger::ops;
-
-use crate::output;
-
-#[derive(Subcommand)]
-pub enum BlockerCommands {
-    /// Add a blocker to a task
-    #[command(visible_alias = "a")]
-    Add {
-        /// Task key or prefix (the blocked task)
-        task: String,
-        /// Blocking task key or prefix
-        blocked_by: String,
-    },
-    /// Remove a blocker from a task
-    #[command(visible_alias = "rm")]
-    Remove {
-        /// Task key or prefix (the blocked task)
-        task: String,
-        /// Blocking task key or prefix
-        blocked_by: String,
-    },
-}
-
-pub async fn run(pool: &SqlitePool, command: BlockerCommands, json: bool) -> Result<()> {
-    let mut conn = pool.acquire().await?;
-
-    match command {
-        BlockerCommands::Add { task, blocked_by } => {
-            let t = ops::task::get_by_key_prefix(&mut conn, &task).await?;
-            let bt = ops::task::get_by_key_prefix(&mut conn, &blocked_by).await?;
-            let all_keys = ops::task::all_keys(&mut conn).await?;
-            let prefixes = key::unique_prefix_lengths(&all_keys);
-            let blocker = ops::blocker::add(&mut conn, t.id, bt.id).await?;
-            output::print(&blocker, json, |_| {
-                println!(
-                    "{} blocked by {} {}",
-                    output::format_key_from_map(&t.key, &prefixes),
-                    output::format_key_from_map(&bt.key, &prefixes),
-                    bt.title
-                );
-            });
-        }
-        BlockerCommands::Remove { task, blocked_by } => {
-            let t = ops::task::get_by_key_prefix(&mut conn, &task).await?;
-            let bt = ops::task::get_by_key_prefix(&mut conn, &blocked_by).await?;
-            let all_keys = ops::task::all_keys(&mut conn).await?;
-            let prefixes = key::unique_prefix_lengths(&all_keys);
-            ops::blocker::remove(&mut conn, t.id, bt.id).await?;
-            println!(
-                "Removed blocker {} from {}",
-                output::format_key_from_map(&bt.key, &prefixes),
-                output::format_key_from_map(&t.key, &prefixes)
-            );
-        }
-    }
-    Ok(())
-}
diff --git a/src/bin/ranger/commands/mod.rs b/src/bin/ranger/commands/mod.rs
index 3674cef..e0afbd2 100644
--- a/src/bin/ranger/commands/mod.rs
+++ b/src/bin/ranger/commands/mod.rs
@@ -1,5 +1,3 @@
 pub mod backlog;
-pub mod blocker;
 pub mod comment;
-pub mod tag;
 pub mod task;
diff --git a/src/bin/ranger/commands/tag.rs b/src/bin/ranger/commands/tag.rs
deleted file mode 100644
index 07edc1f..0000000
--- a/src/bin/ranger/commands/tag.rs
+++ /dev/null
@@ -1,27 +0,0 @@
-use clap::Subcommand;
-use color_eyre::eyre::Result;
-use ranger::db::SqlitePool;
-use ranger::ops;
-
-use crate::output;
-
-#[derive(Subcommand)]
-pub enum TagCommands {
-    /// List all tags
-    #[command(visible_alias = "ls")]
-    List,
-}
-
-pub async fn run(pool: &SqlitePool, command: TagCommands, json: bool) -> Result<()> {
-    let mut conn = pool.acquire().await?;
-
-    match command {
-        TagCommands::List => {
-            let tags = ops::tag::list(&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 ba72eff..417694f 100644
--- a/src/bin/ranger/commands/task.rs
+++ b/src/bin/ranger/commands/task.rs
@@ -77,9 +77,6 @@ pub enum TaskCommands {
         /// Parent task key or prefix (makes this a subtask)
         #[arg(long)]
         parent: Option<String>,
-        /// Tags to add (comma-separated)
-        #[arg(long)]
-        tag: Option<String>,
         #[command(flatten)]
         position: PositionArgs,
     },
@@ -141,7 +138,6 @@ pub async fn run(pool: &SqlitePool, command: TaskCommands, json: bool) -> Result
             description,
             state,
             parent,
-            tag,
             position,
         } => {
             let mut tx = pool.begin().await?;
@@ -171,13 +167,6 @@ pub async fn run(pool: &SqlitePool, command: TaskCommands, json: bool) -> Result
                 ops::task::move_task(&mut tx, &task, anchors.as_placement()).await?;
             }
 
-            if let Some(tags) = &tag {
-                for tag_name in tags.split(',').map(str::trim) {
-                    let t = ops::tag::get_or_create(&mut tx, tag_name).await?;
-                    ops::tag::add_to_task(&mut tx, task.id, t.id).await?;
-                }
-            }
-
             tx.commit().await?;
 
             let mut conn = pool.acquire().await?;
@@ -215,15 +204,11 @@ 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).await?;
             let comments = ops::comment::list(&mut conn, task.id).await?;
-            let tags = ops::tag::list_for_task(&mut conn, task.id).await?;
-            let blockers = ops::blocker::list_for_task(&mut conn, task.id).await?;
 
             if json {
                 let detail = serde_json::json!({
                     "task": task,
                     "comments": comments,
-                    "tags": tags,
-                    "blockers": blockers,
                 });
                 println!("{}", serde_json::to_string_pretty(&detail).unwrap());
             } else {
@@ -231,23 +216,6 @@ 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 !blockers.is_empty() {
-                    println!("Blocked by:");
-                    for b in &blockers {
-                        if let Ok(bt) = ops::task::get_by_id(&mut conn, b.blocked_by_task_id).await
-                        {
-                            println!(
-                                "  {} {}",
-                                output::format_key_from_map(&bt.key, &prefixes),
-                                bt.title
-                            );
-                        }
-                    }
-                }
                 if !comments.is_empty() {
                     println!();
                     for c in &comments {
diff --git a/src/bin/ranger/main.rs b/src/bin/ranger/main.rs
index 687275d..0a39733 100644
--- a/src/bin/ranger/main.rs
+++ b/src/bin/ranger/main.rs
@@ -39,16 +39,6 @@ enum Commands {
         #[command(subcommand)]
         command: commands::comment::CommentCommands,
     },
-    /// Manage tags
-    Tag {
-        #[command(subcommand)]
-        command: commands::tag::TagCommands,
-    },
-    /// Manage blockers
-    Blocker {
-        #[command(subcommand)]
-        command: commands::blocker::BlockerCommands,
-    },
 }
 
 fn resolve_db_path(cli_path: Option<PathBuf>) -> PathBuf {
@@ -81,12 +71,6 @@ async fn main() -> color_eyre::Result<()> {
         Commands::Comment { command } => {
             commands::comment::run(&pool, command, cli.json).await?;
         }
-        Commands::Tag { command } => {
-            commands::tag::run(&pool, command, cli.json).await?;
-        }
-        Commands::Blocker { command } => {
-            commands::blocker::run(&pool, command, cli.json).await?;
-        }
     }
 
     Ok(())
diff --git a/src/db.rs b/src/db.rs
index b6444df..6ec8125 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(&"blockers".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 a080c97..b1f83c1 100644
--- a/src/models.rs
+++ b/src/models.rs
@@ -108,19 +108,6 @@ pub struct Comment {
     pub created_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 Blocker {
-    pub id: i64,
-    pub task_id: i64,
-    pub blocked_by_task_id: i64,
-}
-
 #[cfg(test)]
 mod tests {
     use super::*;
diff --git a/src/ops/blocker.rs b/src/ops/blocker.rs
deleted file mode 100644
index e05bc09..0000000
--- a/src/ops/blocker.rs
+++ /dev/null
@@ -1,133 +0,0 @@
-use crate::error::RangerError;
-use crate::models::Blocker;
-use sqlx::sqlite::SqliteConnection;
-
-pub async fn add(
-    conn: &mut SqliteConnection,
-    task_id: i64,
-    blocked_by_task_id: i64,
-) -> Result<Blocker, RangerError> {
-    let blocker = sqlx::query_as::<_, Blocker>(
-        "INSERT INTO blockers (task_id, blocked_by_task_id) VALUES (?, ?) \
-         RETURNING id, task_id, blocked_by_task_id",
-    )
-    .bind(task_id)
-    .bind(blocked_by_task_id)
-    .fetch_one(&mut *conn)
-    .await?;
-    Ok(blocker)
-}
-
-pub async fn remove(
-    conn: &mut SqliteConnection,
-    task_id: i64,
-    blocked_by_task_id: i64,
-) -> Result<(), RangerError> {
-    sqlx::query("DELETE FROM blockers WHERE task_id = ? AND blocked_by_task_id = ?")
-        .bind(task_id)
-        .bind(blocked_by_task_id)
-        .execute(&mut *conn)
-        .await?;
-    Ok(())
-}
-
-pub async fn list_for_task(
-    conn: &mut SqliteConnection,
-    task_id: i64,
-) -> Result<Vec<Blocker>, RangerError> {
-    let blockers = sqlx::query_as::<_, Blocker>(
-        "SELECT id, task_id, blocked_by_task_id FROM blockers WHERE task_id = ?",
-    )
-    .bind(task_id)
-    .fetch_all(&mut *conn)
-    .await?;
-    Ok(blockers)
-}
-
-#[cfg(test)]
-mod tests {
-    use super::*;
-    use crate::db;
-    use crate::ops::{backlog, task};
-    use tempfile::tempdir;
-
-    async fn test_pool() -> sqlx::SqlitePool {
-        let dir = tempdir().unwrap();
-        let dir = Box::leak(Box::new(dir));
-        db::connect(&dir.path().join("test.db")).await.unwrap()
-    }
-
-    #[tokio::test]
-    async fn add_and_list_blockers() {
-        let pool = test_pool().await;
-        let mut conn = pool.acquire().await.unwrap();
-        let bl = backlog::create(&mut conn, "Test").await.unwrap();
-        let t1 = task::create(
-            &mut conn,
-            task::CreateTask {
-                title: "Blocked",
-                backlog_id: bl.id,
-                state: None,
-                parent_id: None,
-                description: None,
-            },
-        )
-        .await
-        .unwrap();
-        let t2 = task::create(
-            &mut conn,
-            task::CreateTask {
-                title: "Blocker",
-                backlog_id: bl.id,
-                state: None,
-                parent_id: None,
-                description: None,
-            },
-        )
-        .await
-        .unwrap();
-
-        add(&mut conn, t1.id, t2.id).await.unwrap();
-
-        let blockers = list_for_task(&mut conn, t1.id).await.unwrap();
-        assert_eq!(blockers.len(), 1);
-        assert_eq!(blockers[0].blocked_by_task_id, t2.id);
-    }
-
-    #[tokio::test]
-    async fn remove_blocker() {
-        let pool = test_pool().await;
-        let mut conn = pool.acquire().await.unwrap();
-        let bl = backlog::create(&mut conn, "Test").await.unwrap();
-        let t1 = task::create(
-            &mut conn,
-            task::CreateTask {
-                title: "Blocked",
-                backlog_id: bl.id,
-                state: None,
-                parent_id: None,
-                description: None,
-            },
-        )
-        .await
-        .unwrap();
-        let t2 = task::create(
-            &mut conn,
-            task::CreateTask {
-                title: "Blocker",
-                backlog_id: bl.id,
-                state: None,
-                parent_id: None,
-                description: None,
-            },
-        )
-        .await
-        .unwrap();
-
-        add(&mut conn, t1.id, t2.id).await.unwrap();
-        remove(&mut conn, t1.id, t2.id).await.unwrap();
-
-        let blockers = list_for_task(&mut conn, t1.id).await.unwrap();
-        assert_eq!(blockers.len(), 0);
-    }
-}
diff --git a/src/ops/mod.rs b/src/ops/mod.rs
index 3674cef..e0afbd2 100644
--- a/src/ops/mod.rs
+++ b/src/ops/mod.rs
@@ -1,5 +1,3 @@
 pub mod backlog;
-pub mod blocker;
 pub mod comment;
-pub mod tag;
 pub mod task;
diff --git a/src/ops/tag.rs b/src/ops/tag.rs
deleted file mode 100644
index 9c74076..0000000
--- a/src/ops/tag.rs
+++ /dev/null
@@ -1,141 +0,0 @@
-use crate::error::RangerError;
-use crate::models::Tag;
-use sqlx::sqlite::SqliteConnection;
-
-/// Get or create a tag by name.
-pub async fn get_or_create(conn: &mut SqliteConnection, name: &str) -> Result<Tag, RangerError> {
-    // Try insert, ignore conflict
-    sqlx::query("INSERT OR IGNORE INTO tags (name) VALUES (?)")
-        .bind(name)
-        .execute(&mut *conn)
-        .await?;
-
-    let tag = sqlx::query_as::<_, Tag>("SELECT id, name FROM tags WHERE name = ?")
-        .bind(name)
-        .fetch_one(&mut *conn)
-        .await?;
-    Ok(tag)
-}
-
-pub async fn list(conn: &mut SqliteConnection) -> Result<Vec<Tag>, RangerError> {
-    let tags = sqlx::query_as::<_, Tag>("SELECT id, name FROM tags ORDER BY name")
-        .fetch_all(&mut *conn)
-        .await?;
-    Ok(tags)
-}
-
-pub async fn add_to_task(
-    conn: &mut SqliteConnection,
-    task_id: i64,
-    tag_id: i64,
-) -> Result<(), RangerError> {
-    sqlx::query("INSERT OR IGNORE INTO task_tags (task_id, tag_id) VALUES (?, ?)")
-        .bind(task_id)
-        .bind(tag_id)
-        .execute(&mut *conn)
-        .await?;
-    Ok(())
-}
-
-pub async fn list_for_task(
-    conn: &mut SqliteConnection,
-    task_id: i64,
-) -> Result<Vec<Tag>, RangerError> {
-    let tags = sqlx::query_as::<_, Tag>(
-        "SELECT t.id, t.name FROM tags t \
-         JOIN task_tags tt ON tt.tag_id = t.id \
-         WHERE tt.task_id = ? ORDER BY t.name",
-    )
-    .bind(task_id)
-    .fetch_all(&mut *conn)
-    .await?;
-    Ok(tags)
-}
-
-#[cfg(test)]
-mod tests {
-    use super::*;
-    use crate::db;
-    use crate::ops::{backlog, task};
-    use tempfile::tempdir;
-
-    async fn test_pool() -> sqlx::SqlitePool {
-        let dir = tempdir().unwrap();
-        let dir = Box::leak(Box::new(dir));
-        db::connect(&dir.path().join("test.db")).await.unwrap()
-    }
-
-    #[tokio::test]
-    async fn get_or_create_is_idempotent() {
-        let pool = test_pool().await;
-        let mut conn = pool.acquire().await.unwrap();
-        let t1 = get_or_create(&mut conn, "urgent").await.unwrap();
-        let t2 = get_or_create(&mut conn, "urgent").await.unwrap();
-        assert_eq!(t1.id, t2.id);
-    }
-
-    #[tokio::test]
-    async fn list_tags() {
-        let pool = test_pool().await;
-        let mut conn = pool.acquire().await.unwrap();
-        get_or_create(&mut conn, "beta").await.unwrap();
-        get_or_create(&mut conn, "alpha").await.unwrap();
-
-        let tags = list(&mut conn).await.unwrap();
-        assert_eq!(tags.len(), 2);
-        assert_eq!(tags[0].name, "alpha");
-        assert_eq!(tags[1].name, "beta");
-    }
-
-    #[tokio::test]
-    async fn add_tag_to_task_and_list() {
-        let pool = test_pool().await;
-        let mut conn = pool.acquire().await.unwrap();
-        let bl = backlog::create(&mut conn, "Test").await.unwrap();
-        let t = task::create(
-            &mut conn,
-            task::CreateTask {
-                title: "Task",
-                backlog_id: bl.id,
-                state: None,
-                parent_id: None,
-                description: None,
-            },
-        )
-        .await
-        .unwrap();
-        let tag = get_or_create(&mut conn, "important").await.unwrap();
-
-        add_to_task(&mut conn, t.id, tag.id).await.unwrap();
-
-        let tags = list_for_task(&mut conn, t.id).await.unwrap();
-        assert_eq!(tags.len(), 1);
-        assert_eq!(tags[0].name, "important");
-    }
-
-    #[tokio::test]
-    async fn add_tag_to_task_is_idempotent() {
-        let pool = test_pool().await;
-        let mut conn = pool.acquire().await.unwrap();
-        let bl = backlog::create(&mut conn, "Test").await.unwrap();
-        let t = task::create(
-            &mut conn,
-            task::CreateTask {
-                title: "Task",
-                backlog_id: bl.id,
-                state: None,
-                parent_id: None,
-                description: None,
-            },
-        )
-        .await
-        .unwrap();
-        let tag = get_or_create(&mut conn, "dup").await.unwrap();
-
-        add_to_task(&mut conn, t.id, tag.id).await.unwrap();
-        add_to_task(&mut conn, t.id, tag.id).await.unwrap();
-
-        let tags = list_for_task(&mut conn, t.id).await.unwrap();
-        assert_eq!(tags.len(), 1);
-    }
-}
diff --git a/tests/cli.rs b/tests/cli.rs
index fb3df8e..a9e2aff 100644
--- a/tests/cli.rs
+++ b/tests/cli.rs
@@ -41,7 +41,7 @@ fn full_workflow() {
     assert!(output.status.success());
 
     let output = ranger(db_path)
-        .args(["task", "create", "Second task", "--tag", "urgent"])
+        .args(["task", "create", "Second task"])
         .output()
         .unwrap();
     assert!(output.status.success());
@@ -86,19 +86,6 @@ fn full_workflow() {
     let stdout = String::from_utf8(output.stdout).unwrap();
     assert!(stdout.contains("Started working on this"));
 
-    // Add a blocker
-    let output = ranger(db_path)
-        .args(["blocker", "add", &t2_key[..4], &t1_key[..4]])
-        .output()
-        .unwrap();
-    assert!(output.status.success());
-
-    // List 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("urgent"));
-
     // Show task (JSON) — verify all data present
     let output = ranger(db_path)
         .args(["task", "show", &t2_key[..4], "--json"])
@@ -107,8 +94,6 @@ fn full_workflow() {
     assert!(output.status.success());
     let detail: serde_json::Value = serde_json::from_slice(&output.stdout).unwrap();
     assert_eq!(detail["task"]["title"], "Second task");
-    assert_eq!(detail["tags"][0]["name"], "urgent");
-    assert_eq!(detail["blockers"].as_array().unwrap().len(), 1);
 
     // Create two queued tasks and use edit --before to reposition within the same state
     let output = ranger(db_path)