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(["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("score:"));
119}
120
121#[test]
122fn next_verbose_effort_mode_shows_squared() {
123    let tmp = init_tmp();
124    create_task(&tmp, "Task A", "high", "medium");
125
126    td(&tmp)
127        .args(["next", "--verbose", "--mode", "effort"])
128        .current_dir(&tmp)
129        .assert()
130        .success()
131        .stdout(predicate::str::contains("SCORE"))
132        .stdout(predicate::str::contains("score:"));
133}
134
135#[test]
136fn next_limit_truncates() {
137    let tmp = init_tmp();
138    create_task(&tmp, "A", "high", "low");
139    create_task(&tmp, "B", "medium", "medium");
140    create_task(&tmp, "C", "low", "high");
141
142    let out = td(&tmp)
143        .args(["--json", "next", "-n", "2"])
144        .current_dir(&tmp)
145        .output()
146        .unwrap();
147    let v: serde_json::Value = serde_json::from_slice(&out.stdout).unwrap();
148    assert_eq!(v.as_array().unwrap().len(), 2);
149}
150
151#[test]
152fn next_json_empty() {
153    let tmp = init_tmp();
154
155    let out = td(&tmp)
156        .args(["--json", "next"])
157        .current_dir(&tmp)
158        .output()
159        .unwrap();
160    let v: serde_json::Value = serde_json::from_slice(&out.stdout).unwrap();
161    assert_eq!(v.as_array().unwrap().len(), 0);
162}
163
164#[test]
165fn next_json_priority_and_effort_are_strings() {
166    // Regression: next --json was emitting priority/effort as raw integers
167    // (1/2/3) instead of string labels ("high"/"medium"/"low"). Every other
168    // JSON endpoint uses string labels; next must match.
169    let tmp = init_tmp();
170    create_task(&tmp, "Task", "high", "low");
171
172    let out = td(&tmp)
173        .args(["--json", "next"])
174        .current_dir(&tmp)
175        .output()
176        .unwrap();
177    let v: serde_json::Value = serde_json::from_slice(&out.stdout).unwrap();
178    let results = v.as_array().unwrap();
179
180    assert_eq!(results.len(), 1);
181    assert_eq!(results[0]["priority"].as_str().unwrap(), "high");
182    assert_eq!(results[0]["effort"].as_str().unwrap(), "low");
183}
184
185#[test]
186fn next_invalid_mode_fails() {
187    let tmp = init_tmp();
188    create_task(&tmp, "X", "medium", "medium");
189
190    td(&tmp)
191        .args(["next", "--mode", "bogus"])
192        .current_dir(&tmp)
193        .assert()
194        .failure()
195        .stderr(predicate::str::contains("invalid mode"));
196}
197
198#[test]
199fn next_transitive_chain_scores_correctly() {
200    let tmp = init_tmp();
201    // A blocks B, B blocks C. Only A is ready.
202    let a = create_task(&tmp, "Root", "medium", "low");
203    let b = create_task(&tmp, "Mid", "high", "medium");
204    let c = create_task(&tmp, "Leaf", "low", "high");
205
206    td(&tmp)
207        .args(["dep", "add", &b, &a])
208        .current_dir(&tmp)
209        .assert()
210        .success();
211    td(&tmp)
212        .args(["dep", "add", &c, &b])
213        .current_dir(&tmp)
214        .assert()
215        .success();
216
217    let out = td(&tmp)
218        .args(["--json", "next"])
219        .current_dir(&tmp)
220        .output()
221        .unwrap();
222    let v: serde_json::Value = serde_json::from_slice(&out.stdout).unwrap();
223    let results = v.as_array().unwrap();
224
225    assert_eq!(results.len(), 1);
226    assert_eq!(results[0]["id"].as_str().unwrap(), a);
227    // Downstream: pw(B=high=3) + pw(C=low=1) = 4.0
228    assert!((results[0]["downstream_score"].as_f64().unwrap() - 4.0).abs() < f64::EPSILON);
229    assert_eq!(results[0]["total_unblocked"].as_u64().unwrap(), 2);
230    assert_eq!(results[0]["direct_unblocked"].as_u64().unwrap(), 1);
231}
232
233#[test]
234fn next_ignores_closed_tasks() {
235    let tmp = init_tmp();
236    let a = create_task(&tmp, "Open", "high", "low");
237    let b = create_task(&tmp, "Closed", "high", "low");
238
239    td(&tmp)
240        .args(["done", &b])
241        .current_dir(&tmp)
242        .assert()
243        .success();
244
245    let out = td(&tmp)
246        .args(["--json", "next"])
247        .current_dir(&tmp)
248        .output()
249        .unwrap();
250    let v: serde_json::Value = serde_json::from_slice(&out.stdout).unwrap();
251    let results = v.as_array().unwrap();
252
253    assert_eq!(results.len(), 1);
254    assert_eq!(results[0]["id"].as_str().unwrap(), a);
255}
256
257#[test]
258fn next_excludes_parent_with_open_subtasks() {
259    let tmp = init_tmp();
260    let parent = create_task(&tmp, "Parent task", "high", "low");
261    // Create a subtask under the parent.
262    let out = td(&tmp)
263        .args([
264            "--json",
265            "create",
266            "Child task",
267            "-p",
268            "medium",
269            "-e",
270            "medium",
271            "--parent",
272            &parent,
273        ])
274        .current_dir(&tmp)
275        .output()
276        .unwrap();
277    let child: serde_json::Value = serde_json::from_slice(&out.stdout).unwrap();
278    let child_id = child["id"].as_str().unwrap().to_string();
279
280    let out = td(&tmp)
281        .args(["--json", "next"])
282        .current_dir(&tmp)
283        .output()
284        .unwrap();
285    let v: serde_json::Value = serde_json::from_slice(&out.stdout).unwrap();
286    let results = v.as_array().unwrap();
287    let ids: Vec<&str> = results.iter().map(|r| r["id"].as_str().unwrap()).collect();
288
289    // Parent should be excluded; only the child subtask should appear.
290    assert!(!ids.contains(&parent.as_str()), "parent should be excluded");
291    assert!(
292        ids.contains(&child_id.as_str()),
293        "child should be a candidate"
294    );
295}
296
297#[test]
298fn next_includes_parent_when_all_subtasks_closed() {
299    let tmp = init_tmp();
300    let parent = create_task(&tmp, "Parent task", "high", "low");
301    let out = td(&tmp)
302        .args([
303            "--json",
304            "create",
305            "Child task",
306            "-p",
307            "medium",
308            "-e",
309            "medium",
310            "--parent",
311            &parent,
312        ])
313        .current_dir(&tmp)
314        .output()
315        .unwrap();
316    let child: serde_json::Value = serde_json::from_slice(&out.stdout).unwrap();
317    let child_id = child["id"].as_str().unwrap().to_string();
318
319    // Close the subtask.
320    td(&tmp)
321        .args(["done", &child_id])
322        .current_dir(&tmp)
323        .assert()
324        .success();
325
326    let out = td(&tmp)
327        .args(["--json", "next"])
328        .current_dir(&tmp)
329        .output()
330        .unwrap();
331    let v: serde_json::Value = serde_json::from_slice(&out.stdout).unwrap();
332    let results = v.as_array().unwrap();
333    let ids: Vec<&str> = results.iter().map(|r| r["id"].as_str().unwrap()).collect();
334
335    // Parent should reappear as a candidate once all children are closed.
336    assert!(
337        ids.contains(&parent.as_str()),
338        "parent should be a candidate"
339    );
340}
341
342#[test]
343fn next_nested_parents_excluded_at_each_level() {
344    let tmp = init_tmp();
345    // grandparent → parent → child (nested subtasks)
346    let gp = create_task(&tmp, "Grandparent", "high", "low");
347    let out = td(&tmp)
348        .args([
349            "--json", "create", "Parent", "-p", "medium", "-e", "medium", "--parent", &gp,
350        ])
351        .current_dir(&tmp)
352        .output()
353        .unwrap();
354    let p: serde_json::Value = serde_json::from_slice(&out.stdout).unwrap();
355    let p_id = p["id"].as_str().unwrap().to_string();
356
357    let out = td(&tmp)
358        .args([
359            "--json", "create", "Child", "-p", "low", "-e", "low", "--parent", &p_id,
360        ])
361        .current_dir(&tmp)
362        .output()
363        .unwrap();
364    let c: serde_json::Value = serde_json::from_slice(&out.stdout).unwrap();
365    let c_id = c["id"].as_str().unwrap().to_string();
366
367    let out = td(&tmp)
368        .args(["--json", "next"])
369        .current_dir(&tmp)
370        .output()
371        .unwrap();
372    let v: serde_json::Value = serde_json::from_slice(&out.stdout).unwrap();
373    let results = v.as_array().unwrap();
374    let ids: Vec<&str> = results.iter().map(|r| r["id"].as_str().unwrap()).collect();
375
376    // Both grandparent and parent are excluded; only the leaf child appears.
377    assert!(
378        !ids.contains(&gp.as_str()),
379        "grandparent should be excluded"
380    );
381    assert!(!ids.contains(&p_id.as_str()), "parent should be excluded");
382    assert!(
383        ids.contains(&c_id.as_str()),
384        "leaf child should be a candidate"
385    );
386}