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}