Identify backlogs by name instead of key
Backlogs no longer expose their jj-style key. The key column remains in
the schema for now (with a dummy value on insert), but all code references
use name. Added unique index on backlog name and RANGER_DEFAULT_BACKLOG
env var support on all --backlog flags.

Assisted-by: Claude Opus 4.6 via pi
change nrnvppwwwvyxmusmyppowoowkoquroqy
commit dddbc78dc474cf1632197b8eef7e5b113e4e5a90
author Alpha Chen <alpha@kejadlen.dev>
date
parent vzomktmp
diff --git a/AGENTS.md b/AGENTS.md
index f4da21a..9b35acb 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -32,8 +32,8 @@ ranger backlog create "Ranger"
 Before starting work, check the backlog:
 
 ```bash
-ranger task list --backlog <key> --state queued
-ranger task list --backlog <key> --state in_progress
+ranger task list --backlog <name> --state queued
+ranger task list --backlog <name> --state in_progress
 ```
 
 When picking up a task:
@@ -60,9 +60,9 @@ ranger comment add <key> "Completed — summary of what was done"
 To add new work:
 
 ```bash
-ranger task create "Title" --backlog <key>                    # icebox by default
-ranger task create "Title" --backlog <key> --state queued     # committed work
-ranger task create "Subtask" --backlog <key> --parent <key>   # subtask
+ranger task create "Title" --backlog <name>                    # icebox by default
+ranger task create "Title" --backlog <name> --state queued     # committed work
+ranger task create "Subtask" --backlog <name> --parent <key>   # subtask
 ```
 
 ### Prioritization
@@ -70,12 +70,12 @@ ranger task create "Subtask" --backlog <key> --parent <key>   # subtask
 When adding queued tasks, consider where they belong relative to existing work. New tasks land at the bottom by default — reposition them if they're higher priority:
 
 ```bash
-ranger task list --backlog <key> --state queued               # see current order
-ranger task move <key> --backlog <key> --before <key>         # place before a task
-ranger task move <key> --backlog <key> --after <key>          # place after a task
+ranger task list --backlog <name> --state queued               # see current order
+ranger task move <key> --backlog <name> --before <key>         # place before a task
+ranger task move <key> --backlog <name> --after <key>          # place after a task
 ```
 
-Top of the queue = most important. Ask the user where a task should go if priority isn't obvious. Don't just append everything to the bottom — a backlog that isn't ordered isn't useful.
+Top of the queue = most important. Only move tasks within the queued state — don't reposition done, in_progress, or icebox tasks. Ask the user where a task should go if priority isn't obvious. Don't just append everything to the bottom — a backlog that isn't ordered isn't useful.
 
 **Bias toward quick wins**: Small, easy tasks should be prioritized higher by default — even if they're just nice-to-haves. A 5-minute fix that improves quality of life is worth doing before a multi-hour feature. When suggesting priority, bump quick wins up rather than defaulting them to the bottom.
 
diff --git a/migrations/002_unique_backlog_name.sql b/migrations/002_unique_backlog_name.sql
new file mode 100644
index 0000000..7a05c24
--- /dev/null
+++ b/migrations/002_unique_backlog_name.sql
@@ -0,0 +1 @@
+CREATE UNIQUE INDEX IF NOT EXISTS idx_backlogs_name ON backlogs(name);
diff --git a/src/bin/ranger/commands/backlog.rs b/src/bin/ranger/commands/backlog.rs
index 67bd5c4..3d05216 100644
--- a/src/bin/ranger/commands/backlog.rs
+++ b/src/bin/ranger/commands/backlog.rs
@@ -17,8 +17,8 @@ pub enum BacklogCommands {
     List,
     /// Show a backlog's details
     Show {
-        /// Key or key prefix of the backlog
-        key: String,
+        /// Backlog name
+        name: String,
     },
 }
 
@@ -32,8 +32,8 @@ pub async fn run(pool: &SqlitePool, command: BacklogCommands, json: bool) -> Res
             let backlogs = ops::backlog::list(pool).await?;
             output::print_list(&backlogs, json, print_backlog);
         }
