Add backlog rebalance subcommand
Reassigns well-spaced positions to all tasks in a backlog
via recursive bisection, preserving existing order.

Assisted-by: Claude Opus 4.6 via pi
change zpsuzlnkouzlwmknkkkvupvtvknkunpy
commit c570351452c1bcb88ee7d7bc58efc3fbf884a97e
author Alpha Chen <alpha@kejadlen.dev>
date
parent onlntuqu
diff --git a/Cargo.lock b/Cargo.lock
index a928f35..f62b2e7 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -454,6 +454,15 @@ version = "0.1.9"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582"
 
+[[package]]
+name = "float-cmp"
+version = "0.10.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b09cf3155332e944990140d967ff5eceb70df778b34f77d8075db46e4704e6d8"
+dependencies = [
+ "num-traits",
+]
+
 [[package]]
 name = "flume"
 version = "0.11.1"
@@ -957,6 +966,12 @@ dependencies = [
  "windows-sys 0.61.2",
 ]
 
+[[package]]
+name = "normalize-line-endings"
+version = "0.3.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "61807f77802ff30975e01f4f071c8ba10c022052f98b3294119f3e615d13e5be"
+
 [[package]]
 name = "nu-ansi-term"
 version = "0.50.3"
@@ -1163,7 +1178,10 @@ checksum = "ada8f2932f28a27ee7b70dd6c1c39ea0675c55a36879ab92f3a715eaa1e63cfe"
 dependencies = [
  "anstyle",
  "difflib",
+ "float-cmp",
+ "normalize-line-endings",
  "predicates-core",
+ "regex",
 ]
 
 [[package]]
@@ -1254,6 +1272,7 @@ dependencies = [
  "clap",
  "color-eyre",
  "jiff",
+ "predicates",
  "rand",
  "serde",
  "serde_json",
@@ -1283,6 +1302,18 @@ dependencies = [
  "bitflags",
 ]
 
+[[package]]
+name = "regex"
+version = "1.12.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e10754a14b9137dd7b1e3e5b0493cc9171fdd105e0ab477f51b72e7f3ac0e276"
+dependencies = [
+ "aho-corasick",
+ "memchr",
+ "regex-automata",
+ "regex-syntax",
+]
+
 [[package]]
 name = "regex-automata"
 version = "0.4.14"
diff --git a/Cargo.toml b/Cargo.toml
index b39bfb0..3b23f2f 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -22,4 +22,5 @@ xdg = "*"
 
 [dev-dependencies]
 assert_cmd = "*"
+predicates = "3.1.4"
 tempfile = "*"
diff --git a/src/bin/ranger/commands/backlog.rs b/src/bin/ranger/commands/backlog.rs
index 86a32b7..312335b 100644
--- a/src/bin/ranger/commands/backlog.rs
+++ b/src/bin/ranger/commands/backlog.rs
@@ -24,6 +24,12 @@ pub enum BacklogCommands {
         #[arg(env = "RANGER_DEFAULT_BACKLOG")]
         name: String,
     },
+    /// Rebalance task positions in a backlog
+    Rebalance {
+        /// Backlog name
+        #[arg(env = "RANGER_DEFAULT_BACKLOG")]
+        name: String,
+    },
 }
 
 pub async fn run(pool: &SqlitePool, command: BacklogCommands, json: bool) -> Result<()> {
@@ -38,6 +44,11 @@ pub async fn run(pool: &SqlitePool, command: BacklogCommands, json: bool) -> Res
             let backlogs = ops::backlog::list(&mut conn).await?;
             output::print_list(&backlogs, json, print_backlog);
         }
