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}