handoff-mcp-server 0.7.1 → 0.7.3

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.
@@ -1,535 +0,0 @@
1
- use handoff_mcp::storage::sessions::*;
2
- use std::fs;
3
- use tempfile::TempDir;
4
-
5
- fn setup() -> TempDir {
6
- tempfile::tempdir().expect("failed to create temp dir")
7
- }
8
-
9
- fn make_session(summary: &str, ended_at: &str) -> SessionData {
10
- SessionData {
11
- version: 2,
12
- id: None,
13
- ended_at: Some(ended_at.to_string()),
14
- summary: summary.to_string(),
15
- branch: Some("main".to_string()),
16
- commit: Some("abc1234".to_string()),
17
- dirty_files: Vec::new(),
18
- decisions: Vec::new(),
19
- blockers: Vec::new(),
20
- checklist: Vec::new(),
21
- handoff_notes: Vec::new(),
22
- references: Vec::new(),
23
- context_pointers: Vec::new(),
24
- environment: None,
25
- }
26
- }
27
-
28
- #[test]
29
- fn write_open_session_creates_file() {
30
- let dir = setup();
31
- let sessions_dir = dir.path().join("sessions");
32
- fs::create_dir_all(&sessions_dir).unwrap();
33
-
34
- let data = make_session("test session", "2026-06-13T14:30:00Z");
35
- let path = write_open_session(&sessions_dir, &data).unwrap();
36
-
37
- assert!(path.exists());
38
- assert!(
39
- path.to_string_lossy().ends_with(".open.json"),
40
- "filename: {}",
41
- path.display()
42
- );
43
-
44
- let content = fs::read_to_string(&path).unwrap();
45
- let parsed: serde_json::Value = serde_json::from_str(&content).unwrap();
46
- assert_eq!(parsed["summary"], "test session");
47
- assert_eq!(parsed["version"], 2);
48
- }
49
-
50
- #[test]
51
- fn read_open_sessions_empty() {
52
- let dir = setup();
53
- let sessions_dir = dir.path().join("sessions");
54
- fs::create_dir_all(&sessions_dir).unwrap();
55
-
56
- let sessions = read_open_sessions(&sessions_dir).unwrap();
57
- assert!(sessions.is_empty());
58
- }
59
-
60
- #[test]
61
- fn read_open_sessions_returns_open_only() {
62
- let dir = setup();
63
- let sessions_dir = dir.path().join("sessions");
64
- fs::create_dir_all(&sessions_dir).unwrap();
65
-
66
- let s1 = make_session("open one", "2026-06-13T10:00:00Z");
67
- write_open_session(&sessions_dir, &s1).unwrap();
68
-
69
- let s2 = make_session("open two", "2026-06-13T11:00:00Z");
70
- write_open_session(&sessions_dir, &s2).unwrap();
71
-
72
- fs::write(
73
- sessions_dir.join("20260612-090000-old.closed.json"),
74
- r#"{"version":2,"summary":"old closed","branch":"main","commit":"111"}"#,
75
- )
76
- .unwrap();
77
-
78
- let sessions = read_open_sessions(&sessions_dir).unwrap();
79
- assert_eq!(sessions.len(), 2);
80
- }
81
-
82
- #[test]
83
- fn activate_open_sessions_renames_to_active() {
84
- let dir = setup();
85
- let sessions_dir = dir.path().join("sessions");
86
- fs::create_dir_all(&sessions_dir).unwrap();
87
-
88
- let s1 = make_session("session one", "2026-06-13T10:00:00Z");
89
- let open_path = write_open_session(&sessions_dir, &s1).unwrap();
90
-
91
- let activated = activate_open_sessions(&sessions_dir).unwrap();
92
- assert_eq!(activated.len(), 1);
93
- assert!(!open_path.exists());
94
- assert!(activated[0].exists());
95
- assert!(activated[0].to_string_lossy().contains(".active.json"));
96
- }
97
-
98
- #[test]
99
- fn close_active_sessions_renames_to_closed() {
100
- let dir = setup();
101
- let sessions_dir = dir.path().join("sessions");
102
- fs::create_dir_all(&sessions_dir).unwrap();
103
-
104
- let s1 = make_session("session one", "2026-06-13T10:00:00Z");
105
- write_open_session(&sessions_dir, &s1).unwrap();
106
- let activated = activate_open_sessions(&sessions_dir).unwrap();
107
- let active_path = &activated[0];
108
-
109
- let closed = close_active_sessions(&sessions_dir).unwrap();
110
- assert_eq!(closed.len(), 1);
111
- assert!(!active_path.exists());
112
- assert!(closed[0].exists());
113
- assert!(closed[0].to_string_lossy().contains(".closed.json"));
114
- }
115
-
116
- #[test]
117
- fn close_open_sessions_renames_to_closed() {
118
- let dir = setup();
119
- let sessions_dir = dir.path().join("sessions");
120
- fs::create_dir_all(&sessions_dir).unwrap();
121
-
122
- let s1 = make_session("session one", "2026-06-13T10:00:00Z");
123
- let open_path = write_open_session(&sessions_dir, &s1).unwrap();
124
-
125
- let closed = close_open_sessions(&sessions_dir).unwrap();
126
- assert_eq!(closed.len(), 1);
127
- assert!(!open_path.exists());
128
- assert!(closed[0].exists());
129
- assert!(closed[0].to_string_lossy().contains(".closed.json"));
130
- }
131
-
132
- #[test]
133
- fn full_session_lifecycle_open_active_closed() {
134
- let dir = setup();
135
- let sessions_dir = dir.path().join("sessions");
136
- fs::create_dir_all(&sessions_dir).unwrap();
137
-
138
- let s1 = make_session("first session", "2026-06-13T10:00:00Z");
139
- write_open_session(&sessions_dir, &s1).unwrap();
140
-
141
- let open = read_open_sessions(&sessions_dir).unwrap();
142
- assert_eq!(open.len(), 1);
143
- assert_eq!(open[0].summary, "first session");
144
-
145
- activate_open_sessions(&sessions_dir).unwrap();
146
-
147
- assert!(read_open_sessions(&sessions_dir).unwrap().is_empty());
148
- let active = read_active_sessions(&sessions_dir).unwrap();
149
- assert_eq!(active.len(), 1);
150
- assert_eq!(active[0].summary, "first session");
151
-
152
- close_active_sessions(&sessions_dir).unwrap();
153
-
154
- assert!(read_active_sessions(&sessions_dir).unwrap().is_empty());
155
-
156
- let s2 = make_session("second session", "2026-06-13T14:00:00Z");
157
- write_open_session(&sessions_dir, &s2).unwrap();
158
-
159
- let open2 = read_open_sessions(&sessions_dir).unwrap();
160
- assert_eq!(open2.len(), 1);
161
- assert_eq!(open2[0].summary, "second session");
162
- }
163
-
164
- #[test]
165
- fn enforce_history_limit_removes_oldest() {
166
- let dir = setup();
167
- let sessions_dir = dir.path().join("sessions");
168
- fs::create_dir_all(&sessions_dir).unwrap();
169
-
170
- for i in 1..=5 {
171
- let name = format!("20260610-{i:06}-s{i}.closed.json");
172
- fs::write(
173
- sessions_dir.join(&name),
174
- format!(r#"{{"version":2,"summary":"session {i}"}}"#),
175
- )
176
- .unwrap();
177
- }
178
-
179
- let removed = enforce_history_limit(&sessions_dir, 3).unwrap();
180
- assert_eq!(removed, 2);
181
-
182
- let remaining: Vec<_> = fs::read_dir(&sessions_dir)
183
- .unwrap()
184
- .filter_map(|e| e.ok())
185
- .filter(|e| e.file_name().to_string_lossy().ends_with(".closed.json"))
186
- .collect();
187
- assert_eq!(remaining.len(), 3);
188
- }
189
-
190
- #[test]
191
- fn enforce_history_limit_ignores_open_and_active() {
192
- let dir = setup();
193
- let sessions_dir = dir.path().join("sessions");
194
- fs::create_dir_all(&sessions_dir).unwrap();
195
-
196
- for i in 1..=3 {
197
- let name = format!("20260610-{i:06}-s{i}.closed.json");
198
- fs::write(
199
- sessions_dir.join(&name),
200
- format!(r#"{{"version":2,"summary":"closed {i}"}}"#),
201
- )
202
- .unwrap();
203
- }
204
-
205
- let s = make_session("open session", "2026-06-13T10:00:00Z");
206
- write_open_session(&sessions_dir, &s).unwrap();
207
-
208
- enforce_history_limit(&sessions_dir, 2).unwrap();
209
-
210
- let open = read_open_sessions(&sessions_dir).unwrap();
211
- assert_eq!(open.len(), 1);
212
- }
213
-
214
- #[test]
215
- fn enforce_history_limit_under_limit_removes_nothing() {
216
- let dir = setup();
217
- let sessions_dir = dir.path().join("sessions");
218
- fs::create_dir_all(&sessions_dir).unwrap();
219
-
220
- fs::write(
221
- sessions_dir.join("20260610-000001-s1.closed.json"),
222
- r#"{"version":2,"summary":"s1"}"#,
223
- )
224
- .unwrap();
225
-
226
- let removed = enforce_history_limit(&sessions_dir, 5).unwrap();
227
- assert_eq!(removed, 0);
228
- }
229
-
230
- #[test]
231
- fn generate_session_filename_format() {
232
- let name = generate_session_filename("Pattern run fix", "20260613-143000");
233
- assert!(name.starts_with("20260613-143000-"));
234
- assert!(name.contains("pattern-run-fix"));
235
- }
236
-
237
- #[test]
238
- fn read_open_sessions_nonexistent_dir() {
239
- let dir = setup();
240
- let sessions_dir = dir.path().join("nonexistent");
241
- let sessions = read_open_sessions(&sessions_dir).unwrap();
242
- assert!(sessions.is_empty());
243
- }
244
-
245
- #[test]
246
- fn generate_session_id_format() {
247
- let id = generate_session_id();
248
- assert!(id.starts_with("s-"), "should start with s-: {id}");
249
- assert!(id.len() > 20, "should be long enough: {id}");
250
- let parts: Vec<&str> = id.splitn(2, '-').collect();
251
- assert_eq!(parts[0], "s");
252
- }
253
-
254
- #[test]
255
- fn write_open_session_assigns_id() {
256
- let dir = setup();
257
- let sessions_dir = dir.path().join("sessions");
258
- fs::create_dir_all(&sessions_dir).unwrap();
259
-
260
- let s = make_session("test", "2026-06-15T10:00:00Z");
261
- assert!(s.id.is_none());
262
-
263
- write_open_session(&sessions_dir, &s).unwrap();
264
-
265
- let sessions = read_open_sessions(&sessions_dir).unwrap();
266
- assert_eq!(sessions.len(), 1);
267
- assert!(
268
- sessions[0].id.is_some(),
269
- "written session should have an id"
270
- );
271
- assert!(sessions[0].id.as_ref().unwrap().starts_with("s-"));
272
- }
273
-
274
- #[test]
275
- fn read_old_session_without_id_gets_synthesized_id() {
276
- let dir = setup();
277
- let sessions_dir = dir.path().join("sessions");
278
- fs::create_dir_all(&sessions_dir).unwrap();
279
-
280
- // Write a session file without id field (simulating pre-upgrade file)
281
- fs::write(
282
- sessions_dir.join("20260613-143000-old-session.open.json"),
283
- r#"{"version":2,"summary":"old session"}"#,
284
- )
285
- .unwrap();
286
-
287
- let sessions = read_open_sessions(&sessions_dir).unwrap();
288
- assert_eq!(sessions.len(), 1);
289
- assert!(
290
- sessions[0].id.is_some(),
291
- "old session should get a synthesized id"
292
- );
293
- let id = sessions[0].id.as_ref().unwrap();
294
- assert!(id.starts_with("s-"), "synthesized id format: {id}");
295
- assert!(
296
- id.contains("20260613-143000"),
297
- "synthesized id should contain original timestamp: {id}"
298
- );
299
- }
300
-
301
- #[test]
302
- fn close_session_by_id_closes_only_targeted() {
303
- let dir = setup();
304
- let sessions_dir = dir.path().join("sessions");
305
- fs::create_dir_all(&sessions_dir).unwrap();
306
-
307
- let s1 = make_session("session one", "2026-06-15T10:00:00Z");
308
- let s2 = make_session("session two", "2026-06-15T11:00:00Z");
309
- write_open_session(&sessions_dir, &s1).unwrap();
310
- write_open_session(&sessions_dir, &s2).unwrap();
311
-
312
- let sessions = read_open_sessions(&sessions_dir).unwrap();
313
- assert_eq!(sessions.len(), 2);
314
-
315
- let target_id = sessions[0].id.as_ref().unwrap().clone();
316
- let other_id = sessions[1].id.as_ref().unwrap().clone();
317
-
318
- let result = close_session_by_id(&sessions_dir, &target_id).unwrap();
319
- assert!(result.is_some(), "should close the targeted session");
320
-
321
- let remaining_open = read_open_sessions(&sessions_dir).unwrap();
322
- assert_eq!(remaining_open.len(), 1);
323
- assert_eq!(remaining_open[0].id.as_deref().unwrap(), other_id);
324
- }
325
-
326
- #[test]
327
- fn activate_session_by_id_activates_only_targeted() {
328
- let dir = setup();
329
- let sessions_dir = dir.path().join("sessions");
330
- fs::create_dir_all(&sessions_dir).unwrap();
331
-
332
- let s1 = make_session("session one", "2026-06-15T10:00:00Z");
333
- let s2 = make_session("session two", "2026-06-15T11:00:00Z");
334
- write_open_session(&sessions_dir, &s1).unwrap();
335
- write_open_session(&sessions_dir, &s2).unwrap();
336
-
337
- let sessions = read_open_sessions(&sessions_dir).unwrap();
338
- let target_id = sessions[0].id.as_ref().unwrap().clone();
339
-
340
- let result = activate_session_by_id(&sessions_dir, &target_id).unwrap();
341
- assert!(result.is_some(), "should activate the targeted session");
342
-
343
- let remaining_open = read_open_sessions(&sessions_dir).unwrap();
344
- assert_eq!(remaining_open.len(), 1, "one session should remain open");
345
-
346
- let active = read_active_sessions(&sessions_dir).unwrap();
347
- assert_eq!(active.len(), 1, "one session should be active");
348
- assert_eq!(active[0].id.as_deref().unwrap(), target_id);
349
- }
350
-
351
- #[test]
352
- fn close_session_by_id_nonexistent_returns_none() {
353
- let dir = setup();
354
- let sessions_dir = dir.path().join("sessions");
355
- fs::create_dir_all(&sessions_dir).unwrap();
356
-
357
- let result = close_session_by_id(&sessions_dir, "s-nonexistent").unwrap();
358
- assert!(result.is_none());
359
- }
360
-
361
- #[test]
362
- fn pause_active_sessions_renames_to_paused() {
363
- let dir = setup();
364
- let sessions_dir = dir.path().join("sessions");
365
- fs::create_dir_all(&sessions_dir).unwrap();
366
-
367
- let s = make_session("working on feature", "2026-06-15T10:00:00Z");
368
- write_open_session(&sessions_dir, &s).unwrap();
369
- activate_open_sessions(&sessions_dir).unwrap();
370
-
371
- let paused = pause_active_sessions(&sessions_dir).unwrap();
372
- assert_eq!(paused.len(), 1);
373
- assert!(paused[0].to_string_lossy().contains(".paused.json"));
374
- assert!(paused[0].exists());
375
-
376
- assert!(read_active_sessions(&sessions_dir).unwrap().is_empty());
377
- let paused_sessions = read_paused_sessions(&sessions_dir).unwrap();
378
- assert_eq!(paused_sessions.len(), 1);
379
- assert_eq!(paused_sessions[0].summary, "working on feature");
380
- }
381
-
382
- #[test]
383
- fn pause_session_by_id_pauses_only_targeted() {
384
- let dir = setup();
385
- let sessions_dir = dir.path().join("sessions");
386
- fs::create_dir_all(&sessions_dir).unwrap();
387
-
388
- let s1 = make_session("session one", "2026-06-15T10:00:00Z");
389
- let s2 = make_session("session two", "2026-06-15T11:00:00Z");
390
- write_open_session(&sessions_dir, &s1).unwrap();
391
- write_open_session(&sessions_dir, &s2).unwrap();
392
- activate_open_sessions(&sessions_dir).unwrap();
393
-
394
- let active = read_active_sessions(&sessions_dir).unwrap();
395
- assert_eq!(active.len(), 2);
396
- let target_id = active[0].id.as_ref().unwrap().clone();
397
-
398
- let result = pause_session_by_id(&sessions_dir, &target_id).unwrap();
399
- assert!(result.is_some());
400
-
401
- let remaining_active = read_active_sessions(&sessions_dir).unwrap();
402
- assert_eq!(remaining_active.len(), 1);
403
-
404
- let paused = read_paused_sessions(&sessions_dir).unwrap();
405
- assert_eq!(paused.len(), 1);
406
- assert_eq!(paused[0].id.as_deref().unwrap(), target_id);
407
- }
408
-
409
- #[test]
410
- fn resume_paused_session_by_id_reactivates() {
411
- let dir = setup();
412
- let sessions_dir = dir.path().join("sessions");
413
- fs::create_dir_all(&sessions_dir).unwrap();
414
-
415
- let s = make_session("paused work", "2026-06-15T10:00:00Z");
416
- write_open_session(&sessions_dir, &s).unwrap();
417
- activate_open_sessions(&sessions_dir).unwrap();
418
- pause_active_sessions(&sessions_dir).unwrap();
419
-
420
- let paused = read_paused_sessions(&sessions_dir).unwrap();
421
- assert_eq!(paused.len(), 1);
422
- let sid = paused[0].id.as_ref().unwrap().clone();
423
-
424
- let result = resume_paused_session_by_id(&sessions_dir, &sid).unwrap();
425
- assert!(result.is_some());
426
- assert!(result.unwrap().to_string_lossy().contains(".active.json"));
427
-
428
- assert!(read_paused_sessions(&sessions_dir).unwrap().is_empty());
429
- let active = read_active_sessions(&sessions_dir).unwrap();
430
- assert_eq!(active.len(), 1);
431
- assert_eq!(active[0].summary, "paused work");
432
- }
433
-
434
- #[test]
435
- fn close_session_by_id_closes_paused() {
436
- let dir = setup();
437
- let sessions_dir = dir.path().join("sessions");
438
- fs::create_dir_all(&sessions_dir).unwrap();
439
-
440
- let s = make_session("will close from paused", "2026-06-15T10:00:00Z");
441
- write_open_session(&sessions_dir, &s).unwrap();
442
- activate_open_sessions(&sessions_dir).unwrap();
443
- pause_active_sessions(&sessions_dir).unwrap();
444
-
445
- let paused = read_paused_sessions(&sessions_dir).unwrap();
446
- let sid = paused[0].id.as_ref().unwrap().clone();
447
-
448
- let result = close_session_by_id(&sessions_dir, &sid).unwrap();
449
- assert!(result.is_some());
450
- assert!(result.unwrap().to_string_lossy().contains(".closed.json"));
451
-
452
- assert!(read_paused_sessions(&sessions_dir).unwrap().is_empty());
453
- }
454
-
455
- #[test]
456
- fn close_paused_sessions_closes_all() {
457
- let dir = setup();
458
- let sessions_dir = dir.path().join("sessions");
459
- fs::create_dir_all(&sessions_dir).unwrap();
460
-
461
- let s1 = make_session("paused one", "2026-06-15T10:00:00Z");
462
- let s2 = make_session("paused two", "2026-06-15T11:00:00Z");
463
- write_open_session(&sessions_dir, &s1).unwrap();
464
- write_open_session(&sessions_dir, &s2).unwrap();
465
- activate_open_sessions(&sessions_dir).unwrap();
466
- pause_active_sessions(&sessions_dir).unwrap();
467
-
468
- assert_eq!(read_paused_sessions(&sessions_dir).unwrap().len(), 2);
469
-
470
- let closed = close_paused_sessions(&sessions_dir).unwrap();
471
- assert_eq!(closed.len(), 2);
472
- assert!(read_paused_sessions(&sessions_dir).unwrap().is_empty());
473
- }
474
-
475
- #[test]
476
- fn full_lifecycle_with_pause_and_resume() {
477
- let dir = setup();
478
- let sessions_dir = dir.path().join("sessions");
479
- fs::create_dir_all(&sessions_dir).unwrap();
480
-
481
- let s1 = make_session("feature work", "2026-06-15T10:00:00Z");
482
- write_open_session(&sessions_dir, &s1).unwrap();
483
- activate_open_sessions(&sessions_dir).unwrap();
484
-
485
- let active = read_active_sessions(&sessions_dir).unwrap();
486
- assert_eq!(active.len(), 1);
487
- let s1_id = active[0].id.as_ref().unwrap().clone();
488
-
489
- pause_session_by_id(&sessions_dir, &s1_id).unwrap();
490
- assert!(read_active_sessions(&sessions_dir).unwrap().is_empty());
491
- assert_eq!(read_paused_sessions(&sessions_dir).unwrap().len(), 1);
492
-
493
- let s2 = make_session("urgent fix", "2026-06-15T12:00:00Z");
494
- write_open_session(&sessions_dir, &s2).unwrap();
495
- activate_open_sessions(&sessions_dir).unwrap();
496
-
497
- let active2 = read_active_sessions(&sessions_dir).unwrap();
498
- assert_eq!(active2.len(), 1);
499
- assert_eq!(active2[0].summary, "urgent fix");
500
- assert_eq!(read_paused_sessions(&sessions_dir).unwrap().len(), 1);
501
-
502
- close_active_sessions(&sessions_dir).unwrap();
503
-
504
- resume_paused_session_by_id(&sessions_dir, &s1_id).unwrap();
505
- let active3 = read_active_sessions(&sessions_dir).unwrap();
506
- assert_eq!(active3.len(), 1);
507
- assert_eq!(active3[0].summary, "feature work");
508
- assert!(read_paused_sessions(&sessions_dir).unwrap().is_empty());
509
- }
510
-
511
- #[test]
512
- fn enforce_history_limit_ignores_paused() {
513
- let dir = setup();
514
- let sessions_dir = dir.path().join("sessions");
515
- fs::create_dir_all(&sessions_dir).unwrap();
516
-
517
- for i in 1..=3 {
518
- let name = format!("20260610-{i:06}-s{i}.closed.json");
519
- fs::write(
520
- sessions_dir.join(&name),
521
- format!(r#"{{"version":2,"summary":"closed {i}"}}"#),
522
- )
523
- .unwrap();
524
- }
525
-
526
- let s = make_session("paused session", "2026-06-15T10:00:00Z");
527
- write_open_session(&sessions_dir, &s).unwrap();
528
- activate_open_sessions(&sessions_dir).unwrap();
529
- pause_active_sessions(&sessions_dir).unwrap();
530
-
531
- enforce_history_limit(&sessions_dir, 2).unwrap();
532
-
533
- let paused = read_paused_sessions(&sessions_dir).unwrap();
534
- assert_eq!(paused.len(), 1, "paused sessions should not be removed");
535
- }