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 PAYLOAD_TIMESTAMP_FIELDS: &[&str] = &["trigger_at", "due"];
393
394const SCORE_FIELDS: &[&str] = &[
396 "score",
397 "salience",
398 "decay_factor",
399 "rrf_score",
400 "similarity",
401 "cross_encoder_score",
402 "graph_proximity_score",
403];
404
405const UUID_CANONICAL_LEN: usize = 36;
407
408fn should_shorten_uuid_field(key: &str) -> bool {
416 if key == "full_id" {
417 return false;
418 }
419 key == "id" || key.ends_with("_id") || matches!(key, "superseded_by" | "replaced_by")
420}
421
422pub fn present(value: Value, mode: PresentationMode, now_unix_seconds: i64) -> Value {
432 match mode {
433 PresentationMode::Verbose | PresentationMode::Human => value,
434 PresentationMode::Agent => {
435 let lifecycle_preserve: HashSet<&str> =
436 LIFECYCLE_NULL_PRESERVE.iter().copied().collect();
437 let score_fields: HashSet<&str> = SCORE_FIELDS.iter().copied().collect();
438 let payload_timestamps: HashSet<&str> =
439 PAYLOAD_TIMESTAMP_FIELDS.iter().copied().collect();
440 transform_agent(
441 value,
442 &lifecycle_preserve,
443 &score_fields,
444 &payload_timestamps,
445 now_unix_seconds,
446 false,
447 )
448 }
449 }
450}
451
452fn transform_agent(
458 value: Value,
459 lifecycle: &HashSet<&str>,
460 scores: &HashSet<&str>,
461 payload_timestamps: &HashSet<&str>,
462 now: i64,
463 inside_properties: bool,
464) -> Value {
465 match value {
466 Value::Object(map) => {
467 let mut out = Map::new();
468 for (k, v) in map {
469 let child_inside_properties = inside_properties || k == "properties";
470 let transformed = transform_field_agent(
471 &k,
472 v,
473 lifecycle,
474 scores,
475 payload_timestamps,
476 now,
477 child_inside_properties,
478 );
479 match transformed {
480 None => {} Some(tv) => {
482 out.insert(k, tv);
483 }
484 }
485 }
486 Value::Object(out)
487 }
488 Value::Array(arr) => {
489 let items: Vec<Value> = arr
490 .into_iter()
491 .map(|v| {
492 transform_agent(
493 v,
494 lifecycle,
495 scores,
496 payload_timestamps,
497 now,
498 inside_properties,
499 )
500 })
501 .collect();
502 Value::Array(items)
503 }
504 other => other,
505 }
506}
507
508fn transform_field_agent(
520 key: &str,
521 value: Value,
522 lifecycle: &HashSet<&str>,
523 scores: &HashSet<&str>,
524 payload_timestamps: &HashSet<&str>,
525 now: i64,
526 inside_properties: bool,
527) -> Option<Value> {
528 match &value {
529 Value::Null => {
531 if lifecycle.contains(key) {
532 Some(value)
533 } else {
534 None
535 }
536 }
537 Value::String(s) if s.is_empty() => None,
539 Value::Array(a) if a.is_empty() => None,
540 Value::Object(o) if o.is_empty() => None,
541 Value::Number(_) if scores.contains(key) => {
543 if let Some(f) = value.as_f64() {
544 Some(truncate_to_3_sig_figs(f))
545 } else {
546 Some(value)
547 }
548 }
549 Value::String(s) if is_canonical_uuid(s) && should_shorten_uuid_field(key) => {
551 Some(Value::String(s[..8].to_string()))
552 }
553 Value::String(s)
556 if !inside_properties && !payload_timestamps.contains(key) && looks_like_iso8601(s) =>
557 {
558 Some(Value::String(compact_timestamp(s, now)))
559 }
560 Value::Object(_) | Value::Array(_) => Some(transform_agent(
562 value,
563 lifecycle,
564 scores,
565 payload_timestamps,
566 now,
567 inside_properties,
568 )),
569 _ => Some(value),
571 }
572}
573
574fn is_canonical_uuid(s: &str) -> bool {
576 if s.len() != UUID_CANONICAL_LEN {
577 return false;
578 }
579 let b = s.as_bytes();
580 b[8] == b'-'
582 && b[13] == b'-'
583 && b[18] == b'-'
584 && b[23] == b'-'
585 && b[..8].iter().all(|c| c.is_ascii_hexdigit())
586 && b[9..13].iter().all(|c| c.is_ascii_hexdigit())
587 && b[14..18].iter().all(|c| c.is_ascii_hexdigit())
588 && b[19..23].iter().all(|c| c.is_ascii_hexdigit())
589 && b[24..].iter().all(|c| c.is_ascii_hexdigit())
590}
591
592fn looks_like_iso8601(s: &str) -> bool {
596 if s.len() < 16 {
597 return false;
598 }
599 let b = s.as_bytes();
600 b[4] == b'-'
601 && b[7] == b'-'
602 && b[10] == b'T'
603 && b[13] == b':'
604 && b[..4].iter().all(|c| c.is_ascii_digit())
605 && b[5..7].iter().all(|c| c.is_ascii_digit())
606 && b[8..10].iter().all(|c| c.is_ascii_digit())
607 && b[11..13].iter().all(|c| c.is_ascii_digit())
608}
609
610fn compact_timestamp(s: &str, now: i64) -> String {
615 if let Some(unix) = parse_iso8601_unix(s) {
617 let diff = now - unix;
618 if (0..86400).contains(&diff) {
619 return relative_time(diff);
620 }
621 }
622 s.chars().take(16).collect()
624}
625
626fn parse_iso8601_unix(s: &str) -> Option<i64> {
633 if s.len() < 19 {
635 return None;
636 }
637 let b = s.as_bytes();
638 let year: i64 = parse_digits(&b[0..4])?;
639 let month: i64 = parse_digits(&b[5..7])?;
640 let day: i64 = parse_digits(&b[8..10])?;
641 let hour: i64 = parse_digits(&b[11..13])?;
642 let minute: i64 = parse_digits(&b[14..16])?;
643 let second: i64 = parse_digits(&b[17..19])?;
644
645 let days_since_epoch = days_from_civil(year, month, day);
649 let local = days_since_epoch * 86400 + hour * 3600 + minute * 60 + second;
650 let offset_secs = parse_tz_offset_secs(&s[19..])?;
651 Some(local - offset_secs)
652}
653
654fn parse_tz_offset_secs(tail: &str) -> Option<i64> {
664 let mut rest = tail;
665 if let Some(after_dot) = rest.strip_prefix('.') {
666 let frac_len = after_dot.bytes().take_while(u8::is_ascii_digit).count();
667 if frac_len == 0 {
668 return None;
669 }
670 rest = &after_dot[frac_len..];
671 }
672
673 if rest.is_empty() || rest == "Z" {
674 return Some(0);
675 }
676
677 let sign: i64 = match rest.as_bytes().first()? {
678 b'+' => 1,
679 b'-' => -1,
680 _ => return None,
681 };
682 let digits = &rest[1..];
683 let (hh, mm) = match digits.len() {
684 5 if digits.as_bytes()[2] == b':' => (
686 parse_digits(&digits.as_bytes()[0..2])?,
687 parse_digits(&digits.as_bytes()[3..5])?,
688 ),
689 4 => (
691 parse_digits(&digits.as_bytes()[0..2])?,
692 parse_digits(&digits.as_bytes()[2..4])?,
693 ),
694 _ => return None,
695 };
696 if hh > 23 || mm > 59 {
697 return None;
698 }
699 Some(sign * (hh * 3600 + mm * 60))
700}
701
702fn parse_digits(b: &[u8]) -> Option<i64> {
703 let s = std::str::from_utf8(b).ok()?;
704 s.parse().ok()
705}
706
707fn days_from_civil(y: i64, m: i64, d: i64) -> i64 {
709 let y = if m <= 2 { y - 1 } else { y };
710 let era = y.div_euclid(400);
711 let yoe = y - era * 400;
712 let doy = (153 * (if m > 2 { m - 3 } else { m + 9 }) + 2) / 5 + d - 1;
713 let doe = yoe * 365 + yoe / 4 - yoe / 100 + doy;
714 era * 146097 + doe - 719468
715}
716
717fn relative_time(diff_secs: i64) -> String {
719 if diff_secs < 60 {
720 format!("{diff_secs}s ago")
721 } else if diff_secs < 3600 {
722 format!("{}m ago", diff_secs / 60)
723 } else {
724 format!("{}h ago", diff_secs / 3600)
725 }
726}
727
728fn truncate_to_3_sig_figs(f: f64) -> Value {
730 if f == 0.0 || !f.is_finite() {
731 return Value::from(f);
732 }
733 let magnitude = f.abs().log10().floor() as i32;
734 let factor = 10f64.powi(2 - magnitude);
735 let rounded = (f * factor).round() / factor;
736 serde_json::Number::from_f64(rounded)
738 .map(Value::Number)
739 .unwrap_or(Value::from(rounded))
740}
741
742#[cfg(test)]
743mod tests {
744 use super::*;
745 use serde_json::json;
746
747 const NOW: i64 = 1_748_016_480;
749
750 fn agent(v: Value) -> Value {
751 present(v, PresentationMode::Agent, NOW)
752 }
753
754 #[test]
755 fn verbose_passthrough() {
756 let v = json!({"id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890", "title": "X"});
757 let out = present(v.clone(), PresentationMode::Verbose, NOW);
758 assert_eq!(out, v);
759 }
760
761 #[test]
762 fn agent_shortens_uuid() {
763 let v = json!({"id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890"});
764 let out = agent(v);
765 assert_eq!(out["id"], json!("a1b2c3d4"));
766 }
767
768 #[test]
769 fn agent_drops_empty_string() {
770 let v = json!({"title": "ok", "description": ""});
771 let out = agent(v);
772 assert!(out.get("description").is_none());
773 assert_eq!(out["title"], json!("ok"));
774 }
775
776 #[test]
777 fn agent_drops_empty_array() {
778 let v = json!({"tags": [], "title": "ok"});
779 let out = agent(v);
780 assert!(out.get("tags").is_none());
781 }
782
783 #[test]
784 fn agent_drops_empty_object() {
785 let v = json!({"properties": {}, "title": "ok"});
786 let out = agent(v);
787 assert!(out.get("properties").is_none());
788 }
789
790 #[test]
791 fn agent_drops_non_lifecycle_null() {
792 let v = json!({"result": null, "title": "ok"});
793 let out = agent(v);
794 assert!(out.get("result").is_none());
795 }
796
797 #[test]
798 fn agent_preserves_lifecycle_null() {
799 let v = json!({"completed_at": null, "due_at": null, "title": "ok"});
800 let out = agent(v);
801 assert_eq!(out["completed_at"], json!(null));
802 assert_eq!(out["due_at"], json!(null));
803 }
804
805 #[test]
806 fn agent_preserves_relationship_null() {
807 let v = json!({"parent_id": null, "superseded_by": null});
808 let out = agent(v);
809 assert_eq!(out["parent_id"], json!(null));
810 assert_eq!(out["superseded_by"], json!(null));
811 }
812
813 #[test]
814 fn agent_truncates_score_field() {
815 let v = json!({"score": 0.12345678});
816 let out = agent(v);
817 let s = out["score"].as_f64().unwrap();
818 assert!((s - 0.123).abs() < 1e-9, "expected ~0.123, got {s}");
819 }
820
821 #[test]
822 fn agent_compacts_old_timestamp_to_minutes() {
823 let v = json!({"created_at": "2020-01-01T10:30:45.123456Z"});
825 let out = agent(v);
826 assert_eq!(out["created_at"], json!("2020-01-01T10:30"));
827 }
828
829 #[test]
830 fn agent_compacts_recent_timestamp_to_relative() {
831 let ts_unix = NOW - 180;
833 let ts = unix_to_iso8601(ts_unix);
835 let v = json!({"updated_at": ts});
836 let out = agent(v);
837 assert_eq!(out["updated_at"], json!("3m ago"));
838 }
839
840 #[test]
841 fn agent_does_not_compact_top_level_trigger_at_field() {
842 let at = "2026-07-11T19:00:00-04:00";
851 let v = json!({
852 "id": "a1b2c3d4",
853 "full_id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
854 "event_type": "remind",
855 "trigger_at": at,
856 "repeat": null,
857 "status": "pending",
858 });
859 let out = agent(v);
860 assert_eq!(out["trigger_at"], json!(at));
861 }
862
863 #[test]
864 fn agent_does_not_compact_top_level_trigger_at_utc() {
865 let at = "2026-07-11T23:00:00Z";
866 let v = json!({"trigger_at": at});
867 let out = agent(v);
868 assert_eq!(out["trigger_at"], json!(at));
869 }
870
871 #[test]
872 fn agent_does_not_compact_top_level_trigger_at_offset_less() {
873 let at = "2026-07-11T23:00:00";
874 let v = json!({"trigger_at": at});
875 let out = agent(v);
876 assert_eq!(out["trigger_at"], json!(at));
877 }
878
879 #[test]
880 fn agent_still_compacts_other_top_level_timestamps_alongside_trigger_at() {
881 let v = json!({
885 "trigger_at": "2026-07-11T19:00:00-04:00",
886 "created_at": "2020-01-01T10:30:45.123456Z",
887 });
888 let out = agent(v);
889 assert_eq!(out["trigger_at"], json!("2026-07-11T19:00:00-04:00"));
890 assert_eq!(out["created_at"], json!("2020-01-01T10:30"));
891 }
892
893 #[test]
894 fn agent_does_not_compact_top_level_due() {
895 let due = "2026-08-01T09:30:15-04:00";
900 let v = json!({"due": due});
901 let out = agent(v);
902 assert_eq!(out["due"], json!(due));
903 }
904
905 #[test]
906 fn agent_still_protects_nested_trigger_at_under_properties() {
907 let at = "2026-07-11T19:00:00-04:00";
911 let v = json!({
912 "id": "a1b2c3d4",
913 "properties": {"trigger_at": at, "status": "pending"},
914 });
915 let out = agent(v);
916 assert_eq!(out["properties"]["trigger_at"], json!(at));
917 }
918
919 #[test]
920 fn agent_recurses_into_nested_objects() {
921 let v = json!({
922 "items": [
923 {
924 "id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
925 "tags": [],
926 "score": 0.9999
927 }
928 ]
929 });
930 let out = agent(v);
931 let item = &out["items"][0];
932 assert_eq!(item["id"], json!("a1b2c3d4"));
933 assert!(item.get("tags").is_none());
934 let s = item["score"].as_f64().unwrap();
935 assert!((s - 1.0).abs() < 1e-9);
936 }
937
938 #[test]
941 fn agent_preserves_full_id_as_36_chars() {
942 let uuid = "a1b2c3d4-e5f6-7890-abcd-ef1234567890";
943 let v = json!({"id": uuid, "full_id": uuid, "title": "X"});
944 let out = agent(v);
945 assert_eq!(
947 out["id"],
948 json!("a1b2c3d4"),
949 "id should be 8-char short form"
950 );
951 assert_eq!(
953 out["full_id"].as_str().unwrap().len(),
954 36,
955 "full_id must be 36 chars in agent mode"
956 );
957 assert_eq!(
958 out["full_id"],
959 json!(uuid),
960 "full_id must equal the original UUID"
961 );
962 assert!(
964 out["full_id"]
965 .as_str()
966 .unwrap()
967 .starts_with(out["id"].as_str().unwrap()),
968 "full_id must start with the short id prefix"
969 );
970 }
971
972 #[test]
973 fn is_canonical_uuid_recognizes_valid() {
974 assert!(is_canonical_uuid("a1b2c3d4-e5f6-7890-abcd-ef1234567890"));
975 assert!(!is_canonical_uuid("a1b2c3d4"));
976 assert!(!is_canonical_uuid("not-a-uuid-at-all-here---------"));
977 }
978
979 #[test]
980 fn looks_like_iso8601_recognizes_valid() {
981 assert!(looks_like_iso8601("2026-05-23T16:18:15.234567Z"));
982 assert!(!looks_like_iso8601("not a timestamp"));
983 assert!(!looks_like_iso8601("2026-05-23"));
984 }
985
986 fn unix_to_iso8601(unix: i64) -> String {
988 let (y, mo, d, h, mi, s) = unix_to_civil(unix);
989 format!("{y:04}-{mo:02}-{d:02}T{h:02}:{mi:02}:{s:02}Z")
990 }
991
992 fn unix_to_civil(unix: i64) -> (i64, i64, i64, i64, i64, i64) {
993 let s = unix % 86400;
994 let days = unix / 86400;
995 let h = s / 3600;
996 let m = (s % 3600) / 60;
997 let sec = s % 60;
998 let z = days + 719468;
1000 let era = z.div_euclid(146097);
1001 let doe = z - era * 146097;
1002 let yoe = (doe - doe / 1460 + doe / 36524 - doe / 146096) / 365;
1003 let y = yoe + era * 400;
1004 let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
1005 let mp = (5 * doy + 2) / 153;
1006 let d = doy - (153 * mp + 2) / 5 + 1;
1007 let mo = if mp < 10 { mp + 3 } else { mp - 9 };
1008 let y = if mo <= 2 { y + 1 } else { y };
1009 (y, mo, d, h, m, sec)
1010 }
1011
1012 #[test]
1013 fn agent_does_not_shorten_uuid_shaped_content_fields() {
1014 let uuid = "a1b2c3d4-e5f6-7890-abcd-ef1234567890";
1015 let out = agent(json!({
1016 "id": uuid,
1017 "full_id": uuid,
1018 "content": uuid,
1019 "description": uuid,
1020 "title": uuid,
1021 "query": uuid,
1022 }));
1023
1024 assert_eq!(out["id"], json!("a1b2c3d4"));
1025 assert_eq!(out["full_id"], json!(uuid));
1026 assert_eq!(out["content"], json!(uuid));
1027 assert_eq!(out["description"], json!(uuid));
1028 assert_eq!(out["title"], json!(uuid));
1029 assert_eq!(out["query"], json!(uuid));
1030 }
1031
1032 #[test]
1033 fn agent_shortens_suffix_id_fields() {
1034 let uuid = "a1b2c3d4-e5f6-7890-abcd-ef1234567890";
1035 let out = agent(json!({
1036 "note_id": uuid,
1037 "source_id": uuid,
1038 "target_id": uuid,
1039 }));
1040
1041 assert_eq!(out["note_id"], json!("a1b2c3d4"));
1042 assert_eq!(out["source_id"], json!("a1b2c3d4"));
1043 assert_eq!(out["target_id"], json!("a1b2c3d4"));
1044 }
1045
1046 #[test]
1050 fn format_json_preserves_full_shape() {
1051 let v = json!({
1052 "full_id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
1053 "namespace": "local",
1054 "properties": {"k": "v"},
1055 "title": "test"
1056 });
1057 let rendered = render_format(v.clone(), OutputFormat::Json, PresentationMode::Agent);
1058 let parsed: Value = serde_json::from_str(&rendered).unwrap();
1059 assert!(
1061 parsed.get("full_id").is_some(),
1062 "json mode must keep full_id"
1063 );
1064 assert_eq!(
1066 parsed.get("namespace").and_then(Value::as_str),
1067 Some("local")
1068 );
1069 assert!(parsed.get("properties").is_some());
1071 }
1072
1073 #[test]
1075 fn format_auto_drops_versus_json_keeps() {
1076 let v = json!({
1077 "full_id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
1078 "namespace": "local",
1079 "title": "test"
1080 });
1081 let json_rendered = render_format(v.clone(), OutputFormat::Json, PresentationMode::Agent);
1082 let auto_rendered = render_format(v.clone(), OutputFormat::Auto, PresentationMode::Agent);
1083 let json_parsed: Value = serde_json::from_str(&json_rendered).unwrap();
1085 assert!(
1086 json_parsed.get("full_id").is_some(),
1087 "json must keep full_id"
1088 );
1089 assert_eq!(
1090 json_parsed.get("namespace").and_then(Value::as_str),
1091 Some("local")
1092 );
1093 assert!(
1096 !auto_rendered.contains("full_id"),
1097 "auto kv block must drop full_id"
1098 );
1099 assert!(
1100 !auto_rendered.contains("namespace"),
1101 "auto kv block must elide namespace=local"
1102 );
1103 }
1104
1105 #[test]
1107 fn format_auto_homogeneous_array_renders_markdown_table() {
1108 let v = json!([
1109 {"id": "abc", "title": "First"},
1110 {"id": "def", "title": "Second"}
1111 ]);
1112 let rendered = render_format(v, OutputFormat::Auto, PresentationMode::Agent);
1113 assert!(rendered.starts_with('|'), "must start with |");
1114 assert!(
1115 rendered.contains("| id |") || rendered.contains("| id"),
1116 "must have id column"
1117 );
1118 assert!(rendered.contains("title"), "must have title column");
1119 assert!(rendered.contains("|---|"), "must have separator row");
1120 assert!(rendered.contains("abc"), "must have first row data");
1121 assert!(rendered.contains("Second"), "must have second row data");
1122 }
1123
1124 #[test]
1126 fn format_auto_single_record_renders_kv_block() {
1127 let v = json!({"id": "abc", "title": "Hello World"});
1128 let rendered = render_format(v, OutputFormat::Auto, PresentationMode::Agent);
1129 assert!(rendered.contains("id: abc"), "must have id: abc");
1131 assert!(
1132 rendered.contains("title: Hello World"),
1133 "must have title line"
1134 );
1135 assert!(
1137 !rendered.starts_with('|'),
1138 "single record must not be a markdown table"
1139 );
1140 }
1141
1142 #[test]
1144 fn format_auto_scalar_fallback_compact_json() {
1145 let v = json!(42);
1146 let rendered = render_format(v, OutputFormat::Auto, PresentationMode::Agent);
1147 assert_eq!(rendered, "42");
1148 }
1149
1150 #[test]
1152 fn format_table_forces_markdown_when_array() {
1153 let v = json!({
1154 "items": [
1155 {"name": "A", "score": 1},
1156 {"name": "B", "score": 2}
1157 ]
1158 });
1159 let rendered = render_format(v, OutputFormat::Table, PresentationMode::Agent);
1160 assert!(
1161 rendered.contains("|"),
1162 "table format must produce markdown table"
1163 );
1164 assert!(rendered.contains("name"), "must have name column");
1165 assert!(rendered.contains("score"), "must have score column");
1166 }
1167
1168 #[test]
1170 fn format_table_falls_back_to_json_when_no_array() {
1171 let v = json!({"single": "value"});
1172 let rendered = render_format(v, OutputFormat::Table, PresentationMode::Agent);
1173 let parsed: Value = serde_json::from_str(&rendered).unwrap();
1175 assert_eq!(parsed["single"], json!("value"));
1176 }
1177
1178 #[test]
1180 fn format_auto_verbose_skips_redundancy_drop() {
1181 let v = json!({
1182 "full_id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
1183 "namespace": "local",
1184 "title": "test"
1185 });
1186 let rendered = render_format(v, OutputFormat::Auto, PresentationMode::Verbose);
1189 assert!(
1190 rendered.contains("full_id"),
1191 "verbose must preserve full_id"
1192 );
1193 assert!(
1194 rendered.contains("namespace"),
1195 "verbose must preserve namespace"
1196 );
1197 }
1198
1199 #[test]
1203 fn redundancy_drop_does_not_corrupt_error_shape() {
1204 let v = json!({"ok": false, "error": "something failed", "namespace": "local"});
1205 let reduced = apply_redundancy_drop(v.clone());
1210 assert!(
1211 reduced.get("error").is_some(),
1212 "redundancy drop must preserve error field"
1213 );
1214 assert_eq!(
1215 reduced.get("ok").and_then(Value::as_bool),
1216 Some(false),
1217 "redundancy drop must preserve ok=false"
1218 );
1219 }
1220
1221 #[test]
1223 fn redundancy_drop_properties_dedup() {
1224 let v = json!({
1225 "id": "abc",
1226 "title": "Same",
1227 "properties": {
1228 "title": "Same", "extra": "unique" }
1231 });
1232 let reduced = apply_redundancy_drop(v);
1233 let props = reduced.get("properties").expect("properties must remain");
1234 assert!(props.get("extra").is_some(), "unique property must be kept");
1235 assert!(
1236 props.get("title").is_none(),
1237 "duplicate top-level property must be removed"
1238 );
1239 }
1240
1241 #[test]
1243 fn cell_text_truncates_long_values() {
1244 let long = "X".repeat(200);
1245 let v = json!([
1246 {"col": long.clone()},
1247 {"col": "short"}
1248 ]);
1249 let rendered = render_format(v, OutputFormat::Auto, PresentationMode::Agent);
1250 assert!(
1252 rendered.contains("..."),
1253 "long cell must be truncated with ..."
1254 );
1255 assert!(
1256 !rendered.contains(&long),
1257 "full long string must not appear in table"
1258 );
1259 }
1260
1261 #[test]
1267 fn cell_text_truncation_is_utf8_safe() {
1268 let prefix = "a".repeat(119);
1271 let suffix = "中".repeat(10); let long_multibyte = format!("{prefix}{suffix}trailing");
1273 let v = json!([
1274 {"col": long_multibyte.clone()},
1275 {"col": "ok"}
1276 ]);
1277 let rendered = render_format(v, OutputFormat::Auto, PresentationMode::Agent);
1279 assert!(
1280 rendered.contains("..."),
1281 "multibyte cell must be truncated with ..."
1282 );
1283 assert!(
1285 std::str::from_utf8(rendered.as_bytes()).is_ok(),
1286 "rendered output must be valid UTF-8"
1287 );
1288 }
1289
1290 #[test]
1293 fn parse_iso8601_unix_negative_offset_matches_equivalent_utc() {
1294 assert_eq!(
1296 parse_iso8601_unix("2026-07-09T11:55:00-04:00"),
1297 parse_iso8601_unix("2026-07-09T15:55:00Z")
1298 );
1299 }
1300
1301 #[test]
1302 fn parse_iso8601_unix_positive_offset_matches_equivalent_utc() {
1303 assert_eq!(
1305 parse_iso8601_unix("2026-05-23T20:15:00+04:00"),
1306 parse_iso8601_unix("2026-05-23T16:15:00Z")
1307 );
1308 }
1309
1310 #[test]
1311 fn parse_iso8601_unix_zero_offset_matches_z() {
1312 assert_eq!(
1313 parse_iso8601_unix("2026-07-09T15:55:00+00:00"),
1314 parse_iso8601_unix("2026-07-09T15:55:00Z")
1315 );
1316 }
1317
1318 #[test]
1319 fn parse_iso8601_unix_compact_offset_form_matches_colon_form() {
1320 assert_eq!(
1321 parse_iso8601_unix("2026-07-09T11:55:00-0400"),
1322 parse_iso8601_unix("2026-07-09T11:55:00-04:00")
1323 );
1324 }
1325
1326 #[test]
1327 fn parse_iso8601_unix_fractional_seconds_with_offset() {
1328 assert_eq!(
1331 parse_iso8601_unix("2026-07-09T11:55:00.123-04:00"),
1332 parse_iso8601_unix("2026-07-09T15:55:00Z")
1333 );
1334 }
1335
1336 #[test]
1337 fn parse_iso8601_unix_fractional_seconds_with_z() {
1338 assert_eq!(
1339 parse_iso8601_unix("2026-07-09T15:55:00.999Z"),
1340 parse_iso8601_unix("2026-07-09T15:55:00Z")
1341 );
1342 }
1343
1344 #[test]
1345 fn parse_iso8601_unix_bare_form_unchanged() {
1346 assert_eq!(
1348 parse_iso8601_unix("2026-07-09T15:55:00"),
1349 parse_iso8601_unix("2026-07-09T15:55:00Z")
1350 );
1351 }
1352
1353 #[test]
1354 fn parse_iso8601_unix_malformed_tail_returns_none() {
1355 assert_eq!(parse_iso8601_unix("2026-07-09T15:55:00X"), None);
1356 assert_eq!(parse_iso8601_unix("2026-07-09T15:55:00+04"), None);
1357 assert_eq!(parse_iso8601_unix("2026-07-09T15:55:00."), None);
1358 }
1359
1360 #[test]
1361 fn parse_iso8601_unix_out_of_range_offset_returns_none() {
1362 assert_eq!(parse_iso8601_unix("2026-07-09T15:55:00+24:00"), None);
1364 assert_eq!(parse_iso8601_unix("2026-07-09T15:55:00+2400"), None);
1365 assert_eq!(parse_iso8601_unix("2026-07-09T15:55:00+01:60"), None);
1367 assert_eq!(parse_iso8601_unix("2026-07-09T15:55:00+0160"), None);
1368 }
1369
1370 #[test]
1371 fn parse_iso8601_unix_max_valid_offset_boundary_is_accepted() {
1372 assert!(parse_iso8601_unix("2026-07-09T15:55:00+23:59").is_some());
1374 assert!(parse_iso8601_unix("2026-07-09T15:55:00-23:59").is_some());
1375 assert!(parse_iso8601_unix("2026-07-09T15:55:00+2359").is_some());
1376 }
1377
1378 #[test]
1379 fn compact_timestamp_offset_bearing_future_time_not_shown_as_ago() {
1380 let out = compact_timestamp("2025-05-23T16:08:00-02:00", NOW);
1384 assert_ne!(out, "0s ago");
1385 assert_eq!(out, "2025-05-23T16:08");
1386 }
1387
1388 #[test]
1389 fn compact_timestamp_offset_bearing_past_time_renders_relative() {
1390 let out = compact_timestamp("2025-05-23T20:05:00+04:00", NOW);
1396 assert_eq!(out, "3m ago");
1397 }
1398}