handoff-mcp-server 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,408 @@
1
+ use serde_json::{json, Value};
2
+ use tempfile::TempDir;
3
+
4
+ fn send(input: &str) -> Option<Value> {
5
+ let result = handoff_mcp::mcp::protocol::process_line(input)?;
6
+ Some(serde_json::from_str(&result).expect("response should be valid JSON"))
7
+ }
8
+
9
+ fn setup_project() -> TempDir {
10
+ let dir = tempfile::tempdir().expect("failed to create temp dir");
11
+ let req = json!({
12
+ "jsonrpc": "2.0", "id": 0,
13
+ "method": "tools/call",
14
+ "params": {
15
+ "name": "handoff_init",
16
+ "arguments": {
17
+ "project_dir": dir.path().to_string_lossy(),
18
+ "project_name": "test"
19
+ }
20
+ }
21
+ });
22
+ send(&req.to_string()).unwrap();
23
+ dir
24
+ }
25
+
26
+ fn call_tool(name: &str, arguments: Value) -> Value {
27
+ let req = json!({
28
+ "jsonrpc": "2.0", "id": 1,
29
+ "method": "tools/call",
30
+ "params": { "name": name, "arguments": arguments }
31
+ });
32
+ send(&req.to_string()).unwrap()
33
+ }
34
+
35
+ fn get_text(resp: &Value) -> String {
36
+ resp["result"]["content"][0]["text"]
37
+ .as_str()
38
+ .unwrap_or("")
39
+ .to_string()
40
+ }
41
+
42
+ fn is_error(resp: &Value) -> bool {
43
+ resp["result"]["isError"].as_bool().unwrap_or(false)
44
+ }
45
+
46
+ #[test]
47
+ fn create_top_level_task() {
48
+ let dir = setup_project();
49
+ let resp = call_tool(
50
+ "handoff_update_task",
51
+ json!({
52
+ "project_dir": dir.path().to_string_lossy(),
53
+ "task": { "title": "First task" }
54
+ }),
55
+ );
56
+
57
+ assert!(!is_error(&resp), "error: {}", get_text(&resp));
58
+ let text = get_text(&resp);
59
+ assert!(text.contains("t1"));
60
+ assert!(text.contains("First task"));
61
+
62
+ let tasks = std::fs::read_dir(dir.path().join(".handoff/tasks"))
63
+ .unwrap()
64
+ .filter_map(|e| e.ok())
65
+ .filter(|e| e.file_type().unwrap().is_dir())
66
+ .count();
67
+ assert_eq!(tasks, 1);
68
+ }
69
+
70
+ #[test]
71
+ fn create_child_task() {
72
+ let dir = setup_project();
73
+ let pd = dir.path().to_string_lossy().to_string();
74
+
75
+ call_tool(
76
+ "handoff_update_task",
77
+ json!({ "project_dir": &pd, "task": { "title": "Parent" } }),
78
+ );
79
+
80
+ let resp = call_tool(
81
+ "handoff_update_task",
82
+ json!({
83
+ "project_dir": &pd,
84
+ "task": { "title": "Child task" },
85
+ "parent_id": "t1"
86
+ }),
87
+ );
88
+
89
+ assert!(!is_error(&resp));
90
+ let text = get_text(&resp);
91
+ assert!(text.contains("t1.1"));
92
+ }
93
+
94
+ #[test]
95
+ fn update_existing_task() {
96
+ let dir = setup_project();
97
+ let pd = dir.path().to_string_lossy().to_string();
98
+
99
+ call_tool(
100
+ "handoff_update_task",
101
+ json!({ "project_dir": &pd, "task": { "title": "Original" } }),
102
+ );
103
+
104
+ let resp = call_tool(
105
+ "handoff_update_task",
106
+ json!({
107
+ "project_dir": &pd,
108
+ "task": {
109
+ "id": "t1",
110
+ "title": "Updated title",
111
+ "status": "in_progress",
112
+ "notes": "Working on it"
113
+ }
114
+ }),
115
+ );
116
+
117
+ assert!(!is_error(&resp));
118
+ let text = get_text(&resp);
119
+ assert!(text.contains("Updated title"));
120
+ assert!(text.contains("in_progress"));
121
+ }
122
+
123
+ #[test]
124
+ fn status_change_renames_file() {
125
+ let dir = setup_project();
126
+ let pd = dir.path().to_string_lossy().to_string();
127
+
128
+ call_tool(
129
+ "handoff_update_task",
130
+ json!({ "project_dir": &pd, "task": { "title": "Task" } }),
131
+ );
132
+
133
+ call_tool(
134
+ "handoff_update_task",
135
+ json!({
136
+ "project_dir": &pd,
137
+ "task": { "id": "t1", "title": "Task", "status": "in_progress" }
138
+ }),
139
+ );
140
+
141
+ let task_dir = dir.path().join(".handoff/tasks");
142
+ let found: Vec<String> = walkdir(task_dir.to_str().unwrap());
143
+ assert!(
144
+ found.iter().any(|f| f.contains("_task.in_progress.json")),
145
+ "expected in_progress file, found: {found:?}"
146
+ );
147
+ assert!(
148
+ !found.iter().any(|f| f.contains("_task.todo.json")),
149
+ "old status file should be gone"
150
+ );
151
+ }
152
+
153
+ fn walkdir(dir: &str) -> Vec<String> {
154
+ let mut result = Vec::new();
155
+ if let Ok(entries) = std::fs::read_dir(dir) {
156
+ for entry in entries.flatten() {
157
+ let path = entry.path();
158
+ if path.is_dir() {
159
+ result.extend(walkdir(path.to_str().unwrap()));
160
+ } else {
161
+ result.push(path.to_string_lossy().to_string());
162
+ }
163
+ }
164
+ }
165
+ result
166
+ }
167
+
168
+ #[test]
169
+ fn done_with_unchecked_criteria_fails() {
170
+ let dir = setup_project();
171
+ let pd = dir.path().to_string_lossy().to_string();
172
+
173
+ call_tool(
174
+ "handoff_update_task",
175
+ json!({
176
+ "project_dir": &pd,
177
+ "task": {
178
+ "title": "Task with criteria",
179
+ "done_criteria": [
180
+ { "item": "test passes", "checked": false }
181
+ ]
182
+ }
183
+ }),
184
+ );
185
+
186
+ let resp = call_tool(
187
+ "handoff_update_task",
188
+ json!({
189
+ "project_dir": &pd,
190
+ "task": { "id": "t1", "title": "Task with criteria", "status": "done" }
191
+ }),
192
+ );
193
+
194
+ assert!(is_error(&resp), "should fail: {}", get_text(&resp));
195
+ assert!(get_text(&resp).contains("done_criteria"));
196
+ }
197
+
198
+ #[test]
199
+ fn done_with_non_terminal_children_fails() {
200
+ let dir = setup_project();
201
+ let pd = dir.path().to_string_lossy().to_string();
202
+
203
+ call_tool(
204
+ "handoff_update_task",
205
+ json!({ "project_dir": &pd, "task": { "title": "Parent" } }),
206
+ );
207
+
208
+ call_tool(
209
+ "handoff_update_task",
210
+ json!({
211
+ "project_dir": &pd,
212
+ "task": { "title": "Child in progress", "status": "in_progress" },
213
+ "parent_id": "t1"
214
+ }),
215
+ );
216
+
217
+ let resp = call_tool(
218
+ "handoff_update_task",
219
+ json!({
220
+ "project_dir": &pd,
221
+ "task": { "id": "t1", "title": "Parent", "status": "done" }
222
+ }),
223
+ );
224
+
225
+ assert!(is_error(&resp));
226
+ }
227
+
228
+ #[test]
229
+ fn done_with_all_terminal_children_succeeds() {
230
+ let dir = setup_project();
231
+ let pd = dir.path().to_string_lossy().to_string();
232
+
233
+ call_tool(
234
+ "handoff_update_task",
235
+ json!({ "project_dir": &pd, "task": { "title": "Parent" } }),
236
+ );
237
+
238
+ call_tool(
239
+ "handoff_update_task",
240
+ json!({
241
+ "project_dir": &pd,
242
+ "task": { "title": "Child done", "status": "done" },
243
+ "parent_id": "t1"
244
+ }),
245
+ );
246
+
247
+ let resp = call_tool(
248
+ "handoff_update_task",
249
+ json!({
250
+ "project_dir": &pd,
251
+ "task": { "id": "t1", "title": "Parent", "status": "done" }
252
+ }),
253
+ );
254
+
255
+ assert!(!is_error(&resp), "error: {}", get_text(&resp));
256
+ }
257
+
258
+ #[test]
259
+ fn move_task_to_new_parent() {
260
+ let dir = setup_project();
261
+ let pd = dir.path().to_string_lossy().to_string();
262
+
263
+ call_tool(
264
+ "handoff_update_task",
265
+ json!({ "project_dir": &pd, "task": { "title": "Task A" } }),
266
+ );
267
+ call_tool(
268
+ "handoff_update_task",
269
+ json!({ "project_dir": &pd, "task": { "title": "Task B" } }),
270
+ );
271
+
272
+ let resp = call_tool(
273
+ "handoff_update_task",
274
+ json!({
275
+ "project_dir": &pd,
276
+ "task": { "id": "t1", "title": "Task A" },
277
+ "move_to": "t2"
278
+ }),
279
+ );
280
+
281
+ assert!(!is_error(&resp), "error: {}", get_text(&resp));
282
+ assert!(get_text(&resp).contains("Moved"));
283
+
284
+ let t2_children: Vec<_> = std::fs::read_dir(
285
+ dir.path()
286
+ .join(".handoff/tasks")
287
+ .read_dir()
288
+ .unwrap()
289
+ .find(|e| {
290
+ e.as_ref()
291
+ .unwrap()
292
+ .file_name()
293
+ .to_string_lossy()
294
+ .starts_with("t2-")
295
+ })
296
+ .unwrap()
297
+ .unwrap()
298
+ .path(),
299
+ )
300
+ .unwrap()
301
+ .filter_map(|e| e.ok())
302
+ .filter(|e| e.file_type().unwrap().is_dir())
303
+ .collect();
304
+
305
+ assert!(!t2_children.is_empty(), "t1 should be moved under t2");
306
+ }
307
+
308
+ #[test]
309
+ fn list_tasks_returns_tree() {
310
+ let dir = setup_project();
311
+ let pd = dir.path().to_string_lossy().to_string();
312
+
313
+ call_tool(
314
+ "handoff_update_task",
315
+ json!({ "project_dir": &pd, "task": { "title": "Task 1" } }),
316
+ );
317
+ call_tool(
318
+ "handoff_update_task",
319
+ json!({ "project_dir": &pd, "task": { "title": "Task 2", "status": "in_progress" } }),
320
+ );
321
+
322
+ let resp = call_tool("handoff_list_tasks", json!({ "project_dir": &pd }));
323
+
324
+ assert!(!is_error(&resp));
325
+ let text = get_text(&resp);
326
+ let parsed: Value = serde_json::from_str(&text).unwrap();
327
+
328
+ assert_eq!(parsed["task_summary"]["total"], 2);
329
+ assert!(parsed["task_tree"].as_array().unwrap().len() >= 2);
330
+ }
331
+
332
+ #[test]
333
+ fn list_tasks_with_status_filter() {
334
+ let dir = setup_project();
335
+ let pd = dir.path().to_string_lossy().to_string();
336
+
337
+ call_tool(
338
+ "handoff_update_task",
339
+ json!({ "project_dir": &pd, "task": { "title": "Todo task" } }),
340
+ );
341
+ call_tool(
342
+ "handoff_update_task",
343
+ json!({ "project_dir": &pd, "task": { "title": "Active task", "status": "in_progress" } }),
344
+ );
345
+
346
+ let resp = call_tool(
347
+ "handoff_list_tasks",
348
+ json!({ "project_dir": &pd, "status_filter": "in_progress" }),
349
+ );
350
+
351
+ let text = get_text(&resp);
352
+ let parsed: Value = serde_json::from_str(&text).unwrap();
353
+ let tree = parsed["task_tree"].as_array().unwrap();
354
+
355
+ assert!(tree.iter().all(|t| t["status"] == "in_progress"));
356
+ }
357
+
358
+ #[test]
359
+ fn list_tasks_uninitialized_project_returns_error() {
360
+ let dir = tempfile::tempdir().unwrap();
361
+ let resp = call_tool(
362
+ "handoff_list_tasks",
363
+ json!({ "project_dir": dir.path().to_string_lossy() }),
364
+ );
365
+ assert!(is_error(&resp));
366
+ }
367
+
368
+ #[test]
369
+ fn hierarchical_id_numbering() {
370
+ let dir = setup_project();
371
+ let pd = dir.path().to_string_lossy().to_string();
372
+
373
+ call_tool(
374
+ "handoff_update_task",
375
+ json!({ "project_dir": &pd, "task": { "title": "T1" } }),
376
+ );
377
+ call_tool(
378
+ "handoff_update_task",
379
+ json!({ "project_dir": &pd, "task": { "title": "T2" } }),
380
+ );
381
+ call_tool(
382
+ "handoff_update_task",
383
+ json!({ "project_dir": &pd, "task": { "title": "T3" } }),
384
+ );
385
+
386
+ let resp = call_tool(
387
+ "handoff_update_task",
388
+ json!({
389
+ "project_dir": &pd,
390
+ "task": { "title": "Child of T2" },
391
+ "parent_id": "t2"
392
+ }),
393
+ );
394
+
395
+ let text = get_text(&resp);
396
+ assert!(text.contains("t2.1"), "should be t2.1, got: {text}");
397
+
398
+ let resp2 = call_tool(
399
+ "handoff_update_task",
400
+ json!({
401
+ "project_dir": &pd,
402
+ "task": { "title": "Second child of T2" },
403
+ "parent_id": "t2"
404
+ }),
405
+ );
406
+ let text2 = get_text(&resp2);
407
+ assert!(text2.contains("t2.2"), "should be t2.2, got: {text2}");
408
+ }