+        BacklogCommands::Rebalance { name } => {
+            let backlog = ops::backlog::get_by_name(&mut conn, &name).await?;
+            let count = ops::task::rebalance(&mut conn, backlog.id).await?;
+            println!("Rebalanced {count} tasks in {name}");
+        }
         BacklogCommands::Show { name } => {
             let backlog = ops::backlog::get_by_name(&mut conn, &name).await?;
 
diff --git a/src/ops/task.rs b/src/ops/task.rs
index 2d336b0..d255af3 100644
--- a/src/ops/task.rs
+++ b/src/ops/task.rs
@@ -375,6 +375,17 @@ async fn set_position(
     Ok(())
 }
 
+pub async fn rebalance(conn: &mut SqliteConnection, backlog_id: i64) -> Result<usize, RangerError> {
+    let tasks = list(&mut *conn, backlog_id, None).await?;
+    let positions = position::spread(tasks.len());
+
+    for (task, pos) in tasks.iter().zip(&positions) {
+        set_position(&mut *conn, task.id, pos).await?;
+    }
+
+    Ok(tasks.len())
+}
+
 pub async fn delete(conn: &mut SqliteConnection, task_id: i64) -> Result<(), RangerError> {
     sqlx::query("DELETE FROM tasks WHERE id = ?")
         .bind(task_id)
@@ -1125,4 +1136,62 @@ mod tests {
         assert_eq!(icebox.len(), 1);
         assert_eq!(icebox[0].id, t1.id);
     }
+
+    #[tokio::test]
+    async fn rebalance_reassigns_positions() {
+        let pool = test_pool().await;
+        let mut conn = pool.acquire().await.unwrap();
+        let bl = backlog::create(&mut conn, "Test").await.unwrap();
+
+        // Create tasks — repeated appends make positions drift toward "z"
+        for title in ["A", "B", "C"] {
+            create(
+                &mut conn,
+                CreateTask {
+                    title,
+                    backlog_id: bl.id,
+                    state: None,
+                    parent_id: None,
+                    description: None,
+                },
+            )
+            .await
+            .unwrap();
+        }
+
+        let before: Vec<String> = list(&mut conn, bl.id, None)
+            .await
+            .unwrap()
+            .iter()
+            .map(|t| t.position.clone())
+            .collect();
+
+        let count = rebalance(&mut conn, bl.id).await.unwrap();
+        assert_eq!(count, 3);
+
+        let after = list(&mut conn, bl.id, None).await.unwrap();
+        let after_positions: Vec<String> = after.iter().map(|t| t.position.clone()).collect();
+
+        // Order preserved
+        assert_eq!(
+            after.iter().map(|t| &t.title).collect::<Vec<_>>(),
+            vec!["A", "B", "C"]
+        );
+        // Positions changed
+        assert_ne!(before, after_positions);
+        // Still sorted
+        for w in after_positions.windows(2) {
+            assert!(w[0] < w[1]);
+        }
+    }
+
+    #[tokio::test]
+    async fn rebalance_empty_backlog() {
+        let pool = test_pool().await;
+        let mut conn = pool.acquire().await.unwrap();
+        let bl = backlog::create(&mut conn, "Test").await.unwrap();
+
+        let count = rebalance(&mut conn, bl.id).await.unwrap();
+        assert_eq!(count, 0);
+    }
 }
diff --git a/src/position.rs b/src/position.rs
index 7b8d46e..eadfc0d 100644
--- a/src/position.rs
+++ b/src/position.rs
@@ -46,6 +46,24 @@ pub fn between(a: &str, b: &str) -> String {
     unreachable!("between exhausted without finding a midpoint") // cov-excl-line
 }
 
+/// Generate `n` well-spaced position strings via recursive bisection.
+pub fn spread(n: usize) -> Vec<String> {
+    let mut out = Vec::with_capacity(n);
+    fill("", "", n, &mut out);
+    out
+}
+
+fn fill(lo: &str, hi: &str, n: usize, out: &mut Vec<String>) {
+    if n == 0 {
+        return;
+    }
+    let mid = n / 2;
+    let pos = between(lo, hi);
+    fill(lo, &pos, mid, out);
+    out.push(pos.clone());
+    fill(&pos, hi, n - mid - 1, out);
+}
+
 #[cfg(test)]
 mod tests {
     use super::*;
@@ -119,4 +137,31 @@ mod tests {
             positions.insert(positions.len() - 1, mid);
         }
     }
+
+    #[test]
+    fn spread_empty() {
+        assert!(spread(0).is_empty());
+    }
+
+    #[test]
+    fn spread_one() {
+        let positions = spread(1);
+        assert_eq!(positions.len(), 1);
+        assert_eq!(positions[0], "m");
+    }
+
+    #[test]
+    fn spread_produces_sorted_positions() {
+        for n in [2, 3, 5, 10, 50, 100] {
+            let positions = spread(n);
+            assert_eq!(positions.len(), n);
+            for window in positions.windows(2) {
+                assert!(
+                    window[0] < window[1],
+                    "not sorted at n={n}: {:?}",
+                    positions
+                );
+            }
+        }
+    }
 }
diff --git a/tests/cli.rs b/tests/cli.rs
index 61bd1a5..fb3df8e 100644
--- a/tests/cli.rs
+++ b/tests/cli.rs
@@ -198,4 +198,29 @@ fn full_workflow() {
     assert!(output.status.success());
     let tasks: serde_json::Value = serde_json::from_slice(&output.stdout).unwrap();
     assert_eq!(tasks.as_array().unwrap().len(), 3);
+
+    // Rebalance
+    ranger(db_path)
+        .args(["backlog", "rebalance"])
+        .assert()
+        .success()
+        .stdout(predicates::str::contains("Rebalanced"));
+
+    // Verify ordering preserved after rebalance
+    let output = ranger(db_path)
+        .args(["task", "list", "--json"])
+        .output()
+        .unwrap();
+    let tasks: serde_json::Value = serde_json::from_slice(&output.stdout).unwrap();
+    let titles: Vec<&str> = tasks
+        .as_array()
+        .unwrap()
+        .iter()
+        .map(|t| t["title"].as_str().unwrap())
+        .collect();
+    // Fourth (edited) was moved before Third — ordering should survive rebalance
+    assert!(
+        titles.iter().position(|t| t.contains("Fourth")).unwrap()
+            < titles.iter().position(|t| *t == "Third task").unwrap()
+    );
 }