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,318 @@
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
+
12
+ std::process::Command::new("git")
13
+ .args(["init"])
14
+ .current_dir(dir.path())
15
+ .output()
16
+ .unwrap();
17
+ std::process::Command::new("git")
18
+ .args(["commit", "--allow-empty", "-m", "init"])
19
+ .current_dir(dir.path())
20
+ .output()
21
+ .unwrap();
22
+
23
+ let req = json!({
24
+ "jsonrpc": "2.0", "id": 0,
25
+ "method": "tools/call",
26
+ "params": {
27
+ "name": "handoff_init",
28
+ "arguments": {
29
+ "project_dir": dir.path().to_string_lossy(),
30
+ "project_name": "test"
31
+ }
32
+ }
33
+ });
34
+ send(&req.to_string()).unwrap();
35
+ dir
36
+ }
37
+
38
+ fn call_tool(name: &str, arguments: Value) -> Value {
39
+ let req = json!({
40
+ "jsonrpc": "2.0", "id": 1,
41
+ "method": "tools/call",
42
+ "params": { "name": name, "arguments": arguments }
43
+ });
44
+ send(&req.to_string()).unwrap()
45
+ }
46
+
47
+ fn get_text(resp: &Value) -> String {
48
+ resp["result"]["content"][0]["text"]
49
+ .as_str()
50
+ .unwrap_or("")
51
+ .to_string()
52
+ }
53
+
54
+ fn is_error(resp: &Value) -> bool {
55
+ resp["result"]["isError"].as_bool().unwrap_or(false)
56
+ }
57
+
58
+ #[test]
59
+ fn save_context_creates_session_file() {
60
+ let dir = setup_project();
61
+ let pd = dir.path().to_string_lossy().to_string();
62
+
63
+ let resp = call_tool(
64
+ "handoff_save_context",
65
+ json!({
66
+ "project_dir": &pd,
67
+ "summary": "Implemented feature X"
68
+ }),
69
+ );
70
+
71
+ assert!(!is_error(&resp), "error: {}", get_text(&resp));
72
+ let text = get_text(&resp);
73
+ assert!(text.contains("Session saved"));
74
+
75
+ let sessions_dir = dir.path().join(".handoff/sessions");
76
+ let active_files: Vec<_> = std::fs::read_dir(&sessions_dir)
77
+ .unwrap()
78
+ .filter_map(|e| e.ok())
79
+ .filter(|e| e.file_name().to_string_lossy().ends_with(".active.json"))
80
+ .collect();
81
+ assert_eq!(active_files.len(), 1);
82
+ }
83
+
84
+ #[test]
85
+ fn save_context_captures_git_state() {
86
+ let dir = setup_project();
87
+ let pd = dir.path().to_string_lossy().to_string();
88
+
89
+ call_tool(
90
+ "handoff_save_context",
91
+ json!({
92
+ "project_dir": &pd,
93
+ "summary": "Test session"
94
+ }),
95
+ );
96
+
97
+ let sessions_dir = dir.path().join(".handoff/sessions");
98
+ let active_file = std::fs::read_dir(&sessions_dir)
99
+ .unwrap()
100
+ .filter_map(|e| e.ok())
101
+ .find(|e| e.file_name().to_string_lossy().ends_with(".active.json"))
102
+ .unwrap();
103
+
104
+ let content = std::fs::read_to_string(active_file.path()).unwrap();
105
+ let session: Value = serde_json::from_str(&content).unwrap();
106
+
107
+ assert!(session["branch"].is_string());
108
+ assert!(session["commit"].is_string());
109
+ assert!(session["ended_at"].is_string());
110
+ }
111
+
112
+ #[test]
113
+ fn save_context_closes_previous_active() {
114
+ let dir = setup_project();
115
+ let pd = dir.path().to_string_lossy().to_string();
116
+
117
+ call_tool(
118
+ "handoff_save_context",
119
+ json!({ "project_dir": &pd, "summary": "First session" }),
120
+ );
121
+
122
+ let resp = call_tool(
123
+ "handoff_save_context",
124
+ json!({ "project_dir": &pd, "summary": "Second session" }),
125
+ );
126
+
127
+ let text = get_text(&resp);
128
+ assert!(text.contains("Closed 1 previous"));
129
+
130
+ let sessions_dir = dir.path().join(".handoff/sessions");
131
+ let active: Vec<_> = std::fs::read_dir(&sessions_dir)
132
+ .unwrap()
133
+ .filter_map(|e| e.ok())
134
+ .filter(|e| e.file_name().to_string_lossy().ends_with(".active.json"))
135
+ .collect();
136
+ let closed: Vec<_> = std::fs::read_dir(&sessions_dir)
137
+ .unwrap()
138
+ .filter_map(|e| e.ok())
139
+ .filter(|e| e.file_name().to_string_lossy().ends_with(".closed.json"))
140
+ .collect();
141
+
142
+ assert_eq!(active.len(), 1);
143
+ assert_eq!(closed.len(), 1);
144
+ }
145
+
146
+ #[test]
147
+ fn save_context_with_full_data() {
148
+ let dir = setup_project();
149
+ let pd = dir.path().to_string_lossy().to_string();
150
+
151
+ let resp = call_tool(
152
+ "handoff_save_context",
153
+ json!({
154
+ "project_dir": &pd,
155
+ "summary": "Full session",
156
+ "decisions": [
157
+ { "decision": "Use DMA", "reason": "Better throughput", "confidence": "confirmed" }
158
+ ],
159
+ "blockers": ["Waiting for hardware"],
160
+ "checklist": [
161
+ { "item": "Run smoke test", "checked": false, "owner": "ai" }
162
+ ],
163
+ "handoff_notes": [
164
+ { "note": "Push after approval", "category": "caution" }
165
+ ],
166
+ "references": [
167
+ { "label": "Design doc", "uri": "docs/design.md", "type": "doc" }
168
+ ],
169
+ "context_pointers": [
170
+ { "path": "src/main.rs", "reason": "Entry point", "lines": "1-20" }
171
+ ],
172
+ "environment": { "fw_version": "1.0" }
173
+ }),
174
+ );
175
+
176
+ assert!(!is_error(&resp), "error: {}", get_text(&resp));
177
+ }
178
+
179
+ #[test]
180
+ fn save_context_without_summary_fails() {
181
+ let dir = setup_project();
182
+ let pd = dir.path().to_string_lossy().to_string();
183
+
184
+ let resp = call_tool("handoff_save_context", json!({ "project_dir": &pd }));
185
+
186
+ assert!(is_error(&resp));
187
+ }
188
+
189
+ #[test]
190
+ fn load_context_uninitialized_returns_prompt() {
191
+ let dir = tempfile::tempdir().unwrap();
192
+ let pd = dir.path().to_string_lossy().to_string();
193
+
194
+ let resp = call_tool("handoff_load_context", json!({ "project_dir": &pd }));
195
+
196
+ assert!(!is_error(&resp));
197
+ let text = get_text(&resp);
198
+ let parsed: Value = serde_json::from_str(&text).unwrap();
199
+ assert_eq!(parsed["status"], "not_initialized");
200
+ }
201
+
202
+ #[test]
203
+ fn load_context_empty_project() {
204
+ let dir = setup_project();
205
+ let pd = dir.path().to_string_lossy().to_string();
206
+
207
+ let resp = call_tool("handoff_load_context", json!({ "project_dir": &pd }));
208
+
209
+ assert!(!is_error(&resp), "error: {}", get_text(&resp));
210
+ let text = get_text(&resp);
211
+ let parsed: Value = serde_json::from_str(&text).unwrap();
212
+ assert_eq!(parsed["project"], "test");
213
+ assert!(parsed["task_tree"].as_array().unwrap().is_empty());
214
+ assert_eq!(parsed["task_summary"]["total"], 0);
215
+ }
216
+
217
+ #[test]
218
+ fn load_context_with_session_and_tasks() {
219
+ let dir = setup_project();
220
+ let pd = dir.path().to_string_lossy().to_string();
221
+
222
+ call_tool(
223
+ "handoff_update_task",
224
+ json!({
225
+ "project_dir": &pd,
226
+ "task": { "title": "Task 1", "status": "in_progress" }
227
+ }),
228
+ );
229
+
230
+ call_tool(
231
+ "handoff_save_context",
232
+ json!({
233
+ "project_dir": &pd,
234
+ "summary": "Did some work",
235
+ "decisions": [
236
+ { "decision": "Use approach A", "confidence": "confirmed" }
237
+ ],
238
+ "handoff_notes": [
239
+ { "note": "Check tests", "category": "suggestion" }
240
+ ]
241
+ }),
242
+ );
243
+
244
+ let resp = call_tool("handoff_load_context", json!({ "project_dir": &pd }));
245
+ let text = get_text(&resp);
246
+ let parsed: Value = serde_json::from_str(&text).unwrap();
247
+
248
+ assert_eq!(parsed["project"], "test");
249
+ assert!(parsed["last_session"]["summary"]
250
+ .as_str()
251
+ .unwrap()
252
+ .contains("Did some work"));
253
+ assert_eq!(parsed["task_summary"]["total"], 1);
254
+ assert!(!parsed["task_tree"].as_array().unwrap().is_empty());
255
+ assert!(!parsed["decisions"].as_array().unwrap().is_empty());
256
+ assert!(!parsed["handoff_notes"].as_array().unwrap().is_empty());
257
+ }
258
+
259
+ #[test]
260
+ fn full_session_lifecycle() {
261
+ let dir = setup_project();
262
+ let pd = dir.path().to_string_lossy().to_string();
263
+
264
+ call_tool(
265
+ "handoff_update_task",
266
+ json!({
267
+ "project_dir": &pd,
268
+ "task": { "title": "Feature X" }
269
+ }),
270
+ );
271
+
272
+ call_tool(
273
+ "handoff_save_context",
274
+ json!({
275
+ "project_dir": &pd,
276
+ "summary": "Session A: started feature X"
277
+ }),
278
+ );
279
+
280
+ let resp = call_tool("handoff_load_context", json!({ "project_dir": &pd }));
281
+ let text = get_text(&resp);
282
+ let ctx: Value = serde_json::from_str(&text).unwrap();
283
+ assert!(ctx["last_session"]["summary"]
284
+ .as_str()
285
+ .unwrap()
286
+ .contains("Session A"));
287
+
288
+ call_tool(
289
+ "handoff_update_task",
290
+ json!({
291
+ "project_dir": &pd,
292
+ "task": { "id": "t1", "title": "Feature X", "status": "done" }
293
+ }),
294
+ );
295
+
296
+ call_tool(
297
+ "handoff_save_context",
298
+ json!({
299
+ "project_dir": &pd,
300
+ "summary": "Session B: completed feature X"
301
+ }),
302
+ );
303
+
304
+ let sessions_dir = dir.path().join(".handoff/sessions");
305
+ let active: Vec<_> = std::fs::read_dir(&sessions_dir)
306
+ .unwrap()
307
+ .filter_map(|e| e.ok())
308
+ .filter(|e| e.file_name().to_string_lossy().ends_with(".active.json"))
309
+ .collect();
310
+ let closed: Vec<_> = std::fs::read_dir(&sessions_dir)
311
+ .unwrap()
312
+ .filter_map(|e| e.ok())
313
+ .filter(|e| e.file_name().to_string_lossy().ends_with(".closed.json"))
314
+ .collect();
315
+
316
+ assert_eq!(active.len(), 1);
317
+ assert_eq!(closed.len(), 1);
318
+ }