Scope key prefix resolution to default backlog
When RANGER_DEFAULT_BACKLOG is set, key lookups resolve within
that backlog so the shorter prefixes shown in backlog-scoped
listings actually work in subsequent commands.

Assisted-by: Claude Opus 4.6 via pi
change wukkmmlvoqspkkuwvsrnxqyozrpkyllm
commit c02328494fcfd2f39ef6a622fe6334a1936eec8e
author Alpha Chen <alpha@kejadlen.dev>
date
parent qxuktuko
diff --git a/src/bin/ranger/commands/comment.rs b/src/bin/ranger/commands/comment.rs
index 946e083..9a5bf08 100644
--- a/src/bin/ranger/commands/comment.rs
+++ b/src/bin/ranger/commands/comment.rs
@@ -24,18 +24,19 @@ pub enum CommentCommands {
 }
 
 pub async fn run(pool: &SqlitePool, command: CommentCommands, json: bool) -> Result<()> {
+    let backlog_scope = super::task::default_backlog_id(pool).await;
     let mut conn = pool.acquire().await?;
 
     match command {
         CommentCommands::Add { task, body } => {
-            let t = ops::task::get_by_key_prefix(&mut conn, &task).await?;
+            let t = ops::task::get_by_key_prefix(&mut conn, &task, backlog_scope).await?;
             let comment = ops::comment::add(&mut conn, t.id, &body).await?;
             output::print(&comment, json, |c| {
                 println!("[{}] {}", c.created_at, c.body);
             });
         }
         CommentCommands::List { task } => {
-            let t = ops::task::get_by_key_prefix(&mut conn, &task).await?;
+            let t = ops::task::get_by_key_prefix(&mut conn, &task, backlog_scope).await?;
             let comments = ops::comment::list(&mut conn, t.id).await?;
             output::print_list(&comments, json, |c| {
                 println!("[{}] {}", c.created_at, c.body);
diff --git a/src/bin/ranger/commands/task.rs b/src/bin/ranger/commands/task.rs
index 01487c6..5cf05b8 100644
--- a/src/bin/ranger/commands/task.rs
+++ b/src/bin/ranger/commands/task.rs
@@ -22,20 +22,24 @@ pub struct PositionArgs {
 }
 
 impl PositionArgs {
-    async fn resolve(self, conn: &mut SqliteConnection) -> Result<Option<PositionAnchors>> {
+    async fn resolve(
+        self,
+        conn: &mut SqliteConnection,
+        backlog_id: Option<i64>,
+    ) -> Result<Option<PositionAnchors>> {
         match (self.before, self.after) {
             (None, None) => Ok(None),
             (Some(b), None) => {
-                let before = ops::task::get_by_key_prefix(conn, &b).await?;
+                let before = ops::task::get_by_key_prefix(conn, &b, backlog_id).await?;
                 Ok(Some(PositionAnchors::Before(before)))
             }
             (None, Some(a)) => {
-                let after = ops::task::get_by_key_prefix(conn, &a).await?;
+                let after = ops::task::get_by_key_prefix(conn, &a, backlog_id).await?;
                 Ok(Some(PositionAnchors::After(after)))
             }
             (Some(b), Some(a)) => {
-                let before = ops::task::get_by_key_prefix(conn, &b).await?;
-                let after = ops::task::get_by_key_prefix(conn, &a).await?;
+                let before = ops::task::get_by_key_prefix(conn, &b, backlog_id).await?;
+                let after = ops::task::get_by_key_prefix(conn, &a, backlog_id).await?;
                 Ok(Some(PositionAnchors::Between { before, after }))
             }
         }
@@ -145,7 +149,20 @@ pub enum TaskCommands {
     },
 }
 
+/// Resolve `RANGER_DEFAULT_BACKLOG` to a backlog ID, if set.
+/// Returns `None` when the env var is absent or the backlog doesn't exist.
+pub async fn default_backlog_id(pool: &SqlitePool) -> Option<i64> {
+    let name = std::env::var("RANGER_DEFAULT_BACKLOG").ok()?;
+    let mut conn = pool.acquire().await.ok()?;
+    ops::backlog::get_by_name(&mut conn, &name)
+        .await
+        .ok()
+        .map(|b| b.id)
+}
+
 pub async fn run(pool: &SqlitePool, command: TaskCommands, json: bool) -> Result<()> {
+    let backlog_scope = default_backlog_id(pool).await;
+
     match command {
         TaskCommands::Create {
             title,
@@ -159,11 +176,15 @@ pub async fn run(pool: &SqlitePool, command: TaskCommands, json: bool) -> Result
 
             let bl = ops::backlog::get_by_name(&mut tx, &backlog).await?;
             let parent_id = if let Some(parent_key) = &parent {
-                Some(ops::task::get_by_key_prefix(&mut tx, parent_key).await?.id)
+                Some(
+                    ops::task::get_by_key_prefix(&mut tx, parent_key, Some(bl.id))
+                        .await?
+                        .id,
+                )
             } else {
                 None
             };
-            let anchors = position.resolve(&mut tx).await?;
+            let anchors = position.resolve(&mut tx, Some(bl.id)).await?;
             let state = state.map(|s| s.parse::<State>()).transpose()?;
 
             let task = ops::task::create(
@@ -225,7 +246,7 @@ pub async fn run(pool: &SqlitePool, command: TaskCommands, json: bool) -> Result
         }
         TaskCommands::Show { key } => {
             let mut conn = pool.acquire().await?;
-            let task = ops::task::get_by_key_prefix(&mut conn, &key).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?;
 
             if json {
@@ -257,9 +278,9 @@ pub async fn run(pool: &SqlitePool, command: TaskCommands, json: bool) -> Result
         } => {
             let mut conn = pool.acquire().await?;
             let state = state.map(|s| s.parse::<State>()).transpose()?;
-            let anchors = position.resolve(&mut conn).await?;
+            let anchors = position.resolve(&mut conn, backlog_scope).await?;
 
-            let task = ops::task::get_by_key_prefix(&mut conn, &key).await?;
+            let task = ops::task::get_by_key_prefix(&mut conn, &key, backlog_scope).await?;
             let updated = ops::task::edit(
                 &mut conn,
                 task.id,
@@ -279,8 +300,8 @@ pub async fn run(pool: &SqlitePool, command: TaskCommands, json: bool) -> Result
         }
         TaskCommands::Move { key, position } => {
             let mut conn = pool.acquire().await?;
-            let task = ops::task::get_by_key_prefix(&mut conn, &key).await?;
-            let anchors = position.resolve(&mut conn).await?;
+            let task = ops::task::get_by_key_prefix(&mut conn, &key, backlog_scope).await?;
+            let anchors = position.resolve(&mut conn, backlog_scope).await?;
 
             match anchors {
                 Some(anchors) => {
@@ -298,7 +319,7 @@ pub async fn run(pool: &SqlitePool, command: TaskCommands, json: bool) -> Result
         }
         TaskCommands::Delete { key } => {
             let mut conn = pool.acquire().await?;
-            let task = ops::task::get_by_key_prefix(&mut conn, &key).await?;
+            let task = ops::task::get_by_key_prefix(&mut conn, &key, backlog_scope).await?;
             let all_keys = ops::task::all_keys(&mut conn).await?;
             let prefixes = key::unique_prefix_lengths(&all_keys);
             ops::task::delete(&mut conn, task.id).await?;
@@ -310,7 +331,7 @@ pub async fn run(pool: &SqlitePool, command: TaskCommands, json: bool) -> Result
         }
         TaskCommands::Archive { key } => {
             let mut conn = pool.acquire().await?;
-            let task = ops::task::get_by_key_prefix(&mut conn, &key).await?;
+            let task = ops::task::get_by_key_prefix(&mut conn, &key, backlog_scope).await?;
             let updated = ops::task::set_archived(&mut conn, task.id, true).await?;
             let all_keys = ops::task::all_keys(&mut conn).await?;
             let prefixes = key::unique_prefix_lengths(&all_keys);
@@ -324,7 +345,7 @@ pub async fn run(pool: &SqlitePool, command: TaskCommands, json: bool) -> Result
         }
         TaskCommands::Unarchive { key } => {
             let mut conn = pool.acquire().await?;
-            let task = ops::task::get_by_key_prefix(&mut conn, &key).await?;
+            let task = ops::task::get_by_key_prefix(&mut conn, &key, backlog_scope).await?;
             let updated = ops::task::set_archived(&mut conn, task.id, false).await?;
             let all_keys = ops::task::all_keys(&mut conn).await?;
             let prefixes = key::unique_prefix_lengths(&all_keys);
diff --git a/src/ops/task.rs b/src/ops/task.rs
index 5addaf9..ffa9e6b 100644
--- a/src/ops/task.rs
+++ b/src/ops/task.rs
@@ -124,13 +124,23 @@ pub async fn get_by_id(conn: &mut SqliteConnection, id: i64) -> Result<Task, Ran
 pub async fn get_by_key_prefix(
     conn: &mut SqliteConnection,
     prefix: &str,
+    backlog_id: Option<i64>,
 ) -> Result<Task, RangerError> {
     let pattern = format!("{prefix}%");
-    let query = format!("SELECT {TASK_COLUMNS} FROM tasks WHERE key LIKE ?");
-    let matches = sqlx::query_as::<_, Task>(&query)
-        .bind(&pattern)
-        .fetch_all(&mut *conn)
-        .await?;
+    let matches = if let Some(bid) = backlog_id {
+        let query = format!("SELECT {TASK_COLUMNS} FROM tasks WHERE key LIKE ? AND backlog_id = ?");
+        sqlx::query_as::<_, Task>(&query)
+            .bind(&pattern)
+            .bind(bid)
+            .fetch_all(&mut *conn)
+            .await?
+    } else {
+        let query = format!("SELECT {TASK_COLUMNS} FROM tasks WHERE key LIKE ?");
+        sqlx::query_as::<_, Task>(&query)
+            .bind(&pattern)
+            .fetch_all(&mut *conn)
+            .await?
+    };
 
     match matches.len() {
         0 => Err(RangerError::KeyNotFound(prefix.to_string())),
@@ -613,7 +623,9 @@ mod tests {
         .await
         .unwrap();
 
-        let found = get_by_key_prefix(&mut conn, &task.key[..3]).await.unwrap();
+        let found = get_by_key_prefix(&mut conn, &task.key[..3], None)
+            .await
+            .unwrap();
         assert_eq!(found.id, task.id);
     }
 
@@ -670,7 +682,7 @@ mod tests {
 
         delete(&mut conn, task.id).await.unwrap();
 
-        let result = get_by_key_prefix(&mut conn, &task.key).await;
+        let result = get_by_key_prefix(&mut conn, &task.key, None).await;
         assert!(result.is_err());
 
         let tasks = list(&mut conn, bl.id, &ListFilter::default())
@@ -720,8 +732,43 @@ mod tests {
             .await
             .unwrap();
 
-        let result = get_by_key_prefix(&mut conn, "kkkk").await;
+        let result = get_by_key_prefix(&mut conn, "kkkk", None).await;
+        assert!(result.is_err());
+    }
+
+    #[tokio::test]
+    async fn get_by_key_prefix_scoped_to_backlog() {
+        let pool = test_pool().await;
+        let mut conn = pool.acquire().await.unwrap();
+        let bl1 = backlog::create(&mut conn, "Alpha").await.unwrap();
+        let bl2 = backlog::create(&mut conn, "Beta").await.unwrap();
+
+        // Two tasks with the same key prefix in different backlogs
+        sqlx::query("INSERT INTO tasks (key, backlog_id, title, state, position) VALUES ('kkkkaaaa', ?, 'In Alpha', 'icebox', 'a')")
+            .bind(bl1.id)
+            .execute(&mut *conn)
+            .await
+            .unwrap();
+        sqlx::query("INSERT INTO tasks (key, backlog_id, title, state, position) VALUES ('kkkkbbbb', ?, 'In Beta', 'icebox', 'a')")
+            .bind(bl2.id)
+            .execute(&mut *conn)
+            .await
+            .unwrap();
+
+        // Globally ambiguous
+        let result = get_by_key_prefix(&mut conn, "kkkk", None).await;
         assert!(result.is_err());
+
+        // Scoped to each backlog resolves uniquely
+        let t1 = get_by_key_prefix(&mut conn, "kkkk", Some(bl1.id))
+            .await
+            .unwrap();
+        assert_eq!(t1.title, "In Alpha");
+
+        let t2 = get_by_key_prefix(&mut conn, "kkkk", Some(bl2.id))
+            .await
+            .unwrap();
+        assert_eq!(t2.title, "In Beta");
     }
 
     #[tokio::test]