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 {
68 value
69 } else {
70 apply_redundancy_drop(value)
71 };
72 match format {
73 OutputFormat::Auto => render_auto(reduced),
74 OutputFormat::Table => render_table_forced(reduced),
75 OutputFormat::Json => unreachable!(),
76 }
77 }
78 }
79}
80
81pub fn apply_redundancy_drop(value: Value) -> Value {
92 match value {
93 Value::Object(_) => drop_record(value),
94 Value::Array(arr) => Value::Array(
95 arr.into_iter()
96 .map(|v| if v.is_object() { drop_record(v) } else { v })
97 .collect(),
98 ),
99 other => other,
100 }
101}
102
103fn drop_record(value: Value) -> Value {
105 let Value::Object(mut map) = value else {
106 return value;
107 };
108
109 map.remove("full_id");
111
112 if map.get("namespace").and_then(Value::as_str) == Some("local") {
114 map.remove("namespace");
115 }
116
117 let props_val = map.remove("properties");
120 if let Some(Value::Object(props)) = props_val {
121 let mut new_props = Map::new();
122 for (k, v) in props {
123 if map.get(&k) != Some(&v) {
124 new_props.insert(k, v);
125 }
126 }
127 if !new_props.is_empty() {
128 map.insert("properties".to_string(), Value::Object(new_props));
129 }
130 } else if let Some(other) = props_val {
131 map.insert("properties".to_string(), other);
132 }
133
134 let out: Map<String, Value> = map
136 .into_iter()
137 .map(|(k, v)| {
138 let v = match v {
139 Value::Array(arr) => Value::Array(
140 arr.into_iter()
141 .map(|item| {
142 if item.is_object() {
143 drop_record(item)
144 } else {
145 item
146 }
147 })
148 .collect(),
149 ),
150 other => other,
151 };
152 (k, v)
153 })
154 .collect();
155 Value::Object(out)
156}
157
158fn render_auto(value: Value) -> String {
162 if let Some((records, keys)) = find_record_array(&value) {
164 return render_table(&records, &keys);
165 }
166 if value.is_object() {
168 return render_kv_block(&value, 0);
169 }
170 serde_json::to_string(&value).unwrap_or_else(|_| "null".to_string())
172}
173
174fn render_table_forced(value: Value) -> String {
176 if let Some((records, keys)) = find_record_array(&value) {
177 return render_table(&records, &keys);
178 }
179 serde_json::to_string(&value).unwrap_or_else(|_| "null".to_string())
181}
182
183fn find_record_array(value: &Value) -> Option<(Vec<Value>, Vec<String>)> {
191 match value {
192 Value::Array(arr) if arr.len() >= 2 && arr.iter().all(Value::is_object) => {
193 let keys = collect_keys(arr);
194 Some((arr.clone(), keys))
195 }
196 Value::Object(map) => {
197 for v in map.values() {
198 if let Value::Array(arr) = v {
199 if arr.len() >= 2 && arr.iter().all(Value::is_object) {
200 let keys = collect_keys(arr);
201 return Some((arr.clone(), keys));
202 }
203 }
204 }
205 None
206 }
207 _ => None,
208 }
209}
210
211fn collect_keys(records: &[Value]) -> Vec<String> {
213 let mut seen = HashSet::new();
214 let mut keys = Vec::new();
215 for record in records {
216 if let Value::Object(map) = record {
217 for k in map.keys() {
218 if seen.insert(k.clone()) {
219 keys.push(k.clone());
220 }
221 }
222 }
223 }
224 keys
225}
226
227fn render_table(records: &[Value], keys: &[String]) -> String {
231 let mut out = String::new();
232
233 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('|');
242 for _ in keys {
243 out.push_str("---|");
244 }
245 out.push('\n');
246
247 for record in records {
248 out.push('|');
249 for k in keys {
250 let cell = record.get(k).unwrap_or(&Value::Null);
251 let text = cell_text(cell);
252 out.push(' ');
253 out.push_str(&text);
254 out.push_str(" |");
255 }
256 out.push('\n');
257 }
258
259 out
260}
261
262fn cell_text(value: &Value) -> String {
264 let raw = match value {
265 Value::Null => String::new(),
266 Value::Bool(b) => b.to_string(),
267 Value::Number(n) => n.to_string(),
268 Value::String(s) => s.clone(),
269 other => serde_json::to_string(other).unwrap_or_default(),
271 };
272
273 let escaped = raw.replace('|', "\\|").replace(['\n', '\r'], " ");
275
276 let char_count = escaped.chars().count();
279 if char_count > CELL_TRUNCATE {
280 let truncated: String = escaped.chars().take(CELL_TRUNCATE).collect();
281 format!("{truncated}...")
282 } else {
283 escaped
284 }
285}
286
287fn render_kv_block(value: &Value, depth: usize) -> String {
291 let indent = " ".repeat(depth);
292 match value {
293 Value::Object(map) => {
294 let mut out = String::new();
295 for (k, v) in map {
296 match v {
297 Value::Object(_) => {
298 out.push_str(&format!("{}{}:\n", indent, k));
299 out.push_str(&render_kv_block(v, depth + 1));
300 }
301 Value::Array(arr) if arr.iter().any(Value::is_object) => {
302 out.push_str(&format!("{}{}:\n", indent, k));
303 for item in arr {
304 if item.is_object() {
305 out.push_str(&render_kv_block(item, depth + 1));
306 } else {
307 out.push_str(&format!("{} - {}\n", indent, cell_text(item)));
308 }
309 }
310 }
311 _ => {
312 out.push_str(&format!("{}{}: {}\n", indent, k, cell_text(v)));
313 }
314 }
315 }
316 out
317 }
318 other => format!("{}{}\n", indent, cell_text(other)),
319 }
320}
321
322pub fn micros_to_iso(micros: i64) -> String {
329 chrono::DateTime::<chrono::Utc>::from_timestamp_micros(micros)
330 .unwrap_or_else(chrono::Utc::now)
331 .to_rfc3339_opts(chrono::SecondsFormat::Micros, true)
332}
333
334#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
336#[serde(rename_all = "snake_case")]
337pub enum PresentationMode {
338 #[default]
344 Agent,
345 Verbose,
349 Human,
356}
357
358const LIFECYCLE_NULL_PRESERVE: &[&str] = &[
362 "completed_at",
363 "deleted_at",
364 "due_at",
365 "read_at",
366 "started_at",
367 "superseded_at",
368 "applied_at",
369 "withdrawn_at",
370 "reviewed_at",
371 "parent_id",
372 "superseded_by",
373 "replaced_by",
374];
375
376const SCORE_FIELDS: &[&str] = &[
378 "score",
379 "salience",
380 "decay_factor",
381 "rrf_score",
382 "similarity",
383 "cross_encoder_score",
384 "graph_proximity_score",
385];
386
387const UUID_CANONICAL_LEN: usize = 36;
389
390fn should_shorten_uuid_field(key: &str) -> bool {
398 if key == "full_id" {
399 return false;
400 }
401 key == "id" || key.ends_with("_id") || matches!(key, "superseded_by" | "replaced_by")
402}
403
404pub fn present(value: Value, mode: PresentationMode, now_unix_seconds: i64) -> Value {
414 match mode {
415 PresentationMode::Verbose | PresentationMode::Human => value,
416 PresentationMode::Agent => {
417 let lifecycle_preserve: HashSet<&str> =
418 LIFECYCLE_NULL_PRESERVE.iter().copied().collect();
419 let score_fields: HashSet<&str> = SCORE_FIELDS.iter().copied().collect();
420 transform_agent(
421 value,
422 &lifecycle_preserve,
423 &score_fields,
424 now_unix_seconds,
425 false,
426 )
427 }
428 }
429}
430
431fn transform_agent(
437 value: Value,
438 lifecycle: &HashSet<&str>,
439 scores: &HashSet<&str>,
440 now: i64,
441 inside_properties: bool,
442) -> Value {
443 match value {
444 Value::Object(map) => {
445 let mut out = Map::new();
446 for (k, v) in map {
447 let child_inside_properties = inside_properties || k == "properties";
448 let transformed =
449 transform_field_agent(&k, v, lifecycle, scores, now, child_inside_properties);
450 match transformed {
451 None => {} Some(tv) => {
453 out.insert(k, tv);
454 }
455 }
456 }
457 Value::Object(out)
458 }
459 Value::Array(arr) => {
460 let items: Vec<Value> = arr
461 .into_iter()
462 .map(|v| transform_agent(v, lifecycle, scores, now, inside_properties))
463 .collect();
464 Value::Array(items)
465 }
466 other => other,
467 }
468}
469
470fn transform_field_agent(
478 key: &str,
479 value: Value,
480 lifecycle: &HashSet<&str>,
481 scores: &HashSet<&str>,
482 now: i64,
483 inside_properties: bool,
484) -> Option<Value> {
485 match &value {
486 Value::Null => {
488 if lifecycle.contains(key) {
489 Some(value)
490 } else {
491 None
492 }
493 }
494 Value::String(s) if s.is_empty() => None,
496 Value::Array(a) if a.is_empty() => None,
497 Value::Object(o) if o.is_empty() => None,
498 Value::Number(_) if scores.contains(key) => {
500 if let Some(f) = value.as_f64() {
501 Some(truncate_to_3_sig_figs(f))
502 } else {
503 Some(value)
504 }
505 }
506 Value::String(s) if is_canonical_uuid(s) && should_shorten_uuid_field(key) => {
508 Some(Value::String(s[..8].to_string()))
509 }
510 Value::String(s) if !inside_properties && looks_like_iso8601(s) => {
512 Some(Value::String(compact_timestamp(s, now)))
513 }
514 Value::Object(_) | Value::Array(_) => Some(transform_agent(
516 value,
517 lifecycle,
518 scores,
519 now,
520 inside_properties,
521 )),
522 _ => Some(value),
524 }
525}
526
527fn is_canonical_uuid(s: &str) -> bool {
529 if s.len() != UUID_CANONICAL_LEN {
530 return false;
531 }
532 let b = s.as_bytes();
533 b[8] == b'-'
535 && b[13] == b'-'
536 && b[18] == b'-'
537 && b[23] == b'-'
538 && b[..8].iter().all(|c| c.is_ascii_hexdigit())
539 && b[9..13].iter().all(|c| c.is_ascii_hexdigit())
540 && b[14..18].iter().all(|c| c.is_ascii_hexdigit())
541 && b[19..23].iter().all(|c| c.is_ascii_hexdigit())
542 && b[24..].iter().all(|c| c.is_ascii_hexdigit())
543}
544
545fn looks_like_iso8601(s: &str) -> bool {
549 if s.len() < 16 {
550 return false;
551 }
552 let b = s.as_bytes();
553 b[4] == b'-'
554 && b[7] == b'-'
555 && b[10] == b'T'
556 && b[13] == b':'
557 && b[..4].iter().all(|c| c.is_ascii_digit())
558 && b[5..7].iter().all(|c| c.is_ascii_digit())
559 && b[8..10].iter().all(|c| c.is_ascii_digit())
560 && b[11..13].iter().all(|c| c.is_ascii_digit())
561}
562
563fn compact_timestamp(s: &str, now: i64) -> String {
568 if let Some(unix) = parse_iso8601_unix(s) {
570 let diff = now - unix;
571 if (0..86400).contains(&diff) {
572 return relative_time(diff);
573 }
574 }
575 s.chars().take(16).collect()
577}
578
579fn parse_iso8601_unix(s: &str) -> Option<i64> {
586 if s.len() < 19 {
588 return None;
589 }
590 let b = s.as_bytes();
591 let year: i64 = parse_digits(&b[0..4])?;
592 let month: i64 = parse_digits(&b[5..7])?;
593 let day: i64 = parse_digits(&b[8..10])?;
594 let hour: i64 = parse_digits(&b[11..13])?;
595 let minute: i64 = parse_digits(&b[14..16])?;
596 let second: i64 = parse_digits(&b[17..19])?;
597
598 let days_since_epoch = days_from_civil(year, month, day);
602 let local = days_since_epoch * 86400 + hour * 3600 + minute * 60 + second;
603 let offset_secs = parse_tz_offset_secs(&s[19..])?;
604 Some(local - offset_secs)
605}
606
607fn parse_tz_offset_secs(tail: &str) -> Option<i64> {
617 let mut rest = tail;
618 if let Some(after_dot) = rest.strip_prefix('.') {
619 let frac_len = after_dot.bytes().take_while(u8::is_ascii_digit).count();
620 if frac_len == 0 {
621 return None;
622 }
623 rest = &after_dot[frac_len..];
624 }
625
626 if rest.is_empty() || rest == "Z" {
627 return Some(0);
628 }
629
630 let sign: i64 = match rest.as_bytes().first()? {
631 b'+' => 1,
632 b'-' => -1,
633 _ => return None,
634 };
635 let digits = &rest[1..];
636 let (hh, mm) = match digits.len() {
637 5 if digits.as_bytes()[2] == b':' => (
639 parse_digits(&digits.as_bytes()[0..2])?,
640 parse_digits(&digits.as_bytes()[3..5])?,
641 ),
642 4 => (
644 parse_digits(&digits.as_bytes()[0..2])?,
645 parse_digits(&digits.as_bytes()[2..4])?,
646 ),
647 _ => return None,
648 };
649 if hh > 23 || mm > 59 {
650 return None;
651 }
652 Some(sign * (hh * 3600 + mm * 60))
653}
654
655fn parse_digits(b: &[u8]) -> Option<i64> {
656 let s = std::str::from_utf8(b).ok()?;
657 s.parse().ok()
658}
659
660fn days_from_civil(y: i64, m: i64, d: i64) -> i64 {
662 let y = if m <= 2 { y - 1 } else { y };
663 let era = y.div_euclid(400);
664 let yoe = y - era * 400;
665 let doy = (153 * (if m > 2 { m - 3 } else { m + 9 }) + 2) / 5 + d - 1;
666 let doe = yoe * 365 + yoe / 4 - yoe / 100 + doy;
667 era * 146097 + doe - 719468
668}
669
670fn relative_time(diff_secs: i64) -> String {
672 if diff_secs < 60 {
673 format!("{diff_secs}s ago")
674 } else if diff_secs < 3600 {
675 format!("{}m ago", diff_secs / 60)
676 } else {
677 format!("{}h ago", diff_secs / 3600)
678 }
679}
680
681fn truncate_to_3_sig_figs(f: f64) -> Value {
683 if f == 0.0 || !f.is_finite() {
684 return Value::from(f);
685 }
686 let magnitude = f.abs().log10().floor() as i32;
687 let factor = 10f64.powi(2 - magnitude);
688 let rounded = (f * factor).round() / factor;
689 serde_json::Number::from_f64(rounded)
691 .map(Value::Number)
692 .unwrap_or(Value::from(rounded))
693}
694
695#[cfg(test)]
696mod tests {
697 use super::*;
698 use serde_json::json;
699
700 const NOW: i64 = 1_748_016_480;
702
703 fn agent(v: Value) -> Value {
704 present(v, PresentationMode::Agent, NOW)
705 }
706
707 #[test]
708 fn verbose_passthrough() {
709 let v = json!({"id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890", "title": "X"});
710 let out = present(v.clone(), PresentationMode::Verbose, NOW);
711 assert_eq!(out, v);
712 }
713
714 #[test]
715 fn agent_shortens_uuid() {
716 let v = json!({"id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890"});
717 let out = agent(v);
718 assert_eq!(out["id"], json!("a1b2c3d4"));
719 }
720
721 #[test]
722 fn agent_drops_empty_string() {
723 let v = json!({"title": "ok", "description": ""});
724 let out = agent(v);
725 assert!(out.get("description").is_none());
726 assert_eq!(out["title"], json!("ok"));
727 }
728
729 #[test]
730 fn agent_drops_empty_array() {
731 let v = json!({"tags": [], "title": "ok"});
732 let out = agent(v);
733 assert!(out.get("tags").is_none());
734 }
735
736 #[test]
737 fn agent_drops_empty_object() {
738 let v = json!({"properties": {}, "title": "ok"});
739 let out = agent(v);
740 assert!(out.get("properties").is_none());
741 }
742
743 #[test]
744 fn agent_drops_non_lifecycle_null() {
745 let v = json!({"result": null, "title": "ok"});
746 let out = agent(v);
747 assert!(out.get("result").is_none());
748 }
749
750 #[test]
751 fn agent_preserves_lifecycle_null() {
752 let v = json!({"completed_at": null, "due_at": null, "title": "ok"});
753 let out = agent(v);
754 assert_eq!(out["completed_at"], json!(null));
755 assert_eq!(out["due_at"], json!(null));
756 }
757
758 #[test]
759 fn agent_preserves_relationship_null() {
760 let v = json!({"parent_id": null, "superseded_by": null});
761 let out = agent(v);
762 assert_eq!(out["parent_id"], json!(null));
763 assert_eq!(out["superseded_by"], json!(null));
764 }
765
766 #[test]
767 fn agent_truncates_score_field() {
768 let v = json!({"score": 0.12345678});
769 let out = agent(v);
770 let s = out["score"].as_f64().unwrap();
771 assert!((s - 0.123).abs() < 1e-9, "expected ~0.123, got {s}");
772 }
773
774 #[test]
775 fn agent_compacts_old_timestamp_to_minutes() {
776 let v = json!({"created_at": "2020-01-01T10:30:45.123456Z"});
778 let out = agent(v);
779 assert_eq!(out["created_at"], json!("2020-01-01T10:30"));
780 }
781
782 #[test]
783 fn agent_compacts_recent_timestamp_to_relative() {
784 let ts_unix = NOW - 180;
786 let ts = unix_to_iso8601(ts_unix);
788 let v = json!({"updated_at": ts});
789 let out = agent(v);
790 assert_eq!(out["updated_at"], json!("3m ago"));
791 }
792
793 #[test]
794 fn agent_recurses_into_nested_objects() {
795 let v = json!({
796 "items": [
797 {
798 "id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
799 "tags": [],
800 "score": 0.9999
801 }
802 ]
803 });
804 let out = agent(v);
805 let item = &out["items"][0];
806 assert_eq!(item["id"], json!("a1b2c3d4"));
807 assert!(item.get("tags").is_none());
808 let s = item["score"].as_f64().unwrap();
809 assert!((s - 1.0).abs() < 1e-9);
810 }
811
812 #[test]
815 fn agent_preserves_full_id_as_36_chars() {
816 let uuid = "a1b2c3d4-e5f6-7890-abcd-ef1234567890";
817 let v = json!({"id": uuid, "full_id": uuid, "title": "X"});
818 let out = agent(v);
819 assert_eq!(
821 out["id"],
822 json!("a1b2c3d4"),
823 "id should be 8-char short form"
824 );
825 assert_eq!(
827 out["full_id"].as_str().unwrap().len(),
828 36,
829 "full_id must be 36 chars in agent mode"
830 );
831 assert_eq!(
832 out["full_id"],
833 json!(uuid),
834 "full_id must equal the original UUID"
835 );
836 assert!(
838 out["full_id"]
839 .as_str()
840 .unwrap()
841 .starts_with(out["id"].as_str().unwrap()),
842 "full_id must start with the short id prefix"
843 );
844 }
845
846 #[test]
847 fn is_canonical_uuid_recognizes_valid() {
848 assert!(is_canonical_uuid("a1b2c3d4-e5f6-7890-abcd-ef1234567890"));
849 assert!(!is_canonical_uuid("a1b2c3d4"));
850 assert!(!is_canonical_uuid("not-a-uuid-at-all-here---------"));
851 }
852
853 #[test]
854 fn looks_like_iso8601_recognizes_valid() {
855 assert!(looks_like_iso8601("2026-05-23T16:18:15.234567Z"));
856 assert!(!looks_like_iso8601("not a timestamp"));
857 assert!(!looks_like_iso8601("2026-05-23"));
858 }
859
860 fn unix_to_iso8601(unix: i64) -> String {
862 let (y, mo, d, h, mi, s) = unix_to_civil(unix);
863 format!("{y:04}-{mo:02}-{d:02}T{h:02}:{mi:02}:{s:02}Z")
864 }
865
866 fn unix_to_civil(unix: i64) -> (i64, i64, i64, i64, i64, i64) {
867 let s = unix % 86400;
868 let days = unix / 86400;
869 let h = s / 3600;
870 let m = (s % 3600) / 60;
871 let sec = s % 60;
872 let z = days + 719468;
874 let era = z.div_euclid(146097);
875 let doe = z - era * 146097;
876 let yoe = (doe - doe / 1460 + doe / 36524 - doe / 146096) / 365;
877 let y = yoe + era * 400;
878 let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
879 let mp = (5 * doy + 2) / 153;
880 let d = doy - (153 * mp + 2) / 5 + 1;
881 let mo = if mp < 10 { mp + 3 } else { mp - 9 };
882 let y = if mo <= 2 { y + 1 } else { y };
883 (y, mo, d, h, m, sec)
884 }
885
886 #[test]
887 fn agent_does_not_shorten_uuid_shaped_content_fields() {
888 let uuid = "a1b2c3d4-e5f6-7890-abcd-ef1234567890";
889 let out = agent(json!({
890 "id": uuid,
891 "full_id": uuid,
892 "content": uuid,
893 "description": uuid,
894 "title": uuid,
895 "query": uuid,
896 }));
897
898 assert_eq!(out["id"], json!("a1b2c3d4"));
899 assert_eq!(out["full_id"], json!(uuid));
900 assert_eq!(out["content"], json!(uuid));
901 assert_eq!(out["description"], json!(uuid));
902 assert_eq!(out["title"], json!(uuid));
903 assert_eq!(out["query"], json!(uuid));
904 }
905
906 #[test]
907 fn agent_shortens_suffix_id_fields() {
908 let uuid = "a1b2c3d4-e5f6-7890-abcd-ef1234567890";
909 let out = agent(json!({
910 "note_id": uuid,
911 "source_id": uuid,
912 "target_id": uuid,
913 }));
914
915 assert_eq!(out["note_id"], json!("a1b2c3d4"));
916 assert_eq!(out["source_id"], json!("a1b2c3d4"));
917 assert_eq!(out["target_id"], json!("a1b2c3d4"));
918 }
919
920 #[test]
924 fn format_json_preserves_full_shape() {
925 let v = json!({
926 "full_id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
927 "namespace": "local",
928 "properties": {"k": "v"},
929 "title": "test"
930 });
931 let rendered = render_format(v.clone(), OutputFormat::Json, PresentationMode::Agent);
932 let parsed: Value = serde_json::from_str(&rendered).unwrap();
933 assert!(
935 parsed.get("full_id").is_some(),
936 "json mode must keep full_id"
937 );
938 assert_eq!(
940 parsed.get("namespace").and_then(Value::as_str),
941 Some("local")
942 );
943 assert!(parsed.get("properties").is_some());
945 }
946
947 #[test]
949 fn format_auto_drops_versus_json_keeps() {
950 let v = json!({
951 "full_id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
952 "namespace": "local",
953 "title": "test"
954 });
955 let json_rendered = render_format(v.clone(), OutputFormat::Json, PresentationMode::Agent);
956 let auto_rendered = render_format(v.clone(), OutputFormat::Auto, PresentationMode::Agent);
957 let json_parsed: Value = serde_json::from_str(&json_rendered).unwrap();
959 assert!(
960 json_parsed.get("full_id").is_some(),
961 "json must keep full_id"
962 );
963 assert_eq!(
964 json_parsed.get("namespace").and_then(Value::as_str),
965 Some("local")
966 );
967 assert!(
970 !auto_rendered.contains("full_id"),
971 "auto kv block must drop full_id"
972 );
973 assert!(
974 !auto_rendered.contains("namespace"),
975 "auto kv block must elide namespace=local"
976 );
977 }
978
979 #[test]
981 fn format_auto_homogeneous_array_renders_markdown_table() {
982 let v = json!([
983 {"id": "abc", "title": "First"},
984 {"id": "def", "title": "Second"}
985 ]);
986 let rendered = render_format(v, OutputFormat::Auto, PresentationMode::Agent);
987 assert!(rendered.starts_with('|'), "must start with |");
988 assert!(
989 rendered.contains("| id |") || rendered.contains("| id"),
990 "must have id column"
991 );
992 assert!(rendered.contains("title"), "must have title column");
993 assert!(rendered.contains("|---|"), "must have separator row");
994 assert!(rendered.contains("abc"), "must have first row data");
995 assert!(rendered.contains("Second"), "must have second row data");
996 }
997
998 #[test]
1000 fn format_auto_single_record_renders_kv_block() {
1001 let v = json!({"id": "abc", "title": "Hello World"});
1002 let rendered = render_format(v, OutputFormat::Auto, PresentationMode::Agent);
1003 assert!(rendered.contains("id: abc"), "must have id: abc");
1005 assert!(
1006 rendered.contains("title: Hello World"),
1007 "must have title line"
1008 );
1009 assert!(
1011 !rendered.starts_with('|'),
1012 "single record must not be a markdown table"
1013 );
1014 }
1015
1016 #[test]
1018 fn format_auto_scalar_fallback_compact_json() {
1019 let v = json!(42);
1020 let rendered = render_format(v, OutputFormat::Auto, PresentationMode::Agent);
1021 assert_eq!(rendered, "42");
1022 }
1023
1024 #[test]
1026 fn format_table_forces_markdown_when_array() {
1027 let v = json!({
1028 "items": [
1029 {"name": "A", "score": 1},
1030 {"name": "B", "score": 2}
1031 ]
1032 });
1033 let rendered = render_format(v, OutputFormat::Table, PresentationMode::Agent);
1034 assert!(
1035 rendered.contains("|"),
1036 "table format must produce markdown table"
1037 );
1038 assert!(rendered.contains("name"), "must have name column");
1039 assert!(rendered.contains("score"), "must have score column");
1040 }
1041
1042 #[test]
1044 fn format_table_falls_back_to_json_when_no_array() {
1045 let v = json!({"single": "value"});
1046 let rendered = render_format(v, OutputFormat::Table, PresentationMode::Agent);
1047 let parsed: Value = serde_json::from_str(&rendered).unwrap();
1049 assert_eq!(parsed["single"], json!("value"));
1050 }
1051
1052 #[test]
1054 fn format_auto_verbose_skips_redundancy_drop() {
1055 let v = json!({
1056 "full_id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
1057 "namespace": "local",
1058 "title": "test"
1059 });
1060 let rendered = render_format(v, OutputFormat::Auto, PresentationMode::Verbose);
1063 assert!(
1064 rendered.contains("full_id"),
1065 "verbose must preserve full_id"
1066 );
1067 assert!(
1068 rendered.contains("namespace"),
1069 "verbose must preserve namespace"
1070 );
1071 }
1072
1073 #[test]
1077 fn redundancy_drop_does_not_corrupt_error_shape() {
1078 let v = json!({"ok": false, "error": "something failed", "namespace": "local"});
1079 let reduced = apply_redundancy_drop(v.clone());
1084 assert!(
1085 reduced.get("error").is_some(),
1086 "redundancy drop must preserve error field"
1087 );
1088 assert_eq!(
1089 reduced.get("ok").and_then(Value::as_bool),
1090 Some(false),
1091 "redundancy drop must preserve ok=false"
1092 );
1093 }
1094
1095 #[test]
1097 fn redundancy_drop_properties_dedup() {
1098 let v = json!({
1099 "id": "abc",
1100 "title": "Same",
1101 "properties": {
1102 "title": "Same", "extra": "unique" }
1105 });
1106 let reduced = apply_redundancy_drop(v);
1107 let props = reduced.get("properties").expect("properties must remain");
1108 assert!(props.get("extra").is_some(), "unique property must be kept");
1109 assert!(
1110 props.get("title").is_none(),
1111 "duplicate top-level property must be removed"
1112 );
1113 }
1114
1115 #[test]
1117 fn cell_text_truncates_long_values() {
1118 let long = "X".repeat(200);
1119 let v = json!([
1120 {"col": long.clone()},
1121 {"col": "short"}
1122 ]);
1123 let rendered = render_format(v, OutputFormat::Auto, PresentationMode::Agent);
1124 assert!(
1126 rendered.contains("..."),
1127 "long cell must be truncated with ..."
1128 );
1129 assert!(
1130 !rendered.contains(&long),
1131 "full long string must not appear in table"
1132 );
1133 }
1134
1135 #[test]
1141 fn cell_text_truncation_is_utf8_safe() {
1142 let prefix = "a".repeat(119);
1145 let suffix = "中".repeat(10); let long_multibyte = format!("{prefix}{suffix}trailing");
1147 let v = json!([
1148 {"col": long_multibyte.clone()},
1149 {"col": "ok"}
1150 ]);
1151 let rendered = render_format(v, OutputFormat::Auto, PresentationMode::Agent);
1153 assert!(
1154 rendered.contains("..."),
1155 "multibyte cell must be truncated with ..."
1156 );
1157 assert!(
1159 std::str::from_utf8(rendered.as_bytes()).is_ok(),
1160 "rendered output must be valid UTF-8"
1161 );
1162 }
1163
1164 #[test]
1167 fn parse_iso8601_unix_negative_offset_matches_equivalent_utc() {
1168 assert_eq!(
1170 parse_iso8601_unix("2026-07-09T11:55:00-04:00"),
1171 parse_iso8601_unix("2026-07-09T15:55:00Z")
1172 );
1173 }
1174
1175 #[test]
1176 fn parse_iso8601_unix_positive_offset_matches_equivalent_utc() {
1177 assert_eq!(
1179 parse_iso8601_unix("2026-05-23T20:15:00+04:00"),
1180 parse_iso8601_unix("2026-05-23T16:15:00Z")
1181 );
1182 }
1183
1184 #[test]
1185 fn parse_iso8601_unix_zero_offset_matches_z() {
1186 assert_eq!(
1187 parse_iso8601_unix("2026-07-09T15:55:00+00:00"),
1188 parse_iso8601_unix("2026-07-09T15:55:00Z")
1189 );
1190 }
1191
1192 #[test]
1193 fn parse_iso8601_unix_compact_offset_form_matches_colon_form() {
1194 assert_eq!(
1195 parse_iso8601_unix("2026-07-09T11:55:00-0400"),
1196 parse_iso8601_unix("2026-07-09T11:55:00-04:00")
1197 );
1198 }
1199
1200 #[test]
1201 fn parse_iso8601_unix_fractional_seconds_with_offset() {
1202 assert_eq!(
1205 parse_iso8601_unix("2026-07-09T11:55:00.123-04:00"),
1206 parse_iso8601_unix("2026-07-09T15:55:00Z")
1207 );
1208 }
1209
1210 #[test]
1211 fn parse_iso8601_unix_fractional_seconds_with_z() {
1212 assert_eq!(
1213 parse_iso8601_unix("2026-07-09T15:55:00.999Z"),
1214 parse_iso8601_unix("2026-07-09T15:55:00Z")
1215 );
1216 }
1217
1218 #[test]
1219 fn parse_iso8601_unix_bare_form_unchanged() {
1220 assert_eq!(
1222 parse_iso8601_unix("2026-07-09T15:55:00"),
1223 parse_iso8601_unix("2026-07-09T15:55:00Z")
1224 );
1225 }
1226
1227 #[test]
1228 fn parse_iso8601_unix_malformed_tail_returns_none() {
1229 assert_eq!(parse_iso8601_unix("2026-07-09T15:55:00X"), None);
1230 assert_eq!(parse_iso8601_unix("2026-07-09T15:55:00+04"), None);
1231 assert_eq!(parse_iso8601_unix("2026-07-09T15:55:00."), None);
1232 }
1233
1234 #[test]
1235 fn parse_iso8601_unix_out_of_range_offset_returns_none() {
1236 assert_eq!(parse_iso8601_unix("2026-07-09T15:55:00+24:00"), None);
1238 assert_eq!(parse_iso8601_unix("2026-07-09T15:55:00+2400"), None);
1239 assert_eq!(parse_iso8601_unix("2026-07-09T15:55:00+01:60"), None);
1241 assert_eq!(parse_iso8601_unix("2026-07-09T15:55:00+0160"), None);
1242 }
1243
1244 #[test]
1245 fn parse_iso8601_unix_max_valid_offset_boundary_is_accepted() {
1246 assert!(parse_iso8601_unix("2026-07-09T15:55:00+23:59").is_some());
1248 assert!(parse_iso8601_unix("2026-07-09T15:55:00-23:59").is_some());
1249 assert!(parse_iso8601_unix("2026-07-09T15:55:00+2359").is_some());
1250 }
1251
1252 #[test]
1253 fn compact_timestamp_offset_bearing_future_time_not_shown_as_ago() {
1254 let out = compact_timestamp("2025-05-23T16:08:00-02:00", NOW);
1258 assert_ne!(out, "0s ago");
1259 assert_eq!(out, "2025-05-23T16:08");
1260 }
1261
1262 #[test]
1263 fn compact_timestamp_offset_bearing_past_time_renders_relative() {
1264 let out = compact_timestamp("2025-05-23T20:05:00+04:00", NOW);
1270 assert_eq!(out, "3m ago");
1271 }
1272}