1use assert_cmd::cargo::cargo_bin_cmd;
2use tempfile::TempDir;
3
4fn td(home: &TempDir) -> assert_cmd::Command {
5 let mut cmd = cargo_bin_cmd!("td");
6 cmd.env("HOME", home.path());
7 cmd
8}
9
10fn init_tmp() -> TempDir {
11 let tmp = TempDir::new().unwrap();
12 td(&tmp)
13 .args(["project", "init", "main"])
14 .current_dir(&tmp)
15 .assert()
16 .success();
17 tmp
18}
19
20fn create_task(dir: &TempDir, title: &str) -> String {
21 let out = td(dir)
22 .args(["--json", "create", title])
23 .current_dir(dir)
24 .output()
25 .unwrap();
26 let v: serde_json::Value = serde_json::from_slice(&out.stdout).unwrap();
27 v["id"].as_str().unwrap().to_string()
28}
29
30#[test]
31fn export_produces_jsonl() {
32 let tmp = init_tmp();
33 create_task(&tmp, "First");
34 create_task(&tmp, "Second");
35
36 let out = td(&tmp).arg("export").current_dir(&tmp).output().unwrap();
37 let stdout = String::from_utf8(out.stdout).unwrap();
38 let lines: Vec<&str> = stdout.lines().collect();
39 assert_eq!(lines.len(), 2, "expected 2 JSONL lines, got: {stdout}");
40
41 // Each line should be valid JSON with an id field.
42 for line in &lines {
43 let v: serde_json::Value = serde_json::from_str(line).unwrap();
44 assert!(v["id"].is_string());
45 }
46}
47
48#[test]
49fn export_includes_labels_and_blockers() {
50 let tmp = init_tmp();
51 td(&tmp)
52 .args(["create", "With labels", "-l", "bug"])
53 .current_dir(&tmp)
54 .assert()
55 .success();
56
57 let out = td(&tmp).arg("export").current_dir(&tmp).output().unwrap();
58 let line = String::from_utf8(out.stdout).unwrap();
59 let v: serde_json::Value = serde_json::from_str(line.trim()).unwrap();
60 assert!(v["labels"].is_array());
61 assert!(v["blockers"].is_array());
62}
63
64#[test]
65fn import_round_trips_with_export() {
66 let tmp = init_tmp();
67 create_task(&tmp, "Alpha");
68
69 td(&tmp)
70 .args(["create", "Bravo", "-l", "important"])
71 .current_dir(&tmp)
72 .assert()
73 .success();
74
75 // Export.
76 let export_out = td(&tmp).arg("export").current_dir(&tmp).output().unwrap();
77 let exported = String::from_utf8(export_out.stdout).unwrap();
78
79 // Write to a file.
80 let export_file = tmp.path().join("backup.jsonl");
81 std::fs::write(&export_file, &exported).unwrap();
82
83 // Create a fresh directory, init, import.
84 let tmp2 = TempDir::new().unwrap();
85 td(&tmp2)
86 .args(["project", "init", "mirror"])
87 .current_dir(&tmp2)
88 .assert()
89 .success();
90
91 td(&tmp2)
92 .args(["import", export_file.to_str().unwrap()])
93 .current_dir(&tmp2)
94 .assert()
95 .success();
96
97 // Verify tasks exist in the new database.
98 let out = td(&tmp2)
99 .args(["--json", "list"])
100 .current_dir(&tmp2)
101 .output()
102 .unwrap();
103 let v: serde_json::Value = serde_json::from_slice(&out.stdout).unwrap();
104 let titles: Vec<&str> = v
105 .as_array()
106 .unwrap()
107 .iter()
108 .map(|t| t["title"].as_str().unwrap())
109 .collect();
110 assert!(titles.contains(&"Alpha"));
111 assert!(titles.contains(&"Bravo"));
112
113 // Verify labels survived.
114 let bravo = v
115 .as_array()
116 .unwrap()
117 .iter()
118 .find(|t| t["title"] == "Bravo")
119 .unwrap();
120 let labels = bravo["labels"].as_array().unwrap();
121 assert!(labels.contains(&serde_json::Value::String("important".into())));
122}
123
124#[test]
125fn export_import_preserves_effort() {
126 let tmp = init_tmp();
127
128 td(&tmp)
129 .args(["create", "High effort", "-e", "high"])
130 .current_dir(&tmp)
131 .assert()
132 .success();
133
134 // Export.
135 let out = td(&tmp).arg("export").current_dir(&tmp).output().unwrap();
136 let exported = String::from_utf8(out.stdout).unwrap();
137
138 // Verify effort is in the JSONL.
139 let v: serde_json::Value = serde_json::from_str(exported.trim()).unwrap();
140 assert_eq!(v["effort"].as_str().unwrap(), "high");
141
142 // Round-trip into a fresh database.
143 let export_file = tmp.path().join("effort.jsonl");
144 std::fs::write(&export_file, &exported).unwrap();
145
146 let tmp2 = TempDir::new().unwrap();
147 td(&tmp2)
148 .args(["project", "init", "mirror"])
149 .current_dir(&tmp2)
150 .assert()
151 .success();
152 td(&tmp2)
153 .args(["import", export_file.to_str().unwrap()])
154 .current_dir(&tmp2)
155 .assert()
156 .success();
157
158 let out2 = td(&tmp2)
159 .args(["--json", "list"])
160 .current_dir(&tmp2)
161 .output()
162 .unwrap();
163 let v2: serde_json::Value = serde_json::from_slice(&out2.stdout).unwrap();
164 assert_eq!(v2[0]["effort"].as_str().unwrap(), "high");
165}
166
167#[test]
168fn import_merges_labels_and_logs_for_existing_task() {
169 let tmp = init_tmp();
170
171 let out = td(&tmp)
172 .args(["--json", "create", "Merge me", "-l", "local"])
173 .current_dir(&tmp)
174 .output()
175 .unwrap();
176 let created: serde_json::Value = serde_json::from_slice(&out.stdout).unwrap();
177 let id = created["id"].as_str().unwrap().to_string();
178
179 td(&tmp)
180 .args(["log", &id, "local note"])
181 .current_dir(&tmp)
182 .assert()
183 .success();
184
185 // Use export (which emits full ULIDs) as the basis for import data,
186 // since import expects full ULID identifiers for CRDT key fidelity.
187 let out = td(&tmp).arg("export").current_dir(&tmp).output().unwrap();
188 let exported = String::from_utf8(out.stdout).unwrap();
189 let mut imported: serde_json::Value = serde_json::from_str(exported.trim()).unwrap();
190 imported["labels"] = serde_json::json!(["remote"]);
191 imported["logs"] = serde_json::json!([
192 {
193 "id": "01ARZ3NDEKTSV4RRFFQ69G5FAV",
194 "timestamp": "2026-03-01T00:00:00Z",
195 "message": "remote note"
196 }
197 ]);
198
199 let import_file = tmp.path().join("merge.jsonl");
200 std::fs::write(&import_file, format!("{}\n", imported)).unwrap();
201
202 td(&tmp)
203 .args(["import", import_file.to_str().unwrap()])
204 .current_dir(&tmp)
205 .assert()
206 .success();
207
208 let out = td(&tmp)
209 .args(["--json", "show", &id])
210 .current_dir(&tmp)
211 .output()
212 .unwrap();
213 let merged: serde_json::Value = serde_json::from_slice(&out.stdout).unwrap();
214
215 let labels = merged["labels"].as_array().unwrap();
216 assert!(labels.contains(&serde_json::Value::String("local".into())));
217 assert!(labels.contains(&serde_json::Value::String("remote".into())));
218
219 let logs = merged["logs"].as_array().unwrap();
220 let messages: Vec<&str> = logs
221 .iter()
222 .filter_map(|entry| entry["message"].as_str())
223 .collect();
224 assert!(messages.contains(&"local note"));
225 assert!(messages.contains(&"remote note"));
226}