1use assert_cmd::cargo::cargo_bin_cmd;
2use predicates::prelude::*;
3use tempfile::TempDir;
4
5fn td(home: &TempDir) -> assert_cmd::Command {
6 let mut cmd = cargo_bin_cmd!("td");
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(["project", "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// ── update ───────────────────────────────────────────────────────────
41
42#[test]
43fn update_changes_status() {
44 let tmp = init_tmp();
45 let id = create_task(&tmp, "In progress");
46
47 td(&tmp)
48 .args(["update", &id, "-s", "in_progress"])
49 .current_dir(&tmp)
50 .assert()
51 .success()
52 .stdout(predicate::str::contains("updated"));
53
54 let t = get_task_json(&tmp, &id);
55 assert_eq!(t["status"].as_str().unwrap(), "in_progress");
56}
57
58#[test]
59fn update_changes_priority() {
60 let tmp = init_tmp();
61 let id = create_task(&tmp, "Reprioritise");
62
63 td(&tmp)
64 .args(["update", &id, "-p", "high"])
65 .current_dir(&tmp)
66 .assert()
67 .success();
68
69 let t = get_task_json(&tmp, &id);
70 assert_eq!(t["priority"].as_str().unwrap(), "high");
71}
72
73#[test]
74fn update_changes_title() {
75 let tmp = init_tmp();
76 let id = create_task(&tmp, "Old title");
77
78 td(&tmp)
79 .args(["update", &id, "-t", "New title"])
80 .current_dir(&tmp)
81 .assert()
82 .success();
83
84 let t = get_task_json(&tmp, &id);
85 assert_eq!(t["title"].as_str().unwrap(), "New title");
86}
87
88#[test]
89fn update_changes_description() {
90 let tmp = init_tmp();
91 let id = create_task(&tmp, "Describe me");
92
93 td(&tmp)
94 .args(["update", &id, "-d", "Now with details"])
95 .current_dir(&tmp)
96 .assert()
97 .success();
98
99 let t = get_task_json(&tmp, &id);
100 assert_eq!(t["description"].as_str().unwrap(), "Now with details");
101}
102
103#[test]
104fn update_json_returns_task() {
105 let tmp = init_tmp();
106 let id = create_task(&tmp, "JSON update");
107
108 let out = td(&tmp)
109 .args(["--json", "update", &id, "-p", "high"])
110 .current_dir(&tmp)
111 .output()
112 .unwrap();
113 let v: serde_json::Value = serde_json::from_slice(&out.stdout).unwrap();
114 assert_eq!(v["priority"].as_str().unwrap(), "high");
115}
116
117#[test]
118fn update_changes_effort() {
119 let tmp = init_tmp();
120 let id = create_task(&tmp, "Re-estimate");
121
122 td(&tmp)
123 .args(["update", &id, "-e", "high"])
124 .current_dir(&tmp)
125 .assert()
126 .success();
127
128 let t = get_task_json(&tmp, &id);
129 assert_eq!(t["effort"].as_str().unwrap(), "high");
130}
131
132#[test]
133fn update_changes_task_type() {
134 let tmp = init_tmp();
135 let id = create_task(&tmp, "Reclassify me");
136
137 // Default type is "task"
138 let t = get_task_json(&tmp, &id);
139 assert_eq!(t["type"].as_str().unwrap(), "task");
140
141 td(&tmp)
142 .args(["update", &id, "--type", "bug"])
143 .current_dir(&tmp)
144 .assert()
145 .success();
146
147 let t = get_task_json(&tmp, &id);
148 assert_eq!(t["type"].as_str().unwrap(), "bug");
149}
150
151// ── done ─────────────────────────────────────────────────────────────
152
153#[test]
154fn done_closes_task() {
155 let tmp = init_tmp();
156 let id = create_task(&tmp, "Close me");
157
158 td(&tmp)
159 .args(["done", &id])
160 .current_dir(&tmp)
161 .assert()
162 .success()
163 .stdout(predicate::str::contains("closed"));
164
165 let t = get_task_json(&tmp, &id);
166 assert_eq!(t["status"].as_str().unwrap(), "closed");
167}
168
169#[test]
170fn done_closes_multiple_tasks() {
171 let tmp = init_tmp();
172 let id1 = create_task(&tmp, "First");
173 let id2 = create_task(&tmp, "Second");
174
175 td(&tmp)
176 .args(["done", &id1, &id2])
177 .current_dir(&tmp)
178 .assert()
179 .success();
180
181 assert_eq!(get_task_json(&tmp, &id1)["status"], "closed");
182 assert_eq!(get_task_json(&tmp, &id2)["status"], "closed");
183}
184
185// ── reopen ───────────────────────────────────────────────────────────
186
187#[test]
188fn reopen_reopens_closed_task() {
189 let tmp = init_tmp();
190 let id = create_task(&tmp, "Reopen me");
191
192 td(&tmp)
193 .args(["done", &id])
194 .current_dir(&tmp)
195 .assert()
196 .success();
197 assert_eq!(get_task_json(&tmp, &id)["status"], "closed");
198
199 td(&tmp)
200 .args(["reopen", &id])
201 .current_dir(&tmp)
202 .assert()
203 .success()
204 .stdout(predicate::str::contains("reopened"));
205
206 assert_eq!(get_task_json(&tmp, &id)["status"], "open");
207}
208
209// ── editor fallback ───────────────────────────────────────────────────────────
210
211#[test]
212fn update_via_editor_changes_title_and_desc() {
213 // Bare `td update <id>` with TD_FORCE_EDITOR should open the editor
214 // pre-populated and apply whatever the fake editor writes back.
215 let tmp = init_tmp();
216 let id = create_task(&tmp, "Original title");
217
218 let fake_editor = "sh -c 'printf \"New title\\nNew description\" > \"$1\"' sh";
219
220 td(&tmp)
221 .args(["update", &id])
222 .env("TD_FORCE_EDITOR", fake_editor)
223 .current_dir(&tmp)
224 .assert()
225 .success();
226
227 let t = get_task_json(&tmp, &id);
228 assert_eq!(t["title"].as_str().unwrap(), "New title");
229 assert_eq!(t["description"].as_str().unwrap(), "New description");
230}
231
232#[test]
233fn update_via_editor_aborts_on_empty_file() {
234 // If the fake editor leaves only comments, the update should be aborted.
235 let tmp = init_tmp();
236 let id = create_task(&tmp, "Stays the same");
237
238 let fake_editor = "sh -c 'printf \"TD: comment only\\n\" > \"$1\"' sh";
239
240 td(&tmp)
241 .args(["update", &id])
242 .env("TD_FORCE_EDITOR", fake_editor)
243 .current_dir(&tmp)
244 .assert()
245 .failure()
246 .stderr(predicate::str::contains("aborted"));
247
248 // Title must be unchanged.
249 let t = get_task_json(&tmp, &id);
250 assert_eq!(t["title"].as_str().unwrap(), "Stays the same");
251}
252
253#[test]
254fn update_via_editor_preserves_existing_content_as_template() {
255 // The editor should be pre-populated with the task's current title and
256 // description, so the user can edit rather than retype from scratch.
257 let tmp = init_tmp();
258 let id = create_task(&tmp, "Existing title");
259
260 // Set description first.
261 td(&tmp)
262 .args(["update", &id, "-d", "Existing description"])
263 .current_dir(&tmp)
264 .assert()
265 .success();
266
267 // Fake editor: grep out the first non-comment, non-blank line (the title),
268 // then write "title: <that line>" so we can assert it was pre-populated.
269 let fake_editor = concat!(
270 "sh -c '",
271 r#"title=$(grep -v "^TD: " "$1" | grep -v "^[[:space:]]*$" | head -1); "#,
272 r#"printf "title: %s" "$title" > "$1""#,
273 "' sh"
274 );
275
276 td(&tmp)
277 .args(["update", &id])
278 .env("TD_FORCE_EDITOR", fake_editor)
279 .current_dir(&tmp)
280 .assert()
281 .success();
282
283 let t = get_task_json(&tmp, &id);
284 // The fake editor wrote the existing title back with a prefix, proving it
285 // received the pre-populated template.
286 assert_eq!(t["title"].as_str().unwrap(), "title: Existing title");
287}