Detailed changes
@@ -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();
@@ -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",
@@ -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);
}
}
@@ -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(())
}
@@ -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);
@@ -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(
@@ -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);
}
}
@@ -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 {
@@ -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 {
@@ -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());
+ }
+}
@@ -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!([
{