cli_dep.rs

  1use assert_cmd::Command;
  2use predicates::prelude::*;
  3use tempfile::TempDir;
  4
  5fn td(home: &TempDir) -> Command {
  6    let mut cmd = Command::cargo_bin("td").unwrap();
  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(["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 get_task_json(dir: &TempDir, id: &str) -> serde_json::Value {
 32    let out = td(dir)
 33        .args(["--json", "show", id])
 34        .current_dir(dir)
 35        .output()
 36        .unwrap();
 37    serde_json::from_slice(&out.stdout).unwrap()
 38}
 39
 40#[test]
 41fn dep_add_creates_blocker() {
 42    let tmp = init_tmp();
 43    let a = create_task(&tmp, "Blocked task");
 44    let b = create_task(&tmp, "Blocker");
 45
 46    td(&tmp)
 47        .args(["dep", "add", &a, &b])
 48        .current_dir(&tmp)
 49        .assert()
 50        .success()
 51        .stdout(predicate::str::contains("blocked by"));
 52
 53    let t = get_task_json(&tmp, &a);
 54    let blockers = t["blockers"].as_array().unwrap();
 55    assert!(blockers.contains(&serde_json::Value::String(b)));
 56}
 57
 58#[test]
 59fn dep_rm_removes_blocker() {
 60    let tmp = init_tmp();
 61    let a = create_task(&tmp, "Was blocked");
 62    let b = create_task(&tmp, "Was blocker");
 63
 64    td(&tmp)
 65        .args(["dep", "add", &a, &b])
 66        .current_dir(&tmp)
 67        .assert()
 68        .success();
 69    td(&tmp)
 70        .args(["dep", "rm", &a, &b])
 71        .current_dir(&tmp)
 72        .assert()
 73        .success();
 74
 75    let t = get_task_json(&tmp, &a);
 76    let blockers = t["blockers"].as_array().unwrap();
 77    assert!(blockers.is_empty());
 78}
 79
 80#[test]
 81fn dep_tree_shows_children() {
 82    let tmp = init_tmp();
 83    let parent = create_task(&tmp, "Parent");
 84
 85    let out = td(&tmp)
 86        .args(["--json", "create", "Subtask one", "--parent", &parent])
 87        .current_dir(&tmp)
 88        .output()
 89        .unwrap();
 90    let subtask_one: serde_json::Value = serde_json::from_slice(&out.stdout).unwrap();
 91    let subtask_one_id = subtask_one["id"].as_str().unwrap().to_string();
 92
 93    let out = td(&tmp)
 94        .args(["--json", "create", "Subtask two", "--parent", &parent])
 95        .current_dir(&tmp)
 96        .output()
 97        .unwrap();
 98    let subtask_two: serde_json::Value = serde_json::from_slice(&out.stdout).unwrap();
 99    let subtask_two_id = subtask_two["id"].as_str().unwrap().to_string();
100
101    td(&tmp)
102        .args(["dep", "tree", &parent])
103        .current_dir(&tmp)
104        .assert()
105        .success()
106        .stdout(predicate::str::contains(&parent[parent.len() - 7..]))
107        .stdout(predicate::str::contains(
108            &subtask_one_id[subtask_one_id.len() - 7..],
109        ))
110        .stdout(predicate::str::contains(
111            &subtask_two_id[subtask_two_id.len() - 7..],
112        ));
113}
114
115#[test]
116fn dep_add_rejects_self_cycle() {
117    let tmp = init_tmp();
118    let a = create_task(&tmp, "Self-referential");
119
120    td(&tmp)
121        .args(["dep", "add", &a, &a])
122        .current_dir(&tmp)
123        .assert()
124        .failure()
125        .stderr(predicate::str::contains("cycle"));
126}
127
128#[test]
129fn dep_add_rejects_direct_cycle() {
130    let tmp = init_tmp();
131    let a = create_task(&tmp, "Task A");
132    let b = create_task(&tmp, "Task B");
133
134    // A blocked by B
135    td(&tmp)
136        .args(["dep", "add", &a, &b])
137        .current_dir(&tmp)
138        .assert()
139        .success();
140
141    // B blocked by A would create A → B → A
142    td(&tmp)
143        .args(["dep", "add", &b, &a])
144        .current_dir(&tmp)
145        .assert()
146        .failure()
147        .stderr(predicate::str::contains("cycle"));
148}
149
150#[test]
151fn dep_add_rejects_transitive_cycle() {
152    let tmp = init_tmp();
153    let a = create_task(&tmp, "Task A");
154    let b = create_task(&tmp, "Task B");
155    let c = create_task(&tmp, "Task C");
156
157    // A blocked by B, B blocked by C
158    td(&tmp)
159        .args(["dep", "add", &a, &b])
160        .current_dir(&tmp)
161        .assert()
162        .success();
163    td(&tmp)
164        .args(["dep", "add", &b, &c])
165        .current_dir(&tmp)
166        .assert()
167        .success();
168
169    // C blocked by A would create A → B → C → A
170    td(&tmp)
171        .args(["dep", "add", &c, &a])
172        .current_dir(&tmp)
173        .assert()
174        .failure()
175        .stderr(predicate::str::contains("cycle"));
176}
177
178#[test]
179fn dep_add_allows_diamond_without_cycle() {
180    let tmp = init_tmp();
181    let a = create_task(&tmp, "Task A");
182    let b = create_task(&tmp, "Task B");
183    let c = create_task(&tmp, "Task C");
184    let d = create_task(&tmp, "Task D");
185
186    // Diamond: D blocked by B and C, both blocked by A
187    td(&tmp)
188        .args(["dep", "add", &d, &b])
189        .current_dir(&tmp)
190        .assert()
191        .success();
192    td(&tmp)
193        .args(["dep", "add", &d, &c])
194        .current_dir(&tmp)
195        .assert()
196        .success();
197    td(&tmp)
198        .args(["dep", "add", &b, &a])
199        .current_dir(&tmp)
200        .assert()
201        .success();
202    td(&tmp)
203        .args(["dep", "add", &c, &a])
204        .current_dir(&tmp)
205        .assert()
206        .success();
207
208    // Verify all edges exist — no false cycle detection
209    let t = get_task_json(&tmp, &d);
210    let blockers = t["blockers"].as_array().unwrap();
211    assert_eq!(blockers.len(), 2);
212}
213
214#[test]
215fn dep_add_rejects_nonexistent_child() {
216    let tmp = init_tmp();
217    let real = create_task(&tmp, "Real task");
218
219    td(&tmp)
220        .args(["dep", "add", "td-ghost", &real])
221        .current_dir(&tmp)
222        .assert()
223        .failure()
224        .stderr(predicate::str::contains("task 'ghost' not found"));
225}
226
227#[test]
228fn dep_add_rejects_nonexistent_parent() {
229    let tmp = init_tmp();
230    let real = create_task(&tmp, "Real task");
231
232    td(&tmp)
233        .args(["dep", "add", &real, "td-phantom"])
234        .current_dir(&tmp)
235        .assert()
236        .failure()
237        .stderr(predicate::str::contains("task 'phantom' not found"));
238}