1use std::collections::HashMap;
2use std::fs::OpenOptions;
3use std::io::{BufRead, BufReader, Write};
4use std::path::PathBuf;
5
6use chrono::{DateTime, Utc};
7use serde::{Deserialize, Serialize};
8use uuid::Uuid;
9
10#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
12pub struct HandoffContext {
13 pub handoff_id: Uuid,
15 pub from_agent: String,
17 pub to_agent: String,
19 pub task: String,
21 pub progress_summary: String,
23 pub decisions: Vec<String>,
25 pub files_touched: Vec<PathBuf>,
27 pub timestamp: chrono::DateTime<chrono::Utc>,
29 #[serde(default, skip_serializing_if = "Option::is_none")]
31 pub ttl_secs: Option<u64>,
32}
33
34impl HandoffContext {
35 pub fn new(
37 from_agent: impl Into<String>,
38 to_agent: impl Into<String>,
39 task: impl Into<String>,
40 ) -> Self {
41 Self {
42 handoff_id: Uuid::new_v4(),
43 from_agent: from_agent.into(),
44 to_agent: to_agent.into(),
45 task: task.into(),
46 progress_summary: String::new(),
47 decisions: Vec::new(),
48 files_touched: Vec::new(),
49 timestamp: chrono::Utc::now(),
50 ttl_secs: None,
51 }
52 }
53
54 pub fn to_json(&self) -> Result<String, serde_json::Error> {
56 serde_json::to_string_pretty(self)
57 }
58
59 pub fn from_json(json: &str) -> Result<Self, serde_json::Error> {
61 serde_json::from_str(json)
62 }
63
64 pub fn from_json_lenient(json: &str) -> Result<Self, serde_json::Error> {
67 let mut value: serde_json::Value = serde_json::from_str(json)?;
68
69 if let Some(obj) = value.as_object_mut() {
71 if !obj.contains_key("handoff_id") {
72 obj.insert("handoff_id".to_string(), serde_json::json!(Uuid::new_v4()));
73 }
74 if !obj.contains_key("from_agent") {
75 obj.insert("from_agent".to_string(), serde_json::json!("unknown"));
76 }
77 if !obj.contains_key("to_agent") {
78 obj.insert("to_agent".to_string(), serde_json::json!("unknown"));
79 }
80 if !obj.contains_key("timestamp") {
81 obj.insert(
82 "timestamp".to_string(),
83 serde_json::json!(chrono::Utc::now()),
84 );
85 }
86 }
88
89 serde_json::from_value(value)
90 }
91
92 pub fn write_to_file(&self, path: impl AsRef<std::path::Path>) -> Result<(), std::io::Error> {
94 let json = serde_json::to_string_pretty(self)
95 .map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e))?;
96 std::fs::write(path, json)
97 }
98
99 pub fn write_to_file_atomic(
101 &self,
102 path: impl AsRef<std::path::Path>,
103 ) -> Result<(), std::io::Error> {
104 let path = path.as_ref();
105 let json = serde_json::to_string_pretty(self)
106 .map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e))?;
107
108 let parent = path.parent().unwrap_or(std::path::Path::new("."));
110 let file_name = path
111 .file_name()
112 .ok_or_else(|| std::io::Error::new(std::io::ErrorKind::InvalidInput, "Invalid path"))?
113 .to_string_lossy();
114 let tmp_path = parent.join(format!(".tmp.{}", file_name));
115
116 std::fs::write(&tmp_path, json)?;
118
119 std::fs::rename(&tmp_path, path)?;
121
122 Ok(())
123 }
124
125 pub fn read_from_file(path: impl AsRef<std::path::Path>) -> Result<Self, std::io::Error> {
127 let content = std::fs::read_to_string(path)?;
128 serde_json::from_str(&content)
129 .map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e))
130 }
131}
132
133#[derive(Debug, Clone)]
135struct BufferEntry {
136 context: HandoffContext,
137 expiry: DateTime<Utc>,
138}
139
140#[derive(Debug)]
142pub struct HandoffBuffer {
143 entries: HashMap<Uuid, BufferEntry>,
144 default_ttl_secs: u64,
145}
146
147impl HandoffBuffer {
148 pub fn new(default_ttl_secs: u64) -> Self {
150 Self {
151 entries: HashMap::new(),
152 default_ttl_secs,
153 }
154 }
155
156 pub fn insert(&mut self, context: HandoffContext) -> Uuid {
159 let ttl_secs = context.ttl_secs.unwrap_or(self.default_ttl_secs);
160 const MAX_TTL_SECS: i64 = 100 * 365 * 24 * 3600;
162 let ttl_i64 = i64::try_from(ttl_secs)
163 .unwrap_or(MAX_TTL_SECS)
164 .min(MAX_TTL_SECS);
165 let expiry = Utc::now() + chrono::Duration::seconds(ttl_i64);
166 let id = context.handoff_id;
167
168 self.entries.insert(id, BufferEntry { context, expiry });
169 id
170 }
171
172 pub fn get(&self, id: &Uuid) -> Option<&HandoffContext> {
175 self.entries.get(id).and_then(|entry| {
176 if Utc::now() < entry.expiry {
177 Some(&entry.context)
178 } else {
179 None
180 }
181 })
182 }
183
184 pub fn latest_for_agent(&self, to_agent: &str) -> Option<&HandoffContext> {
187 let now = Utc::now();
188 self.entries
189 .values()
190 .filter(|entry| entry.context.to_agent == to_agent && now < entry.expiry)
191 .max_by_key(|entry| entry.context.timestamp)
192 .map(|entry| &entry.context)
193 }
194
195 pub fn sweep_expired(&mut self) -> usize {
197 let now = Utc::now();
198 let initial_count = self.entries.len();
199 self.entries.retain(|_, entry| now < entry.expiry);
200 initial_count - self.entries.len()
201 }
202
203 pub fn len(&self) -> usize {
205 self.entries.len()
206 }
207
208 pub fn is_empty(&self) -> bool {
210 self.entries.is_empty()
211 }
212
213 pub fn iter(&self) -> impl Iterator<Item = (&Uuid, &HandoffContext, &DateTime<Utc>)> {
216 self.entries
217 .iter()
218 .map(|(id, entry)| (id, &entry.context, &entry.expiry))
219 }
220
221 pub fn default_ttl_secs(&self) -> u64 {
223 self.default_ttl_secs
224 }
225}
226
227#[derive(Debug)]
230pub struct HandoffLedger {
231 path: PathBuf,
232}
233
234impl HandoffLedger {
235 pub fn new(path: impl Into<PathBuf>) -> Self {
237 Self { path: path.into() }
238 }
239
240 pub fn append(&self, context: &HandoffContext) -> Result<(), std::io::Error> {
243 let json = serde_json::to_string(context)
244 .map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e))?;
245
246 let mut file = OpenOptions::new()
247 .create(true)
248 .append(true)
249 .open(&self.path)?;
250
251 writeln!(file, "{}", json)?;
252 file.sync_all()?;
253
254 Ok(())
255 }
256
257 pub fn read_all(&self) -> Result<Vec<HandoffContext>, std::io::Error> {
260 let file = OpenOptions::new().read(true).open(&self.path)?;
261
262 let reader = BufReader::new(file);
263 let mut entries = Vec::new();
264
265 for line in reader.lines() {
266 let line = line?;
267 if line.trim().is_empty() {
268 continue;
269 }
270 let context: HandoffContext = serde_json::from_str(&line)
271 .map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e))?;
272 entries.push(context);
273 }
274
275 Ok(entries)
276 }
277
278 pub fn count(&self) -> Result<usize, std::io::Error> {
281 let metadata = std::fs::metadata(&self.path)?;
282 if metadata.len() == 0 {
283 return Ok(0);
284 }
285
286 let file = OpenOptions::new().read(true).open(&self.path)?;
287
288 let reader = BufReader::new(file);
289 let mut count = 0;
290
291 for line in reader.lines() {
292 let line = line?;
293 if !line.trim().is_empty() {
294 count += 1;
295 }
296 }
297
298 Ok(count)
299 }
300
301 pub fn size_bytes(&self) -> Result<u64, std::io::Error> {
303 let metadata = std::fs::metadata(&self.path)?;
304 Ok(metadata.len())
305 }
306}
307
308#[cfg(test)]
309mod tests {
310 use super::*;
311 use chrono::Utc;
312
313 fn make_handoff() -> HandoffContext {
314 HandoffContext {
315 handoff_id: Uuid::new_v4(),
316 from_agent: "agent-a".to_string(),
317 to_agent: "agent-b".to_string(),
318 task: "Fix authentication bug".to_string(),
319 progress_summary: "Identified root cause in token validation".to_string(),
320 decisions: vec![
321 "Use JWT instead of session cookies".to_string(),
322 "Add refresh token rotation".to_string(),
323 ],
324 files_touched: vec![
325 PathBuf::from("src/auth/token.rs"),
326 PathBuf::from("src/auth/middleware.rs"),
327 ],
328 timestamp: Utc::now(),
329 ttl_secs: Some(3600),
330 }
331 }
332
333 #[test]
334 fn test_handoff_new_generates_uuid() {
335 let ctx1 = HandoffContext::new("agent-a", "agent-b", "test task");
336 let ctx2 = HandoffContext::new("agent-a", "agent-b", "test task");
337
338 assert_ne!(ctx1.handoff_id, ctx2.handoff_id);
340
341 assert_eq!(ctx1.from_agent, "agent-a");
343 assert_eq!(ctx1.to_agent, "agent-b");
344 assert_eq!(ctx1.task, "test task");
345 assert!(ctx1.progress_summary.is_empty());
346 assert!(ctx1.decisions.is_empty());
347 assert!(ctx1.files_touched.is_empty());
348 assert!(ctx1.ttl_secs.is_none());
349
350 let now = Utc::now();
352 let diff = now.signed_duration_since(ctx1.timestamp);
353 assert!(diff.num_seconds() < 60);
354 }
355
356 #[test]
357 fn test_handoff_roundtrip_json() {
358 let original = make_handoff();
359 let json = original.to_json().unwrap();
360 let restored = HandoffContext::from_json(&json).unwrap();
361 assert_eq!(original, restored);
362 }
363
364 #[test]
365 fn test_handoff_roundtrip_json_with_new_fields() {
366 let original = HandoffContext {
367 handoff_id: Uuid::new_v4(),
368 from_agent: "test-from".to_string(),
369 to_agent: "test-to".to_string(),
370 task: "Test task".to_string(),
371 progress_summary: "Test progress".to_string(),
372 decisions: vec!["decision1".to_string()],
373 files_touched: vec![PathBuf::from("test.rs")],
374 timestamp: Utc::now(),
375 ttl_secs: Some(7200),
376 };
377
378 let json = original.to_json().unwrap();
379 let restored = HandoffContext::from_json(&json).unwrap();
380
381 assert_eq!(original.handoff_id, restored.handoff_id);
382 assert_eq!(original.from_agent, restored.from_agent);
383 assert_eq!(original.to_agent, restored.to_agent);
384 assert_eq!(original.task, restored.task);
385 assert_eq!(original.ttl_secs, restored.ttl_secs);
386 assert_eq!(original, restored);
387 }
388
389 #[test]
390 fn test_handoff_from_json_lenient_missing_new_fields() {
391 let old_json = r#"{
393 "task": "Legacy task",
394 "progress_summary": "Legacy progress",
395 "decisions": ["decision1"],
396 "files_touched": ["file1.rs"],
397 "timestamp": "2024-01-15T10:30:00Z"
398 }"#;
399
400 let ctx = HandoffContext::from_json_lenient(old_json).unwrap();
401
402 assert_eq!(ctx.task, "Legacy task");
404 assert_eq!(ctx.progress_summary, "Legacy progress");
405 assert_eq!(ctx.decisions, vec!["decision1"]);
406 assert_eq!(ctx.files_touched, vec![PathBuf::from("file1.rs")]);
407
408 assert_eq!(ctx.from_agent, "unknown");
410 assert_eq!(ctx.to_agent, "unknown");
411 assert!(ctx.ttl_secs.is_none());
412
413 let expected_ts: chrono::DateTime<Utc> = "2024-01-15T10:30:00Z".parse().unwrap();
416 assert_eq!(ctx.timestamp, expected_ts);
417 }
418
419 #[test]
420 fn test_handoff_from_json_lenient_partial_new_fields() {
421 let partial_json = r#"{
423 "handoff_id": "550e8400-e29b-41d4-a716-446655440000",
424 "task": "Partial task",
425 "progress_summary": "Partial progress",
426 "decisions": [],
427 "files_touched": [],
428 "timestamp": "2024-06-01T12:00:00Z",
429 "from_agent": "agent-source"
430 }"#;
431
432 let ctx = HandoffContext::from_json_lenient(partial_json).unwrap();
433
434 assert_eq!(
436 ctx.handoff_id,
437 Uuid::parse_str("550e8400-e29b-41d4-a716-446655440000").unwrap()
438 );
439 assert_eq!(ctx.from_agent, "agent-source");
440 assert_eq!(ctx.task, "Partial task");
441
442 assert_eq!(ctx.to_agent, "unknown");
444 assert!(ctx.ttl_secs.is_none());
445 }
446
447 #[test]
448 fn test_handoff_roundtrip_file() {
449 let original = make_handoff();
450 let dir = tempfile::tempdir().unwrap();
451 let path = dir.path().join("handoff.json");
452
453 original.write_to_file(&path).unwrap();
454 let restored = HandoffContext::read_from_file(&path).unwrap();
455 assert_eq!(original, restored);
456 }
457
458 #[test]
459 fn test_handoff_write_atomic_creates_file() {
460 let original = make_handoff();
461 let dir = tempfile::tempdir().unwrap();
462 let path = dir.path().join("atomic-handoff.json");
463
464 original.write_to_file_atomic(&path).unwrap();
465
466 assert!(path.exists());
468
469 let restored = HandoffContext::read_from_file(&path).unwrap();
471 assert_eq!(original.handoff_id, restored.handoff_id);
472 assert_eq!(original.from_agent, restored.from_agent);
473 assert_eq!(original.to_agent, restored.to_agent);
474 assert_eq!(original.task, restored.task);
475 }
476
477 #[test]
478 fn test_handoff_write_atomic_no_partial() {
479 let original = make_handoff();
480 let dir = tempfile::tempdir().unwrap();
481 let path = dir.path().join("no-partial.json");
482
483 original.write_to_file_atomic(&path).unwrap();
484
485 let tmp_path = dir.path().join(".tmp.no-partial.json");
487 assert!(!tmp_path.exists());
488
489 assert!(path.exists());
491 }
492
493 #[test]
494 fn test_handoff_empty_decisions() {
495 let ctx = HandoffContext::new("from", "to", "simple task");
496 let json = ctx.to_json().unwrap();
497 let restored = HandoffContext::from_json(&json).unwrap();
498 assert_eq!(ctx.handoff_id, restored.handoff_id);
499 assert_eq!(ctx.from_agent, restored.from_agent);
500 assert_eq!(ctx.to_agent, restored.to_agent);
501 assert_eq!(ctx.task, restored.task);
502 assert!(restored.decisions.is_empty());
503 }
504
505 #[test]
506 fn test_ttl_serialization() {
507 let ctx_without_ttl = HandoffContext {
509 handoff_id: Uuid::new_v4(),
510 from_agent: "a".to_string(),
511 to_agent: "b".to_string(),
512 task: "test".to_string(),
513 progress_summary: String::new(),
514 decisions: vec![],
515 files_touched: vec![],
516 timestamp: Utc::now(),
517 ttl_secs: None,
518 };
519
520 let json = ctx_without_ttl.to_json().unwrap();
521 assert!(!json.contains("ttl_secs"));
522
523 let ctx_with_ttl = HandoffContext {
525 ttl_secs: Some(3600),
526 ..ctx_without_ttl
527 };
528
529 let json = ctx_with_ttl.to_json().unwrap();
530 assert!(json.contains("ttl_secs"));
531 }
532
533 #[test]
538 fn test_buffer_new() {
539 let buffer = HandoffBuffer::new(3600);
540 assert_eq!(buffer.len(), 0);
541 assert!(buffer.is_empty());
542 assert_eq!(buffer.default_ttl_secs(), 3600);
543 }
544
545 #[test]
546 fn test_buffer_insert_and_get() {
547 let mut buffer = HandoffBuffer::new(3600);
548 let ctx = HandoffContext::new("agent-a", "agent-b", "test task");
549 let id = ctx.handoff_id;
550
551 buffer.insert(ctx.clone());
552
553 assert_eq!(buffer.len(), 1);
554 assert!(!buffer.is_empty());
555
556 let retrieved = buffer.get(&id);
557 assert!(retrieved.is_some());
558 assert_eq!(retrieved.unwrap().handoff_id, id);
559 assert_eq!(retrieved.unwrap().from_agent, "agent-a");
560 assert_eq!(retrieved.unwrap().to_agent, "agent-b");
561 }
562
563 #[test]
564 fn test_buffer_get_returns_none_for_unknown() {
565 let buffer = HandoffBuffer::new(3600);
566 let unknown_id = Uuid::new_v4();
567
568 let retrieved = buffer.get(&unknown_id);
569 assert!(retrieved.is_none());
570 }
571
572 #[test]
573 fn test_buffer_latest_for_agent() {
574 let mut buffer = HandoffBuffer::new(3600);
575
576 let ctx1 = HandoffContext::new("agent-a", "agent-c", "task 1");
578 let ctx2 = HandoffContext::new("agent-b", "agent-c", "task 2");
579
580 buffer.insert(ctx1.clone());
581 buffer.insert(ctx2.clone());
582
583 let latest = buffer.latest_for_agent("agent-c");
585 assert!(latest.is_some());
586 assert_eq!(latest.unwrap().handoff_id, ctx2.handoff_id);
588 }
589
590 #[test]
591 fn test_buffer_latest_for_agent_returns_none_for_unknown() {
592 let buffer = HandoffBuffer::new(3600);
593
594 let latest = buffer.latest_for_agent("unknown-agent");
595 assert!(latest.is_none());
596 }
597
598 #[test]
599 fn test_buffer_sweep_expired() {
600 let mut buffer = HandoffBuffer::new(0); let ctx = HandoffContext::new("agent-a", "agent-b", "test task");
602 let id = ctx.handoff_id;
603
604 buffer.insert(ctx);
605 assert_eq!(buffer.len(), 1);
606
607 let swept = buffer.sweep_expired();
609 assert_eq!(swept, 1);
610 assert_eq!(buffer.len(), 0);
611 assert!(buffer.is_empty());
612
613 let retrieved = buffer.get(&id);
615 assert!(retrieved.is_none());
616 }
617
618 #[test]
619 fn test_buffer_sweep_preserves_live() {
620 let mut buffer = HandoffBuffer::new(3600); let ctx = HandoffContext::new("agent-a", "agent-b", "test task");
622 let id = ctx.handoff_id;
623
624 buffer.insert(ctx);
625 assert_eq!(buffer.len(), 1);
626
627 let swept = buffer.sweep_expired();
629 assert_eq!(swept, 0);
630 assert_eq!(buffer.len(), 1);
631
632 let retrieved = buffer.get(&id);
634 assert!(retrieved.is_some());
635 }
636
637 #[test]
638 fn test_buffer_get_returns_none_for_expired() {
639 let mut buffer = HandoffBuffer::new(0); let ctx = HandoffContext::new("agent-a", "agent-b", "test task");
641 let id = ctx.handoff_id;
642
643 buffer.insert(ctx);
644 assert_eq!(buffer.len(), 1);
645
646 let retrieved = buffer.get(&id);
648 assert!(retrieved.is_none());
649
650 assert_eq!(buffer.len(), 1);
652 }
653
654 #[test]
655 fn test_buffer_iter() {
656 let mut buffer = HandoffBuffer::new(3600);
657 let ctx1 = HandoffContext::new("agent-a", "agent-b", "task 1");
658 let ctx2 = HandoffContext::new("agent-c", "agent-d", "task 2");
659
660 buffer.insert(ctx1.clone());
661 buffer.insert(ctx2.clone());
662
663 let mut count = 0;
664 for (id, ctx, expiry) in buffer.iter() {
665 count += 1;
666 assert!(*id == ctx1.handoff_id || *id == ctx2.handoff_id);
667 assert!(!ctx.task.is_empty());
668 assert!(expiry > &Utc::now());
669 }
670 assert_eq!(count, 2);
671 }
672
673 #[test]
674 fn test_buffer_uses_context_ttl() {
675 let mut buffer = HandoffBuffer::new(3600); let mut ctx = HandoffContext::new("agent-a", "agent-b", "test task");
677 ctx.ttl_secs = Some(0); let id = ctx.handoff_id;
679
680 buffer.insert(ctx);
681
682 let retrieved = buffer.get(&id);
684 assert!(retrieved.is_none());
685 }
686
687 #[test]
688 fn test_buffer_default_ttl_when_context_ttl_none() {
689 let mut buffer = HandoffBuffer::new(3600); let ctx = HandoffContext::new("agent-a", "agent-b", "test task");
691 let id = ctx.handoff_id;
693
694 buffer.insert(ctx);
695
696 let retrieved = buffer.get(&id);
698 assert!(retrieved.is_some());
699 }
700
701 #[test]
702 fn test_buffer_multiple_agents() {
703 let mut buffer = HandoffBuffer::new(3600);
704
705 buffer.insert(HandoffContext::new("agent-a", "target-1", "task 1"));
707 buffer.insert(HandoffContext::new("agent-a", "target-2", "task 2"));
708 buffer.insert(HandoffContext::new("agent-b", "target-1", "task 3"));
709
710 assert_eq!(buffer.len(), 3);
711
712 let latest = buffer.latest_for_agent("target-1");
714 assert!(latest.is_some());
715 assert_eq!(latest.unwrap().task, "task 3");
716
717 let latest = buffer.latest_for_agent("target-2");
719 assert!(latest.is_some());
720 assert_eq!(latest.unwrap().task, "task 2");
721 }
722
723 #[test]
728 fn test_ledger_append_and_read_all() {
729 let dir = tempfile::tempdir().unwrap();
730 let ledger_path = dir.path().join("handoff-ledger.jsonl");
731 let ledger = HandoffLedger::new(&ledger_path);
732
733 let ctx1 = HandoffContext::new("agent-a", "agent-b", "task 1");
735 let ctx2 = HandoffContext::new("agent-b", "agent-c", "task 2");
736 let ctx3 = HandoffContext::new("agent-c", "agent-d", "task 3");
737
738 ledger.append(&ctx1).unwrap();
739 ledger.append(&ctx2).unwrap();
740 ledger.append(&ctx3).unwrap();
741
742 let entries = ledger.read_all().unwrap();
744 assert_eq!(entries.len(), 3);
745
746 assert_eq!(entries[0].from_agent, "agent-a");
748 assert_eq!(entries[0].to_agent, "agent-b");
749 assert_eq!(entries[0].task, "task 1");
750
751 assert_eq!(entries[1].from_agent, "agent-b");
752 assert_eq!(entries[1].to_agent, "agent-c");
753 assert_eq!(entries[1].task, "task 2");
754
755 assert_eq!(entries[2].from_agent, "agent-c");
756 assert_eq!(entries[2].to_agent, "agent-d");
757 assert_eq!(entries[2].task, "task 3");
758 }
759
760 #[test]
761 fn test_ledger_append_creates_file() {
762 let dir = tempfile::tempdir().unwrap();
763 let ledger_path = dir.path().join("new-ledger.jsonl");
764
765 assert!(!ledger_path.exists());
767
768 let ledger = HandoffLedger::new(&ledger_path);
769 let ctx = HandoffContext::new("agent-a", "agent-b", "test task");
770
771 ledger.append(&ctx).unwrap();
773
774 assert!(ledger_path.exists());
776
777 let entries = ledger.read_all().unwrap();
779 assert_eq!(entries.len(), 1);
780 assert_eq!(entries[0].task, "test task");
781 }
782
783 #[test]
784 fn test_ledger_count() {
785 let dir = tempfile::tempdir().unwrap();
786 let ledger_path = dir.path().join("count-ledger.jsonl");
787 let ledger = HandoffLedger::new(&ledger_path);
788
789 let ctx = HandoffContext::new("agent-a", "agent-b", "first");
791 ledger.append(&ctx).unwrap();
792
793 let n = 5;
795 for i in 1..n {
796 let ctx = HandoffContext::new("agent-a", "agent-b", format!("task {}", i));
797 ledger.append(&ctx).unwrap();
798 }
799
800 let count = ledger.count().unwrap();
801 assert_eq!(count, n);
802 }
803
804 #[test]
805 fn test_ledger_append_is_one_line_per_entry() {
806 let dir = tempfile::tempdir().unwrap();
807 let ledger_path = dir.path().join("line-ledger.jsonl");
808 let ledger = HandoffLedger::new(&ledger_path);
809
810 let ctx = HandoffContext::new("agent-a", "agent-b", "test task");
811 ledger.append(&ctx).unwrap();
812 ledger.append(&ctx).unwrap();
813 ledger.append(&ctx).unwrap();
814
815 let content = std::fs::read_to_string(&ledger_path).unwrap();
817 let lines: Vec<&str> = content.lines().collect();
818
819 assert_eq!(lines.len(), 3);
821
822 for (i, line) in lines.iter().enumerate() {
825 assert!(!line.is_empty(), "Line {} should not be empty", i);
826 let parsed: serde_json::Value = serde_json::from_str(line).unwrap();
827 assert!(parsed.is_object());
828 }
829 }
830
831 #[test]
832 fn test_ledger_handles_special_chars() {
833 let dir = tempfile::tempdir().unwrap();
834 let ledger_path = dir.path().join("special-ledger.jsonl");
835 let ledger = HandoffLedger::new(&ledger_path);
836
837 let mut ctx = HandoffContext::new("agent-a", "agent-b", "line1\nline2\nline3");
839 ctx.progress_summary = "Contains \"quotes\" and \t tabs".to_string();
840 ctx.decisions = vec![
841 "Unicode: 日本語".to_string(),
842 "Emoji: 🎉🚀".to_string(),
843 "Backslash: C:\\path\\to\\file".to_string(),
844 ];
845
846 ledger.append(&ctx).unwrap();
847
848 let entries = ledger.read_all().unwrap();
850 assert_eq!(entries.len(), 1);
851
852 let restored = &entries[0];
853 assert_eq!(restored.task, "line1\nline2\nline3");
854 assert_eq!(restored.progress_summary, "Contains \"quotes\" and \t tabs");
855 assert_eq!(restored.decisions.len(), 3);
856 assert_eq!(restored.decisions[0], "Unicode: 日本語");
857 assert_eq!(restored.decisions[1], "Emoji: 🎉🚀");
858 assert_eq!(restored.decisions[2], "Backslash: C:\\path\\to\\file");
859 }
860
861 #[test]
862 fn test_ledger_size_bytes() {
863 let dir = tempfile::tempdir().unwrap();
864 let ledger_path = dir.path().join("size-ledger.jsonl");
865 let ledger = HandoffLedger::new(&ledger_path);
866
867 let ctx = HandoffContext::new("agent-a", "agent-b", "test task");
870 ledger.append(&ctx).unwrap();
871
872 let size = ledger.size_bytes().unwrap();
873 assert!(
874 size > 0,
875 "Ledger file should have non-zero size after append"
876 );
877
878 ledger.append(&ctx).unwrap();
880 let new_size = ledger.size_bytes().unwrap();
881 assert!(
882 new_size > size,
883 "Ledger size should increase after second append"
884 );
885 }
886
887 #[test]
888 fn test_ttl_overflow_saturates() {
889 let mut buffer = HandoffBuffer::new(3600);
890 let mut ctx = HandoffContext::new("agent-a", "agent-b", "overflow test");
891 ctx.ttl_secs = Some(u64::MAX); let id = buffer.insert(ctx);
895
896 let retrieved = buffer.get(&id);
898 assert!(retrieved.is_some());
899 }
900}