cli_dep.rs

  1use assert_cmd::Command;
  2use predicates::prelude::*;
  3use tempfile::TempDir;
  4
  5fn td() -> Command {
  6    Command::cargo_bin("td").unwrap()
  7}
  8
  9fn init_tmp() -> TempDir {
 10    let tmp = TempDir::new().unwrap();
 11    td().arg("init").current_dir(&tmp).assert().success();
 12    tmp
 13}
 14
 15fn create_task(dir: &TempDir, title: &str) -> String {
 16    let out = td()
 17        .args(["--json", "create", title])
 18        .current_dir(dir)
 19        .output()
 20        .unwrap();
 21    let v: serde_json::Value = serde_json::from_slice(&out.stdout).unwrap();
 22    v["id"].as_str().unwrap().to_string()
 23}
 24
 25fn get_task_json(dir: &TempDir, id: &str) -> serde_json::Value {
 26    let out = td()
 27        .args(["--json", "show", id])
 28        .current_dir(dir)
 29        .output()
 30        .unwrap();
 31    serde_json::from_slice(&out.stdout).unwrap()
 32}
 33
 34#[test]
 35fn dep_add_creates_blocker() {
 36    let tmp = init_tmp();
 37    let a = create_task(&tmp, "Blocked task");
 38    let b = create_task(&tmp, "Blocker");
 39
 40    td().args(["dep", "add", &a, &b])
 41        .current_dir(&tmp)
 42        .assert()
 43        .success()
 44        .stdout(predicate::str::contains("blocked by"));
 45
 46    let t = get_task_json(&tmp, &a);
 47    let blockers = t["blockers"].as_array().unwrap();
 48    assert!(blockers.contains(&serde_json::Value::String(b)));
 49}
 50
 51#[test]
 52fn dep_rm_removes_blocker() {
 53    let tmp = init_tmp();
 54    let a = create_task(&tmp, "Was blocked");
 55    let b = create_task(&tmp, "Was blocker");
 56
 57    td().args(["dep", "add", &a, &b])
 58        .current_dir(&tmp)
 59        .assert()
 60        .success();
 61    td().args(["dep", "rm", &a, &b])
 62        .current_dir(&tmp)
 63        .assert()
 64        .success();
 65
 66    let t = get_task_json(&tmp, &a);
 67    let blockers = t["blockers"].as_array().unwrap();
 68    assert!(blockers.is_empty());
 69}
 70
 71#[test]
 72fn dep_tree_shows_children() {
 73    let tmp = init_tmp();
 74    let parent = create_task(&tmp, "Parent");
 75
 76    td().args(["create", "Child one", "--parent", &parent])
 77        .current_dir(&tmp)
 78        .assert()
 79        .success();
 80    td().args(["create", "Child two", "--parent", &parent])
 81        .current_dir(&tmp)
 82        .assert()
 83        .success();
 84
 85    td().args(["dep", "tree", &parent])
 86        .current_dir(&tmp)
 87        .assert()
 88        .success()
 89        .stdout(predicate::str::contains(&parent))
 90        .stdout(predicate::str::contains(".1"))
 91        .stdout(predicate::str::contains(".2"));
 92}
 93
 94#[test]
 95fn dep_add_rejects_self_cycle() {
 96    let tmp = init_tmp();
 97    let a = create_task(&tmp, "Self-referential");
 98
 99    td().args(["dep", "add", &a, &a])
100        .current_dir(&tmp)
101        .assert()
102        .failure()
103        .stderr(predicate::str::contains("cycle"));
104}
105
106#[test]
107fn dep_add_rejects_direct_cycle() {
108    let tmp = init_tmp();
109    let a = create_task(&tmp, "Task A");
110    let b = create_task(&tmp, "Task B");
111
112    // A blocked by B
113    td().args(["dep", "add", &a, &b])
114        .current_dir(&tmp)
115        .assert()
116        .success();
117
118    // B blocked by A would create A → B → A
119    td().args(["dep", "add", &b, &a])
120        .current_dir(&tmp)
121        .assert()
122        .failure()
123        .stderr(predicate::str::contains("cycle"));
124}
125
126#[test]
127fn dep_add_rejects_transitive_cycle() {
128    let tmp = init_tmp();
129    let a = create_task(&tmp, "Task A");
130    let b = create_task(&tmp, "Task B");
131    let c = create_task(&tmp, "Task C");
132
133    // A blocked by B, B blocked by C
134    td().args(["dep", "add", &a, &b])
135        .current_dir(&tmp)
136        .assert()
137        .success();
138    td().args(["dep", "add", &b, &c])
139        .current_dir(&tmp)
140        .assert()
141        .success();
142
143    // C blocked by A would create A → B → C → A
144    td().args(["dep", "add", &c, &a])
145        .current_dir(&tmp)
146        .assert()
147        .failure()
148        .stderr(predicate::str::contains("cycle"));
149}
150
151#[test]
152fn dep_add_allows_diamond_without_cycle() {
153    let tmp = init_tmp();
154    let a = create_task(&tmp, "Task A");
155    let b = create_task(&tmp, "Task B");
156    let c = create_task(&tmp, "Task C");
157    let d = create_task(&tmp, "Task D");
158
159    // Diamond: D blocked by B and C, both blocked by A
160    td().args(["dep", "add", &d, &b])
161        .current_dir(&tmp)
162        .assert()
163        .success();
164    td().args(["dep", "add", &d, &c])
165        .current_dir(&tmp)
166        .assert()
167        .success();
168    td().args(["dep", "add", &b, &a])
169        .current_dir(&tmp)
170        .assert()
171        .success();
172    td().args(["dep", "add", &c, &a])
173        .current_dir(&tmp)
174        .assert()
175        .success();
176
177    // Verify all edges exist — no false cycle detection
178    let t = get_task_json(&tmp, &d);
179    let blockers = t["blockers"].as_array().unwrap();
180    assert_eq!(blockers.len(), 2);
181}
182
183#[test]
184fn dep_add_rejects_nonexistent_child() {
185    let tmp = init_tmp();
186    let real = create_task(&tmp, "Real task");
187
188    td().args(["dep", "add", "td-ghost", &real])
189        .current_dir(&tmp)
190        .assert()
191        .failure()
192        .stderr(predicate::str::contains("task 'td-ghost' not found"));
193}
194
195#[test]
196fn dep_add_rejects_nonexistent_parent() {
197    let tmp = init_tmp();
198    let real = create_task(&tmp, "Real task");
199
200    td().args(["dep", "add", &real, "td-phantom"])
201        .current_dir(&tmp)
202        .assert()
203        .failure()
204        .stderr(predicate::str::contains("task 'td-phantom' not found"));
205}