Restore verbose output for next command

Amolith created

The Loro migration stripped the verbose breakdown down to just an ID and
final score. Restore the three pieces that were lost:

- Active scoring mode printed once before the per-task list
- Expanded formula with actual values substituted, branching on mode:
    impact: (downstream + 1.00) × priority / effort^0.25 = score
    effort: (downstream × 0.25 + 1.00) × priority / effort² = score
- Unblocks count: N tasks (M directly), with correct singular/plural

All data was already present in ScoredTask; only the rendering in
src/cmd/next.rs needed updating.

Update the two cli_next verbose tests whose assertions reflected the old
'score:' label rather than the restored output. Add two new tests
covering the singular 'task' grammar and transitive unblocks counts,
both of which were untested before.

Change summary

src/cmd/next.rs   | 31 ++++++++++++++++++++++++-
tests/cli_next.rs | 57 +++++++++++++++++++++++++++++++++++++++++++++++-
2 files changed, 84 insertions(+), 4 deletions(-)

Detailed changes

src/cmd/next.rs 🔗

@@ -107,10 +107,37 @@ pub fn run(root: &Path, mode_str: &str, verbose: bool, limit: usize, json: bool)
         println!("{table}");
 
         if verbose {
-            println!();
+            let mode_label = match mode {
+                Mode::Impact => "impact",
+                Mode::Effort => "effort",
+            };
+            println!("\nmode: {mode_label}");
             for (i, s) in scored.iter().enumerate() {
                 let short = db::TaskId::display_id(&s.id);
-                println!("{}. {} — score: {:.2}", i + 1, short, s.score);
+                let formula = match mode {
+                    Mode::Impact => format!(
+                        "({:.2} + 1.00) × {:.2} / {:.2}^0.25 = {:.2}",
+                        s.downstream_score, s.priority_weight, s.effort_weight, s.score
+                    ),
+                    Mode::Effort => format!(
+                        "({:.2} × 0.25 + 1.00) × {:.2} / {:.2}² = {:.2}",
+                        s.downstream_score, s.priority_weight, s.effort_weight, s.score
+                    ),
+                };
+                let task_word = if s.total_unblocked == 1 {
+                    "task"
+                } else {
+                    "tasks"
+                };
+                println!(
+                    "\n{}. {}\n   {}\n   Unblocks: {} {} ({} directly)",
+                    i + 1,
+                    short,
+                    formula,
+                    s.total_unblocked,
+                    task_word,
+                    s.direct_unblocked
+                );
             }
         }
     }

tests/cli_next.rs 🔗

@@ -115,7 +115,9 @@ fn next_verbose_shows_equation() {
         .assert()
         .success()
         .stdout(predicate::str::contains("SCORE"))
-        .stdout(predicate::str::contains("score:"));
+        .stdout(predicate::str::contains("mode: impact"))
+        .stdout(predicate::str::contains("^0.25"))
+        .stdout(predicate::str::contains("Unblocks:"));
 }
 
 #[test]
@@ -129,7 +131,58 @@ fn next_verbose_effort_mode_shows_squared() {
         .assert()
         .success()
         .stdout(predicate::str::contains("SCORE"))
-        .stdout(predicate::str::contains("score:"));
+        .stdout(predicate::str::contains("mode: effort"))
+        .stdout(predicate::str::contains("²"))
+        .stdout(predicate::str::contains("Unblocks:"));
+}
+
+#[test]
+fn next_verbose_unblocks_singular() {
+    // A blocks B. A is ready and directly unblocks exactly 1 task, so the
+    // output must say "1 task", not "1 tasks".
+    let tmp = init_tmp();
+    let a = create_task(&tmp, "Blocker", "medium", "low");
+    let b = create_task(&tmp, "Blocked", "medium", "low");
+
+    td(&tmp)
+        .args(["dep", "add", &b, &a])
+        .current_dir(&tmp)
+        .assert()
+        .success();
+
+    td(&tmp)
+        .args(["next", "--verbose"])
+        .current_dir(&tmp)
+        .assert()
+        .success()
+        .stdout(predicate::str::contains("Unblocks: 1 task (1 directly)"));
+}
+
+#[test]
+fn next_verbose_unblocks_count_with_transitive() {
+    // A blocks B, B blocks C. A is ready with 2 total unblocked (1 directly).
+    let tmp = init_tmp();
+    let a = create_task(&tmp, "Root", "medium", "low");
+    let b = create_task(&tmp, "Mid", "medium", "low");
+    let c = create_task(&tmp, "Leaf", "medium", "low");
+
+    td(&tmp)
+        .args(["dep", "add", &b, &a])
+        .current_dir(&tmp)
+        .assert()
+        .success();
+    td(&tmp)
+        .args(["dep", "add", &c, &b])
+        .current_dir(&tmp)
+        .assert()
+        .success();
+
+    td(&tmp)
+        .args(["next", "--verbose"])
+        .current_dir(&tmp)
+        .assert()
+        .success()
+        .stdout(predicate::str::contains("Unblocks: 2 tasks (1 directly)"));
 }
 
 #[test]