Emit short td-XXXXXXX IDs consistently in all output

Amolith created

Change TaskId serialization from the raw 26-char ULID to the short
td-XXXXXXX display form. This affects all JSON output from every
command that emits task identifiers: create, show, list, update,
done, reopen, dep, label, rm, log, next, ready, search, and doctor.

The root cause was TaskId's serde Serialize impl using
#[serde(transparent)] which forwarded the inner ULID string.
Replace it with a custom impl that emits short(). Additionally,
several commands built JSON manually via .as_str() or stored the
full ULID in plain Strings for human output — fix all of those.

Export retains full ULIDs via a dedicated Task::to_export_value()
method so that import round-tripping continues to work (import
needs exact ULIDs for CRDT key fidelity).

Update one test in cli_io.rs that was feeding show --json output
into import — the correct data source for import is export, which
is the designed round-trip path.

Change summary

src/cmd/dep.rs         |   2 
src/cmd/doctor.rs      |  14 
src/cmd/done.rs        |   5 
src/cmd/export.rs      |   2 
src/cmd/label.rs       |   5 
src/cmd/next.rs        |   6 
src/cmd/reopen.rs      |   5 
src/cmd/rm.rs          |  10 
src/db.rs              |  44 ++++
tests/cli_id_format.rs | 454 ++++++++++++++++++++++++++++++++++++++++++++
tests/cli_io.rs        |  11 
11 files changed, 520 insertions(+), 38 deletions(-)

Detailed changes

src/cmd/dep.rs 🔗

