1use std::collections::HashSet;
28
29use serde::{Deserialize, Serialize};
30use serde_json::{Map, Value};
31
32#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
34#[serde(rename_all = "snake_case")]
35pub enum PresentationMode {
36 #[default]
42 Agent,
43 Verbose,
47 Human,
52}
53
54const LIFECYCLE_NULL_PRESERVE: &[&str] = &[
59 "completed_at",
60 "deleted_at",
61 "due_at",
62 "read_at",
63 "started_at",
64 "superseded_at",
65 "applied_at",
66 "withdrawn_at",
67 "reviewed_at",
68 "parent_id",
69 "superseded_by",
70 "replaced_by",
71];
72
73const SCORE_FIELDS: &[&str] = &[
77 "score",
78 "salience",
79 "decay_factor",
80 "rrf_score",
81 "similarity",
82 "cross_encoder_score",
83 "graph_proximity_score",
84];
85
86const UUID_CANONICAL_LEN: usize = 36;
88
89pub fn present(value: Value, mode: PresentationMode, now_unix_seconds: i64) -> Value {
99 match mode {
100 PresentationMode::Verbose | PresentationMode::Human => value,
101 PresentationMode::Agent => {
102 let lifecycle_preserve: HashSet<&str> =
103 LIFECYCLE_NULL_PRESERVE.iter().copied().collect();
104 let score_fields: HashSet<&str> = SCORE_FIELDS.iter().copied().collect();
105 transform_agent(value, &lifecycle_preserve, &score_fields, now_unix_seconds)
106 }
107 }
108}
109
110fn transform_agent(
112 value: Value,
113 lifecycle: &HashSet<&str>,
114 scores: &HashSet<&str>,
115 now: i64,
116) -> Value {
117 match value {
118 Value::Object(map) => {
119 let mut out = Map::new();
120 for (k, v) in map {
121 let transformed = transform_field_agent(&k, v, lifecycle, scores, now);
122 match transformed {
123 None => {} Some(tv) => {
125 out.insert(k, tv);
126 }
127 }
128 }
129 Value::Object(out)
130 }
131 Value::Array(arr) => {
132 let items: Vec<Value> = arr
133 .into_iter()
134 .map(|v| transform_agent(v, lifecycle, scores, now))
135 .collect();
136 Value::Array(items)
137 }
138 other => other,
139 }
140}
141
142fn transform_field_agent(
146 key: &str,
147 value: Value,
148 lifecycle: &HashSet<&str>,
149 scores: &HashSet<&str>,
150 now: i64,
151) -> Option<Value> {
152 match &value {
153 Value::Null => {
155 if lifecycle.contains(key) {
156 Some(value)
157 } else {
158 None
159 }
160 }
161 Value::String(s) if s.is_empty() => None,
163 Value::Array(a) if a.is_empty() => None,
164 Value::Object(o) if o.is_empty() => None,
165 Value::Number(_) if scores.contains(key) => {
167 if let Some(f) = value.as_f64() {
168 Some(truncate_to_3_sig_figs(f))
169 } else {
170 Some(value)
171 }
172 }
173 Value::String(s) if is_canonical_uuid(s) => Some(Value::String(s[..8].to_string())),
175 Value::String(s) if looks_like_iso8601(s) => Some(Value::String(compact_timestamp(s, now))),
177 Value::Object(_) | Value::Array(_) => Some(transform_agent(value, lifecycle, scores, now)),
179 _ => Some(value),
181 }
182}
183
184fn is_canonical_uuid(s: &str) -> bool {
186 if s.len() != UUID_CANONICAL_LEN {
187 return false;
188 }
189 let b = s.as_bytes();
190 b[8] == b'-'
192 && b[13] == b'-'
193 && b[18] == b'-'
194 && b[23] == b'-'
195 && b[..8].iter().all(|c| c.is_ascii_hexdigit())
196 && b[9..13].iter().all(|c| c.is_ascii_hexdigit())
197 && b[14..18].iter().all(|c| c.is_ascii_hexdigit())
198 && b[19..23].iter().all(|c| c.is_ascii_hexdigit())
199 && b[24..].iter().all(|c| c.is_ascii_hexdigit())
200}
201
202fn looks_like_iso8601(s: &str) -> bool {
206 if s.len() < 16 {
207 return false;
208 }
209 let b = s.as_bytes();
210 b[4] == b'-'
211 && b[7] == b'-'
212 && b[10] == b'T'
213 && b[13] == b':'
214 && b[..4].iter().all(|c| c.is_ascii_digit())
215 && b[5..7].iter().all(|c| c.is_ascii_digit())
216 && b[8..10].iter().all(|c| c.is_ascii_digit())
217 && b[11..13].iter().all(|c| c.is_ascii_digit())
218}
219
220fn compact_timestamp(s: &str, now: i64) -> String {
225 if let Some(unix) = parse_iso8601_unix(s) {
227 let diff = now - unix;
228 if (0..86400).contains(&diff) {
229 return relative_time(diff);
230 }
231 }
232 s.chars().take(16).collect()
234}
235
236fn parse_iso8601_unix(s: &str) -> Option<i64> {
242 if s.len() < 19 {
244 return None;
245 }
246 let b = s.as_bytes();
247 let year: i64 = parse_digits(&b[0..4])?;
248 let month: i64 = parse_digits(&b[5..7])?;
249 let day: i64 = parse_digits(&b[8..10])?;
250 let hour: i64 = parse_digits(&b[11..13])?;
251 let minute: i64 = parse_digits(&b[14..16])?;
252 let second: i64 = parse_digits(&b[17..19])?;
253
254 let days_since_epoch = days_from_civil(year, month, day);
257 Some(days_since_epoch * 86400 + hour * 3600 + minute * 60 + second)
258}
259
260fn parse_digits(b: &[u8]) -> Option<i64> {
261 let s = std::str::from_utf8(b).ok()?;
262 s.parse().ok()
263}
264
265fn days_from_civil(y: i64, m: i64, d: i64) -> i64 {
267 let y = if m <= 2 { y - 1 } else { y };
268 let era = y.div_euclid(400);
269 let yoe = y - era * 400;
270 let doy = (153 * (if m > 2 { m - 3 } else { m + 9 }) + 2) / 5 + d - 1;
271 let doe = yoe * 365 + yoe / 4 - yoe / 100 + doy;
272 era * 146097 + doe - 719468
273}
274
275fn relative_time(diff_secs: i64) -> String {
277 if diff_secs < 60 {
278 format!("{diff_secs}s ago")
279 } else if diff_secs < 3600 {
280 format!("{}m ago", diff_secs / 60)
281 } else {
282 format!("{}h ago", diff_secs / 3600)
283 }
284}
285
286fn truncate_to_3_sig_figs(f: f64) -> Value {
288 if f == 0.0 || !f.is_finite() {
289 return Value::from(f);
290 }
291 let magnitude = f.abs().log10().floor() as i32;
292 let factor = 10f64.powi(2 - magnitude);
293 let rounded = (f * factor).round() / factor;
294 serde_json::Number::from_f64(rounded)
296 .map(Value::Number)
297 .unwrap_or(Value::from(rounded))
298}
299
300#[cfg(test)]
301mod tests {
302 use super::*;
303 use serde_json::json;
304
305 const NOW: i64 = 1_748_016_480;
307
308 fn agent(v: Value) -> Value {
309 present(v, PresentationMode::Agent, NOW)
310 }
311
312 #[test]
313 fn verbose_passthrough() {
314 let v = json!({"id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890", "title": "X"});
315 let out = present(v.clone(), PresentationMode::Verbose, NOW);
316 assert_eq!(out, v);
317 }
318
319 #[test]
320 fn agent_shortens_uuid() {
321 let v = json!({"id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890"});
322 let out = agent(v);
323 assert_eq!(out["id"], json!("a1b2c3d4"));
324 }
325
326 #[test]
327 fn agent_drops_empty_string() {
328 let v = json!({"title": "ok", "description": ""});
329 let out = agent(v);
330 assert!(out.get("description").is_none());
331 assert_eq!(out["title"], json!("ok"));
332 }
333
334 #[test]
335 fn agent_drops_empty_array() {
336 let v = json!({"tags": [], "title": "ok"});
337 let out = agent(v);
338 assert!(out.get("tags").is_none());
339 }
340
341 #[test]
342 fn agent_drops_empty_object() {
343 let v = json!({"properties": {}, "title": "ok"});
344 let out = agent(v);
345 assert!(out.get("properties").is_none());
346 }
347
348 #[test]
349 fn agent_drops_non_lifecycle_null() {
350 let v = json!({"result": null, "title": "ok"});
351 let out = agent(v);
352 assert!(out.get("result").is_none());
353 }
354
355 #[test]
356 fn agent_preserves_lifecycle_null() {
357 let v = json!({"completed_at": null, "due_at": null, "title": "ok"});
358 let out = agent(v);
359 assert_eq!(out["completed_at"], json!(null));
360 assert_eq!(out["due_at"], json!(null));
361 }
362
363 #[test]
364 fn agent_preserves_relationship_null() {
365 let v = json!({"parent_id": null, "superseded_by": null});
366 let out = agent(v);
367 assert_eq!(out["parent_id"], json!(null));
368 assert_eq!(out["superseded_by"], json!(null));
369 }
370
371 #[test]
372 fn agent_truncates_score_field() {
373 let v = json!({"score": 0.12345678});
374 let out = agent(v);
375 let s = out["score"].as_f64().unwrap();
376 assert!((s - 0.123).abs() < 1e-9, "expected ~0.123, got {s}");
377 }
378
379 #[test]
380 fn agent_compacts_old_timestamp_to_minutes() {
381 let v = json!({"created_at": "2020-01-01T10:30:45.123456Z"});
383 let out = agent(v);
384 assert_eq!(out["created_at"], json!("2020-01-01T10:30"));
385 }
386
387 #[test]
388 fn agent_compacts_recent_timestamp_to_relative() {
389 let ts_unix = NOW - 180;
391 let ts = unix_to_iso8601(ts_unix);
393 let v = json!({"updated_at": ts});
394 let out = agent(v);
395 assert_eq!(out["updated_at"], json!("3m ago"));
396 }
397
398 #[test]
399 fn agent_recurses_into_nested_objects() {
400 let v = json!({
401 "items": [
402 {
403 "id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
404 "tags": [],
405 "score": 0.9999
406 }
407 ]
408 });
409 let out = agent(v);
410 let item = &out["items"][0];
411 assert_eq!(item["id"], json!("a1b2c3d4"));
412 assert!(item.get("tags").is_none());
413 let s = item["score"].as_f64().unwrap();
414 assert!((s - 1.0).abs() < 1e-9);
415 }
416
417 #[test]
418 fn is_canonical_uuid_recognizes_valid() {
419 assert!(is_canonical_uuid("a1b2c3d4-e5f6-7890-abcd-ef1234567890"));
420 assert!(!is_canonical_uuid("a1b2c3d4"));
421 assert!(!is_canonical_uuid("not-a-uuid-at-all-here---------"));
422 }
423
424 #[test]
425 fn looks_like_iso8601_recognizes_valid() {
426 assert!(looks_like_iso8601("2026-05-23T16:18:15.234567Z"));
427 assert!(!looks_like_iso8601("not a timestamp"));
428 assert!(!looks_like_iso8601("2026-05-23"));
429 }
430
431 fn unix_to_iso8601(unix: i64) -> String {
433 let (y, mo, d, h, mi, s) = unix_to_civil(unix);
434 format!("{y:04}-{mo:02}-{d:02}T{h:02}:{mi:02}:{s:02}Z")
435 }
436
437 fn unix_to_civil(unix: i64) -> (i64, i64, i64, i64, i64, i64) {
438 let s = unix % 86400;
439 let days = unix / 86400;
440 let h = s / 3600;
441 let m = (s % 3600) / 60;
442 let sec = s % 60;
443 let z = days + 719468;
445 let era = z.div_euclid(146097);
446 let doe = z - era * 146097;
447 let yoe = (doe - doe / 1460 + doe / 36524 - doe / 146096) / 365;
448 let y = yoe + era * 400;
449 let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
450 let mp = (5 * doy + 2) / 153;
451 let d = doy - (153 * mp + 2) / 5 + 1;
452 let mo = if mp < 10 { mp + 3 } else { mp - 9 };
453 let y = if mo <= 2 { y + 1 } else { y };
454 (y, mo, d, h, m, sec)
455 }
456}