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}