-        BacklogCommands::Show { key } => {
-            let backlog = ops::backlog::get_by_key_prefix(pool, &key).await?;
+        BacklogCommands::Show { name } => {
+            let backlog = ops::backlog::get_by_name(pool, &name).await?;
 
             if json {
                 let mut state_groups = serde_json::Map::new();
@@ -68,11 +68,10 @@ pub async fn run(pool: &SqlitePool, command: BacklogCommands, json: bool) -> Res
 }
 
 fn print_backlog(b: &Backlog) {
-    println!("{} {}", &b.key[..8], b.name);
+    println!("{}", b.name);
 }
 
 fn print_backlog_detail(b: &Backlog) {
-    println!("Key:     {}", b.key);
     println!("Name:    {}", b.name);
     println!("Created: {}", b.created_at);
     println!("Updated: {}", b.updated_at);
diff --git a/src/bin/ranger/commands/task.rs b/src/bin/ranger/commands/task.rs
index 76bc30b..b9fd053 100644
--- a/src/bin/ranger/commands/task.rs
+++ b/src/bin/ranger/commands/task.rs
@@ -129,7 +129,7 @@ pub async fn run(pool: &SqlitePool, command: TaskCommands, json: bool) -> Result
             tag,
             position,
         } => {
-            let bl = ops::backlog::get_by_key_prefix(pool, &backlog).await?;
+            let bl = ops::backlog::get_by_name(pool, &backlog).await?;
             let parent_id = if let Some(parent_key) = &parent {
                 Some(ops::task::get_by_key_prefix(pool, parent_key).await?.id)
             } else {
@@ -165,7 +165,7 @@ pub async fn run(pool: &SqlitePool, command: TaskCommands, json: bool) -> Result
             let state = state.map(|s| s.parse::<State>()).transpose()?;
 
             if let Some(backlog_key) = &backlog {
-                let bl = ops::backlog::get_by_key_prefix(pool, backlog_key).await?;
+                let bl = ops::backlog::get_by_name(pool, backlog_key).await?;
                 let tasks = ops::task::list(pool, bl.id, state).await?;
                 output::print_list(&tasks, json, print_task);
             } else {
@@ -244,7 +244,7 @@ pub async fn run(pool: &SqlitePool, command: TaskCommands, json: bool) -> Result
             backlog,
             position,
         } => {
-            let bl = ops::backlog::get_by_key_prefix(pool, &backlog).await?;
+            let bl = ops::backlog::get_by_name(pool, &backlog).await?;
             let task = ops::task::get_by_key_prefix(pool, &key).await?;
             let (before_id, after_id) = position.resolve(pool).await?;
 
@@ -253,13 +253,13 @@ pub async fn run(pool: &SqlitePool, command: TaskCommands, json: bool) -> Result
         }
         TaskCommands::Add { task, backlog } => {
             let t = ops::task::get_by_key_prefix(pool, &task).await?;
-            let bl = ops::backlog::get_by_key_prefix(pool, &backlog).await?;
+            let bl = ops::backlog::get_by_name(pool, &backlog).await?;
             ops::task::add_to_backlog(pool, t.id, bl.id).await?;
             println!("Added {} to {}", &t.key[..8], bl.name);
         }
         TaskCommands::Remove { task, backlog } => {
             let t = ops::task::get_by_key_prefix(pool, &task).await?;
-            let bl = ops::backlog::get_by_key_prefix(pool, &backlog).await?;
+            let bl = ops::backlog::get_by_name(pool, &backlog).await?;
             ops::task::remove_from_backlog(pool, t.id, bl.id).await?;
             println!("Removed {} from {}", &t.key[..8], bl.name);
         }
diff --git a/src/db.rs b/src/db.rs
index 6ead83d..0eaa40c 100644
--- a/src/db.rs
+++ b/src/db.rs
@@ -27,6 +27,9 @@ async fn migrate(pool: &SqlitePool) -> Result<(), RangerError> {
     sqlx::raw_sql(include_str!("../migrations/001_initial.sql"))
         .execute(pool)
         .await?;
+    sqlx::raw_sql(include_str!("../migrations/002_unique_backlog_name.sql"))
+        .execute(pool)
+        .await?;
     Ok(())
 }
 
diff --git a/src/models.rs b/src/models.rs
index e81a22f..56b23be 100644
--- a/src/models.rs
+++ b/src/models.rs
@@ -72,7 +72,6 @@ impl sqlx::Encode<'_, sqlx::Sqlite> for State {
 #[derive(Debug, Clone, Serialize, Deserialize, FromRow)]
 pub struct Backlog {
     pub id: i64,
-    pub key: String,
     pub name: String,
     pub created_at: Timestamp,
     pub updated_at: Timestamp,
diff --git a/src/ops/backlog.rs b/src/ops/backlog.rs
index 860d1cf..4f48ce7 100644
--- a/src/ops/backlog.rs
+++ b/src/ops/backlog.rs
@@ -4,9 +4,10 @@ use crate::models::Backlog;
 use sqlx::SqlitePool;
 
 pub async fn create(pool: &SqlitePool, name: &str) -> Result<Backlog, RangerError> {
+    // key column still exists in the schema but is unused; generate a dummy value
     let key = key::generate_key();
     let backlog = sqlx::query_as::<_, Backlog>(
-        "INSERT INTO backlogs (key, name) VALUES (?, ?) RETURNING id, key, name, created_at, updated_at",
+        "INSERT INTO backlogs (key, name) VALUES (?, ?) RETURNING id, name, created_at, updated_at",
     )
     .bind(&key)
     .bind(name)
@@ -17,27 +18,22 @@ pub async fn create(pool: &SqlitePool, name: &str) -> Result<Backlog, RangerErro
 
 pub async fn list(pool: &SqlitePool) -> Result<Vec<Backlog>, RangerError> {
     let backlogs = sqlx::query_as::<_, Backlog>(
-        "SELECT id, key, name, created_at, updated_at FROM backlogs ORDER BY name",
+        "SELECT id, name, created_at, updated_at FROM backlogs ORDER BY name",
     )
     .fetch_all(pool)
     .await?;
     Ok(backlogs)
 }
 
-pub async fn get_by_key_prefix(pool: &SqlitePool, prefix: &str) -> Result<Backlog, RangerError> {
-    let pattern = format!("{prefix}%");
-    let matches = sqlx::query_as::<_, Backlog>(
-        "SELECT id, key, name, created_at, updated_at FROM backlogs WHERE key LIKE ?",
+pub async fn get_by_name(pool: &SqlitePool, name: &str) -> Result<Backlog, RangerError> {
+    let backlog = sqlx::query_as::<_, Backlog>(
+        "SELECT id, name, created_at, updated_at FROM backlogs WHERE name = ?",
     )
-    .bind(&pattern)
-    .fetch_all(pool)
-    .await?;
-
-    match matches.len() {
-        0 => Err(RangerError::KeyNotFound(prefix.to_string())),
-        1 => Ok(matches.into_iter().next().unwrap()),
-        _ => Err(RangerError::AmbiguousPrefix(prefix.to_string())),
-    }
+    .bind(name)
+    .fetch_optional(pool)
+    .await?
+    .ok_or_else(|| RangerError::KeyNotFound(name.to_string()))?;
+    Ok(backlog)
 }
 
 #[cfg(test)]
@@ -57,9 +53,8 @@ mod tests {
         let pool = test_pool().await;
         let backlog = create(&pool, "My Backlog").await.unwrap();
         assert_eq!(backlog.name, "My Backlog");
-        assert!(!backlog.key.is_empty());
 
-        let fetched = get_by_key_prefix(&pool, &backlog.key[..3]).await.unwrap();
+        let fetched = get_by_name(&pool, "My Backlog").await.unwrap();
         assert_eq!(fetched.id, backlog.id);
     }
 
@@ -74,9 +69,9 @@ mod tests {
     }
 
     #[tokio::test]
-    async fn get_by_key_prefix_not_found() {
+    async fn get_by_name_not_found() {
         let pool = test_pool().await;
-        let result = get_by_key_prefix(&pool, "nonexistent").await;
+        let result = get_by_name(&pool, "nonexistent").await;
         assert!(result.is_err());
     }
 }
diff --git a/tests/cli.rs b/tests/cli.rs
index 0eb181f..500d385 100644
--- a/tests/cli.rs
+++ b/tests/cli.rs
@@ -5,6 +5,7 @@ use tempfile::tempdir;
 fn ranger(db_path: &str) -> Command {
     let mut cmd = Command::from(cargo_bin_cmd!("ranger"));
     cmd.env("RANGER_DB", db_path);
+    cmd.env("RANGER_DEFAULT_BACKLOG", "Ranger");
     cmd
 }
 
@@ -23,48 +24,31 @@ fn full_workflow() {
     let stdout = String::from_utf8(output.stdout).unwrap();
     assert!(stdout.contains("Ranger"));
 
-    // List backlogs (JSON) and extract key
+    // List backlogs (JSON)
     let output = ranger(db_path)
         .args(["backlog", "list", "--json"])
         .output()
         .unwrap();
     assert!(output.status.success());
     let backlogs: serde_json::Value = serde_json::from_slice(&output.stdout).unwrap();
-    let backlog_key = backlogs[0]["key"].as_str().unwrap().to_string();
-    let bl_prefix = &backlog_key[..4];
+    assert_eq!(backlogs[0]["name"], "Ranger");
 
-    // Create tasks
+    // Create tasks (using RANGER_DEFAULT_BACKLOG)
     let output = ranger(db_path)
-        .args([
-            "task",
-            "create",
-            "First task",
-            "--backlog",
-            bl_prefix,
-            "--state",
-            "queued",
-        ])
+        .args(["task", "create", "First task", "--state", "queued"])
         .output()
         .unwrap();
     assert!(output.status.success());
 
     let output = ranger(db_path)
-        .args([
-            "task",
-            "create",
-            "Second task",
-            "--backlog",
-            bl_prefix,
-            "--tag",
-            "urgent",
-        ])
+        .args(["task", "create", "Second task", "--tag", "urgent"])
         .output()
         .unwrap();
     assert!(output.status.success());
 
     // List tasks (JSON) and verify ordering
     let output = ranger(db_path)
-        .args(["task", "list", "--backlog", bl_prefix, "--json"])
+        .args(["task", "list", "--json"])
         .output()
         .unwrap();
     assert!(output.status.success());
@@ -135,7 +119,7 @@ fn full_workflow() {
 
     // Verify deletion
     let output = ranger(db_path)
-        .args(["task", "list", "--backlog", bl_prefix, "--json"])
+        .args(["task", "list", "--json"])
         .output()
         .unwrap();
     assert!(output.status.success());