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_invalid_mode_fails() {
166    let tmp = init_tmp();
167    create_task(&tmp, "X", "medium", "medium");
168
169    td(&tmp)
170        .args(["next", "--mode", "bogus"])
171        .current_dir(&tmp)
172        .assert()
173        .failure()
174        .stderr(predicate::str::contains("invalid mode"));
175}
176
177#[test]
178fn next_transitive_chain_scores_correctly() {
179    let tmp = init_tmp();
180    // A blocks B, B blocks C. Only A is ready.
181    let a = create_task(&tmp, "Root", "medium", "low");
182    let b = create_task(&tmp, "Mid", "high", "medium");
183    let c = create_task(&tmp, "Leaf", "low", "high");
184
185    td(&tmp)
186        .args(["dep", "add", &b, &a])
187        .current_dir(&tmp)
188        .assert()
189        .success();
190    td(&tmp)
191        .args(["dep", "add", &c, &b])
192        .current_dir(&tmp)
193        .assert()
194        .success();
195
196    let out = td(&tmp)
197        .args(["--json", "next"])
198        .current_dir(&tmp)
199        .output()
200        .unwrap();
201    let v: serde_json::Value = serde_json::from_slice(&out.stdout).unwrap();
202    let results = v.as_array().unwrap();
203
204    assert_eq!(results.len(), 1);
205    assert_eq!(results[0]["id"].as_str().unwrap(), a);
206    // Downstream: pw(B=high=3) + pw(C=low=1) = 4.0
207    assert!((results[0]["downstream_score"].as_f64().unwrap() - 4.0).abs() < f64::EPSILON);
208    assert_eq!(results[0]["total_unblocked"].as_u64().unwrap(), 2);
209    assert_eq!(results[0]["direct_unblocked"].as_u64().unwrap(), 1);
210}
211
212#[test]
213fn next_ignores_closed_tasks() {
214    let tmp = init_tmp();
215    let a = create_task(&tmp, "Open", "high", "low");
216    let b = create_task(&tmp, "Closed", "high", "low");
217
218    td(&tmp)
219        .args(["done", &b])
220        .current_dir(&tmp)
221        .assert()
222        .success();
223
224    let out = td(&tmp)
225        .args(["--json", "next"])
226        .current_dir(&tmp)
227        .output()
228        .unwrap();
229    let v: serde_json::Value = serde_json::from_slice(&out.stdout).unwrap();
230    let results = v.as_array().unwrap();
231
232    assert_eq!(results.len(), 1);
233    assert_eq!(results[0]["id"].as_str().unwrap(), a);
234}
235
236#[test]
237fn next_excludes_parent_with_open_subtasks() {
238    let tmp = init_tmp();
239    let parent = create_task(&tmp, "Parent task", "high", "low");
240    // Create a subtask under the parent.
241    let out = td(&tmp)
242        .args([
243            "--json",
244            "create",
245            "Child task",
246            "-p",
247            "medium",
248            "-e",
249            "medium",
250            "--parent",
251            &parent,
252        ])
253        .current_dir(&tmp)
254        .output()
255        .unwrap();
256    let child: serde_json::Value = serde_json::from_slice(&out.stdout).unwrap();
257    let child_id = child["id"].as_str().unwrap().to_string();
258
259    let out = td(&tmp)
260        .args(["--json", "next"])
261        .current_dir(&tmp)
262        .output()
263        .unwrap();
264    let v: serde_json::Value = serde_json::from_slice(&out.stdout).unwrap();
265    let results = v.as_array().unwrap();
266    let ids: Vec<&str> = results.iter().map(|r| r["id"].as_str().unwrap()).collect();
267
268    // Parent should be excluded; only the child subtask should appear.
269    assert!(!ids.contains(&parent.as_str()), "parent should be excluded");
270    assert!(
271        ids.contains(&child_id.as_str()),
272        "child should be a candidate"
273    );
274}
275
276#[test]
277fn next_includes_parent_when_all_subtasks_closed() {
278    let tmp = init_tmp();
279    let parent = create_task(&tmp, "Parent task", "high", "low");
280    let out = td(&tmp)
281        .args([
282            "--json",
283            "create",
284            "Child task",
285            "-p",
286            "medium",
287            "-e",
288            "medium",
289            "--parent",
290            &parent,
291        ])
292        .current_dir(&tmp)
293        .output()
294        .unwrap();
295    let child: serde_json::Value = serde_json::from_slice(&out.stdout).unwrap();
296    let child_id = child["id"].as_str().unwrap().to_string();
297
298    // Close the subtask.
299    td(&tmp)
300        .args(["done", &child_id])
301        .current_dir(&tmp)
302        .assert()
303        .success();
304
305    let out = td(&tmp)
306        .args(["--json", "next"])
307        .current_dir(&tmp)
308        .output()
309        .unwrap();
310    let v: serde_json::Value = serde_json::from_slice(&out.stdout).unwrap();
311    let results = v.as_array().unwrap();
312    let ids: Vec<&str> = results.iter().map(|r| r["id"].as_str().unwrap()).collect();
313
314    // Parent should reappear as a candidate once all children are closed.
315    assert!(
316        ids.contains(&parent.as_str()),
317        "parent should be a candidate"
318    );
319}
320
321#[test]
322fn next_nested_parents_excluded_at_each_level() {
323    let tmp = init_tmp();
324    // grandparent → parent → child (nested subtasks)
325    let gp = create_task(&tmp, "Grandparent", "high", "low");
326    let out = td(&tmp)
327        .args([
328            "--json", "create", "Parent", "-p", "medium", "-e", "medium", "--parent", &gp,
329        ])
330        .current_dir(&tmp)
331        .output()
332        .unwrap();
333    let p: serde_json::Value = serde_json::from_slice(&out.stdout).unwrap();
334    let p_id = p["id"].as_str().unwrap().to_string();
335
336    let out = td(&tmp)
337        .args([
338            "--json", "create", "Child", "-p", "low", "-e", "low", "--parent", &p_id,
339        ])
340        .current_dir(&tmp)
341        .output()
342        .unwrap();
343    let c: serde_json::Value = serde_json::from_slice(&out.stdout).unwrap();
344    let c_id = c["id"].as_str().unwrap().to_string();
345
346    let out = td(&tmp)
347        .args(["--json", "next"])
348        .current_dir(&tmp)
349        .output()
350        .unwrap();
351    let v: serde_json::Value = serde_json::from_slice(&out.stdout).unwrap();
352    let results = v.as_array().unwrap();
353    let ids: Vec<&str> = results.iter().map(|r| r["id"].as_str().unwrap()).collect();
354
355    // Both grandparent and parent are excluded; only the leaf child appears.
356    assert!(
357        !ids.contains(&gp.as_str()),
358        "grandparent should be excluded"
359    );
360    assert!(!ids.contains(&p_id.as_str()), "parent should be excluded");
361    assert!(
362        ids.contains(&c_id.as_str()),
363        "leaf child should be a candidate"
364    );
365}