1use assert_cmd::cargo::cargo_bin_cmd;
2use predicates::prelude::*;
3use tempfile::TempDir;
4
5fn td(home: &TempDir) -> assert_cmd::Command {
6 let mut cmd = cargo_bin_cmd!("td");
7 cmd.env("HOME", home.path());
8 cmd
9}
10
11fn init_tmp() -> TempDir {
12 let tmp = TempDir::new().unwrap();
13 td(&tmp)
14 .args(["project", "init", "main"])
15 .current_dir(&tmp)
16 .assert()
17 .success();
18 tmp
19}
20
21fn create_task(dir: &TempDir, title: &str) -> String {
22 let out = td(dir)
23 .args(["--json", "create", title])
24 .current_dir(dir)
25 .output()
26 .unwrap();
27 let v: serde_json::Value = serde_json::from_slice(&out.stdout).unwrap();
28 v["id"].as_str().unwrap().to_string()
29}
30
31fn import_jsonl(dir: &TempDir, lines: &[&str]) {
32 let file = dir.path().join("import.jsonl");
33 std::fs::write(&file, lines.join("\n")).unwrap();
34 td(dir)
35 .args(["import", file.to_str().unwrap()])
36 .current_dir(dir)
37 .assert()
38 .success();
39}
40
41fn doctor_json(dir: &TempDir, fix: bool) -> serde_json::Value {
42 let mut args = vec!["--json", "doctor"];
43 if fix {
44 args.push("--fix");
45 }
46 let out = td(dir).args(&args).current_dir(dir).output().unwrap();
47 assert!(
48 out.status.success(),
49 "doctor failed: {}",
50 String::from_utf8_lossy(&out.stderr)
51 );
52 serde_json::from_slice(&out.stdout).unwrap()
53}
54
55// Valid 26-char ULIDs that don't correspond to any real task.
56// Crockford Base32 excludes I, L, O, U — all chars below are valid.
57const GHOST1: &str = "00000000000000000000DEAD01";
58const GHOST2: &str = "00000000000000000000DEAD02";
59const GHOST3: &str = "00000000000000000000DEAD03";
60const GHOST4: &str = "00000000000000000000DEAD04";
61const GHOST5: &str = "00000000000000000000DEAD05";
62const GHOST6: &str = "00000000000000000000DEAD06";
63
64// Fixed ULIDs for tasks we create via import.
65const TASK01: &str = "01HQ0000000000000000000001";
66const TASK02: &str = "01HQ0000000000000000000002";
67const TASK03: &str = "01HQ0000000000000000000003";
68const TASK04: &str = "01HQ0000000000000000000004";
69const TASK05: &str = "01HQ0000000000000000000005";
70const TASK06: &str = "01HQ0000000000000000000006";
71const TASK07: &str = "01HQ0000000000000000000007";
72const TASK08: &str = "01HQ0000000000000000000008";
73const TASK0A: &str = "01HQ000000000000000000000A";
74const TASK0B: &str = "01HQ000000000000000000000B";
75const TASK0C: &str = "01HQ000000000000000000000C";
76const TASK10: &str = "01HQ0000000000000000000010";
77const TASK11: &str = "01HQ0000000000000000000011";
78const TASK12: &str = "01HQ0000000000000000000012";
79const TASK13: &str = "01HQ0000000000000000000013";
80const TASK20: &str = "01HQ0000000000000000000020";
81const TASK21: &str = "01HQ0000000000000000000021";
82const TASK22: &str = "01HQ0000000000000000000022";
83const TASK30: &str = "01HQ0000000000000000000030";
84
85// --- Clean project ---
86
87#[test]
88fn doctor_clean_project_reports_no_issues() {
89 let tmp = init_tmp();
90 create_task(&tmp, "Healthy task");
91
92 td(&tmp)
93 .args(["doctor"])
94 .current_dir(&tmp)
95 .assert()
96 .success()
97 .stderr(predicate::str::contains("no issues found"));
98}
99
100#[test]
101fn doctor_clean_project_json() {
102 let tmp = init_tmp();
103 create_task(&tmp, "Healthy task");
104
105 let report = doctor_json(&tmp, false);
106 assert_eq!(report["summary"]["total"], 0);
107 assert!(report["findings"].as_array().unwrap().is_empty());
108}
109
110// --- Dangling parent ---
111
112#[test]
113fn doctor_detects_dangling_parent_missing() {
114 let tmp = init_tmp();
115
116 // Import a task whose parent ULID doesn't exist.
117 import_jsonl(
118 &tmp,
119 &[&format!(
120 r#"{{"id": "{TASK01}", "title": "Orphan", "parent": "{GHOST1}", "created_at": "2026-01-01T00:00:00Z", "updated_at": "2026-01-01T00:00:00Z"}}"#
121 )],
122 );
123
124 let report = doctor_json(&tmp, false);
125 assert_eq!(report["summary"]["dangling_parents"], 1);
126 assert_eq!(report["summary"]["total"], 1);
127 assert_eq!(report["findings"][0]["kind"], "dangling_parent");
128 assert!(!report["findings"][0]["fixed"].as_bool().unwrap());
129}
130
131#[test]
132fn doctor_detects_dangling_parent_tombstoned() {
133 let tmp = init_tmp();
134
135 // Import a tombstoned parent and a live child still pointing at it.
136 import_jsonl(
137 &tmp,
138 &[
139 &format!(
140 r#"{{"id": "{TASK21}", "title": "Dead parent", "deleted_at": "2026-01-01T00:00:00Z", "created_at": "2026-01-01T00:00:00Z", "updated_at": "2026-01-01T00:00:00Z"}}"#
141 ),
142 &format!(
143 r#"{{"id": "{TASK22}", "title": "Orphaned child", "parent": "{TASK21}", "created_at": "2026-01-01T00:00:00Z", "updated_at": "2026-01-01T00:00:00Z"}}"#
144 ),
145 ],
146 );
147
148 let report = doctor_json(&tmp, false);
149 assert_eq!(report["summary"]["dangling_parents"], 1);
150 assert!(report["findings"][0]["detail"]
151 .as_str()
152 .unwrap()
153 .contains("tombstoned"));
154}
155
156#[test]
157fn doctor_fix_clears_dangling_parent() {
158 let tmp = init_tmp();
159
160 import_jsonl(
161 &tmp,
162 &[&format!(
163 r#"{{"id": "{TASK01}", "title": "Orphan", "parent": "{GHOST1}", "created_at": "2026-01-01T00:00:00Z", "updated_at": "2026-01-01T00:00:00Z"}}"#
164 )],
165 );
166
167 let report = doctor_json(&tmp, true);
168 assert_eq!(report["summary"]["fixed"], 1);
169 assert!(report["findings"][0]["fixed"].as_bool().unwrap());
170
171 // Re-run: should be clean now.
172 let clean = doctor_json(&tmp, false);
173 assert_eq!(clean["summary"]["total"], 0);
174}
175
176// --- Dangling blocker ---
177
178#[test]
179fn doctor_detects_dangling_blocker() {
180 let tmp = init_tmp();
181
182 import_jsonl(
183 &tmp,
184 &[&format!(
185 r#"{{"id": "{TASK02}", "title": "Blocked", "blockers": ["{GHOST2}"], "created_at": "2026-01-01T00:00:00Z", "updated_at": "2026-01-01T00:00:00Z"}}"#
186 )],
187 );
188
189 let report = doctor_json(&tmp, false);
190 assert_eq!(report["summary"]["dangling_blockers"], 1);
191 assert_eq!(report["findings"][0]["kind"], "dangling_blocker");
192}
193
194#[test]
195fn doctor_fix_removes_dangling_blocker() {
196 let tmp = init_tmp();
197
198 import_jsonl(
199 &tmp,
200 &[&format!(
201 r#"{{"id": "{TASK02}", "title": "Blocked", "blockers": ["{GHOST2}"], "created_at": "2026-01-01T00:00:00Z", "updated_at": "2026-01-01T00:00:00Z"}}"#
202 )],
203 );
204
205 let report = doctor_json(&tmp, true);
206 assert_eq!(report["summary"]["fixed"], 1);
207
208 // Re-run: clean.
209 let clean = doctor_json(&tmp, false);
210 assert_eq!(clean["summary"]["total"], 0);
211}
212
213// --- Blocker cycle ---
214
215#[test]
216fn doctor_detects_blocker_cycle() {
217 let tmp = init_tmp();
218
219 // Import two tasks that block each other (cycle bypassing dep add's check).
220 import_jsonl(
221 &tmp,
222 &[
223 &format!(
224 r#"{{"id": "{TASK03}", "title": "Task A", "blockers": ["{TASK04}"], "created_at": "2026-01-01T00:00:00Z", "updated_at": "2026-01-01T00:00:00Z"}}"#
225 ),
226 &format!(
227 r#"{{"id": "{TASK04}", "title": "Task B", "blockers": ["{TASK03}"], "created_at": "2026-01-01T00:00:00Z", "updated_at": "2026-01-01T00:00:00Z"}}"#
228 ),
229 ],
230 );
231
232 let report = doctor_json(&tmp, false);
233 assert_eq!(report["summary"]["blocker_cycles"], 1);
234 assert!(report["findings"][0]["active"].as_bool().unwrap());
235}
236
237#[test]
238fn doctor_fix_breaks_blocker_cycle() {
239 let tmp = init_tmp();
240
241 import_jsonl(
242 &tmp,
243 &[
244 &format!(
245 r#"{{"id": "{TASK03}", "title": "Task A", "blockers": ["{TASK04}"], "created_at": "2026-01-01T00:00:00Z", "updated_at": "2026-01-01T00:00:00Z"}}"#
246 ),
247 &format!(
248 r#"{{"id": "{TASK04}", "title": "Task B", "blockers": ["{TASK03}"], "created_at": "2026-01-01T00:00:00Z", "updated_at": "2026-01-01T00:00:00Z"}}"#
249 ),
250 ],
251 );
252
253 let report = doctor_json(&tmp, true);
254 assert_eq!(report["summary"]["fixed"], 1);
255
256 // Re-run: clean.
257 let clean = doctor_json(&tmp, false);
258 assert_eq!(clean["summary"]["total"], 0);
259}
260
261#[test]
262fn doctor_blocker_cycle_inert_when_one_node_closed() {
263 let tmp = init_tmp();
264
265 // Create two tasks that block each other, but one is closed.
266 import_jsonl(
267 &tmp,
268 &[
269 &format!(
270 r#"{{"id": "{TASK05}", "title": "Open task", "blockers": ["{TASK06}"], "created_at": "2026-01-01T00:00:00Z", "updated_at": "2026-01-01T00:00:00Z"}}"#
271 ),
272 &format!(
273 r#"{{"id": "{TASK06}", "title": "Closed task", "status": "closed", "blockers": ["{TASK05}"], "created_at": "2026-01-01T00:00:00Z", "updated_at": "2026-01-01T00:00:00Z"}}"#
274 ),
275 ],
276 );
277
278 let report = doctor_json(&tmp, false);
279 assert_eq!(report["summary"]["blocker_cycles"], 1);
280 assert!(!report["findings"][0]["active"].as_bool().unwrap());
281}
282
283#[test]
284fn doctor_fix_skips_inert_blocker_cycle() {
285 let tmp = init_tmp();
286
287 import_jsonl(
288 &tmp,
289 &[
290 &format!(
291 r#"{{"id": "{TASK05}", "title": "Open task", "blockers": ["{TASK06}"], "created_at": "2026-01-01T00:00:00Z", "updated_at": "2026-01-01T00:00:00Z"}}"#
292 ),
293 &format!(
294 r#"{{"id": "{TASK06}", "title": "Closed task", "status": "closed", "blockers": ["{TASK05}"], "created_at": "2026-01-01T00:00:00Z", "updated_at": "2026-01-01T00:00:00Z"}}"#
295 ),
296 ],
297 );
298
299 let report = doctor_json(&tmp, true);
300 // Inert cycle is reported but not fixed.
301 assert_eq!(report["summary"]["blocker_cycles"], 1);
302 assert_eq!(report["summary"]["fixed"], 0);
303 assert!(!report["findings"][0]["fixed"].as_bool().unwrap());
304}
305
306// --- Parent cycle ---
307
308#[test]
309fn doctor_detects_parent_cycle() {
310 let tmp = init_tmp();
311
312 import_jsonl(
313 &tmp,
314 &[
315 &format!(
316 r#"{{"id": "{TASK07}", "title": "Task E", "parent": "{TASK08}", "created_at": "2026-01-01T00:00:00Z", "updated_at": "2026-01-01T00:00:00Z"}}"#
317 ),
318 &format!(
319 r#"{{"id": "{TASK08}", "title": "Task F", "parent": "{TASK07}", "created_at": "2026-01-01T00:00:00Z", "updated_at": "2026-01-01T00:00:00Z"}}"#
320 ),
321 ],
322 );
323
324 let report = doctor_json(&tmp, false);
325 assert_eq!(report["summary"]["parent_cycles"], 1);
326}
327
328#[test]
329fn doctor_fix_breaks_parent_cycle() {
330 let tmp = init_tmp();
331
332 import_jsonl(
333 &tmp,
334 &[
335 &format!(
336 r#"{{"id": "{TASK07}", "title": "Task E", "parent": "{TASK08}", "created_at": "2026-01-01T00:00:00Z", "updated_at": "2026-01-01T00:00:00Z"}}"#
337 ),
338 &format!(
339 r#"{{"id": "{TASK08}", "title": "Task F", "parent": "{TASK07}", "created_at": "2026-01-01T00:00:00Z", "updated_at": "2026-01-01T00:00:00Z"}}"#
340 ),
341 ],
342 );
343
344 let report = doctor_json(&tmp, true);
345 assert_eq!(report["summary"]["fixed"], 1);
346
347 // The lower ULID (TASK07) should have its parent cleared.
348 let out = td(&tmp)
349 .args(["--json", "show", TASK07])
350 .current_dir(&tmp)
351 .output()
352 .unwrap();
353 let task: serde_json::Value = serde_json::from_slice(&out.stdout).unwrap();
354 assert!(
355 task["parent"].is_null(),
356 "expected parent to be cleared (null or absent), got: {}",
357 task["parent"]
358 );
359
360 // Re-run: clean.
361 let clean = doctor_json(&tmp, false);
362 assert_eq!(clean["summary"]["total"], 0);
363}
364
365// --- Transitive blocker cycle ---
366
367#[test]
368fn doctor_detects_transitive_blocker_cycle() {
369 let tmp = init_tmp();
370
371 // Three-node cycle: A → B → C → A
372 import_jsonl(
373 &tmp,
374 &[
375 &format!(
376 r#"{{"id": "{TASK0A}", "title": "Task A", "blockers": ["{TASK0B}"], "created_at": "2026-01-01T00:00:00Z", "updated_at": "2026-01-01T00:00:00Z"}}"#
377 ),
378 &format!(
379 r#"{{"id": "{TASK0B}", "title": "Task B", "blockers": ["{TASK0C}"], "created_at": "2026-01-01T00:00:00Z", "updated_at": "2026-01-01T00:00:00Z"}}"#
380 ),
381 &format!(
382 r#"{{"id": "{TASK0C}", "title": "Task C", "blockers": ["{TASK0A}"], "created_at": "2026-01-01T00:00:00Z", "updated_at": "2026-01-01T00:00:00Z"}}"#
383 ),
384 ],
385 );
386
387 let report = doctor_json(&tmp, false);
388 assert_eq!(report["summary"]["blocker_cycles"], 1);
389 assert!(report["findings"][0]["active"].as_bool().unwrap());
390}
391
392#[test]
393fn doctor_fix_breaks_transitive_blocker_cycle() {
394 let tmp = init_tmp();
395
396 import_jsonl(
397 &tmp,
398 &[
399 &format!(
400 r#"{{"id": "{TASK0A}", "title": "Task A", "blockers": ["{TASK0B}"], "created_at": "2026-01-01T00:00:00Z", "updated_at": "2026-01-01T00:00:00Z"}}"#
401 ),
402 &format!(
403 r#"{{"id": "{TASK0B}", "title": "Task B", "blockers": ["{TASK0C}"], "created_at": "2026-01-01T00:00:00Z", "updated_at": "2026-01-01T00:00:00Z"}}"#
404 ),
405 &format!(
406 r#"{{"id": "{TASK0C}", "title": "Task C", "blockers": ["{TASK0A}"], "created_at": "2026-01-01T00:00:00Z", "updated_at": "2026-01-01T00:00:00Z"}}"#
407 ),
408 ],
409 );
410
411 let report = doctor_json(&tmp, true);
412 assert_eq!(report["summary"]["fixed"], 1);
413
414 // Re-run: clean.
415 let clean = doctor_json(&tmp, false);
416 assert_eq!(clean["summary"]["total"], 0);
417}
418
419// --- Multiple issues ---
420
421#[test]
422fn doctor_detects_multiple_issues() {
423 let tmp = init_tmp();
424
425 import_jsonl(
426 &tmp,
427 &[
428 // Dangling parent.
429 &format!(
430 r#"{{"id": "{TASK10}", "title": "Orphan", "parent": "{GHOST3}", "created_at": "2026-01-01T00:00:00Z", "updated_at": "2026-01-01T00:00:00Z"}}"#
431 ),
432 // Dangling blocker.
433 &format!(
434 r#"{{"id": "{TASK11}", "title": "Bad dep", "blockers": ["{GHOST4}"], "created_at": "2026-01-01T00:00:00Z", "updated_at": "2026-01-01T00:00:00Z"}}"#
435 ),
436 // Blocker cycle.
437 &format!(
438 r#"{{"id": "{TASK12}", "title": "Cycle A", "blockers": ["{TASK13}"], "created_at": "2026-01-01T00:00:00Z", "updated_at": "2026-01-01T00:00:00Z"}}"#
439 ),
440 &format!(
441 r#"{{"id": "{TASK13}", "title": "Cycle B", "blockers": ["{TASK12}"], "created_at": "2026-01-01T00:00:00Z", "updated_at": "2026-01-01T00:00:00Z"}}"#
442 ),
443 ],
444 );
445
446 let report = doctor_json(&tmp, false);
447 assert_eq!(report["summary"]["dangling_parents"], 1);
448 assert_eq!(report["summary"]["dangling_blockers"], 1);
449 assert_eq!(report["summary"]["blocker_cycles"], 1);
450 assert_eq!(report["summary"]["total"], 3);
451}
452
453#[test]
454fn doctor_fix_repairs_all_issues_at_once() {
455 let tmp = init_tmp();
456
457 import_jsonl(
458 &tmp,
459 &[
460 &format!(
461 r#"{{"id": "{TASK10}", "title": "Orphan", "parent": "{GHOST3}", "created_at": "2026-01-01T00:00:00Z", "updated_at": "2026-01-01T00:00:00Z"}}"#
462 ),
463 &format!(
464 r#"{{"id": "{TASK11}", "title": "Bad dep", "blockers": ["{GHOST4}"], "created_at": "2026-01-01T00:00:00Z", "updated_at": "2026-01-01T00:00:00Z"}}"#
465 ),
466 &format!(
467 r#"{{"id": "{TASK12}", "title": "Cycle A", "blockers": ["{TASK13}"], "created_at": "2026-01-01T00:00:00Z", "updated_at": "2026-01-01T00:00:00Z"}}"#
468 ),
469 &format!(
470 r#"{{"id": "{TASK13}", "title": "Cycle B", "blockers": ["{TASK12}"], "created_at": "2026-01-01T00:00:00Z", "updated_at": "2026-01-01T00:00:00Z"}}"#
471 ),
472 ],
473 );
474
475 let report = doctor_json(&tmp, true);
476 assert_eq!(report["summary"]["total"], 3);
477 assert_eq!(report["summary"]["fixed"], 3);
478
479 let clean = doctor_json(&tmp, false);
480 assert_eq!(clean["summary"]["total"], 0);
481}
482
483// --- Without --fix, doctor is read-only ---
484
485#[test]
486fn doctor_without_fix_does_not_modify_data() {
487 let tmp = init_tmp();
488
489 import_jsonl(
490 &tmp,
491 &[&format!(
492 r#"{{"id": "{TASK20}", "title": "Orphan", "parent": "{GHOST5}", "created_at": "2026-01-01T00:00:00Z", "updated_at": "2026-01-01T00:00:00Z"}}"#
493 )],
494 );
495
496 // Run doctor twice without --fix.
497 let first = doctor_json(&tmp, false);
498 let second = doctor_json(&tmp, false);
499
500 // Same findings both times: nothing changed.
501 // Compare findings arrays specifically (not full report to avoid timestamp noise).
502 assert_eq!(
503 first["findings"], second["findings"],
504 "Running doctor without --fix should be idempotent"
505 );
506 assert_eq!(first["summary"]["fixed"], 0);
507 assert_eq!(second["summary"]["fixed"], 0);
508}
509
510// --- Human-readable output ---
511
512#[test]
513fn doctor_human_output_suggests_fix() {
514 let tmp = init_tmp();
515
516 import_jsonl(
517 &tmp,
518 &[&format!(
519 r#"{{"id": "{TASK30}", "title": "Bad parent", "parent": "{GHOST6}", "created_at": "2026-01-01T00:00:00Z", "updated_at": "2026-01-01T00:00:00Z"}}"#
520 )],
521 );
522
523 td(&tmp)
524 .args(["doctor"])
525 .current_dir(&tmp)
526 .assert()
527 .success()
528 .stderr(predicate::str::contains("dangling parent"))
529 .stderr(predicate::str::contains("Run with --fix to repair"));
530}
531
532#[test]
533fn doctor_human_output_shows_fixed() {
534 let tmp = init_tmp();
535
536 import_jsonl(
537 &tmp,
538 &[&format!(
539 r#"{{"id": "{TASK30}", "title": "Bad parent", "parent": "{GHOST6}", "created_at": "2026-01-01T00:00:00Z", "updated_at": "2026-01-01T00:00:00Z"}}"#
540 )],
541 );
542
543 td(&tmp)
544 .args(["doctor", "--fix"])
545 .current_dir(&tmp)
546 .assert()
547 .success()
548 .stderr(predicate::str::contains("fixed:"));
549}