cli_id_format.rs

  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}