cli_doctor.rs

  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}