@@ -31,7 +31,7 @@ pub fn run(root: &Path, action: &DepAction, json: bool) -> Result<()> {
             if json {
                 println!(
                     "{}",
-                    serde_json::json!({"child": child_id.as_str(), "blocker": parent_id.as_str()})
+                    serde_json::json!({"child": child_id, "blocker": parent_id})
                 );
             } else {
                 let c = crate::color::stdout_theme();

src/cmd/doctor.rs 🔗

@@ -26,7 +26,7 @@ enum FindingKind {
 #[derive(Debug, Clone, Serialize)]
 struct Finding {
     kind: FindingKind,
-    /// Full ULID of the primarily affected task.
+    /// Short display ID (`td-XXXXXXX`) of the primarily affected task.
     task: String,
     /// Human-readable description of the issue.
     detail: String,
@@ -151,7 +151,7 @@ fn check_dangling_parents(
         if !all_ids.contains(parent.as_str()) {
             findings.push(Finding {
                 kind: FindingKind::DanglingParent,
-                task: task.id.as_str().to_string(),
+                task: task.id.to_string(),
                 detail: format!(
                     "parent references missing task {}",
                     db::TaskId::display_id(parent.as_str()),
@@ -168,7 +168,7 @@ fn check_dangling_parents(
             if pt.deleted_at.is_some() {
                 findings.push(Finding {
                     kind: FindingKind::DanglingParent,
-                    task: task.id.as_str().to_string(),
+                    task: task.id.to_string(),
                     detail: format!(
                         "parent references tombstoned task {}",
                         db::TaskId::display_id(parent.as_str()),
@@ -200,7 +200,7 @@ fn check_dangling_blockers(
             if !all_ids.contains(blocker.as_str()) {
                 findings.push(Finding {
                     kind: FindingKind::DanglingBlocker,
-                    task: task.id.as_str().to_string(),
+                    task: task.id.to_string(),
                     detail: format!(
                         "blocker references missing task {}",
                         db::TaskId::display_id(blocker.as_str()),
@@ -272,7 +272,7 @@ fn check_blocker_cycles(
 
         findings.push(Finding {
             kind: FindingKind::BlockerCycle,
-            task: task_id.clone(),
+            task: db::TaskId::display_id(&task_id),
             detail: cycle_str,
             active,
             fixed: false,
@@ -351,7 +351,7 @@ fn check_parent_cycles(
 
                 findings.push(Finding {
                     kind: FindingKind::ParentCycle,
-                    task: lowest.clone(),
+                    task: db::TaskId::display_id(&lowest),
                     detail: cycle_str,
                     active: true,
                     fixed: false,
@@ -462,7 +462,7 @@ fn print_human(report: &Report, fix: bool) {
     }
 
     for f in &report.findings {
-        let short = db::TaskId::display_id(&f.task);
+        let short = &f.task;
         let kind_label = match f.kind {
             FindingKind::DanglingParent => "dangling parent",
             FindingKind::DanglingBlocker => "dangling blocker",

src/cmd/done.rs 🔗

@@ -10,7 +10,6 @@ pub fn run(root: &Path, ids: &[String], json: bool) -> Result<()> {
     let mut closed = Vec::new();
     for raw in ids {
         let id = db::resolve_task_id(&store, raw, false)?;
-        let id_key = id.as_str().to_string();
         store.apply_and_persist(|doc| {
             let tasks = doc.get_map("tasks");
             if let Some(task) = db::get_task_map(&tasks, &id)? {
@@ -19,7 +18,7 @@ pub fn run(root: &Path, ids: &[String], json: bool) -> Result<()> {
             }
             Ok(())
         })?;
-        closed.push(id_key);
+        closed.push(id);
     }
 
     if json {
@@ -30,7 +29,7 @@ pub fn run(root: &Path, ids: &[String], json: bool) -> Result<()> {
         println!("{}", serde_json::to_string(&out)?);
     } else {
         let c = crate::color::stdout_theme();
-        for id in closed {
+        for id in &closed {
             println!("{}closed{} {id}", c.green, c.reset);
         }
     }

src/cmd/export.rs 🔗

@@ -6,7 +6,7 @@ use crate::db;
 pub fn run(root: &Path) -> Result<()> {
     let store = db::open(root)?;
     for task in store.list_tasks_unfiltered()? {
-        println!("{}", serde_json::to_string(&task)?);
+        println!("{}", task.to_export_value());
     }
     Ok(())
 }

src/cmd/label.rs 🔗

@@ -23,10 +23,7 @@ pub fn run(root: &Path, action: &LabelAction, json: bool) -> Result<()> {
             })?;
 
             if json {
-                println!(
-                    "{}",
-                    serde_json::json!({"id": task_id.as_str(), "label": label})
-                );
+                println!("{}", serde_json::json!({"id": task_id, "label": label}));
             } else {
                 let c = crate::color::stdout_theme();
                 println!("{}added{} label {label}", c.green, c.reset);

src/cmd/next.rs 🔗

@@ -25,7 +25,7 @@ pub fn run(root: &Path, mode_str: &str, verbose: bool, limit: usize, json: bool)
         .iter()
         .filter(|t| t.status == db::Status::Open)
         .map(|t| score::TaskInput {
-            id: t.id.as_str().to_string(),
+            id: t.id.to_string(),
             title: t.title.clone(),
             priority_score: t.priority.score(),
             effort_score: t.effort.score(),
@@ -40,7 +40,7 @@ pub fn run(root: &Path, mode_str: &str, verbose: bool, limit: usize, json: bool)
         .flat_map(|t| {
             t.blockers
                 .iter()
-                .map(|b| (t.id.as_str().to_string(), b.as_str().to_string()))
+                .map(|b| (t.id.to_string(), b.to_string()))
                 .collect::<Vec<_>>()
         })
         .collect();
@@ -48,7 +48,7 @@ pub fn run(root: &Path, mode_str: &str, verbose: bool, limit: usize, json: bool)
     let parents_with_open_children: HashSet<String> = all
         .iter()
         .filter(|t| t.status == db::Status::Open)
-        .filter_map(|t| t.parent.as_ref().map(|p| p.as_str().to_string()))
+        .filter_map(|t| t.parent.as_ref().map(ToString::to_string))
         .collect();
 
     let scored = score::rank(

src/cmd/reopen.rs 🔗

@@ -10,7 +10,6 @@ pub fn run(root: &Path, ids: &[String], json: bool) -> Result<()> {
     let mut reopened = Vec::new();
     for raw in ids {
         let id = db::resolve_task_id(&store, raw, false)?;
-        let id_key = id.as_str().to_string();
         store.apply_and_persist(|doc| {
             let tasks = doc.get_map("tasks");
             if let Some(task) = db::get_task_map(&tasks, &id)? {
@@ -19,7 +18,7 @@ pub fn run(root: &Path, ids: &[String], json: bool) -> Result<()> {
             }
             Ok(())
         })?;
-        reopened.push(id_key);
+        reopened.push(id);
     }
 
     if json {
@@ -30,7 +29,7 @@ pub fn run(root: &Path, ids: &[String], json: bool) -> Result<()> {
         println!("{}", serde_json::to_string(&out)?);
     } else {
         let c = crate::color::stdout_theme();
-        for id in reopened {
+        for id in &reopened {
             println!("{}reopened{} {id}", c.green, c.reset);
         }
     }

src/cmd/rm.rs 🔗

@@ -80,14 +80,8 @@ pub fn run(root: &Path, ids: &[String], recursive: bool, force: bool, json: bool
     if json {
         let out = RmResult {
             requested_ids: ids.to_vec(),
-            deleted_ids: deleted_ids
-                .iter()
-                .map(|id| id.as_str().to_string())
-                .collect(),
-            unblocked_ids: unblocked_ids
-                .iter()
-                .map(|id| id.as_str().to_string())
-                .collect(),
+            deleted_ids: deleted_ids.iter().map(ToString::to_string).collect(),
+            unblocked_ids: unblocked_ids.iter().map(ToString::to_string).collect(),
         };
         println!("{}", serde_json::to_string(&out)?);
     } else {

src/db.rs 🔗

@@ -126,10 +126,19 @@ impl Effort {
 }
 
 /// A stable task identifier backed by a ULID.
-#[derive(Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize)]
-#[serde(transparent)]
+///
+/// Serializes as the short display form (`td-XXXXXXX`) for user-facing
+/// JSON. Use [`TaskId::as_str`] when the full ULID is needed (e.g.
+/// for CRDT keys or export round-tripping).
+#[derive(Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord)]
 pub struct TaskId(String);
 
+impl Serialize for TaskId {
+    fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
+        serializer.serialize_str(&self.short())
+    }
+}
+
 impl TaskId {
     pub fn new(id: Ulid) -> Self {
         Self(id.to_string())
@@ -193,6 +202,37 @@ pub struct Task {
     pub logs: Vec<LogEntry>,
 }
 
+impl Task {
+    /// Serialize this task with full ULIDs instead of short display IDs.
+    ///
+    /// Used by `export` so that `import` can round-trip data losslessly —
+    /// `import` needs the full ULID to recreate exact CRDT keys.
+    pub fn to_export_value(&self) -> serde_json::Value {
+        serde_json::json!({
+            "id": self.id.as_str(),
+            "title": self.title,
+            "description": self.description,
+            "type": self.task_type,
+            "priority": self.priority,
+            "status": self.status,
+            "effort": self.effort,
+            "parent": self.parent.as_ref().map(|p| p.as_str()),
+            "created_at": self.created_at,
+            "updated_at": self.updated_at,
+            "deleted_at": self.deleted_at,
+            "labels": self.labels,
+            "blockers": self.blockers.iter().map(|b| b.as_str()).collect::<Vec<_>>(),
+            "logs": self.logs.iter().map(|l| {
+                serde_json::json!({
+                    "id": l.id.as_str(),
+                    "timestamp": l.timestamp,
+                    "message": l.message,
+                })
+            }).collect::<Vec<_>>(),
+        })
+    }
+}
+
 /// Result type for partitioning blockers by task state.
 #[derive(Debug, Default, Clone, Serialize)]
 pub struct BlockerPartition {

tests/cli_id_format.rs 🔗

@@ -0,0 +1,454 @@
+//! Tests that all user-visible task ID output uses the short `td-XXXXXXX` form.
+//!
+//! IDs emitted in JSON, human output, and cross-referencing fields (parent,
+//! blockers, log entry IDs) must all consistently use the short form. The one
+//! exception is `export`, which must emit full ULIDs so that `import` can
+//! round-trip data losslessly.
+
+use assert_cmd::cargo::cargo_bin_cmd;
+use tempfile::TempDir;
+
+fn td(home: &TempDir) -> assert_cmd::Command {
+    let mut cmd = cargo_bin_cmd!("td");
+    cmd.env("HOME", home.path());
+    cmd
+}
+
+fn init_tmp() -> TempDir {
+    let tmp = TempDir::new().unwrap();
+    td(&tmp)
+        .args(["project", "init", "main"])
+        .current_dir(&tmp)
+        .assert()
+        .success();
+    tmp
+}
+
+fn create_task(dir: &TempDir, title: &str) -> String {
+    let out = td(dir)
+        .args(["--json", "create", title])
+        .current_dir(dir)
+        .output()
+        .unwrap();
+    let v: serde_json::Value = serde_json::from_slice(&out.stdout).unwrap();
+    v["id"].as_str().unwrap().to_string()
+}
+
+/// Assert a string matches the `td-XXXXXXX` pattern (3-char prefix + 7 uppercase alphanumeric).
+fn assert_short_id(id: &str) {
+    assert!(
+        id.starts_with("td-") && id.len() == 10,
+        "expected short ID like td-XXXXXXX, got: {id}"
+    );
+}
+
+// ── create ───────────────────────────────────────────────────────────
+
+#[test]
+fn create_json_emits_short_id() {
+    let tmp = init_tmp();
+    let id = create_task(&tmp, "Test task");
+    assert_short_id(&id);
+}
+
+#[test]
+fn create_json_parent_is_short_id() {
+    let tmp = init_tmp();
+    let parent = create_task(&tmp, "Parent");
+
+    let out = td(&tmp)
+        .args(["--json", "create", "Child", "--parent", &parent])
+        .current_dir(&tmp)
+        .output()
+        .unwrap();
+    let v: serde_json::Value = serde_json::from_slice(&out.stdout).unwrap();
+    let parent_field = v["parent"].as_str().unwrap();
+    assert_short_id(parent_field);
+}
+
+// ── show ─────────────────────────────────────────────────────────────
+
+#[test]
+fn show_json_emits_short_ids() {
+    let tmp = init_tmp();
+    let id = create_task(&tmp, "Show me");
+
+    let out = td(&tmp)
+        .args(["--json", "show", &id])
+        .current_dir(&tmp)
+        .output()
+        .unwrap();
+    let v: serde_json::Value = serde_json::from_slice(&out.stdout).unwrap();
+    assert_short_id(v["id"].as_str().unwrap());
+}
+
+#[test]
+fn show_json_blockers_are_short_ids() {
+    let tmp = init_tmp();
+    let a = create_task(&tmp, "Blocked");
+    let b = create_task(&tmp, "Blocker");
+
+    td(&tmp)
+        .args(["dep", "add", &a, &b])
+        .current_dir(&tmp)
+        .assert()
+        .success();
+
+    let out = td(&tmp)
+        .args(["--json", "show", &a])
+        .current_dir(&tmp)
+        .output()
+        .unwrap();
+    let v: serde_json::Value = serde_json::from_slice(&out.stdout).unwrap();
+    for blocker in v["blockers"].as_array().unwrap() {
+        assert_short_id(blocker.as_str().unwrap());
+    }
+}
+
+// ── list ─────────────────────────────────────────────────────────────
+
+#[test]
+fn list_json_emits_short_ids() {
+    let tmp = init_tmp();
+    create_task(&tmp, "Listed");
+
+    let out = td(&tmp)
+        .args(["--json", "list"])
+        .current_dir(&tmp)
+        .output()
+        .unwrap();
+    let v: serde_json::Value = serde_json::from_slice(&out.stdout).unwrap();
+    for task in v.as_array().unwrap() {
+        assert_short_id(task["id"].as_str().unwrap());
+    }
+}
+
+// ── done ─────────────────────────────────────────────────────────────
+
+#[test]
+fn done_json_emits_short_id() {
+    let tmp = init_tmp();
+    let id = create_task(&tmp, "Close me");
+
+    let out = td(&tmp)
+        .args(["--json", "done", &id])
+        .current_dir(&tmp)
+        .output()
+        .unwrap();
+    let v: serde_json::Value = serde_json::from_slice(&out.stdout).unwrap();
+    assert_short_id(v[0]["id"].as_str().unwrap());
+}
+
+#[test]
+fn done_human_emits_short_id() {
+    let tmp = init_tmp();
+    let id = create_task(&tmp, "Close me too");
+
+    let out = td(&tmp)
+        .args(["done", &id])
+        .current_dir(&tmp)
+        .output()
+        .unwrap();
+    let stdout = String::from_utf8(out.stdout).unwrap();
+    // The human output should contain the short ID, not a 26-char ULID.
+    assert!(
+        stdout.contains(&id),
+        "human output should contain short ID {id}, got: {stdout}"
+    );
+}
+
+// ── reopen ───────────────────────────────────────────────────────────
+
+#[test]
+fn reopen_json_emits_short_id() {
+    let tmp = init_tmp();
+    let id = create_task(&tmp, "Reopen me");
+
+    td(&tmp)
+        .args(["done", &id])
+        .current_dir(&tmp)
+        .assert()
+        .success();
+
+    let out = td(&tmp)
+        .args(["--json", "reopen", &id])
+        .current_dir(&tmp)
+        .output()
+        .unwrap();
+    let v: serde_json::Value = serde_json::from_slice(&out.stdout).unwrap();
+    assert_short_id(v[0]["id"].as_str().unwrap());
+}
+
+#[test]
+fn reopen_human_emits_short_id() {
+    let tmp = init_tmp();
+    let id = create_task(&tmp, "Reopen me too");
+
+    td(&tmp)
+        .args(["done", &id])
+        .current_dir(&tmp)
+        .assert()
+        .success();
+
+    let out = td(&tmp)
+        .args(["reopen", &id])
+        .current_dir(&tmp)
+        .output()
+        .unwrap();
+    let stdout = String::from_utf8(out.stdout).unwrap();
+    assert!(
+        stdout.contains(&id),
+        "human output should contain short ID {id}, got: {stdout}"
+    );
+}
+
+// ── dep ──────────────────────────────────────────────────────────────
+
+#[test]
+fn dep_add_json_emits_short_ids() {
+    let tmp = init_tmp();
+    let a = create_task(&tmp, "Child");
+    let b = create_task(&tmp, "Parent");
+
+    let out = td(&tmp)
+        .args(["--json", "dep", "add", &a, &b])
+        .current_dir(&tmp)
+        .output()
+        .unwrap();
+    let v: serde_json::Value = serde_json::from_slice(&out.stdout).unwrap();
+    assert_short_id(v["child"].as_str().unwrap());
+    assert_short_id(v["blocker"].as_str().unwrap());
+}
+
+// ── label ────────────────────────────────────────────────────────────
+
+#[test]
+fn label_add_json_emits_short_id() {
+    let tmp = init_tmp();
+    let id = create_task(&tmp, "Label me");
+
+    let out = td(&tmp)
+        .args(["--json", "label", "add", &id, "urgent"])
+        .current_dir(&tmp)
+        .output()
+        .unwrap();
+    let v: serde_json::Value = serde_json::from_slice(&out.stdout).unwrap();
+    assert_short_id(v["id"].as_str().unwrap());
+}
+
+// ── rm ───────────────────────────────────────────────────────────────
+
+#[test]
+fn rm_json_emits_short_ids() {
+    let tmp = init_tmp();
+    let id = create_task(&tmp, "Delete me");
+
+    let out = td(&tmp)
+        .args(["--json", "rm", &id, "--force"])
+        .current_dir(&tmp)
+        .output()
+        .unwrap();
+    let v: serde_json::Value = serde_json::from_slice(&out.stdout).unwrap();
+    for deleted in v["deleted_ids"].as_array().unwrap() {
+        assert_short_id(deleted.as_str().unwrap());
+    }
+}
+
+// ── log ──────────────────────────────────────────────────────────────
+
+#[test]
+fn log_json_entry_id_is_short() {
+    let tmp = init_tmp();
+    let id = create_task(&tmp, "Log target");
+
+    let out = td(&tmp)
+        .args(["--json", "log", &id, "a note"])
+        .current_dir(&tmp)
+        .output()
+        .unwrap();
+    let v: serde_json::Value = serde_json::from_slice(&out.stdout).unwrap();
+    assert_short_id(v["id"].as_str().unwrap());
+}
+
+// ── next ─────────────────────────────────────────────────────────────
+
+#[test]
+fn next_json_emits_short_ids() {
+    let tmp = init_tmp();
+    create_task(&tmp, "A task");
+
+    let out = td(&tmp)
+        .args(["--json", "next"])
+        .current_dir(&tmp)
+        .output()
+        .unwrap();
+    let v: serde_json::Value = serde_json::from_slice(&out.stdout).unwrap();
+    for entry in v.as_array().unwrap() {
+        assert_short_id(entry["id"].as_str().unwrap());
+    }
+}
+
+// ── search ───────────────────────────────────────────────────────────
+
+#[test]
+fn search_json_emits_short_ids() {
+    let tmp = init_tmp();
+    create_task(&tmp, "Searchable task");
+
+    let out = td(&tmp)
+        .args(["--json", "search", "Searchable"])
+        .current_dir(&tmp)
+        .output()
+        .unwrap();
+    let v: serde_json::Value = serde_json::from_slice(&out.stdout).unwrap();
+    for task in v.as_array().unwrap() {
+        assert_short_id(task["id"].as_str().unwrap());
+    }
+}
+
+// ── ready ────────────────────────────────────────────────────────────
+
+#[test]
+fn ready_json_emits_short_ids() {
+    let tmp = init_tmp();
+    create_task(&tmp, "Ready task");
+
+    let out = td(&tmp)
+        .args(["--json", "ready"])
+        .current_dir(&tmp)
+        .output()
+        .unwrap();
+    let v: serde_json::Value = serde_json::from_slice(&out.stdout).unwrap();
+    for task in v.as_array().unwrap() {
+        assert_short_id(task["id"].as_str().unwrap());
+    }
+}
+
+// ── update ───────────────────────────────────────────────────────────
+
+#[test]
+fn update_json_emits_short_id() {
+    let tmp = init_tmp();
+    let id = create_task(&tmp, "Update me");
+
+    let out = td(&tmp)
+        .args(["--json", "update", &id, "-t", "Updated"])
+        .current_dir(&tmp)
+        .output()
+        .unwrap();
+    let v: serde_json::Value = serde_json::from_slice(&out.stdout).unwrap();
+    assert_short_id(v["id"].as_str().unwrap());
+}
+
+// ── export/import round-trip ─────────────────────────────────────────
+
+#[test]
+fn export_emits_full_ulids_for_import() {
+    let tmp = init_tmp();
+    create_task(&tmp, "Exportable");
+
+    let out = td(&tmp).arg("export").current_dir(&tmp).output().unwrap();
+    let stdout = String::from_utf8(out.stdout).unwrap();
+    let mut checked = false;
+    for line in stdout.lines() {
+        let v: serde_json::Value = serde_json::from_str(line).unwrap();
+        let id = v["id"].as_str().unwrap();
+        // Export IDs must be full 26-char ULIDs, not the short form.
+        assert!(
+            !id.starts_with("td-") && id.len() == 26,
+            "export should emit full ULID, got: {id}"
+        );
+        checked = true;
+    }
+    assert!(checked, "export produced no output to check");
+}
+
+#[test]
+fn export_import_round_trip_preserves_ids() {
+    let tmp = init_tmp();
+    create_task(&tmp, "Round trip");
+
+    // Export from original.
+    let export_out = td(&tmp).arg("export").current_dir(&tmp).output().unwrap();
+    let exported = String::from_utf8(export_out.stdout).unwrap();
+    let export_file = tmp.path().join("rt.jsonl");
+    std::fs::write(&export_file, &exported).unwrap();
+
+    // Import into fresh project.
+    let tmp2 = TempDir::new().unwrap();
+    td(&tmp2)
+        .args(["project", "init", "mirror"])
+        .current_dir(&tmp2)
+        .assert()
+        .success();
+    td(&tmp2)
+        .args(["import", export_file.to_str().unwrap()])
+        .current_dir(&tmp2)
+        .assert()
+        .success();
+
+    // The JSON list output in the new project should have valid short IDs.
+    let out = td(&tmp2)
+        .args(["--json", "list"])
+        .current_dir(&tmp2)
+        .output()
+        .unwrap();
+    let v: serde_json::Value = serde_json::from_slice(&out.stdout).unwrap();
+    assert_short_id(v[0]["id"].as_str().unwrap());
+}
+
+// ── cross-command consistency ────────────────────────────────────────
+
+#[test]
+fn json_ids_are_usable_as_input_across_commands() {
+    let tmp = init_tmp();
+    let id = create_task(&tmp, "Cross-command");
+    assert_short_id(&id);
+
+    // The short ID from create --json should work as input to show.
+    let out = td(&tmp)
+        .args(["--json", "show", &id])
+        .current_dir(&tmp)
+        .output()
+        .unwrap();
+    let v: serde_json::Value = serde_json::from_slice(&out.stdout).unwrap();
+    assert_eq!(v["id"].as_str().unwrap(), &id);
+
+    // And to done.
+    td(&tmp)
+        .args(["done", &id])
+        .current_dir(&tmp)
+        .assert()
+        .success();
+
+    // And to reopen.
+    td(&tmp)
+        .args(["reopen", &id])
+        .current_dir(&tmp)
+        .assert()
+        .success();
+}
+
+// ── show --json logs contain short IDs ───────────────────────────────
+
+#[test]
+fn show_json_log_entry_ids_are_short() {
+    let tmp = init_tmp();
+    let id = create_task(&tmp, "Log host");
+
+    td(&tmp)
+        .args(["log", &id, "first note"])
+        .current_dir(&tmp)
+        .assert()
+        .success();
+
+    let out = td(&tmp)
+        .args(["--json", "show", &id])
+        .current_dir(&tmp)
+        .output()
+        .unwrap();
+    let v: serde_json::Value = serde_json::from_slice(&out.stdout).unwrap();
+    for entry in v["logs"].as_array().unwrap() {
+        assert_short_id(entry["id"].as_str().unwrap());
+    }
+}

tests/cli_io.rs 🔗

@@ -182,12 +182,11 @@ fn import_merges_labels_and_logs_for_existing_task() {
         .assert()
         .success();
 
-    let out = td(&tmp)
-        .args(["--json", "show", &id])
-        .current_dir(&tmp)
-        .output()
-        .unwrap();
-    let mut imported: serde_json::Value = serde_json::from_slice(&out.stdout).unwrap();
+    // Use export (which emits full ULIDs) as the basis for import data,
+    // since import expects full ULID identifiers for CRDT key fidelity.
+    let out = td(&tmp).arg("export").current_dir(&tmp).output().unwrap();
+    let exported = String::from_utf8(out.stdout).unwrap();
+    let mut imported: serde_json::Value = serde_json::from_str(exported.trim()).unwrap();
     imported["labels"] = serde_json::json!(["remote"]);
     imported["logs"] = serde_json::json!([
         {