cli_io.rs

  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(["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    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}