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.
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)