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}