cli_next.rs

  1use assert_cmd::Command;
  2use predicates::prelude::*;
  3use tempfile::TempDir;
  4
  5fn td(home: &TempDir) -> Command {
  6    let mut cmd = Command::cargo_bin("td").unwrap();
  7    cmd.env("HOME", home.path());
  8    cmd
  9}
 10
 11fn init_tmp() -> TempDir {
 12    let tmp = TempDir::new().unwrap();
 13    td(&tmp)
 14        .args(["project", "init", "main"])
 15        .current_dir(&tmp)
 16        .assert()
 17        .success();
 18    tmp
 19}
 20
 21fn create_task(dir: &TempDir, title: &str, pri: &str, eff: &str) -> String {
 22    let out = td(dir)
 23        .args(["--json", "create", title, "-p", pri, "-e", eff])
 24        .current_dir(dir)
 25        .output()
 26        .unwrap();
 27    let v: serde_json::Value = serde_json::from_slice(&out.stdout).unwrap();
 28    v["id"].as_str().unwrap().to_string()
 29}
 30
 31#[test]
 32fn next_no_open_tasks() {
 33    let tmp = init_tmp();
 34
 35    td(&tmp)
 36        .arg("next")
 37        .current_dir(&tmp)
 38        .assert()
 39        .success()
 40        .stdout(predicate::str::contains("No open tasks"));
 41}
 42
 43#[test]
 44fn next_single_task() {
 45    let tmp = init_tmp();
 46    let id = create_task(&tmp, "Only task", "high", "low");
 47
 48    let short = &id[id.len() - 7..];
 49    td(&tmp)
 50        .arg("next")
 51        .current_dir(&tmp)
 52        .assert()
 53        .success()
 54        .stdout(predicate::str::contains(short))
 55        .stdout(predicate::str::contains("Only task"))
 56        .stdout(predicate::str::contains("SCORE"));
 57}
 58
 59#[test]
 60fn next_impact_ranks_by_downstream() {
 61    let tmp = init_tmp();
 62    // A blocks B. Same priority/effort. A should rank higher (unblocks B).
 63    let a = create_task(&tmp, "Blocker", "medium", "medium");
 64    let b = create_task(&tmp, "Blocked", "medium", "medium");
 65
 66    td(&tmp)
 67        .args(["dep", "add", &b, &a])
 68        .current_dir(&tmp)
 69        .assert()
 70        .success();
 71
 72    let out = td(&tmp)
 73        .args(["--json", "next"])
 74        .current_dir(&tmp)
 75        .output()
 76        .unwrap();
 77    let v: serde_json::Value = serde_json::from_slice(&out.stdout).unwrap();
 78    let results = v.as_array().unwrap();
 79
 80    // Only A is ready (B is blocked).
 81    assert_eq!(results.len(), 1);
 82    assert_eq!(results[0]["id"].as_str().unwrap(), a);
 83    assert!(results[0]["total_unblocked"].as_u64().unwrap() > 0);
 84}
 85
 86#[test]
 87fn next_effort_mode_prefers_low_effort() {
 88    let tmp = init_tmp();
 89    // Both standalone. A is high-effort, B is low-effort. Same priority.
 90    let a = create_task(&tmp, "Heavy", "medium", "high");
 91    let b = create_task(&tmp, "Light", "medium", "low");
 92
 93    let out = td(&tmp)
 94        .args(["--json", "next", "--mode", "effort"])
 95        .current_dir(&tmp)
 96        .output()
 97        .unwrap();
 98    let v: serde_json::Value = serde_json::from_slice(&out.stdout).unwrap();
 99    let results = v.as_array().unwrap();
100
101    assert_eq!(results.len(), 2);
102    // B (low effort) should be ranked first.
103    assert_eq!(results[0]["id"].as_str().unwrap(), b);
104    assert_eq!(results[1]["id"].as_str().unwrap(), a);
105}
106
107#[test]
108fn next_verbose_shows_equation() {
109    let tmp = init_tmp();
110    create_task(&tmp, "Task A", "high", "low");
111
112    td(&tmp)
113        .args(["next", "--verbose"])
114        .current_dir(&tmp)
115        .assert()
116        .success()
117        .stdout(predicate::str::contains("SCORE"))
118        .stdout(predicate::str::contains("mode: impact"))
119        .stdout(predicate::str::contains("^0.25"))
120        .stdout(predicate::str::contains("Unblocks:"));
121}
122
123#[test]
124fn next_verbose_effort_mode_shows_squared() {
125    let tmp = init_tmp();
126    create_task(&tmp, "Task A", "high", "medium");
127
128    td(&tmp)
129        .args(["next", "--verbose", "--mode", "effort"])
130        .current_dir(&tmp)
131        .assert()
132        .success()
133        .stdout(predicate::str::contains("SCORE"))
134        .stdout(predicate::str::contains("mode: effort"))
135        .stdout(predicate::str::contains("²"))
136        .stdout(predicate::str::contains("Unblocks:"));
137}
138
139#[test]
140fn next_verbose_unblocks_singular() {
141    // A blocks B. A is ready and directly unblocks exactly 1 task, so the
142    // output must say "1 task", not "1 tasks".
143    let tmp = init_tmp();
144    let a = create_task(&tmp, "Blocker", "medium", "low");
145    let b = create_task(&tmp, "Blocked", "medium", "low");
146
147    td(&tmp)
148        .args(["dep", "add", &b, &a])
149        .current_dir(&tmp)
150        .assert()
151        .success();
152
153    td(&tmp)
154        .args(["next", "--verbose"])
155        .current_dir(&tmp)
156        .assert()
157        .success()
158        .stdout(predicate::str::contains("Unblocks: 1 task (1 directly)"));
159}
160
161#[test]
162fn next_verbose_unblocks_count_with_transitive() {
163    // A blocks B, B blocks C. A is ready with 2 total unblocked (1 directly).
164    let tmp = init_tmp();
165    let a = create_task(&tmp, "Root", "medium", "low");
166    let b = create_task(&tmp, "Mid", "medium", "low");
167    let c = create_task(&tmp, "Leaf", "medium", "low");
168
169    td(&tmp)
170        .args(["dep", "add", &b, &a])
171        .current_dir(&tmp)
172        .assert()
173        .success();
174    td(&tmp)
175        .args(["dep", "add", &c, &b])
176        .current_dir(&tmp)
177        .assert()
178        .success();
179
180    td(&tmp)
181        .args(["next", "--verbose"])
182        .current_dir(&tmp)
183        .assert()
184        .success()
185        .stdout(predicate::str::contains("Unblocks: 2 tasks (1 directly)"));
186}
187
188#[test]
189fn next_limit_truncates() {
190    let tmp = init_tmp();
191    create_task(&tmp, "A", "high", "low");
192    create_task(&tmp, "B", "medium", "medium");
193    create_task(&tmp, "C", "low", "high");
194
195    let out = td(&tmp)
196        .args(["--json", "next", "-n", "2"])
197        .current_dir(&tmp)
198        .output()
199        .unwrap();
200    let v: serde_json::Value = serde_json::from_slice(&out.stdout).unwrap();
201    assert_eq!(v.as_array().unwrap().len(), 2);
202}
203
204#[test]
205fn next_json_empty() {
206    let tmp = init_tmp();
207
208    let out = td(&tmp)
209        .args(["--json", "next"])
210        .current_dir(&tmp)
211        .output()
212        .unwrap();
213    let v: serde_json::Value = serde_json::from_slice(&out.stdout).unwrap();
214    assert_eq!(v.as_array().unwrap().len(), 0);
215}
216
217#[test]
218fn next_json_priority_and_effort_are_strings() {
219    // Regression: next --json was emitting priority/effort as raw integers
220    // (1/2/3) instead of string labels ("high"/"medium"/"low"). Every other
221    // JSON endpoint uses string labels; next must match.
222    let tmp = init_tmp();
223    create_task(&tmp, "Task", "high", "low");
224
225    let out = td(&tmp)
226        .args(["--json", "next"])
227        .current_dir(&tmp)
228        .output()
229        .unwrap();
230    let v: serde_json::Value = serde_json::from_slice(&out.stdout).unwrap();
231    let results = v.as_array().unwrap();
232
233    assert_eq!(results.len(), 1);
234    assert_eq!(results[0]["priority"].as_str().unwrap(), "high");
235    assert_eq!(results[0]["effort"].as_str().unwrap(), "low");
236}
237
238#[test]
239fn next_invalid_mode_fails() {
240    let tmp = init_tmp();
241    create_task(&tmp, "X", "medium", "medium");
242
243    td(&tmp)
244        .args(["next", "--mode", "bogus"])
245        .current_dir(&tmp)
246        .assert()
247        .failure()
248        .stderr(predicate::str::contains("invalid mode"));
249}
250
251#[test]
252fn next_transitive_chain_scores_correctly() {
253    let tmp = init_tmp();
254    // A blocks B, B blocks C. Only A is ready.
255    let a = create_task(&tmp, "Root", "medium", "low");
256    let b = create_task(&tmp, "Mid", "high", "medium");
257    let c = create_task(&tmp, "Leaf", "low", "high");
258
259    td(&tmp)
260        .args(["dep", "add", &b, &a])
261        .current_dir(&tmp)
262        .assert()
263        .success();
264    td(&tmp)
265        .args(["dep", "add", &c, &b])
266        .current_dir(&tmp)
267        .assert()
268        .success();
269
270    let out = td(&tmp)
271        .args(["--json", "next"])
272        .current_dir(&tmp)
273        .output()
274        .unwrap();
275    let v: serde_json::Value = serde_json::from_slice(&out.stdout).unwrap();
276    let results = v.as_array().unwrap();
277
278    assert_eq!(results.len(), 1);
279    assert_eq!(results[0]["id"].as_str().unwrap(), a);
280    // Downstream: pw(B=high=3) + pw(C=low=1) = 4.0
281    assert!((results[0]["downstream_score"].as_f64().unwrap() - 4.0).abs() < f64::EPSILON);
282    assert_eq!(results[0]["total_unblocked"].as_u64().unwrap(), 2);
283    assert_eq!(results[0]["direct_unblocked"].as_u64().unwrap(), 1);
284}
285
286#[test]
287fn next_ignores_closed_tasks() {
288    let tmp = init_tmp();
289    let a = create_task(&tmp, "Open", "high", "low");
290    let b = create_task(&tmp, "Closed", "high", "low");
291
292    td(&tmp)
293        .args(["done", &b])
294        .current_dir(&tmp)
295        .assert()
296        .success();
297
298    let out = td(&tmp)
299        .args(["--json", "next"])
300        .current_dir(&tmp)
301        .output()
302        .unwrap();
303    let v: serde_json::Value = serde_json::from_slice(&out.stdout).unwrap();
304    let results = v.as_array().unwrap();
305
306    assert_eq!(results.len(), 1);
307    assert_eq!(results[0]["id"].as_str().unwrap(), a);
308}
309
310#[test]
311fn next_excludes_parent_with_open_subtasks() {
312    let tmp = init_tmp();
313    let parent = create_task(&tmp, "Parent task", "high", "low");
314    // Create a subtask under the parent.
315    let out = td(&tmp)
316        .args([
317            "--json",
318            "create",
319            "Child task",
320            "-p",
321            "medium",
322            "-e",
323            "medium",
324            "--parent",
325            &parent,
326        ])
327        .current_dir(&tmp)
328        .output()
329        .unwrap();
330    let child: serde_json::Value = serde_json::from_slice(&out.stdout).unwrap();
331    let child_id = child["id"].as_str().unwrap().to_string();
332
333    let out = td(&tmp)
334        .args(["--json", "next"])
335        .current_dir(&tmp)
336        .output()
337        .unwrap();
338    let v: serde_json::Value = serde_json::from_slice(&out.stdout).unwrap();
339    let results = v.as_array().unwrap();
340    let ids: Vec<&str> = results.iter().map(|r| r["id"].as_str().unwrap()).collect();
341
342    // Parent should be excluded; only the child subtask should appear.
343    assert!(!ids.contains(&parent.as_str()), "parent should be excluded");
344    assert!(
345        ids.contains(&child_id.as_str()),
346        "child should be a candidate"
347    );
348}
349
350#[test]
351fn next_includes_parent_when_all_subtasks_closed() {
352    let tmp = init_tmp();
353    let parent = create_task(&tmp, "Parent task", "high", "low");
354    let out = td(&tmp)
355        .args([
356            "--json",
357            "create",
358            "Child task",
359            "-p",
360            "medium",
361            "-e",
362            "medium",
363            "--parent",
364            &parent,
365        ])
366        .current_dir(&tmp)
367        .output()
368        .unwrap();
369    let child: serde_json::Value = serde_json::from_slice(&out.stdout).unwrap();
370    let child_id = child["id"].as_str().unwrap().to_string();
371
372    // Close the subtask.
373    td(&tmp)
374        .args(["done", &child_id])
375        .current_dir(&tmp)
376        .assert()
377        .success();
378
379    let out = td(&tmp)
380        .args(["--json", "next"])
381        .current_dir(&tmp)
382        .output()
383        .unwrap();
384    let v: serde_json::Value = serde_json::from_slice(&out.stdout).unwrap();
385    let results = v.as_array().unwrap();
386    let ids: Vec<&str> = results.iter().map(|r| r["id"].as_str().unwrap()).collect();
387
388    // Parent should reappear as a candidate once all children are closed.
389    assert!(
390        ids.contains(&parent.as_str()),
391        "parent should be a candidate"
392    );
393}
394
395#[test]
396fn next_nested_parents_excluded_at_each_level() {
397    let tmp = init_tmp();
398    // grandparent → parent → child (nested subtasks)
399    let gp = create_task(&tmp, "Grandparent", "high", "low");
400    let out = td(&tmp)
401        .args([
402            "--json", "create", "Parent", "-p", "medium", "-e", "medium", "--parent", &gp,
403        ])
404        .current_dir(&tmp)
405        .output()
406        .unwrap();
407    let p: serde_json::Value = serde_json::from_slice(&out.stdout).unwrap();
408    let p_id = p["id"].as_str().unwrap().to_string();
409
410    let out = td(&tmp)
411        .args([
412            "--json", "create", "Child", "-p", "low", "-e", "low", "--parent", &p_id,
413        ])
414        .current_dir(&tmp)
415        .output()
416        .unwrap();
417    let c: serde_json::Value = serde_json::from_slice(&out.stdout).unwrap();
418    let c_id = c["id"].as_str().unwrap().to_string();
419
420    let out = td(&tmp)
421        .args(["--json", "next"])
422        .current_dir(&tmp)
423        .output()
424        .unwrap();
425    let v: serde_json::Value = serde_json::from_slice(&out.stdout).unwrap();
426    let results = v.as_array().unwrap();
427    let ids: Vec<&str> = results.iter().map(|r| r["id"].as_str().unwrap()).collect();
428
429    // Both grandparent and parent are excluded; only the leaf child appears.
430    assert!(
431        !ids.contains(&gp.as_str()),
432        "grandparent should be excluded"
433    );
434    assert!(!ids.contains(&p_id.as_str()), "parent should be excluded");
435    assert!(
436        ids.contains(&c_id.as_str()),
437        "leaf child should be a candidate"
438    );
439}