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}