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}