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    td(&tmp)
 49        .arg("next")
 50        .current_dir(&tmp)
 51        .assert()
 52        .success()
 53        .stdout(predicate::str::contains(&id))
 54        .stdout(predicate::str::contains("Only task"))
 55        .stdout(predicate::str::contains("SCORE"));
 56}
 57
 58#[test]
 59fn next_impact_ranks_by_downstream() {
 60    let tmp = init_tmp();
 61    // A blocks B. Same priority/effort. A should rank higher (unblocks B).
 62    let a = create_task(&tmp, "Blocker", "medium", "medium");
 63    let b = create_task(&tmp, "Blocked", "medium", "medium");
 64
 65    td(&tmp)
 66        .args(["dep", "add", &b, &a])
 67        .current_dir(&tmp)
 68        .assert()
 69        .success();
 70
 71    let out = td(&tmp)
 72        .args(["--json", "next"])
 73        .current_dir(&tmp)
 74        .output()
 75        .unwrap();
 76    let v: serde_json::Value = serde_json::from_slice(&out.stdout).unwrap();
 77    let results = v.as_array().unwrap();
 78
 79    // Only A is ready (B is blocked).
 80    assert_eq!(results.len(), 1);
 81    assert_eq!(results[0]["id"].as_str().unwrap(), a);
 82    assert!(results[0]["total_unblocked"].as_u64().unwrap() > 0);
 83}
 84
 85#[test]
 86fn next_effort_mode_prefers_low_effort() {
 87    let tmp = init_tmp();
 88    // Both standalone. A is high-effort, B is low-effort. Same priority.
 89    let a = create_task(&tmp, "Heavy", "medium", "high");
 90    let b = create_task(&tmp, "Light", "medium", "low");
 91
 92    let out = td(&tmp)
 93        .args(["--json", "next", "--mode", "effort"])
 94        .current_dir(&tmp)
 95        .output()
 96        .unwrap();
 97    let v: serde_json::Value = serde_json::from_slice(&out.stdout).unwrap();
 98    let results = v.as_array().unwrap();
 99
100    assert_eq!(results.len(), 2);
101    // B (low effort) should be ranked first.
102    assert_eq!(results[0]["id"].as_str().unwrap(), b);
103    assert_eq!(results[1]["id"].as_str().unwrap(), a);
104}
105
106#[test]
107fn next_verbose_shows_equation() {
108    let tmp = init_tmp();
109    create_task(&tmp, "Task A", "high", "low");
110
111    td(&tmp)
112        .args(["next", "--verbose"])
113        .current_dir(&tmp)
114        .assert()
115        .success()
116        .stdout(predicate::str::contains("SCORE"))
117        .stdout(predicate::str::contains("score:"));
118}
119
120#[test]
121fn next_verbose_effort_mode_shows_squared() {
122    let tmp = init_tmp();
123    create_task(&tmp, "Task A", "high", "medium");
124
125    td(&tmp)
126        .args(["next", "--verbose", "--mode", "effort"])
127        .current_dir(&tmp)
128        .assert()
129        .success()
130        .stdout(predicate::str::contains("SCORE"))
131        .stdout(predicate::str::contains("score:"));
132}
133
134#[test]
135fn next_limit_truncates() {
136    let tmp = init_tmp();
137    create_task(&tmp, "A", "high", "low");
138    create_task(&tmp, "B", "medium", "medium");
139    create_task(&tmp, "C", "low", "high");
140
141    let out = td(&tmp)
142        .args(["--json", "next", "-n", "2"])
143        .current_dir(&tmp)
144        .output()
145        .unwrap();
146    let v: serde_json::Value = serde_json::from_slice(&out.stdout).unwrap();
147    assert_eq!(v.as_array().unwrap().len(), 2);
148}
149
150#[test]
151fn next_json_empty() {
152    let tmp = init_tmp();
153
154    let out = td(&tmp)
155        .args(["--json", "next"])
156        .current_dir(&tmp)
157        .output()
158        .unwrap();
159    let v: serde_json::Value = serde_json::from_slice(&out.stdout).unwrap();
160    assert_eq!(v.as_array().unwrap().len(), 0);
161}
162
163#[test]
164fn next_invalid_mode_fails() {
165    let tmp = init_tmp();
166    create_task(&tmp, "X", "medium", "medium");
167
168    td(&tmp)
169        .args(["next", "--mode", "bogus"])
170        .current_dir(&tmp)
171        .assert()
172        .failure()
173        .stderr(predicate::str::contains("invalid mode"));
174}
175
176#[test]
177fn next_transitive_chain_scores_correctly() {
178    let tmp = init_tmp();
179    // A blocks B, B blocks C. Only A is ready.
180    let a = create_task(&tmp, "Root", "medium", "low");
181    let b = create_task(&tmp, "Mid", "high", "medium");
182    let c = create_task(&tmp, "Leaf", "low", "high");
183
184    td(&tmp)
185        .args(["dep", "add", &b, &a])
186        .current_dir(&tmp)
187        .assert()
188        .success();
189    td(&tmp)
190        .args(["dep", "add", &c, &b])
191        .current_dir(&tmp)
192        .assert()
193        .success();
194
195    let out = td(&tmp)
196        .args(["--json", "next"])
197        .current_dir(&tmp)
198        .output()
199        .unwrap();
200    let v: serde_json::Value = serde_json::from_slice(&out.stdout).unwrap();
201    let results = v.as_array().unwrap();
202
203    assert_eq!(results.len(), 1);
204    assert_eq!(results[0]["id"].as_str().unwrap(), a);
205    // Downstream: pw(B=high=3) + pw(C=low=1) = 4.0
206    assert!((results[0]["downstream_score"].as_f64().unwrap() - 4.0).abs() < f64::EPSILON);
207    assert_eq!(results[0]["total_unblocked"].as_u64().unwrap(), 2);
208    assert_eq!(results[0]["direct_unblocked"].as_u64().unwrap(), 1);
209}
210
211#[test]
212fn next_ignores_closed_tasks() {
213    let tmp = init_tmp();
214    let a = create_task(&tmp, "Open", "high", "low");
215    let b = create_task(&tmp, "Closed", "high", "low");
216
217    td(&tmp)
218        .args(["done", &b])
219        .current_dir(&tmp)
220        .assert()
221        .success();
222
223    let out = td(&tmp)
224        .args(["--json", "next"])
225        .current_dir(&tmp)
226        .output()
227        .unwrap();
228    let v: serde_json::Value = serde_json::from_slice(&out.stdout).unwrap();
229    let results = v.as_array().unwrap();
230
231    assert_eq!(results.len(), 1);
232    assert_eq!(results[0]["id"].as_str().unwrap(), a);
233}
234
235#[test]
236fn next_excludes_parent_with_open_subtasks() {
237    let tmp = init_tmp();
238    let parent = create_task(&tmp, "Parent task", "high", "low");
239    // Create a subtask under the parent.
240    let out = td(&tmp)
241        .args([
242            "--json",
243            "create",
244            "Child task",
245            "-p",
246            "medium",
247            "-e",
248            "medium",
249            "--parent",
250            &parent,
251        ])
252        .current_dir(&tmp)
253        .output()
254        .unwrap();
255    let child: serde_json::Value = serde_json::from_slice(&out.stdout).unwrap();
256    let child_id = child["id"].as_str().unwrap().to_string();
257
258    let out = td(&tmp)
259        .args(["--json", "next"])
260        .current_dir(&tmp)
261        .output()
262        .unwrap();
263    let v: serde_json::Value = serde_json::from_slice(&out.stdout).unwrap();
264    let results = v.as_array().unwrap();
265    let ids: Vec<&str> = results.iter().map(|r| r["id"].as_str().unwrap()).collect();
266
267    // Parent should be excluded; only the child subtask should appear.
268    assert!(!ids.contains(&parent.as_str()), "parent should be excluded");
269    assert!(
270        ids.contains(&child_id.as_str()),
271        "child should be a candidate"
272    );
273}
274
275#[test]
276fn next_includes_parent_when_all_subtasks_closed() {
277    let tmp = init_tmp();
278    let parent = create_task(&tmp, "Parent task", "high", "low");
279    let out = td(&tmp)
280        .args([
281            "--json",
282            "create",
283            "Child task",
284            "-p",
285            "medium",
286            "-e",
287            "medium",
288            "--parent",
289            &parent,
290        ])
291        .current_dir(&tmp)
292        .output()
293        .unwrap();
294    let child: serde_json::Value = serde_json::from_slice(&out.stdout).unwrap();
295    let child_id = child["id"].as_str().unwrap().to_string();
296
297    // Close the subtask.
298    td(&tmp)
299        .args(["done", &child_id])
300        .current_dir(&tmp)
301        .assert()
302        .success();
303
304    let out = td(&tmp)
305        .args(["--json", "next"])
306        .current_dir(&tmp)
307        .output()
308        .unwrap();
309    let v: serde_json::Value = serde_json::from_slice(&out.stdout).unwrap();
310    let results = v.as_array().unwrap();
311    let ids: Vec<&str> = results.iter().map(|r| r["id"].as_str().unwrap()).collect();
312
313    // Parent should reappear as a candidate once all children are closed.
314    assert!(
315        ids.contains(&parent.as_str()),
316        "parent should be a candidate"
317    );
318}
319
320#[test]
321fn next_nested_parents_excluded_at_each_level() {
322    let tmp = init_tmp();
323    // grandparent → parent → child (nested subtasks)
324    let gp = create_task(&tmp, "Grandparent", "high", "low");
325    let out = td(&tmp)
326        .args([
327            "--json", "create", "Parent", "-p", "medium", "-e", "medium", "--parent", &gp,
328        ])
329        .current_dir(&tmp)
330        .output()
331        .unwrap();
332    let p: serde_json::Value = serde_json::from_slice(&out.stdout).unwrap();
333    let p_id = p["id"].as_str().unwrap().to_string();
334
335    let out = td(&tmp)
336        .args([
337            "--json", "create", "Child", "-p", "low", "-e", "low", "--parent", &p_id,
338        ])
339        .current_dir(&tmp)
340        .output()
341        .unwrap();
342    let c: serde_json::Value = serde_json::from_slice(&out.stdout).unwrap();
343    let c_id = c["id"].as_str().unwrap().to_string();
344
345    let out = td(&tmp)
346        .args(["--json", "next"])
347        .current_dir(&tmp)
348        .output()
349        .unwrap();
350    let v: serde_json::Value = serde_json::from_slice(&out.stdout).unwrap();
351    let results = v.as_array().unwrap();
352    let ids: Vec<&str> = results.iter().map(|r| r["id"].as_str().unwrap()).collect();
353
354    // Both grandparent and parent are excluded; only the leaf child appears.
355    assert!(
356        !ids.contains(&gp.as_str()),
357        "grandparent should be excluded"
358    );
359    assert!(!ids.contains(&p_id.as_str()), "parent should be excluded");
360    assert!(
361        ids.contains(&c_id.as_str()),
362        "leaf child should be a candidate"
363    );
364}