1use std::collections::HashMap;
2use std::fs;
3use std::io::{BufRead, BufReader, Write};
4use std::path::PathBuf;
5
6use serde::{Deserialize, Serialize};
7use tokio::sync::Mutex as TokioMutex;
8
9#[derive(Debug, Clone, Serialize, Deserialize)]
15#[serde(rename_all = "camelCase")]
16pub struct Decision {
17 pub action: String,
18 #[serde(skip_serializing_if = "Option::is_none")]
19 pub symbol: Option<String>,
20 #[serde(skip_serializing_if = "Option::is_none")]
21 pub size: Option<f64>,
22 #[serde(flatten)]
24 pub extra: HashMap<String, serde_json::Value>,
25}
26
27#[derive(Debug, Clone, Serialize, Deserialize)]
29#[serde(rename_all = "camelCase")]
30pub struct Execution {
31 #[serde(skip_serializing_if = "Option::is_none")]
32 pub order_id: Option<String>,
33 pub status: String,
34 #[serde(flatten)]
36 pub extra: HashMap<String, serde_json::Value>,
37}
38
39#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
46#[serde(rename_all = "snake_case")]
47pub enum ThinkingLogStatus {
48 Order,
49 NoAction,
50 RiskBlocked,
51 Error,
52}
53
54#[derive(Debug, Clone, Serialize, Deserialize)]
57#[serde(rename_all = "camelCase")]
58pub struct ThinkingLogEntry {
59 pub timestamp: String,
60 pub agent_id: String,
61 pub market_summary: String,
62 pub claude_reasoning: String,
63 pub decision: Decision,
64 pub risk_check: String,
65 #[serde(skip_serializing_if = "Option::is_none")]
66 pub execution: Option<Execution>,
67 pub status: ThinkingLogStatus,
68 #[serde(skip_serializing_if = "Option::is_none")]
70 pub error_message: Option<String>,
71 #[serde(skip_serializing_if = "Option::is_none")]
73 pub technical_snapshot: Option<String>,
74}
75
76#[derive(Debug, Clone, Serialize, Deserialize)]
78#[serde(rename_all = "camelCase")]
79pub struct ThinkingLogQuery {
80 pub agent_id: String,
81 pub limit: Option<usize>,
82 pub offset: Option<usize>,
83 #[serde(skip_serializing_if = "Option::is_none")]
84 pub status_filter: Option<ThinkingLogStatus>,
85}
86
87#[derive(Debug, Clone, Serialize, Deserialize)]
89#[serde(rename_all = "camelCase")]
90pub struct ThinkingLogPage {
91 pub entries: Vec<ThinkingLogEntry>,
92 pub total: usize,
93 pub limit: usize,
94 pub offset: usize,
95}
96
97pub struct ThinkingLogStore {
103 cache: TokioMutex<HashMap<String, Vec<ThinkingLogEntry>>>,
105}
106
107impl ThinkingLogStore {
108 pub fn new() -> Self {
109 Self {
110 cache: TokioMutex::new(HashMap::new()),
111 }
112 }
113
114 pub async fn append(&self, entry: ThinkingLogEntry) -> Result<(), String> {
117 write_thinking_log(&entry)?;
119
120 let mut cache = self.cache.lock().await;
122 cache
123 .entry(entry.agent_id.clone())
124 .or_insert_with(Vec::new)
125 .push(entry);
126
127 Ok(())
128 }
129
130 pub async fn get_logs(&self, agent_id: &str, limit: usize, offset: usize) -> ThinkingLogPage {
132 let mut cache = self.cache.lock().await;
133
134 if !cache.contains_key(agent_id) {
136 let entries = load_thinking_logs(agent_id);
137 cache.insert(agent_id.to_string(), entries);
138 }
139
140 let entries = cache.get(agent_id).cloned().unwrap_or_default();
141 let total = entries.len();
142
143 let page: Vec<ThinkingLogEntry> =
145 entries.into_iter().rev().skip(offset).take(limit).collect();
146
147 ThinkingLogPage {
148 entries: page,
149 total,
150 limit,
151 offset,
152 }
153 }
154
155 pub async fn get_detail(&self, agent_id: &str, index: usize) -> Option<ThinkingLogEntry> {
157 let mut cache = self.cache.lock().await;
158
159 if !cache.contains_key(agent_id) {
160 let entries = load_thinking_logs(agent_id);
161 cache.insert(agent_id.to_string(), entries);
162 }
163
164 let entries = cache.get(agent_id)?;
165 let total = entries.len();
166
167 if index < total {
169 Some(entries[total - 1 - index].clone())
170 } else {
171 None
172 }
173 }
174
175 pub async fn search(
178 &self,
179 agent_id: &str,
180 query: &str,
181 limit: usize,
182 status_filter: Option<&ThinkingLogStatus>,
183 ) -> Vec<ThinkingLogEntry> {
184 let mut cache = self.cache.lock().await;
185
186 if !cache.contains_key(agent_id) {
187 let entries = load_thinking_logs(agent_id);
188 cache.insert(agent_id.to_string(), entries);
189 }
190
191 let entries = cache.get(agent_id).cloned().unwrap_or_default();
192 let query_lower = query.to_lowercase();
193
194 entries
195 .into_iter()
196 .rev()
197 .filter(|e| {
198 if let Some(sf) = status_filter {
200 if &e.status != sf {
201 return false;
202 }
203 }
204 if query.is_empty() {
206 return true;
207 }
208 e.market_summary.to_lowercase().contains(&query_lower)
209 || e.claude_reasoning.to_lowercase().contains(&query_lower)
210 || e.decision.action.to_lowercase().contains(&query_lower)
211 || e.agent_id.to_lowercase().contains(&query_lower)
212 || e.error_message
213 .as_ref()
214 .map(|m| m.to_lowercase().contains(&query_lower))
215 .unwrap_or(false)
216 || e.technical_snapshot
217 .as_ref()
218 .map(|s| s.to_lowercase().contains(&query_lower))
219 .unwrap_or(false)
220 })
221 .take(limit)
222 .collect()
223 }
224
225 pub async fn get_all_entries(&self, agent_id: &str) -> Vec<ThinkingLogEntry> {
228 let mut cache = self.cache.lock().await;
229
230 if !cache.contains_key(agent_id) {
231 let entries = load_thinking_logs(agent_id);
232 cache.insert(agent_id.to_string(), entries);
233 }
234
235 cache.get(agent_id).cloned().unwrap_or_default()
236 }
237
238 pub async fn get_cached_agent_ids(&self) -> Vec<String> {
241 let cache = self.cache.lock().await;
242 cache.keys().cloned().collect()
243 }
244
245 pub async fn filter_by_status(
247 &self,
248 agent_id: &str,
249 status: &ThinkingLogStatus,
250 limit: usize,
251 offset: usize,
252 ) -> ThinkingLogPage {
253 let mut cache = self.cache.lock().await;
254
255 if !cache.contains_key(agent_id) {
256 let entries = load_thinking_logs(agent_id);
257 cache.insert(agent_id.to_string(), entries);
258 }
259
260 let entries = cache.get(agent_id).cloned().unwrap_or_default();
261 let filtered: Vec<ThinkingLogEntry> = entries
262 .into_iter()
263 .filter(|e| &e.status == status)
264 .collect();
265
266 let total = filtered.len();
267 let page: Vec<ThinkingLogEntry> = filtered
268 .into_iter()
269 .rev()
270 .skip(offset)
271 .take(limit)
272 .collect();
273
274 ThinkingLogPage {
275 entries: page,
276 total,
277 limit,
278 offset,
279 }
280 }
281}
282
283fn thinking_log_dir() -> PathBuf {
289 let mut path = dirs::data_dir().unwrap_or_else(|| PathBuf::from("."));
290 path.push("hyper-agent");
291 path.push("thinking-logs");
292 let _ = fs::create_dir_all(&path);
293 path
294}
295
296fn thinking_log_path(agent_id: &str) -> PathBuf {
297 thinking_log_dir().join(format!("{}.jsonl", agent_id))
298}
299
300fn write_thinking_log(entry: &ThinkingLogEntry) -> Result<(), String> {
302 let path = thinking_log_path(&entry.agent_id);
303 let json = serde_json::to_string(entry)
304 .map_err(|e| format!("Failed to serialize thinking log: {}", e))?;
305
306 let mut file = fs::OpenOptions::new()
307 .create(true)
308 .append(true)
309 .open(&path)
310 .map_err(|e| format!("Failed to open thinking log file: {}", e))?;
311
312 writeln!(file, "{}", json).map_err(|e| format!("Failed to write thinking log: {}", e))?;
313
314 Ok(())
315}
316
317fn load_thinking_logs(agent_id: &str) -> Vec<ThinkingLogEntry> {
319 let path = thinking_log_path(agent_id);
320 let file = match fs::File::open(&path) {
321 Ok(f) => f,
322 Err(_) => return Vec::new(),
323 };
324
325 let reader = BufReader::new(file);
326 let mut entries = Vec::new();
327
328 for line in reader.lines() {
329 let line = match line {
330 Ok(l) => l,
331 Err(_) => continue,
332 };
333 if line.trim().is_empty() {
334 continue;
335 }
336 match serde_json::from_str::<ThinkingLogEntry>(&line) {
337 Ok(entry) => entries.push(entry),
338 Err(_) => continue, }
340 }
341
342 entries
343}
344
345#[cfg(test)]
350mod tests {
351 use super::*;
352 use std::fs;
353
354 fn make_entry(agent_id: &str, status: ThinkingLogStatus, action: &str) -> ThinkingLogEntry {
355 ThinkingLogEntry {
356 timestamp: chrono::Utc::now().to_rfc3339(),
357 agent_id: agent_id.to_string(),
358 market_summary: "BTC at 95k, funding rate low".to_string(),
359 claude_reasoning: "Based on technical analysis, trend is up".to_string(),
360 decision: Decision {
361 action: action.to_string(),
362 symbol: Some("BTC-PERP".to_string()),
363 size: Some(0.05),
364 extra: HashMap::new(),
365 },
366 risk_check: "passed".to_string(),
367 execution: Some(Execution {
368 order_id: Some("0xabc123".to_string()),
369 status: "filled".to_string(),
370 extra: HashMap::new(),
371 }),
372 status,
373 error_message: None,
374 technical_snapshot: None,
375 }
376 }
377
378 fn cleanup_log(agent_id: &str) {
379 let _ = fs::remove_file(thinking_log_path(agent_id));
380 }
381
382 #[test]
385 fn test_thinking_log_status_serialization() {
386 assert_eq!(
387 serde_json::to_string(&ThinkingLogStatus::Order).unwrap(),
388 "\"order\""
389 );
390 assert_eq!(
391 serde_json::to_string(&ThinkingLogStatus::NoAction).unwrap(),
392 "\"no_action\""
393 );
394 assert_eq!(
395 serde_json::to_string(&ThinkingLogStatus::RiskBlocked).unwrap(),
396 "\"risk_blocked\""
397 );
398 assert_eq!(
399 serde_json::to_string(&ThinkingLogStatus::Error).unwrap(),
400 "\"error\""
401 );
402 }
403
404 #[test]
405 fn test_thinking_log_status_deserialization() {
406 let s: ThinkingLogStatus = serde_json::from_str("\"order\"").unwrap();
407 assert_eq!(s, ThinkingLogStatus::Order);
408
409 let s: ThinkingLogStatus = serde_json::from_str("\"no_action\"").unwrap();
410 assert_eq!(s, ThinkingLogStatus::NoAction);
411
412 let s: ThinkingLogStatus = serde_json::from_str("\"risk_blocked\"").unwrap();
413 assert_eq!(s, ThinkingLogStatus::RiskBlocked);
414
415 let s: ThinkingLogStatus = serde_json::from_str("\"error\"").unwrap();
416 assert_eq!(s, ThinkingLogStatus::Error);
417 }
418
419 #[test]
420 fn test_thinking_log_entry_serialization() {
421 let entry = make_entry("agent_001", ThinkingLogStatus::Order, "buy");
422 let json = serde_json::to_value(&entry).unwrap();
423
424 assert_eq!(json["agentId"], "agent_001");
425 assert_eq!(json["marketSummary"], "BTC at 95k, funding rate low");
426 assert_eq!(json["decision"]["action"], "buy");
427 assert_eq!(json["decision"]["symbol"], "BTC-PERP");
428 assert_eq!(json["decision"]["size"], 0.05);
429 assert_eq!(json["riskCheck"], "passed");
430 assert_eq!(json["execution"]["orderId"], "0xabc123");
431 assert_eq!(json["execution"]["status"], "filled");
432 assert_eq!(json["status"], "order");
433 }
434
435 #[test]
436 fn test_thinking_log_entry_deserialization() {
437 let json = serde_json::json!({
438 "timestamp": "2026-03-09T14:32:00Z",
439 "agentId": "agent_001",
440 "marketSummary": "BTC at 95k",
441 "claudeReasoning": "Trend is up",
442 "decision": { "action": "buy", "symbol": "BTC-PERP", "size": 0.05 },
443 "riskCheck": "passed",
444 "execution": { "orderId": "0x123", "status": "filled" },
445 "status": "order"
446 });
447 let entry: ThinkingLogEntry = serde_json::from_value(json).unwrap();
448 assert_eq!(entry.agent_id, "agent_001");
449 assert_eq!(entry.decision.action, "buy");
450 assert_eq!(entry.decision.symbol, Some("BTC-PERP".to_string()));
451 assert_eq!(entry.status, ThinkingLogStatus::Order);
452 }
453
454 #[test]
455 fn test_thinking_log_entry_no_execution() {
456 let mut entry = make_entry("agent_002", ThinkingLogStatus::NoAction, "hold");
457 entry.execution = None;
458 let json = serde_json::to_value(&entry).unwrap();
459 assert!(json.get("execution").is_none());
460 }
461
462 #[test]
463 fn test_thinking_log_entry_with_error() {
464 let mut entry = make_entry("agent_003", ThinkingLogStatus::Error, "buy");
465 entry.error_message = Some("Connection timeout".to_string());
466 entry.execution = None;
467 let json = serde_json::to_value(&entry).unwrap();
468 assert_eq!(json["status"], "error");
469 assert_eq!(json["errorMessage"], "Connection timeout");
470 }
471
472 #[test]
473 fn test_decision_extra_fields() {
474 let mut extra = HashMap::new();
475 extra.insert(
476 "leverage".to_string(),
477 serde_json::Value::Number(serde_json::Number::from(5)),
478 );
479 let decision = Decision {
480 action: "buy".to_string(),
481 symbol: Some("ETH-PERP".to_string()),
482 size: Some(1.0),
483 extra,
484 };
485 let json = serde_json::to_value(&decision).unwrap();
486 assert_eq!(json["leverage"], 5);
487 }
488
489 #[test]
492 fn test_write_and_load_thinking_log() {
493 let agent_id = "test-thinking-log-persistence";
494 cleanup_log(agent_id);
495
496 let entry1 = make_entry(agent_id, ThinkingLogStatus::Order, "buy");
497 write_thinking_log(&entry1).unwrap();
498
499 let mut entry2 = make_entry(agent_id, ThinkingLogStatus::NoAction, "hold");
500 entry2.execution = None;
501 write_thinking_log(&entry2).unwrap();
502
503 let loaded = load_thinking_logs(agent_id);
504 assert_eq!(loaded.len(), 2);
505 assert_eq!(loaded[0].decision.action, "buy");
506 assert_eq!(loaded[1].decision.action, "hold");
507
508 cleanup_log(agent_id);
509 }
510
511 #[test]
512 fn test_load_nonexistent_returns_empty() {
513 let entries = load_thinking_logs("nonexistent-agent-xyz");
514 assert!(entries.is_empty());
515 }
516
517 #[test]
518 fn test_write_thinking_log_creates_file() {
519 let agent_id = "test-thinking-log-create";
520 cleanup_log(agent_id);
521
522 let entry = make_entry(agent_id, ThinkingLogStatus::Order, "buy");
523 write_thinking_log(&entry).unwrap();
524
525 assert!(thinking_log_path(agent_id).exists());
526 cleanup_log(agent_id);
527 }
528
529 #[tokio::test]
532 async fn test_store_append_and_get() {
533 let agent_id = "test-store-append";
534 cleanup_log(agent_id);
535
536 let store = ThinkingLogStore::new();
537
538 let entry1 = make_entry(agent_id, ThinkingLogStatus::Order, "buy");
539 store.append(entry1).await.unwrap();
540
541 let entry2 = make_entry(agent_id, ThinkingLogStatus::NoAction, "hold");
542 store.append(entry2).await.unwrap();
543
544 let page = store.get_logs(agent_id, 10, 0).await;
545 assert_eq!(page.total, 2);
546 assert_eq!(page.entries.len(), 2);
547 assert_eq!(page.entries[0].decision.action, "hold");
549 assert_eq!(page.entries[1].decision.action, "buy");
550
551 cleanup_log(agent_id);
552 }
553
554 #[tokio::test]
555 async fn test_store_pagination() {
556 let agent_id = "test-store-pagination";
557 cleanup_log(agent_id);
558
559 let store = ThinkingLogStore::new();
560
561 for i in 0..5 {
562 let entry = make_entry(agent_id, ThinkingLogStatus::Order, &format!("action_{}", i));
563 store.append(entry).await.unwrap();
564 }
565
566 let page = store.get_logs(agent_id, 2, 0).await;
567 assert_eq!(page.total, 5);
568 assert_eq!(page.entries.len(), 2);
569 assert_eq!(page.entries[0].decision.action, "action_4");
570 assert_eq!(page.entries[1].decision.action, "action_3");
571
572 let page = store.get_logs(agent_id, 2, 2).await;
573 assert_eq!(page.total, 5);
574 assert_eq!(page.entries.len(), 2);
575 assert_eq!(page.entries[0].decision.action, "action_2");
576 assert_eq!(page.entries[1].decision.action, "action_1");
577
578 let page = store.get_logs(agent_id, 2, 4).await;
579 assert_eq!(page.entries.len(), 1);
580 assert_eq!(page.entries[0].decision.action, "action_0");
581
582 cleanup_log(agent_id);
583 }
584
585 #[tokio::test]
586 async fn test_store_get_detail() {
587 let agent_id = "test-store-detail";
588 cleanup_log(agent_id);
589
590 let store = ThinkingLogStore::new();
591
592 let entry1 = make_entry(agent_id, ThinkingLogStatus::Order, "buy");
593 store.append(entry1).await.unwrap();
594
595 let entry2 = make_entry(agent_id, ThinkingLogStatus::NoAction, "hold");
596 store.append(entry2).await.unwrap();
597
598 let detail = store.get_detail(agent_id, 0).await.unwrap();
599 assert_eq!(detail.decision.action, "hold");
600
601 let detail = store.get_detail(agent_id, 1).await.unwrap();
602 assert_eq!(detail.decision.action, "buy");
603
604 assert!(store.get_detail(agent_id, 2).await.is_none());
605
606 cleanup_log(agent_id);
607 }
608
609 #[tokio::test]
610 async fn test_store_search_by_text() {
611 let agent_id = "test-store-search";
612 cleanup_log(agent_id);
613
614 let store = ThinkingLogStore::new();
615
616 let mut entry1 = make_entry(agent_id, ThinkingLogStatus::Order, "buy");
617 entry1.market_summary = "BTC breaking 95k resistance".to_string();
618 store.append(entry1).await.unwrap();
619
620 let mut entry2 = make_entry(agent_id, ThinkingLogStatus::NoAction, "hold");
621 entry2.market_summary = "ETH consolidating at 3k".to_string();
622 store.append(entry2).await.unwrap();
623
624 let results = store.search(agent_id, "BTC", 50, None).await;
625 assert_eq!(results.len(), 1);
626 assert_eq!(results[0].decision.action, "buy");
627
628 let results = store.search(agent_id, "ETH", 50, None).await;
629 assert_eq!(results.len(), 1);
630 assert_eq!(results[0].decision.action, "hold");
631
632 let results = store.search(agent_id, "technical", 50, None).await;
633 assert_eq!(results.len(), 2);
634
635 let results = store.search(agent_id, "", 50, None).await;
636 assert_eq!(results.len(), 2);
637
638 cleanup_log(agent_id);
639 }
640
641 #[tokio::test]
642 async fn test_store_search_with_status_filter() {
643 let agent_id = "test-store-search-filter";
644 cleanup_log(agent_id);
645
646 let store = ThinkingLogStore::new();
647
648 store
649 .append(make_entry(agent_id, ThinkingLogStatus::Order, "buy"))
650 .await
651 .unwrap();
652 store
653 .append(make_entry(agent_id, ThinkingLogStatus::NoAction, "hold"))
654 .await
655 .unwrap();
656 store
657 .append(make_entry(agent_id, ThinkingLogStatus::RiskBlocked, "buy"))
658 .await
659 .unwrap();
660
661 let results = store
662 .search(agent_id, "", 50, Some(&ThinkingLogStatus::Order))
663 .await;
664 assert_eq!(results.len(), 1);
665 assert_eq!(results[0].status, ThinkingLogStatus::Order);
666
667 let results = store
668 .search(agent_id, "", 50, Some(&ThinkingLogStatus::RiskBlocked))
669 .await;
670 assert_eq!(results.len(), 1);
671 assert_eq!(results[0].status, ThinkingLogStatus::RiskBlocked);
672
673 cleanup_log(agent_id);
674 }
675
676 #[tokio::test]
677 async fn test_store_filter_by_status() {
678 let agent_id = "test-store-filter-status";
679 cleanup_log(agent_id);
680
681 let store = ThinkingLogStore::new();
682
683 store
684 .append(make_entry(agent_id, ThinkingLogStatus::Order, "buy"))
685 .await
686 .unwrap();
687 store
688 .append(make_entry(agent_id, ThinkingLogStatus::NoAction, "hold"))
689 .await
690 .unwrap();
691 store
692 .append(make_entry(agent_id, ThinkingLogStatus::Order, "sell"))
693 .await
694 .unwrap();
695 store
696 .append(make_entry(agent_id, ThinkingLogStatus::RiskBlocked, "buy"))
697 .await
698 .unwrap();
699
700 let page = store
701 .filter_by_status(agent_id, &ThinkingLogStatus::Order, 10, 0)
702 .await;
703 assert_eq!(page.total, 2);
704 assert_eq!(page.entries.len(), 2);
705 assert_eq!(page.entries[0].decision.action, "sell");
706 assert_eq!(page.entries[1].decision.action, "buy");
707
708 cleanup_log(agent_id);
709 }
710
711 #[tokio::test]
712 async fn test_store_search_limit() {
713 let agent_id = "test-store-search-limit";
714 cleanup_log(agent_id);
715
716 let store = ThinkingLogStore::new();
717
718 for _ in 0..10 {
719 store
720 .append(make_entry(agent_id, ThinkingLogStatus::Order, "buy"))
721 .await
722 .unwrap();
723 }
724
725 let results = store.search(agent_id, "", 3, None).await;
726 assert_eq!(results.len(), 3);
727
728 cleanup_log(agent_id);
729 }
730
731 #[tokio::test]
732 async fn test_store_loads_from_disk_on_cache_miss() {
733 let agent_id = "test-store-disk-load";
734 cleanup_log(agent_id);
735
736 let entry = make_entry(agent_id, ThinkingLogStatus::Order, "buy");
737 write_thinking_log(&entry).unwrap();
738
739 let store = ThinkingLogStore::new();
740
741 let page = store.get_logs(agent_id, 10, 0).await;
742 assert_eq!(page.total, 1);
743 assert_eq!(page.entries[0].decision.action, "buy");
744
745 cleanup_log(agent_id);
746 }
747
748 #[tokio::test]
749 async fn test_store_empty_agent() {
750 let store = ThinkingLogStore::new();
751
752 let page = store.get_logs("no-such-agent-thinking", 10, 0).await;
753 assert_eq!(page.total, 0);
754 assert!(page.entries.is_empty());
755 }
756
757 #[test]
758 fn test_thinking_log_query_serialization() {
759 let query = ThinkingLogQuery {
760 agent_id: "agent_001".to_string(),
761 limit: Some(20),
762 offset: Some(5),
763 status_filter: Some(ThinkingLogStatus::Order),
764 };
765 let json = serde_json::to_value(&query).unwrap();
766 assert_eq!(json["agentId"], "agent_001");
767 assert_eq!(json["limit"], 20);
768 assert_eq!(json["offset"], 5);
769 assert_eq!(json["statusFilter"], "order");
770 }
771
772 #[test]
773 fn test_thinking_log_page_serialization() {
774 let page = ThinkingLogPage {
775 entries: vec![make_entry("a", ThinkingLogStatus::Order, "buy")],
776 total: 100,
777 limit: 10,
778 offset: 0,
779 };
780 let json = serde_json::to_value(&page).unwrap();
781 assert_eq!(json["total"], 100);
782 assert_eq!(json["limit"], 10);
783 assert_eq!(json["offset"], 0);
784 assert_eq!(json["entries"].as_array().unwrap().len(), 1);
785 }
786}