cli_next.rs

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