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}