1//! Tests that all user-visible task ID output uses the short `td-XXXXXXX` form.
2//!
3//! IDs emitted in JSON, human output, and cross-referencing fields (parent,
4//! blockers, log entry IDs) must all consistently use the short form. The one
5//! exception is `export`, which must emit full ULIDs so that `import` can
6//! round-trip data losslessly.
7
8use assert_cmd::cargo::cargo_bin_cmd;
9use tempfile::TempDir;
10
11fn td(home: &TempDir) -> assert_cmd::Command {
12 let mut cmd = cargo_bin_cmd!("td");
13 cmd.env("HOME", home.path());
14 cmd
15}
16
17fn init_tmp() -> TempDir {
18 let tmp = TempDir::new().unwrap();
19 td(&tmp)
20 .args(["project", "init", "main"])
21 .current_dir(&tmp)
22 .assert()
23 .success();
24 tmp
25}
26
27fn create_task(dir: &TempDir, title: &str) -> String {
28 let out = td(dir)
29 .args(["--json", "create", title])
30 .current_dir(dir)
31 .output()
32 .unwrap();
33 let v: serde_json::Value = serde_json::from_slice(&out.stdout).unwrap();
34 v["id"].as_str().unwrap().to_string()
35}
36
37/// Assert a string matches the `td-XXXXXXX` pattern (3-char prefix + 7 uppercase alphanumeric).
38fn assert_short_id(id: &str) {
39 assert!(
40 id.starts_with("td-") && id.len() == 10,
41 "expected short ID like td-XXXXXXX, got: {id}"
42 );
43}
44
45// ── create ───────────────────────────────────────────────────────────
46
47#[test]
48fn create_json_emits_short_id() {
49 let tmp = init_tmp();
50 let id = create_task(&tmp, "Test task");
51 assert_short_id(&id);
52}
53
54#[test]
55fn create_json_parent_is_short_id() {
56 let tmp = init_tmp();
57 let parent = create_task(&tmp, "Parent");
58
59 let out = td(&tmp)
60 .args(["--json", "create", "Child", "--parent", &parent])
61 .current_dir(&tmp)
62 .output()
63 .unwrap();
64 let v: serde_json::Value = serde_json::from_slice(&out.stdout).unwrap();
65 let parent_field = v["parent"].as_str().unwrap();
66 assert_short_id(parent_field);
67}
68
69// ── show ─────────────────────────────────────────────────────────────
70
71#[test]
72fn show_json_emits_short_ids() {
73 let tmp = init_tmp();
74 let id = create_task(&tmp, "Show me");
75
76 let out = td(&tmp)
77 .args(["--json", "show", &id])
78 .current_dir(&tmp)
79 .output()
80 .unwrap();
81 let v: serde_json::Value = serde_json::from_slice(&out.stdout).unwrap();
82 assert_short_id(v["id"].as_str().unwrap());
83}
84
85#[test]
86fn show_json_blockers_are_short_ids() {
87 let tmp = init_tmp();
88 let a = create_task(&tmp, "Blocked");
89 let b = create_task(&tmp, "Blocker");
90
91 td(&tmp)
92 .args(["dep", "add", &a, &b])
93 .current_dir(&tmp)
94 .assert()
95 .success();
96
97 let out = td(&tmp)
98 .args(["--json", "show", &a])
99 .current_dir(&tmp)
100 .output()
101 .unwrap();
102 let v: serde_json::Value = serde_json::from_slice(&out.stdout).unwrap();
103 for blocker in v["blockers"].as_array().unwrap() {
104 assert_short_id(blocker.as_str().unwrap());
105 }
106}
107
108// ── list ─────────────────────────────────────────────────────────────
109
110#[test]
111fn list_json_emits_short_ids() {
112 let tmp = init_tmp();
113 create_task(&tmp, "Listed");
114
115 let out = td(&tmp)
116 .args(["--json", "list"])
117 .current_dir(&tmp)
118 .output()
119 .unwrap();
120 let v: serde_json::Value = serde_json::from_slice(&out.stdout).unwrap();
121 for task in v.as_array().unwrap() {
122 assert_short_id(task["id"].as_str().unwrap());
123 }
124}
125
126// ── done ─────────────────────────────────────────────────────────────
127
128#[test]
129fn done_json_emits_short_id() {
130 let tmp = init_tmp();
131 let id = create_task(&tmp, "Close me");
132
133 let out = td(&tmp)
134 .args(["--json", "done", &id])
135 .current_dir(&tmp)
136 .output()
137 .unwrap();
138 let v: serde_json::Value = serde_json::from_slice(&out.stdout).unwrap();
139 assert_short_id(v[0]["id"].as_str().unwrap());
140}
141
142#[test]
143fn done_human_emits_short_id() {
144 let tmp = init_tmp();
145 let id = create_task(&tmp, "Close me too");
146
147 let out = td(&tmp)
148 .args(["done", &id])
149 .current_dir(&tmp)
150 .output()
151 .unwrap();
152 let stdout = String::from_utf8(out.stdout).unwrap();
153 // The human output should contain the short ID, not a 26-char ULID.
154 assert!(
155 stdout.contains(&id),
156 "human output should contain short ID {id}, got: {stdout}"
157 );
158}
159
160// ── reopen ───────────────────────────────────────────────────────────
161
162#[test]
163fn reopen_json_emits_short_id() {
164 let tmp = init_tmp();
165 let id = create_task(&tmp, "Reopen me");
166
167 td(&tmp)
168 .args(["done", &id])
169 .current_dir(&tmp)
170 .assert()
171 .success();
172
173 let out = td(&tmp)
174 .args(["--json", "reopen", &id])
175 .current_dir(&tmp)
176 .output()
177 .unwrap();
178 let v: serde_json::Value = serde_json::from_slice(&out.stdout).unwrap();
179 assert_short_id(v[0]["id"].as_str().unwrap());
180}
181
182#[test]
183fn reopen_human_emits_short_id() {
184 let tmp = init_tmp();
185 let id = create_task(&tmp, "Reopen me too");
186
187 td(&tmp)
188 .args(["done", &id])
189 .current_dir(&tmp)
190 .assert()
191 .success();
192
193 let out = td(&tmp)
194 .args(["reopen", &id])
195 .current_dir(&tmp)
196 .output()
197 .unwrap();
198 let stdout = String::from_utf8(out.stdout).unwrap();
199 assert!(
200 stdout.contains(&id),
201 "human output should contain short ID {id}, got: {stdout}"
202 );
203}
204
205// ── dep ──────────────────────────────────────────────────────────────
206
207#[test]
208fn dep_add_json_emits_short_ids() {
209 let tmp = init_tmp();
210 let a = create_task(&tmp, "Child");
211 let b = create_task(&tmp, "Parent");
212
213 let out = td(&tmp)
214 .args(["--json", "dep", "add", &a, &b])
215 .current_dir(&tmp)
216 .output()
217 .unwrap();
218 let v: serde_json::Value = serde_json::from_slice(&out.stdout).unwrap();
219 assert_short_id(v["child"].as_str().unwrap());
220 assert_short_id(v["blocker"].as_str().unwrap());
221}
222
223// ── label ────────────────────────────────────────────────────────────
224
225#[test]
226fn label_add_json_emits_short_id() {
227 let tmp = init_tmp();
228 let id = create_task(&tmp, "Label me");
229
230 let out = td(&tmp)
231 .args(["--json", "label", "add", &id, "urgent"])
232 .current_dir(&tmp)
233 .output()
234 .unwrap();
235 let v: serde_json::Value = serde_json::from_slice(&out.stdout).unwrap();
236 assert_short_id(v["id"].as_str().unwrap());
237}
238
239// ── rm ───────────────────────────────────────────────────────────────
240
241#[test]
242fn rm_json_emits_short_ids() {
243 let tmp = init_tmp();
244 let id = create_task(&tmp, "Delete me");
245
246 let out = td(&tmp)
247 .args(["--json", "rm", &id, "--force"])
248 .current_dir(&tmp)
249 .output()
250 .unwrap();
251 let v: serde_json::Value = serde_json::from_slice(&out.stdout).unwrap();
252 for deleted in v["deleted_ids"].as_array().unwrap() {
253 assert_short_id(deleted.as_str().unwrap());
254 }
255}
256
257// ── log ──────────────────────────────────────────────────────────────
258
259#[test]
260fn log_json_entry_id_is_short() {
261 let tmp = init_tmp();
262 let id = create_task(&tmp, "Log target");
263
264 let out = td(&tmp)
265 .args(["--json", "log", &id, "a note"])
266 .current_dir(&tmp)
267 .output()
268 .unwrap();
269 let v: serde_json::Value = serde_json::from_slice(&out.stdout).unwrap();
270 assert_short_id(v["id"].as_str().unwrap());
271}
272
273// ── next ─────────────────────────────────────────────────────────────
274
275#[test]
276fn next_json_emits_short_ids() {
277 let tmp = init_tmp();
278 create_task(&tmp, "A task");
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 for entry in v.as_array().unwrap() {
287 assert_short_id(entry["id"].as_str().unwrap());
288 }
289}
290
291// ── search ───────────────────────────────────────────────────────────
292
293#[test]
294fn search_json_emits_short_ids() {
295 let tmp = init_tmp();
296 create_task(&tmp, "Searchable task");
297
298 let out = td(&tmp)
299 .args(["--json", "search", "Searchable"])
300 .current_dir(&tmp)
301 .output()
302 .unwrap();
303 let v: serde_json::Value = serde_json::from_slice(&out.stdout).unwrap();
304 for task in v.as_array().unwrap() {
305 assert_short_id(task["id"].as_str().unwrap());
306 }
307}
308
309// ── ready ────────────────────────────────────────────────────────────
310
311#[test]
312fn ready_json_emits_short_ids() {
313 let tmp = init_tmp();
314 create_task(&tmp, "Ready task");
315
316 let out = td(&tmp)
317 .args(["--json", "ready"])
318 .current_dir(&tmp)
319 .output()
320 .unwrap();
321 let v: serde_json::Value = serde_json::from_slice(&out.stdout).unwrap();
322 for task in v.as_array().unwrap() {
323 assert_short_id(task["id"].as_str().unwrap());
324 }
325}
326
327// ── update ───────────────────────────────────────────────────────────
328
329#[test]
330fn update_json_emits_short_id() {
331 let tmp = init_tmp();
332 let id = create_task(&tmp, "Update me");
333
334 let out = td(&tmp)
335 .args(["--json", "update", &id, "-t", "Updated"])
336 .current_dir(&tmp)
337 .output()
338 .unwrap();
339 let v: serde_json::Value = serde_json::from_slice(&out.stdout).unwrap();
340 assert_short_id(v["id"].as_str().unwrap());
341}
342
343// ── export/import round-trip ─────────────────────────────────────────
344
345#[test]
346fn export_emits_full_ulids_for_import() {
347 let tmp = init_tmp();
348 create_task(&tmp, "Exportable");
349
350 let out = td(&tmp).arg("export").current_dir(&tmp).output().unwrap();
351 let stdout = String::from_utf8(out.stdout).unwrap();
352 let mut checked = false;
353 for line in stdout.lines() {
354 let v: serde_json::Value = serde_json::from_str(line).unwrap();
355 let id = v["id"].as_str().unwrap();
356 // Export IDs must be full 26-char ULIDs, not the short form.
357 assert!(
358 !id.starts_with("td-") && id.len() == 26,
359 "export should emit full ULID, got: {id}"
360 );
361 checked = true;
362 }
363 assert!(checked, "export produced no output to check");
364}
365
366#[test]
367fn export_import_round_trip_preserves_ids() {
368 let tmp = init_tmp();
369 create_task(&tmp, "Round trip");
370
371 // Export from original.
372 let export_out = td(&tmp).arg("export").current_dir(&tmp).output().unwrap();
373 let exported = String::from_utf8(export_out.stdout).unwrap();
374 let export_file = tmp.path().join("rt.jsonl");
375 std::fs::write(&export_file, &exported).unwrap();
376
377 // Import into fresh project.
378 let tmp2 = TempDir::new().unwrap();
379 td(&tmp2)
380 .args(["project", "init", "mirror"])
381 .current_dir(&tmp2)
382 .assert()
383 .success();
384 td(&tmp2)
385 .args(["import", export_file.to_str().unwrap()])
386 .current_dir(&tmp2)
387 .assert()
388 .success();
389
390 // The JSON list output in the new project should have valid short IDs.
391 let out = td(&tmp2)
392 .args(["--json", "list"])
393 .current_dir(&tmp2)
394 .output()
395 .unwrap();
396 let v: serde_json::Value = serde_json::from_slice(&out.stdout).unwrap();
397 assert_short_id(v[0]["id"].as_str().unwrap());
398}
399
400// ── cross-command consistency ────────────────────────────────────────
401
402#[test]
403fn json_ids_are_usable_as_input_across_commands() {
404 let tmp = init_tmp();
405 let id = create_task(&tmp, "Cross-command");
406 assert_short_id(&id);
407
408 // The short ID from create --json should work as input to show.
409 let out = td(&tmp)
410 .args(["--json", "show", &id])
411 .current_dir(&tmp)
412 .output()
413 .unwrap();
414 let v: serde_json::Value = serde_json::from_slice(&out.stdout).unwrap();
415 assert_eq!(v["id"].as_str().unwrap(), &id);
416
417 // And to done.
418 td(&tmp)
419 .args(["done", &id])
420 .current_dir(&tmp)
421 .assert()
422 .success();
423
424 // And to reopen.
425 td(&tmp)
426 .args(["reopen", &id])
427 .current_dir(&tmp)
428 .assert()
429 .success();
430}
431
432// ── show --json logs contain short IDs ───────────────────────────────
433
434#[test]
435fn show_json_log_entry_ids_are_short() {
436 let tmp = init_tmp();
437 let id = create_task(&tmp, "Log host");
438
439 td(&tmp)
440 .args(["log", &id, "first note"])
441 .current_dir(&tmp)
442 .assert()
443 .success();
444
445 let out = td(&tmp)
446 .args(["--json", "show", &id])
447 .current_dir(&tmp)
448 .output()
449 .unwrap();
450 let v: serde_json::Value = serde_json::from_slice(&out.stdout).unwrap();
451 for entry in v["logs"].as_array().unwrap() {
452 assert_short_id(entry["id"].as_str().unwrap());
453 }
454}