From 1465630667c5b55ae32e83043f3676a18f4af4bf Mon Sep 17 00:00:00 2001 From: Amolith Date: Tue, 3 Mar 2026 05:11:20 +0000 Subject: [PATCH] Emit short td-XXXXXXX IDs consistently in all output MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- 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(-) create mode 100644 tests/cli_id_format.rs diff --git a/src/cmd/dep.rs b/src/cmd/dep.rs index 58d0357ab6d2f678739ffbaf3dc716d4b617f8e0..f075e01e0e9ddf73ee38504ca21c799c6847df6f 100644 --- a/src/cmd/dep.rs +++ b/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(); diff --git a/src/cmd/doctor.rs b/src/cmd/doctor.rs index bd5e70057b12ee93357614139ef7ff60c32708a3..e71c538687d5b34a650611bc832f6b56a2213c54 100644 --- a/src/cmd/doctor.rs +++ b/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", diff --git a/src/cmd/done.rs b/src/cmd/done.rs index f8aa9cdc6fbada3a9edcc14494d557358bdd5558..5e42805bb59838509435d2bcfb95fe2243d6f720 100644 --- a/src/cmd/done.rs +++ b/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); } } diff --git a/src/cmd/export.rs b/src/cmd/export.rs index efd3b716080bfe216e5cc84e06a639130f6f3a3e..bae7ee7b57884d6a50e8c5672e95b53d5d6212e6 100644 --- a/src/cmd/export.rs +++ b/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(()) } diff --git a/src/cmd/label.rs b/src/cmd/label.rs index dde829139bba9c25262203db693db649663fac5d..83181ed65e5ac397d80bb8f8acb8dd3c65c468a4 100644 --- a/src/cmd/label.rs +++ b/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); diff --git a/src/cmd/next.rs b/src/cmd/next.rs index 4d03c388aa8ff084a8e14ecee4d231d412c98c0f..ccdc54cc7757dd70c4d7657aa72db2f878306830 100644 --- a/src/cmd/next.rs +++ b/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::>() }) .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 = 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( diff --git a/src/cmd/reopen.rs b/src/cmd/reopen.rs index fb6ef1701310269a839911cb63d1a4c8e609cfab..cf399bbe6c2ceae0b78faeed9d169bd8c2042a82 100644 --- a/src/cmd/reopen.rs +++ b/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); } } diff --git a/src/cmd/rm.rs b/src/cmd/rm.rs index be0bfc329f61f2face8bf4e6a5db1d74295e1309..5208138dbe12d0d3fc0ba9dac6a9e35bf9031eb5 100644 --- a/src/cmd/rm.rs +++ b/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 { diff --git a/src/db.rs b/src/db.rs index 968ed27f3d8765edb6adc524453e878bbfb8868b..b74dfb9e291da2cc770ae5713beb5b0e80527ae6 100644 --- a/src/db.rs +++ b/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(&self, serializer: S) -> Result { + 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, } +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::>(), + "logs": self.logs.iter().map(|l| { + serde_json::json!({ + "id": l.id.as_str(), + "timestamp": l.timestamp, + "message": l.message, + }) + }).collect::>(), + }) + } +} + /// Result type for partitioning blockers by task state. #[derive(Debug, Default, Clone, Serialize)] pub struct BlockerPartition { diff --git a/tests/cli_id_format.rs b/tests/cli_id_format.rs new file mode 100644 index 0000000000000000000000000000000000000000..51907bb14524ac31858d97e64d48776b212377fd --- /dev/null +++ b/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()); + } +} diff --git a/tests/cli_io.rs b/tests/cli_io.rs index ad1e843cdf8e459f4a27d59d2d5fe77697c77c37..3e239a5f9cce23bc85c7427615cf6145d2abdc4b 100644 --- a/tests/cli_io.rs +++ b/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!([ {