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[..7]))
107        .stdout(predicate::str::contains(&subtask_one_id[..7]))
108        .stdout(predicate::str::contains(&subtask_two_id[..7]));
109}
110
111#[test]
112fn dep_add_rejects_self_cycle() {
113    let tmp = init_tmp();
114    let a = create_task(&tmp, "Self-referential");
115
116    td(&tmp)
117        .args(["dep", "add", &a, &a])
118        .current_dir(&tmp)
119        .assert()
120        .failure()
121        .stderr(predicate::str::contains("cycle"));
122}
123
124#[test]
125fn dep_add_rejects_direct_cycle() {
126    let tmp = init_tmp();
127    let a = create_task(&tmp, "Task A");
128    let b = create_task(&tmp, "Task B");
129
130    // A blocked by B
131    td(&tmp)
132        .args(["dep", "add", &a, &b])
133        .current_dir(&tmp)
134        .assert()
135        .success();
136
137    // B blocked by A would create A → B → A
138    td(&tmp)
139        .args(["dep", "add", &b, &a])
140        .current_dir(&tmp)
141        .assert()
142        .failure()
143        .stderr(predicate::str::contains("cycle"));
144}
145
146#[test]
147fn dep_add_rejects_transitive_cycle() {
148    let tmp = init_tmp();
149    let a = create_task(&tmp, "Task A");
150    let b = create_task(&tmp, "Task B");
151    let c = create_task(&tmp, "Task C");
152
153    // A blocked by B, B blocked by C
154    td(&tmp)
155        .args(["dep", "add", &a, &b])
156        .current_dir(&tmp)
157        .assert()
158        .success();
159    td(&tmp)
160        .args(["dep", "add", &b, &c])
161        .current_dir(&tmp)
162        .assert()
163        .success();
164
165    // C blocked by A would create A → B → C → A
166    td(&tmp)
167        .args(["dep", "add", &c, &a])
168        .current_dir(&tmp)
169        .assert()
170        .failure()
171        .stderr(predicate::str::contains("cycle"));
172}
173
174#[test]
175fn dep_add_allows_diamond_without_cycle() {
176    let tmp = init_tmp();
177    let a = create_task(&tmp, "Task A");
178    let b = create_task(&tmp, "Task B");
179    let c = create_task(&tmp, "Task C");
180    let d = create_task(&tmp, "Task D");
181
182    // Diamond: D blocked by B and C, both blocked by A
183    td(&tmp)
184        .args(["dep", "add", &d, &b])
185        .current_dir(&tmp)
186        .assert()
187        .success();
188    td(&tmp)
189        .args(["dep", "add", &d, &c])
190        .current_dir(&tmp)
191        .assert()
192        .success();
193    td(&tmp)
194        .args(["dep", "add", &b, &a])
195        .current_dir(&tmp)
196        .assert()
197        .success();
198    td(&tmp)
199        .args(["dep", "add", &c, &a])
200        .current_dir(&tmp)
201        .assert()
202        .success();
203
204    // Verify all edges exist — no false cycle detection
205    let t = get_task_json(&tmp, &d);
206    let blockers = t["blockers"].as_array().unwrap();
207    assert_eq!(blockers.len(), 2);
208}
209
210#[test]
211fn dep_add_rejects_nonexistent_child() {
212    let tmp = init_tmp();
213    let real = create_task(&tmp, "Real task");
214
215    td(&tmp)
216        .args(["dep", "add", "td-ghost", &real])
217        .current_dir(&tmp)
218        .assert()
219        .failure()
220        .stderr(predicate::str::contains("task 'td-ghost' not found"));
221}
222
223#[test]
224fn dep_add_rejects_nonexistent_parent() {
225    let tmp = init_tmp();
226    let real = create_task(&tmp, "Real task");
227
228    td(&tmp)
229        .args(["dep", "add", &real, "td-phantom"])
230        .current_dir(&tmp)
231        .assert()
232        .failure()
233        .stderr(predicate::str::contains("task 'td-phantom' not found"));
234}