1use assert_cmd::Command;
2use tempfile::TempDir;
3
4fn td(home: &TempDir) -> Command {
5 let mut cmd = Command::cargo_bin("td").unwrap();
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(["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(["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(["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 let out = td(&tmp)
186 .args(["--json", "show", &id])
187 .current_dir(&tmp)
188 .output()
189 .unwrap();
190 let mut imported: serde_json::Value = serde_json::from_slice(&out.stdout).unwrap();
191 imported["labels"] = serde_json::json!(["remote"]);
192 imported["logs"] = serde_json::json!([
193 {
194 "id": "01ARZ3NDEKTSV4RRFFQ69G5FAV",
195 "timestamp": "2026-03-01T00:00:00Z",
196 "message": "remote note"
197 }
198 ]);
199
200 let import_file = tmp.path().join("merge.jsonl");
201 std::fs::write(&import_file, format!("{}\n", imported)).unwrap();
202
203 td(&tmp)
204 .args(["import", import_file.to_str().unwrap()])
205 .current_dir(&tmp)
206 .assert()
207 .success();
208
209 let out = td(&tmp)
210 .args(["--json", "show", &id])
211 .current_dir(&tmp)
212 .output()
213 .unwrap();
214 let merged: serde_json::Value = serde_json::from_slice(&out.stdout).unwrap();
215
216 let labels = merged["labels"].as_array().unwrap();
217 assert!(labels.contains(&serde_json::Value::String("local".into())));
218 assert!(labels.contains(&serde_json::Value::String("remote".into())));
219
220 let logs = merged["logs"].as_array().unwrap();
221 let messages: Vec<&str> = logs
222 .iter()
223 .filter_map(|entry| entry["message"].as_str())
224 .collect();
225 assert!(messages.contains(&"local note"));
226 assert!(messages.contains(&"remote note"));
227}