1use assert_cmd::cargo::cargo_bin_cmd;
2use predicates::prelude::*;
3use tempfile::TempDir;
4
5fn td(home: &TempDir) -> assert_cmd::Command {
6 let mut cmd = cargo_bin_cmd!("td");
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
21/// Create a task and return its JSON id.
22fn create_task(dir: &TempDir, title: &str) -> String {
23 let out = td(dir)
24 .args(["--json", "create", title])
25 .current_dir(dir)
26 .output()
27 .unwrap();
28 let v: serde_json::Value = serde_json::from_slice(&out.stdout).unwrap();
29 v["id"].as_str().unwrap().to_string()
30}
31
32// ── list ─────────────────────────────────────────────────────────────
33
34#[test]
35fn list_shows_created_tasks() {
36 let tmp = init_tmp();
37 create_task(&tmp, "Alpha");
38 create_task(&tmp, "Bravo");
39
40 td(&tmp)
41 .arg("list")
42 .current_dir(&tmp)
43 .assert()
44 .success()
45 .stdout(predicate::str::contains("Alpha"))
46 .stdout(predicate::str::contains("Bravo"));
47}
48
49#[test]
50fn list_json_returns_array() {
51 let tmp = init_tmp();
52 create_task(&tmp, "One");
53
54 let out = td(&tmp)
55 .args(["--json", "list"])
56 .current_dir(&tmp)
57 .output()
58 .unwrap();
59 let v: serde_json::Value = serde_json::from_slice(&out.stdout).unwrap();
60 assert!(v.is_array(), "expected JSON array, got: {v}");
61 assert_eq!(v.as_array().unwrap().len(), 1);
62 assert_eq!(v[0]["title"].as_str().unwrap(), "One");
63}
64
65#[test]
66fn list_filter_by_status() {
67 let tmp = init_tmp();
68 create_task(&tmp, "Open task");
69
70 // No closed tasks yet.
71 let out = td(&tmp)
72 .args(["--json", "list", "-s", "closed"])
73 .current_dir(&tmp)
74 .output()
75 .unwrap();
76 let v: serde_json::Value = serde_json::from_slice(&out.stdout).unwrap();
77 assert_eq!(v.as_array().unwrap().len(), 0);
78}
79
80#[test]
81fn list_hides_closed_by_default() {
82 let tmp = init_tmp();
83 let id = create_task(&tmp, "Will close");
84
85 td(&tmp)
86 .args(["done", &id])
87 .current_dir(&tmp)
88 .assert()
89 .success();
90
91 // Default list should not show the closed task.
92 let out = td(&tmp)
93 .args(["--json", "list"])
94 .current_dir(&tmp)
95 .output()
96 .unwrap();
97 let v: serde_json::Value = serde_json::from_slice(&out.stdout).unwrap();
98 assert_eq!(v.as_array().unwrap().len(), 0);
99
100 // --all should include it.
101 let out = td(&tmp)
102 .args(["--json", "list", "--all"])
103 .current_dir(&tmp)
104 .output()
105 .unwrap();
106 let v: serde_json::Value = serde_json::from_slice(&out.stdout).unwrap();
107 assert_eq!(v.as_array().unwrap().len(), 1);
108 assert_eq!(v[0]["title"].as_str().unwrap(), "Will close");
109}
110
111#[test]
112fn list_filter_by_priority() {
113 let tmp = init_tmp();
114
115 td(&tmp)
116 .args(["create", "Low prio", "-p", "low"])
117 .current_dir(&tmp)
118 .assert()
119 .success();
120 td(&tmp)
121 .args(["create", "High prio", "-p", "high"])
122 .current_dir(&tmp)
123 .assert()
124 .success();
125
126 let out = td(&tmp)
127 .args(["--json", "list", "-p", "high"])
128 .current_dir(&tmp)
129 .output()
130 .unwrap();
131 let v: serde_json::Value = serde_json::from_slice(&out.stdout).unwrap();
132 let tasks = v.as_array().unwrap();
133 assert_eq!(tasks.len(), 1);
134 assert_eq!(tasks[0]["title"].as_str().unwrap(), "High prio");
135}
136
137#[test]
138fn list_filter_by_type() {
139 let tmp = init_tmp();
140
141 td(&tmp)
142 .args(["create", "A task", "--type", "task"])
143 .current_dir(&tmp)
144 .assert()
145 .success();
146 td(&tmp)
147 .args(["create", "A bug", "--type", "bug"])
148 .current_dir(&tmp)
149 .assert()
150 .success();
151
152 let out = td(&tmp)
153 .args(["--json", "list", "--type", "bug"])
154 .current_dir(&tmp)
155 .output()
156 .unwrap();
157 let v: serde_json::Value = serde_json::from_slice(&out.stdout).unwrap();
158 let tasks = v.as_array().unwrap();
159 assert_eq!(tasks.len(), 1);
160 assert_eq!(tasks[0]["title"].as_str().unwrap(), "A bug");
161}
162
163#[test]
164fn list_filter_by_label() {
165 let tmp = init_tmp();
166
167 td(&tmp)
168 .args(["create", "Tagged", "-l", "urgent"])
169 .current_dir(&tmp)
170 .assert()
171 .success();
172 td(&tmp)
173 .args(["create", "Untagged"])
174 .current_dir(&tmp)
175 .assert()
176 .success();
177
178 let out = td(&tmp)
179 .args(["--json", "list", "-l", "urgent"])
180 .current_dir(&tmp)
181 .output()
182 .unwrap();
183 let v: serde_json::Value = serde_json::from_slice(&out.stdout).unwrap();
184 let tasks = v.as_array().unwrap();
185 assert_eq!(tasks.len(), 1);
186 assert_eq!(tasks[0]["title"].as_str().unwrap(), "Tagged");
187}
188
189#[test]
190fn list_filter_by_effort() {
191 let tmp = init_tmp();
192
193 td(&tmp)
194 .args(["create", "Easy", "-e", "low"])
195 .current_dir(&tmp)
196 .assert()
197 .success();
198 td(&tmp)
199 .args(["create", "Hard", "-e", "high"])
200 .current_dir(&tmp)
201 .assert()
202 .success();
203
204 let out = td(&tmp)
205 .args(["--json", "list", "-e", "low"])
206 .current_dir(&tmp)
207 .output()
208 .unwrap();
209 let v: serde_json::Value = serde_json::from_slice(&out.stdout).unwrap();
210 let tasks = v.as_array().unwrap();
211 assert_eq!(tasks.len(), 1);
212 assert_eq!(tasks[0]["title"].as_str().unwrap(), "Easy");
213}
214
215// ── show ─────────────────────────────────────────────────────────────
216
217#[test]
218fn show_displays_task() {
219 let tmp = init_tmp();
220 let id = create_task(&tmp, "Details here");
221
222 td(&tmp)
223 .args(["show", &id])
224 .current_dir(&tmp)
225 .assert()
226 .success()
227 .stdout(predicate::str::contains("Details here"))
228 .stdout(predicate::str::contains(&id[id.len() - 7..]));
229}
230
231#[test]
232fn show_json_includes_labels_and_blockers() {
233 let tmp = init_tmp();
234
235 td(&tmp)
236 .args(["create", "With labels", "-l", "bug,ui"])
237 .current_dir(&tmp)
238 .assert()
239 .success();
240
241 // Get the id via list.
242 let out = td(&tmp)
243 .args(["--json", "list"])
244 .current_dir(&tmp)
245 .output()
246 .unwrap();
247 let list: serde_json::Value = serde_json::from_slice(&out.stdout).unwrap();
248 let id = list[0]["id"].as_str().unwrap();
249
250 let out = td(&tmp)
251 .args(["--json", "show", id])
252 .current_dir(&tmp)
253 .output()
254 .unwrap();
255 let v: serde_json::Value = serde_json::from_slice(&out.stdout).unwrap();
256 assert_eq!(v["title"].as_str().unwrap(), "With labels");
257
258 let labels = v["labels"].as_array().unwrap();
259 assert!(labels.contains(&serde_json::Value::String("bug".into())));
260 assert!(labels.contains(&serde_json::Value::String("ui".into())));
261
262 // Blockers should be present (even if empty).
263 assert!(v["blockers"].is_array());
264}
265
266#[test]
267fn show_nonexistent_task_fails() {
268 let tmp = init_tmp();
269
270 td(&tmp)
271 .args(["show", "td-nope"])
272 .current_dir(&tmp)
273 .assert()
274 .failure()
275 .stderr(predicate::str::contains("not found"));
276}
277
278#[test]
279fn show_annotates_closed_blockers() {
280 let tmp = init_tmp();
281 let task = create_task(&tmp, "Blocked task");
282 let open_blocker = create_task(&tmp, "Still open");
283 let closed_blocker = create_task(&tmp, "Will close");
284
285 td(&tmp)
286 .args(["dep", "add", &task, &open_blocker])
287 .current_dir(&tmp)
288 .assert()
289 .success();
290 td(&tmp)
291 .args(["dep", "add", &task, &closed_blocker])
292 .current_dir(&tmp)
293 .assert()
294 .success();
295 td(&tmp)
296 .args(["done", &closed_blocker])
297 .current_dir(&tmp)
298 .assert()
299 .success();
300
301 // Plural label, open blocker bare, closed one annotated.
302 td(&tmp)
303 .args(["show", &task])
304 .current_dir(&tmp)
305 .assert()
306 .success()
307 .stdout(predicate::str::contains("blockers"))
308 .stdout(predicate::str::contains(
309 &open_blocker[open_blocker.len() - 7..],
310 ))
311 .stdout(predicate::str::contains(&format!(
312 "{} [closed]",
313 &closed_blocker[closed_blocker.len() - 7..]
314 )));
315}
316
317#[test]
318fn show_all_closed_blockers_prefixed() {
319 let tmp = init_tmp();
320 let task = create_task(&tmp, "Was blocked");
321 let blocker = create_task(&tmp, "Done now");
322
323 td(&tmp)
324 .args(["dep", "add", &task, &blocker])
325 .current_dir(&tmp)
326 .assert()
327 .success();
328 td(&tmp)
329 .args(["done", &blocker])
330 .current_dir(&tmp)
331 .assert()
332 .success();
333
334 // Singular label, [all closed] prefix, no redundant [closed] on IDs.
335 let out = td(&tmp)
336 .args(["show", &task])
337 .current_dir(&tmp)
338 .output()
339 .unwrap();
340 let stdout = String::from_utf8_lossy(&out.stdout);
341 assert!(stdout.contains("blocker"));
342 assert!(stdout.contains("[all closed]"));
343 assert!(stdout.contains(&blocker[blocker.len() - 7..]));
344 // When all are closed, individual IDs should NOT have [closed] appended.
345 assert!(!stdout.contains("[closed]"));
346}
347
348#[test]
349fn show_single_open_blocker_singular_label() {
350 let tmp = init_tmp();
351 let task = create_task(&tmp, "Blocked");
352 let blocker = create_task(&tmp, "Blocking");
353
354 td(&tmp)
355 .args(["dep", "add", &task, &blocker])
356 .current_dir(&tmp)
357 .assert()
358 .success();
359
360 let out = td(&tmp)
361 .args(["show", &task])
362 .current_dir(&tmp)
363 .output()
364 .unwrap();
365 let stdout = String::from_utf8_lossy(&out.stdout);
366
367 // Singular "blocker", no "blockers".
368 assert!(stdout.contains("blocker"));
369 assert!(stdout.contains(&blocker[blocker.len() - 7..]));
370 // Should not contain [closed] or [all closed].
371 assert!(!stdout.contains("[closed]"));
372}