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
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()
+ );
}