1use std::collections::HashSet;
12
13use serde::{Deserialize, Serialize};
14use serde_json::{Map, Value};
15
16#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
32#[serde(rename_all = "snake_case")]
33pub enum OutputFormat {
34 #[default]
36 Json,
37 Auto,
40 Table,
42}
43
44const CELL_TRUNCATE: usize = 120;
46
47pub fn render_format(value: Value, format: OutputFormat, presentation: PresentationMode) -> String {
62 match format {
63 OutputFormat::Json => serde_json::to_string(&value).unwrap_or_else(|_| "null".to_string()),
64 OutputFormat::Auto | OutputFormat::Table => {
65 let reduced = if presentation == PresentationMode::Verbose {
67 value
68 } else {
69 apply_redundancy_drop(value)
70 };
71 match format {
72 OutputFormat::Auto => render_auto(reduced),
73 OutputFormat::Table => render_table_forced(reduced),
74 OutputFormat::Json => unreachable!(),
75 }
76 }
77 }
78}
79
80pub fn apply_redundancy_drop(value: Value) -> Value {
91 match value {
92 Value::Object(_) => drop_record(value),
93 Value::Array(arr) => Value::Array(
94 arr.into_iter()
95 .map(|v| if v.is_object() { drop_record(v) } else { v })
96 .collect(),
97 ),
98 other => other,
99 }
100}
101
102fn drop_record(value: Value) -> Value {
104 let Value::Object(mut map) = value else {
105 return value;
106 };
107
108 map.remove("full_id");
110
111 if map.get("namespace").and_then(Value::as_str) == Some("local") {
113 map.remove("namespace");
114 }
115
116 let props_val = map.remove("properties");
119 if let Some(Value::Object(props)) = props_val {
120 let mut new_props = Map::new();
121 for (k, v) in props {
122 if map.get(&k) != Some(&v) {
123 new_props.insert(k, v);
124 }
125 }
126 if !new_props.is_empty() {
127 map.insert("properties".to_string(), Value::Object(new_props));
128 }
129 } else if let Some(other) = props_val {
130 map.insert("properties".to_string(), other);
131 }
132
133 let out: Map<String, Value> = map
135 .into_iter()
136 .map(|(k, v)| {
137 let v = match v {
138 Value::Array(arr) => Value::Array(
139 arr.into_iter()
140 .map(|item| {
141 if item.is_object() {
142 drop_record(item)
143 } else {
144 item
145 }
146 })
147 .collect(),
148 ),
149 other => other,
150 };
151 (k, v)
152 })
153 .collect();
154 Value::Object(out)
155}
156
157fn render_auto(value: Value) -> String {
161 if let Some((records, keys)) = find_record_array(&value) {
163 return render_table(&records, &keys);
164 }
165 if value.is_object() {
167 return render_kv_block(&value, 0);
168 }
169 serde_json::to_string(&value).unwrap_or_else(|_| "null".to_string())
171}
172
173fn render_table_forced(value: Value) -> String {
175 if let Some((records, keys)) = find_record_array(&value) {
176 return render_table(&records, &keys);
177 }
178 serde_json::to_string(&value).unwrap_or_else(|_| "null".to_string())
180}
181
182fn find_record_array(value: &Value) -> Option<(Vec<Value>, Vec<String>)> {
190 match value {
191 Value::Array(arr) if arr.len() >= 2 && arr.iter().all(Value::is_object) => {
192 let keys = collect_keys(arr);
193 Some((arr.clone(), keys))
194 }
195 Value::Object(map) => {
196 for v in map.values() {
197 if let Value::Array(arr) = v {
198 if arr.len() >= 2 && arr.iter().all(Value::is_object) {
199 let keys = collect_keys(arr);
200 return Some((arr.clone(), keys));
201 }
202 }
203 }
204 None
205 }
206 _ => None,
207 }
208}
209
210fn collect_keys(records: &[Value]) -> Vec<String> {
212 let mut seen = HashSet::new();
213 let mut keys = Vec::new();
214 for record in records {
215 if let Value::Object(map) = record {
216 for k in map.keys() {
217 if seen.insert(k.clone()) {
218 keys.push(k.clone());
219 }
220 }
221 }
222 }
223 keys
224}
225
226fn render_table(records: &[Value], keys: &[String]) -> String {
230 let mut out = String::new();
231
232 out.push('|');
234 for k in keys {
235 out.push(' ');
236 out.push_str(k);
237 out.push_str(" |");
238 }
239 out.push('\n');
240
241 out.push('|');
243 for _ in keys {
244 out.push_str("---|");
245 }
246 out.push('\n');
247
248 for record in records {
250 out.push('|');
251 for k in keys {
252 let cell = record.get(k).unwrap_or(&Value::Null);
253 let text = cell_text(cell);
254 out.push(' ');
255 out.push_str(&text);
256 out.push_str(" |");
257 }
258 out.push('\n');
259 }
260
261 out
262}
263
264fn cell_text(value: &Value) -> String {
266 let raw = match value {
267 Value::Null => String::new(),
268 Value::Bool(b) => b.to_string(),
269 Value::Number(n) => n.to_string(),
270 Value::String(s) => s.clone(),
271 other => serde_json::to_string(other).unwrap_or_default(),
273 };
274
275 let escaped = raw.replace('|', "\\|").replace(['\n', '\r'], " ");
277
278 let char_count = escaped.chars().count();
281 if char_count > CELL_TRUNCATE {
282 let truncated: String = escaped.chars().take(CELL_TRUNCATE).collect();
283 format!("{truncated}...")
284 } else {
285 escaped
286 }
287}
288
289fn render_kv_block(value: &Value, depth: usize) -> String {
293 let indent = " ".repeat(depth);
294 match value {
295 Value::Object(map) => {
296 let mut out = String::new();
297 for (k, v) in map {
298 match v {
299 Value::Object(_) => {
300 out.push_str(&format!("{}{}:\n", indent, k));
301 out.push_str(&render_kv_block(v, depth + 1));
302 }
303 Value::Array(arr) if arr.iter().any(Value::is_object) => {
304 out.push_str(&format!("{}{}:\n", indent, k));
305 for item in arr {
306 if item.is_object() {
307 out.push_str(&render_kv_block(item, depth + 1));
308 } else {
309 out.push_str(&format!("{} - {}\n", indent, cell_text(item)));
310 }
311 }
312 }
313 _ => {
314 out.push_str(&format!("{}{}: {}\n", indent, k, cell_text(v)));
315 }
316 }
317 }
318 out
319 }
320 other => format!("{}{}\n", indent, cell_text(other)),
321 }
322}
323
324pub fn micros_to_iso(micros: i64) -> String {
331 chrono::DateTime::<chrono::Utc>::from_timestamp_micros(micros)
332 .unwrap_or_else(chrono::Utc::now)
333 .to_rfc3339_opts(chrono::SecondsFormat::Micros, true)
334}
335
336#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
338#[serde(rename_all = "snake_case")]
339pub enum PresentationMode {
340 #[default]
346 Agent,
347 Verbose,
351 Human,
358}
359
360const LIFECYCLE_NULL_PRESERVE: &[&str] = &[
364 "completed_at",
365 "deleted_at",
366 "due_at",
367 "read_at",
368 "started_at",
369 "superseded_at",
370 "applied_at",
371 "withdrawn_at",
372 "reviewed_at",
373 "parent_id",
374 "superseded_by",
375 "replaced_by",
376];
377
378const SCORE_FIELDS: &[&str] = &[
380 "score",
381 "salience",
382 "decay_factor",
383 "rrf_score",
384 "similarity",
385 "cross_encoder_score",
386 "graph_proximity_score",
387];
388
389const UUID_CANONICAL_LEN: usize = 36;
391
392fn should_shorten_uuid_field(key: &str) -> bool {
400 if key == "full_id" {
401 return false;
402 }
403 key == "id" || key.ends_with("_id") || matches!(key, "superseded_by" | "replaced_by")
404}
405
406pub fn present(value: Value, mode: PresentationMode, now_unix_seconds: i64) -> Value {
416 match mode {
417 PresentationMode::Verbose | PresentationMode::Human => value,
418 PresentationMode::Agent => {
419 let lifecycle_preserve: HashSet<&str> =
420 LIFECYCLE_NULL_PRESERVE.iter().copied().collect();
421 let score_fields: HashSet<&str> = SCORE_FIELDS.iter().copied().collect();
422 transform_agent(
423 value,
424 &lifecycle_preserve,
425 &score_fields,
426 now_unix_seconds,
427 false,
428 )
429 }
430 }
431}
432
433fn transform_agent(
439 value: Value,
440 lifecycle: &HashSet<&str>,
441 scores: &HashSet<&str>,
442 now: i64,
443 inside_properties: bool,
444) -> Value {
445 match value {
446 Value::Object(map) => {
447 let mut out = Map::new();
448 for (k, v) in map {
449 let child_inside_properties = inside_properties || k == "properties";
450 let transformed =
451 transform_field_agent(&k, v, lifecycle, scores, now, child_inside_properties);
452 match transformed {
453 None => {} Some(tv) => {
455 out.insert(k, tv);
456 }
457 }
458 }
459 Value::Object(out)
460 }
461 Value::Array(arr) => {
462 let items: Vec<Value> = arr
463 .into_iter()
464 .map(|v| transform_agent(v, lifecycle, scores, now, inside_properties))
465 .collect();
466 Value::Array(items)
467 }
468 other => other,
469 }
470}
471
472fn transform_field_agent(
480 key: &str,
481 value: Value,
482 lifecycle: &HashSet<&str>,
483 scores: &HashSet<&str>,
484 now: i64,
485 inside_properties: bool,
486) -> Option<Value> {
487 match &value {
488 Value::Null => {
490 if lifecycle.contains(key) {
491 Some(value)
492 } else {
493 None
494 }
495 }
496 Value::String(s) if s.is_empty() => None,
498 Value::Array(a) if a.is_empty() => None,
499 Value::Object(o) if o.is_empty() => None,
500 Value::Number(_) if scores.contains(key) => {
502 if let Some(f) = value.as_f64() {
503 Some(truncate_to_3_sig_figs(f))
504 } else {
505 Some(value)
506 }
507 }
508 Value::String(s) if is_canonical_uuid(s) && should_shorten_uuid_field(key) => {
510 Some(Value::String(s[..8].to_string()))
511 }
512 Value::String(s) if !inside_properties && looks_like_iso8601(s) => {
514 Some(Value::String(compact_timestamp(s, now)))
515 }
516 Value::Object(_) | Value::Array(_) => Some(transform_agent(
518 value,
519 lifecycle,
520 scores,
521 now,
522 inside_properties,
523 )),
524 _ => Some(value),
526 }
527}
528
529fn is_canonical_uuid(s: &str) -> bool {
531 if s.len() != UUID_CANONICAL_LEN {
532 return false;
533 }
534 let b = s.as_bytes();
535 b[8] == b'-'
537 && b[13] == b'-'
538 && b[18] == b'-'
539 && b[23] == b'-'
540 && b[..8].iter().all(|c| c.is_ascii_hexdigit())
541 && b[9..13].iter().all(|c| c.is_ascii_hexdigit())
542 && b[14..18].iter().all(|c| c.is_ascii_hexdigit())
543 && b[19..23].iter().all(|c| c.is_ascii_hexdigit())
544 && b[24..].iter().all(|c| c.is_ascii_hexdigit())
545}
546
547fn looks_like_iso8601(s: &str) -> bool {
551 if s.len() < 16 {
552 return false;
553 }
554 let b = s.as_bytes();
555 b[4] == b'-'
556 && b[7] == b'-'
557 && b[10] == b'T'
558 && b[13] == b':'
559 && b[..4].iter().all(|c| c.is_ascii_digit())
560 && b[5..7].iter().all(|c| c.is_ascii_digit())
561 && b[8..10].iter().all(|c| c.is_ascii_digit())
562 && b[11..13].iter().all(|c| c.is_ascii_digit())
563}
564
565fn compact_timestamp(s: &str, now: i64) -> String {
570 if let Some(unix) = parse_iso8601_unix(s) {
572 let diff = now - unix;
573 if (0..86400).contains(&diff) {
574 return relative_time(diff);
575 }
576 }
577 s.chars().take(16).collect()
579}
580
581fn parse_iso8601_unix(s: &str) -> Option<i64> {
587 if s.len() < 19 {
589 return None;
590 }
591 let b = s.as_bytes();
592 let year: i64 = parse_digits(&b[0..4])?;
593 let month: i64 = parse_digits(&b[5..7])?;
594 let day: i64 = parse_digits(&b[8..10])?;
595 let hour: i64 = parse_digits(&b[11..13])?;
596 let minute: i64 = parse_digits(&b[14..16])?;
597 let second: i64 = parse_digits(&b[17..19])?;
598
599 let days_since_epoch = days_from_civil(year, month, day);
602 Some(days_since_epoch * 86400 + hour * 3600 + minute * 60 + second)
603}
604
605fn parse_digits(b: &[u8]) -> Option<i64> {
606 let s = std::str::from_utf8(b).ok()?;
607 s.parse().ok()
608}
609
610fn days_from_civil(y: i64, m: i64, d: i64) -> i64 {
612 let y = if m <= 2 { y - 1 } else { y };
613 let era = y.div_euclid(400);
614 let yoe = y - era * 400;
615 let doy = (153 * (if m > 2 { m - 3 } else { m + 9 }) + 2) / 5 + d - 1;
616 let doe = yoe * 365 + yoe / 4 - yoe / 100 + doy;
617 era * 146097 + doe - 719468
618}
619
620fn relative_time(diff_secs: i64) -> String {
622 if diff_secs < 60 {
623 format!("{diff_secs}s ago")
624 } else if diff_secs < 3600 {
625 format!("{}m ago", diff_secs / 60)
626 } else {
627 format!("{}h ago", diff_secs / 3600)
628 }
629}
630
631fn truncate_to_3_sig_figs(f: f64) -> Value {
633 if f == 0.0 || !f.is_finite() {
634 return Value::from(f);
635 }
636 let magnitude = f.abs().log10().floor() as i32;
637 let factor = 10f64.powi(2 - magnitude);
638 let rounded = (f * factor).round() / factor;
639 serde_json::Number::from_f64(rounded)
641 .map(Value::Number)
642 .unwrap_or(Value::from(rounded))
643}
644
645#[cfg(test)]
646mod tests {
647 use super::*;
648 use serde_json::json;
649
650 const NOW: i64 = 1_748_016_480;
652
653 fn agent(v: Value) -> Value {
654 present(v, PresentationMode::Agent, NOW)
655 }
656
657 #[test]
658 fn verbose_passthrough() {
659 let v = json!({"id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890", "title": "X"});
660 let out = present(v.clone(), PresentationMode::Verbose, NOW);
661 assert_eq!(out, v);
662 }
663
664 #[test]
665 fn agent_shortens_uuid() {
666 let v = json!({"id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890"});
667 let out = agent(v);
668 assert_eq!(out["id"], json!("a1b2c3d4"));
669 }
670
671 #[test]
672 fn agent_drops_empty_string() {
673 let v = json!({"title": "ok", "description": ""});
674 let out = agent(v);
675 assert!(out.get("description").is_none());
676 assert_eq!(out["title"], json!("ok"));
677 }
678
679 #[test]
680 fn agent_drops_empty_array() {
681 let v = json!({"tags": [], "title": "ok"});
682 let out = agent(v);
683 assert!(out.get("tags").is_none());
684 }
685
686 #[test]
687 fn agent_drops_empty_object() {
688 let v = json!({"properties": {}, "title": "ok"});
689 let out = agent(v);
690 assert!(out.get("properties").is_none());
691 }
692
693 #[test]
694 fn agent_drops_non_lifecycle_null() {
695 let v = json!({"result": null, "title": "ok"});
696 let out = agent(v);
697 assert!(out.get("result").is_none());
698 }
699
700 #[test]
701 fn agent_preserves_lifecycle_null() {
702 let v = json!({"completed_at": null, "due_at": null, "title": "ok"});
703 let out = agent(v);
704 assert_eq!(out["completed_at"], json!(null));
705 assert_eq!(out["due_at"], json!(null));
706 }
707
708 #[test]
709 fn agent_preserves_relationship_null() {
710 let v = json!({"parent_id": null, "superseded_by": null});
711 let out = agent(v);
712 assert_eq!(out["parent_id"], json!(null));
713 assert_eq!(out["superseded_by"], json!(null));
714 }
715
716 #[test]
717 fn agent_truncates_score_field() {
718 let v = json!({"score": 0.12345678});
719 let out = agent(v);
720 let s = out["score"].as_f64().unwrap();
721 assert!((s - 0.123).abs() < 1e-9, "expected ~0.123, got {s}");
722 }
723
724 #[test]
725 fn agent_compacts_old_timestamp_to_minutes() {
726 let v = json!({"created_at": "2020-01-01T10:30:45.123456Z"});
728 let out = agent(v);
729 assert_eq!(out["created_at"], json!("2020-01-01T10:30"));
730 }
731
732 #[test]
733 fn agent_compacts_recent_timestamp_to_relative() {
734 let ts_unix = NOW - 180;
736 let ts = unix_to_iso8601(ts_unix);
738 let v = json!({"updated_at": ts});
739 let out = agent(v);
740 assert_eq!(out["updated_at"], json!("3m ago"));
741 }
742
743 #[test]
744 fn agent_recurses_into_nested_objects() {
745 let v = json!({
746 "items": [
747 {
748 "id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
749 "tags": [],
750 "score": 0.9999
751 }
752 ]
753 });
754 let out = agent(v);
755 let item = &out["items"][0];
756 assert_eq!(item["id"], json!("a1b2c3d4"));
757 assert!(item.get("tags").is_none());
758 let s = item["score"].as_f64().unwrap();
759 assert!((s - 1.0).abs() < 1e-9);
760 }
761
762 #[test]
764 fn agent_preserves_full_id_as_36_chars() {
765 let uuid = "a1b2c3d4-e5f6-7890-abcd-ef1234567890";
766 let v = json!({"id": uuid, "full_id": uuid, "title": "X"});
767 let out = agent(v);
768 assert_eq!(
770 out["id"],
771 json!("a1b2c3d4"),
772 "id should be 8-char short form"
773 );
774 assert_eq!(
776 out["full_id"].as_str().unwrap().len(),
777 36,
778 "full_id must be 36 chars in agent mode"
779 );
780 assert_eq!(
781 out["full_id"],
782 json!(uuid),
783 "full_id must equal the original UUID"
784 );
785 assert!(
787 out["full_id"]
788 .as_str()
789 .unwrap()
790 .starts_with(out["id"].as_str().unwrap()),
791 "full_id must start with the short id prefix"
792 );
793 }
794
795 #[test]
796 fn is_canonical_uuid_recognizes_valid() {
797 assert!(is_canonical_uuid("a1b2c3d4-e5f6-7890-abcd-ef1234567890"));
798 assert!(!is_canonical_uuid("a1b2c3d4"));
799 assert!(!is_canonical_uuid("not-a-uuid-at-all-here---------"));
800 }
801
802 #[test]
803 fn looks_like_iso8601_recognizes_valid() {
804 assert!(looks_like_iso8601("2026-05-23T16:18:15.234567Z"));
805 assert!(!looks_like_iso8601("not a timestamp"));
806 assert!(!looks_like_iso8601("2026-05-23"));
807 }
808
809 fn unix_to_iso8601(unix: i64) -> String {
811 let (y, mo, d, h, mi, s) = unix_to_civil(unix);
812 format!("{y:04}-{mo:02}-{d:02}T{h:02}:{mi:02}:{s:02}Z")
813 }
814
815 fn unix_to_civil(unix: i64) -> (i64, i64, i64, i64, i64, i64) {
816 let s = unix % 86400;
817 let days = unix / 86400;
818 let h = s / 3600;
819 let m = (s % 3600) / 60;
820 let sec = s % 60;
821 let z = days + 719468;
823 let era = z.div_euclid(146097);
824 let doe = z - era * 146097;
825 let yoe = (doe - doe / 1460 + doe / 36524 - doe / 146096) / 365;
826 let y = yoe + era * 400;
827 let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
828 let mp = (5 * doy + 2) / 153;
829 let d = doy - (153 * mp + 2) / 5 + 1;
830 let mo = if mp < 10 { mp + 3 } else { mp - 9 };
831 let y = if mo <= 2 { y + 1 } else { y };
832 (y, mo, d, h, m, sec)
833 }
834
835 #[test]
836 fn agent_does_not_shorten_uuid_shaped_content_fields() {
837 let uuid = "a1b2c3d4-e5f6-7890-abcd-ef1234567890";
838 let out = agent(json!({
839 "id": uuid,
840 "full_id": uuid,
841 "content": uuid,
842 "description": uuid,
843 "title": uuid,
844 "query": uuid,
845 }));
846
847 assert_eq!(out["id"], json!("a1b2c3d4"));
848 assert_eq!(out["full_id"], json!(uuid));
849 assert_eq!(out["content"], json!(uuid));
850 assert_eq!(out["description"], json!(uuid));
851 assert_eq!(out["title"], json!(uuid));
852 assert_eq!(out["query"], json!(uuid));
853 }
854
855 #[test]
856 fn agent_shortens_suffix_id_fields() {
857 let uuid = "a1b2c3d4-e5f6-7890-abcd-ef1234567890";
858 let out = agent(json!({
859 "note_id": uuid,
860 "source_id": uuid,
861 "target_id": uuid,
862 }));
863
864 assert_eq!(out["note_id"], json!("a1b2c3d4"));
865 assert_eq!(out["source_id"], json!("a1b2c3d4"));
866 assert_eq!(out["target_id"], json!("a1b2c3d4"));
867 }
868
869 #[test]
873 fn format_json_preserves_full_shape() {
874 let v = json!({
875 "full_id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
876 "namespace": "local",
877 "properties": {"k": "v"},
878 "title": "test"
879 });
880 let rendered = render_format(v.clone(), OutputFormat::Json, PresentationMode::Agent);
881 let parsed: Value = serde_json::from_str(&rendered).unwrap();
882 assert!(
884 parsed.get("full_id").is_some(),
885 "json mode must keep full_id"
886 );
887 assert_eq!(
889 parsed.get("namespace").and_then(Value::as_str),
890 Some("local")
891 );
892 assert!(parsed.get("properties").is_some());
894 }
895
896 #[test]
898 fn format_auto_drops_versus_json_keeps() {
899 let v = json!({
900 "full_id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
901 "namespace": "local",
902 "title": "test"
903 });
904 let json_rendered = render_format(v.clone(), OutputFormat::Json, PresentationMode::Agent);
905 let auto_rendered = render_format(v.clone(), OutputFormat::Auto, PresentationMode::Agent);
906 let json_parsed: Value = serde_json::from_str(&json_rendered).unwrap();
908 assert!(
909 json_parsed.get("full_id").is_some(),
910 "json must keep full_id"
911 );
912 assert_eq!(
913 json_parsed.get("namespace").and_then(Value::as_str),
914 Some("local")
915 );
916 assert!(
919 !auto_rendered.contains("full_id"),
920 "auto kv block must drop full_id"
921 );
922 assert!(
923 !auto_rendered.contains("namespace"),
924 "auto kv block must elide namespace=local"
925 );
926 }
927
928 #[test]
930 fn format_auto_homogeneous_array_renders_markdown_table() {
931 let v = json!([
932 {"id": "abc", "title": "First"},
933 {"id": "def", "title": "Second"}
934 ]);
935 let rendered = render_format(v, OutputFormat::Auto, PresentationMode::Agent);
936 assert!(rendered.starts_with('|'), "must start with |");
938 assert!(
939 rendered.contains("| id |") || rendered.contains("| id"),
940 "must have id column"
941 );
942 assert!(rendered.contains("title"), "must have title column");
943 assert!(rendered.contains("|---|"), "must have separator row");
945 assert!(rendered.contains("abc"), "must have first row data");
947 assert!(rendered.contains("Second"), "must have second row data");
948 }
949
950 #[test]
952 fn format_auto_single_record_renders_kv_block() {
953 let v = json!({"id": "abc", "title": "Hello World"});
954 let rendered = render_format(v, OutputFormat::Auto, PresentationMode::Agent);
955 assert!(rendered.contains("id: abc"), "must have id: abc");
957 assert!(
958 rendered.contains("title: Hello World"),
959 "must have title line"
960 );
961 assert!(
963 !rendered.starts_with('|'),
964 "single record must not be a markdown table"
965 );
966 }
967
968 #[test]
970 fn format_auto_scalar_fallback_compact_json() {
971 let v = json!(42);
972 let rendered = render_format(v, OutputFormat::Auto, PresentationMode::Agent);
973 assert_eq!(rendered, "42");
974 }
975
976 #[test]
978 fn format_table_forces_markdown_when_array() {
979 let v = json!({
980 "items": [
981 {"name": "A", "score": 1},
982 {"name": "B", "score": 2}
983 ]
984 });
985 let rendered = render_format(v, OutputFormat::Table, PresentationMode::Agent);
986 assert!(
987 rendered.contains("|"),
988 "table format must produce markdown table"
989 );
990 assert!(rendered.contains("name"), "must have name column");
991 assert!(rendered.contains("score"), "must have score column");
992 }
993
994 #[test]
996 fn format_table_falls_back_to_json_when_no_array() {
997 let v = json!({"single": "value"});
998 let rendered = render_format(v, OutputFormat::Table, PresentationMode::Agent);
999 let parsed: Value = serde_json::from_str(&rendered).unwrap();
1001 assert_eq!(parsed["single"], json!("value"));
1002 }
1003
1004 #[test]
1006 fn format_auto_verbose_skips_redundancy_drop() {
1007 let v = json!({
1008 "full_id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
1009 "namespace": "local",
1010 "title": "test"
1011 });
1012 let rendered = render_format(v, OutputFormat::Auto, PresentationMode::Verbose);
1015 assert!(
1016 rendered.contains("full_id"),
1017 "verbose must preserve full_id"
1018 );
1019 assert!(
1020 rendered.contains("namespace"),
1021 "verbose must preserve namespace"
1022 );
1023 }
1024
1025 #[test]
1032 fn redundancy_drop_does_not_corrupt_error_shape() {
1033 let v = json!({"ok": false, "error": "something failed", "namespace": "local"});
1035 let reduced = apply_redundancy_drop(v.clone());
1040 assert!(
1041 reduced.get("error").is_some(),
1042 "redundancy drop must preserve error field"
1043 );
1044 assert_eq!(
1045 reduced.get("ok").and_then(Value::as_bool),
1046 Some(false),
1047 "redundancy drop must preserve ok=false"
1048 );
1049 }
1050
1051 #[test]
1053 fn redundancy_drop_properties_dedup() {
1054 let v = json!({
1055 "id": "abc",
1056 "title": "Same",
1057 "properties": {
1058 "title": "Same", "extra": "unique" }
1061 });
1062 let reduced = apply_redundancy_drop(v);
1063 let props = reduced.get("properties").expect("properties must remain");
1064 assert!(props.get("extra").is_some(), "unique property must be kept");
1065 assert!(
1066 props.get("title").is_none(),
1067 "duplicate top-level property must be removed"
1068 );
1069 }
1070
1071 #[test]
1073 fn cell_text_truncates_long_values() {
1074 let long = "X".repeat(200);
1075 let v = json!([
1076 {"col": long.clone()},
1077 {"col": "short"}
1078 ]);
1079 let rendered = render_format(v, OutputFormat::Auto, PresentationMode::Agent);
1080 assert!(
1082 rendered.contains("..."),
1083 "long cell must be truncated with ..."
1084 );
1085 assert!(
1086 !rendered.contains(&long),
1087 "full long string must not appear in table"
1088 );
1089 }
1090
1091 #[test]
1097 fn cell_text_truncation_is_utf8_safe() {
1098 let prefix = "a".repeat(119);
1101 let suffix = "中".repeat(10); let long_multibyte = format!("{prefix}{suffix}trailing");
1103 let v = json!([
1104 {"col": long_multibyte.clone()},
1105 {"col": "ok"}
1106 ]);
1107 let rendered = render_format(v, OutputFormat::Auto, PresentationMode::Agent);
1109 assert!(
1110 rendered.contains("..."),
1111 "multibyte cell must be truncated with ..."
1112 );
1113 assert!(
1115 std::str::from_utf8(rendered.as_bytes()).is_ok(),
1116 "rendered output must be valid UTF-8"
1117 );
1118 }
1119}