1use crate::models::field_names;
13use serde_json::Value;
14use std::fmt::Write;
15
16pub const FORMAT_TOON_COMPACT: &str = "toon_compact";
23
24pub const FORMAT_JSON: &str = "json";
28
29pub const FORMAT_TOON: &str = "toon";
31
32#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
38pub enum WireFormat {
39 #[default]
42 Json,
43 Toon,
45 ToonCompact,
48}
49
50#[must_use]
54pub fn invalid_format_msg(got: &str) -> String {
55 format!(
56 "invalid format '{got}': expected one of {FORMAT_JSON}, {FORMAT_TOON}, {FORMAT_TOON_COMPACT}"
57 )
58}
59
60impl WireFormat {
61 pub fn parse_http(raw: Option<&str>) -> Result<Self, String> {
71 match raw {
72 None => Ok(Self::Json),
73 Some(s) if s == FORMAT_JSON => Ok(Self::Json),
74 Some(s) if s == FORMAT_TOON => Ok(Self::Toon),
75 Some(s) if s == FORMAT_TOON_COMPACT => Ok(Self::ToonCompact),
76 Some(other) => Err(invalid_format_msg(other)),
77 }
78 }
79}
80
81const MEMORY_FIELDS: &[&str] = &[
83 "id",
84 "title",
85 "tier",
86 "namespace",
87 "priority",
88 field_names::CONFIDENCE,
89 "score",
90 field_names::ACCESS_COUNT,
91 "tags",
92 "source",
93 field_names::CREATED_AT,
94 field_names::UPDATED_AT,
95 "metadata",
96];
97
98const MEMORY_FIELDS_COMPACT: &[&str] = &[
103 "id",
104 "title",
105 "tier",
106 "namespace",
107 "priority",
108 "score",
109 "tags",
110 "agent_id",
111];
112
113pub fn memories_to_toon(response: &Value, compact: bool) -> String {
126 let fields = if compact {
127 MEMORY_FIELDS_COMPACT
128 } else {
129 MEMORY_FIELDS
130 };
131 let mut out = String::with_capacity(1024);
132
133 let mut meta = Vec::new();
135 if let Some(count) = response.get("count") {
136 meta.push(format!("count:{count}"));
137 }
138 if let Some(mode) = response.get("mode").and_then(|v| v.as_str()) {
139 meta.push(format!("mode:{mode}"));
140 }
141 if let Some(used) = response.get(field_names::TOKENS_USED) {
143 meta.push(format!("tokens_used:{used}"));
144 }
145 if let Some(budget) = response.get(field_names::BUDGET_TOKENS) {
146 meta.push(format!("budget_tokens:{budget}"));
147 }
148 if !meta.is_empty() {
149 out.push_str(&meta.join("|"));
150 out.push('\n');
151 }
152
153 let mut std_list: Vec<&Value> = Vec::new();
155 if let Some(standard) = response.get("standard") {
156 std_list.push(standard);
157 }
158 if let Some(standards) = response.get("standards").and_then(|v| v.as_array()) {
159 std_list.extend(standards.iter());
160 }
161 if !std_list.is_empty() {
162 out.push_str("standards[id|title|content]:\n");
163 for standard in &std_list {
164 let id = format_value(standard.get("id"));
165 let title = format_value(standard.get("title"));
166 let content = format_value(standard.get("content"));
167 let _ = writeln!(out, "{id}|{title}|{content}");
168 }
169 }
170
171 out.push_str("memories[");
173 out.push_str(&fields.join("|"));
174 out.push_str("]:\n");
175
176 if let Some(memories) = response.get("memories").and_then(|v| v.as_array()) {
178 for mem in memories {
179 let row: Vec<String> = fields
180 .iter()
181 .map(|&field| {
182 if field == "agent_id" {
185 format_value(mem.get("metadata").and_then(|m| m.get("agent_id")))
186 } else {
187 format_value(mem.get(field))
188 }
189 })
190 .collect();
191 out.push_str(&row.join("|"));
192 out.push('\n');
193 }
194 }
195
196 out
197}
198
199pub fn search_to_toon(response: &Value, compact: bool) -> String {
201 if response.get("results").is_some() && response.get("memories").is_none() {
203 let mut normalized = response.clone();
204 if let Some(results) = response.get("results") {
205 normalized["memories"] = results.clone();
206 }
207 return memories_to_toon(&normalized, compact);
208 }
209 memories_to_toon(response, compact)
210}
211
212fn format_value(val: Option<&Value>) -> String {
214 match val {
215 None | Some(Value::Null) => String::new(),
216 Some(Value::String(s)) => escape_toon(s),
217 Some(Value::Number(n)) => n.to_string(),
218 Some(Value::Bool(b)) => {
219 if *b {
220 "1".to_string()
221 } else {
222 "0".to_string()
223 }
224 }
225 Some(Value::Array(arr)) => {
226 let items: Vec<String> = arr
228 .iter()
229 .filter_map(|v| v.as_str().map(String::from))
230 .collect();
231 escape_toon(&items.join(","))
232 }
233 Some(obj @ Value::Object(m)) => {
234 if m.is_empty() {
235 String::new()
236 } else {
237 escape_toon(&serde_json::to_string(obj).unwrap_or_default())
238 }
239 }
240 }
241}
242
243fn escape_toon(s: &str) -> String {
245 if s.contains('|')
246 || s.contains('\n')
247 || s.contains('\r')
248 || s.contains('\\')
249 || s.contains(':')
250 {
251 s.replace('\\', "\\\\")
252 .replace('|', "\\|")
253 .replace(':', "\\:")
254 .replace('\n', "\\n")
255 .replace('\r', "\\r")
256 } else {
257 s.to_string()
258 }
259}
260
261#[cfg(test)]
262mod tests {
263 use super::*;
264 use crate::models::Tier;
265 use serde_json::json;
266
267 #[test]
272 fn issue_1579_b4_wire_format_parse_http() {
273 assert_eq!(WireFormat::parse_http(None), Ok(WireFormat::Json));
274 assert_eq!(
275 WireFormat::parse_http(Some(FORMAT_JSON)),
276 Ok(WireFormat::Json)
277 );
278 assert_eq!(
279 WireFormat::parse_http(Some(FORMAT_TOON)),
280 Ok(WireFormat::Toon)
281 );
282 assert_eq!(
283 WireFormat::parse_http(Some(FORMAT_TOON_COMPACT)),
284 Ok(WireFormat::ToonCompact)
285 );
286 }
287
288 #[test]
289 fn issue_1579_b4_wire_format_rejects_unknown_with_ssot_message() {
290 let err = WireFormat::parse_http(Some("yaml")).unwrap_err();
291 assert_eq!(err, invalid_format_msg("yaml"));
292 assert!(err.contains("json") && err.contains("toon") && err.contains("toon_compact"));
293 assert!(WireFormat::parse_http(Some("TOON")).is_err());
296 }
297
298 #[test]
299 fn empty_memories() {
300 let resp = json!({"memories": [], "count": 0, "mode": "keyword"});
301 let toon = memories_to_toon(&resp, false);
302 assert!(toon.contains("count:0"));
303 assert!(toon.contains("mode:keyword"));
304 assert!(toon.contains("memories["));
305 let lines: Vec<&str> = toon.lines().collect();
307 assert_eq!(lines.len(), 2); }
309
310 #[test]
311 fn single_memory() {
312 let resp = json!({
313 "memories": [{
314 "id": "abc-123",
315 "title": "PostgreSQL config",
316 "tier": Tier::Long.as_str(),
317 "namespace": "infra",
318 "priority": 9,
319 "confidence": 1.0,
320 "score": 0.763,
321 "access_count": 2,
322 "tags": ["postgres", "database"],
323 "source": "claude",
324 "created_at": "2026-04-03T15:00:00+00:00",
325 "updated_at": "2026-04-03T15:00:00+00:00"
326 }],
327 "count": 1,
328 "mode": "hybrid"
329 });
330 let toon = memories_to_toon(&resp, false);
331 let lines: Vec<&str> = toon.lines().collect();
332 assert_eq!(lines.len(), 3); assert!(
334 lines[2].starts_with("abc-123|PostgreSQL config|long|infra|9|"),
335 "got: {}",
336 lines[2]
337 );
338 assert!(lines[2].contains("postgres,database"));
339 assert!(lines[2].contains("claude"));
340 }
341
342 #[test]
343 fn compact_mode_fewer_fields() {
344 let resp = json!({
345 "memories": [{"id": "x", "title": "Test", "tier": Tier::Mid.as_str(), "namespace": "test", "priority": 5, "score": 0.5, "tags": []}],
346 "count": 1
347 });
348 let toon = memories_to_toon(&resp, true);
349 assert!(toon.contains("memories[id|title|tier|namespace|priority|score|tags|agent_id]:"));
351 assert!(!toon.contains("created_at"));
352 assert!(!toon.contains("confidence"));
353 }
354
355 #[test]
356 fn compact_mode_surfaces_agent_id_from_metadata() {
357 let resp = json!({
358 "memories": [{
359 "id": "x",
360 "title": "Test",
361 "tier": Tier::Mid.as_str(),
362 "namespace": "test",
363 "priority": 5,
364 "score": 0.5,
365 "tags": [],
366 "metadata": {"agent_id": "alice"}
367 }],
368 "count": 1
369 });
370 let toon = memories_to_toon(&resp, true);
371 let row = toon.lines().last().unwrap();
372 assert!(
373 row.ends_with("|alice"),
374 "agent_id must be the last compact column; row: {row}"
375 );
376 }
377
378 #[test]
379 fn pipe_in_title_escaped() {
380 let resp = json!({"memories": [{"id": "x", "title": "A|B", "tier": Tier::Mid.as_str()}], "count": 1});
381 let toon = memories_to_toon(&resp, true);
382 assert!(toon.contains("A\\|B"));
383 }
384
385 #[test]
386 fn multiple_memories_token_savings() {
387 let resp = json!({
389 "memories": [
390 {"id": "a", "title": "Memory 1", "tier": Tier::Long.as_str(), "namespace": "test", "priority": 9, "score": 0.9, "tags": ["t1"]},
391 {"id": "b", "title": "Memory 2", "tier": Tier::Mid.as_str(), "namespace": "test", "priority": 7, "score": 0.7, "tags": ["t2"]},
392 {"id": "c", "title": "Memory 3", "tier": Tier::Short.as_str(), "namespace": "test", "priority": 5, "score": 0.5, "tags": ["t3"]}
393 ],
394 "count": 3,
395 "mode": "hybrid"
396 });
397 let toon = memories_to_toon(&resp, true);
398 let json_str = serde_json::to_string(&resp).unwrap();
399 assert!(
401 toon.len() < json_str.len(),
402 "TOON ({}) should be shorter than JSON ({})",
403 toon.len(),
404 json_str.len()
405 );
406 }
407
408 #[test]
409 fn search_results_key() {
410 let resp = json!({"results": [{"id": "x", "title": "Found", "tier": Tier::Mid.as_str()}], "count": 1});
411 let toon = search_to_toon(&resp, true);
412 assert!(toon.contains("memories["));
413 assert!(toon.contains("Found"));
414 }
415
416 fn five_memory_fixture() -> Value {
422 json!({
423 "memories": [
424 {
425 "id": "01",
426 "title": "PostgreSQL config",
427 "tier": Tier::Long.as_str(),
428 "namespace": "infra",
429 "priority": 9,
430 "confidence": 1.0,
431 "score": 0.91,
432 "access_count": 4,
433 "tags": ["postgres", "database"],
434 "source": "claude",
435 "created_at": "2026-04-03T15:00:00+00:00",
436 "updated_at": "2026-04-03T15:00:00+00:00",
437 "metadata": {"agent_id": "alice"}
438 },
439 {
440 "id": "02",
441 "title": "Redis cache strategy",
442 "tier": Tier::Long.as_str(),
443 "namespace": "infra",
444 "priority": 8,
445 "confidence": 0.95,
446 "score": 0.84,
447 "access_count": 2,
448 "tags": ["redis", "cache"],
449 "source": "claude",
450 "created_at": "2026-04-03T15:01:00+00:00",
451 "updated_at": "2026-04-03T15:01:00+00:00",
452 "metadata": {"agent_id": "alice"}
453 },
454 {
455 "id": "03",
456 "title": "BIND9 custom build",
457 "tier": Tier::Mid.as_str(),
458 "namespace": "infra/dns",
459 "priority": 7,
460 "confidence": 0.9,
461 "score": 0.71,
462 "access_count": 1,
463 "tags": ["bind", "dns"],
464 "source": "user",
465 "created_at": "2026-04-03T15:02:00+00:00",
466 "updated_at": "2026-04-03T15:02:00+00:00",
467 "metadata": {"agent_id": "bob"}
468 },
469 {
470 "id": "04",
471 "title": "Kubernetes pod recovery",
472 "tier": Tier::Mid.as_str(),
473 "namespace": "platform/k8s",
474 "priority": 6,
475 "confidence": 0.85,
476 "score": 0.62,
477 "access_count": 0,
478 "tags": ["k8s", "ops"],
479 "source": "hook",
480 "created_at": "2026-04-03T15:03:00+00:00",
481 "updated_at": "2026-04-03T15:03:00+00:00",
482 "metadata": {"agent_id": "carol"}
483 },
484 {
485 "id": "05",
486 "title": "Vault secrets rotation",
487 "tier": Tier::Short.as_str(),
488 "namespace": "security",
489 "priority": 5,
490 "confidence": 0.8,
491 "score": 0.55,
492 "access_count": 3,
493 "tags": ["vault", "secrets"],
494 "source": "api",
495 "created_at": "2026-04-03T15:04:00+00:00",
496 "updated_at": "2026-04-03T15:04:00+00:00",
497 "metadata": {"agent_id": "dave"}
498 }
499 ],
500 "count": 5,
501 "mode": "hybrid"
502 })
503 }
504
505 #[test]
506 fn test_toon_size_invariant_5_memories_under_threshold() {
507 let fixture = five_memory_fixture();
512 let json_bytes = serde_json::to_string(&fixture).unwrap().len();
513 let toon_bytes = memories_to_toon(&fixture, true).len();
514
515 let ratio = (toon_bytes as f64) / (json_bytes as f64);
516 assert!(
517 ratio < 0.65,
518 "TOON size invariant violated: toon={toon_bytes} json={json_bytes} \
519 ratio={ratio:.3} (must be < 0.65 for 5-memory compact fixture)"
520 );
521
522 let toon = memories_to_toon(&fixture, true);
525 for id in ["01", "02", "03", "04", "05"] {
526 assert!(toon.contains(id), "TOON output missing id `{id}`");
527 }
528 }
529
530 #[test]
535 fn escape_toon_pipe() {
536 let s = escape_toon("a|b");
537 assert_eq!(s, "a\\|b");
538 }
539
540 #[test]
541 fn escape_toon_newline() {
542 let s = escape_toon("a\nb");
543 assert_eq!(s, "a\\nb");
544 }
545
546 #[test]
547 fn escape_toon_carriage_return() {
548 let s = escape_toon("a\rb");
549 assert_eq!(s, "a\\rb");
550 }
551
552 #[test]
553 fn escape_toon_backslash() {
554 let s = escape_toon("a\\b");
555 assert_eq!(s, "a\\\\b");
557 }
558
559 #[test]
560 fn escape_toon_colon() {
561 let s = escape_toon("a:b");
562 assert_eq!(s, "a\\:b");
563 }
564
565 #[test]
566 fn escape_toon_no_special_chars_passthrough() {
567 let s = escape_toon("plain text 123");
568 assert_eq!(s, "plain text 123");
569 }
570
571 #[test]
572 fn escape_toon_multiple_specials() {
573 let s = escape_toon("a|b:c\nd");
574 assert!(s.contains("\\|"));
575 assert!(s.contains("\\:"));
576 assert!(s.contains("\\n"));
577 }
578
579 #[test]
580 fn format_value_null_is_empty() {
581 let resp = json!({
582 "memories": [{"id": null, "title": "t"}],
583 "count": 1,
584 });
585 let toon = memories_to_toon(&resp, true);
586 let row = toon.lines().last().unwrap();
587 assert!(row.starts_with("|t|"), "got: {row}");
589 }
590
591 #[test]
592 fn format_value_bool_serializes_as_zero_one() {
593 let resp = json!({
595 "memories": [{"id": "x", "title": true}],
596 "count": 1,
597 });
598 let toon = memories_to_toon(&resp, true);
599 let row = toon.lines().last().unwrap();
600 assert!(row.contains("|1|"), "true → 1; got: {row}");
601 }
602
603 #[test]
604 fn format_value_bool_false() {
605 let resp = json!({
606 "memories": [{"id": "x", "title": false}],
607 "count": 1,
608 });
609 let toon = memories_to_toon(&resp, true);
610 let row = toon.lines().last().unwrap();
611 assert!(row.contains("|0|"), "false → 0; got: {row}");
612 }
613
614 #[test]
615 fn format_value_object_empty_is_empty_string() {
616 let resp = json!({
618 "memories": [{
619 "id": "x", "title": "t", "tier": Tier::Long.as_str(), "namespace": "n",
620 "priority": 1, "confidence": 1.0, "score": 0.5, "access_count": 0,
621 "tags": [], "source": "", "created_at": "", "updated_at": "",
622 "metadata": {}
623 }],
624 "count": 1,
625 });
626 let toon = memories_to_toon(&resp, false);
627 let row = toon.lines().last().unwrap();
629 assert!(row.ends_with('|') || row.ends_with("||"), "got: {row}");
630 }
631
632 #[test]
633 fn format_value_object_non_empty_serialized_json() {
634 let resp = json!({
635 "memories": [{
636 "id": "x", "title": "t", "tier": Tier::Long.as_str(), "namespace": "n",
637 "priority": 1, "confidence": 1.0, "score": 0.5, "access_count": 0,
638 "tags": [], "source": "", "created_at": "", "updated_at": "",
639 "metadata": {"k": "v"}
640 }],
641 "count": 1,
642 });
643 let toon = memories_to_toon(&resp, false);
644 assert!(toon.contains("k") && toon.contains("v"));
646 }
647
648 #[test]
649 fn standards_section_emitted_when_present() {
650 let resp = json!({
651 "memories": [],
652 "count": 0,
653 "standard": {"id": "s1", "title": "policy", "content": "be nice"}
654 });
655 let toon = memories_to_toon(&resp, true);
656 assert!(toon.contains("standards[id|title|content]:"));
657 assert!(toon.contains("s1"));
658 }
659
660 #[test]
661 fn standards_array_emitted_when_present() {
662 let resp = json!({
663 "memories": [],
664 "count": 0,
665 "standards": [
666 {"id": "s1", "title": "p1", "content": "c1"},
667 {"id": "s2", "title": "p2", "content": "c2"},
668 ],
669 });
670 let toon = memories_to_toon(&resp, true);
671 assert!(toon.contains("standards["));
672 assert!(toon.contains("s1"));
673 assert!(toon.contains("s2"));
674 }
675
676 #[test]
677 fn meta_line_includes_token_budget() {
678 let resp = json!({
679 "memories": [],
680 "count": 0,
681 "tokens_used": 100,
682 "budget_tokens": 500,
683 });
684 let toon = memories_to_toon(&resp, true);
685 assert!(toon.contains("tokens_used:100"));
686 assert!(toon.contains("budget_tokens:500"));
687 }
688
689 #[test]
690 fn search_to_toon_passes_through_when_memories_present() {
691 let resp = json!({
694 "memories": [{"id": "a", "title": "t1"}],
695 "results": [{"id": "b", "title": "t2"}],
696 "count": 1,
697 });
698 let toon = search_to_toon(&resp, true);
699 assert!(toon.contains("a"));
701 assert!(toon.contains("t1"));
702 }
703
704 #[test]
705 fn test_toon_round_trip_preserves_visible_fields() {
706 let resp = json!({
711 "memories": [{
712 "id": "abc-xyz",
713 "title": "Round-trip test",
714 "tier": Tier::Long.as_str(),
715 "namespace": "test",
716 "priority": 9,
717 "confidence": 1.0,
718 "score": 0.5,
719 "access_count": 7,
720 "tags": ["alpha", "beta"],
721 "source": "claude",
722 "created_at": "2026-04-03T15:00:00+00:00",
723 "updated_at": "2026-04-03T15:00:30+00:00",
724 "metadata": {"agent_id": "alice"}
725 }],
726 "count": 1
727 });
728 let toon = memories_to_toon(&resp, false);
729 for col in [
731 "id",
732 "title",
733 "tier",
734 "namespace",
735 "priority",
736 "confidence",
737 "score",
738 "access_count",
739 "tags",
740 "source",
741 "created_at",
742 "updated_at",
743 "metadata",
744 ] {
745 assert!(
746 toon.contains(col),
747 "TOON header must list column `{col}`; got:\n{toon}"
748 );
749 }
750 assert!(toon.contains("abc-xyz"));
752 assert!(toon.contains("Round-trip test"));
753 assert!(toon.contains("alpha,beta")); assert!(
758 toon.contains(r"2026-04-03T15\:00\:00+00\:00"),
759 "TOON should contain timestamp (with escaped ':'): {toon}"
760 );
761 }
762}