1use std::collections::HashSet;
8
9use serde::{Deserialize, Serialize};
10use serde_json::{Map, Value};
11
12pub fn micros_to_iso(micros: i64) -> String {
19 chrono::DateTime::<chrono::Utc>::from_timestamp_micros(micros)
20 .unwrap_or_else(chrono::Utc::now)
21 .to_rfc3339_opts(chrono::SecondsFormat::Micros, true)
22}
23
24#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
26#[serde(rename_all = "snake_case")]
27pub enum PresentationMode {
28 #[default]
34 Agent,
35 Verbose,
39 Human,
46}
47
48const LIFECYCLE_NULL_PRESERVE: &[&str] = &[
52 "completed_at",
53 "deleted_at",
54 "due_at",
55 "read_at",
56 "started_at",
57 "superseded_at",
58 "applied_at",
59 "withdrawn_at",
60 "reviewed_at",
61 "parent_id",
62 "superseded_by",
63 "replaced_by",
64];
65
66const SCORE_FIELDS: &[&str] = &[
68 "score",
69 "salience",
70 "decay_factor",
71 "rrf_score",
72 "similarity",
73 "cross_encoder_score",
74 "graph_proximity_score",
75];
76
77const UUID_CANONICAL_LEN: usize = 36;
79
80fn should_shorten_uuid_field(key: &str) -> bool {
88 if key == "full_id" {
89 return false;
90 }
91 key == "id" || key.ends_with("_id") || matches!(key, "superseded_by" | "replaced_by")
92}
93
94pub fn present(value: Value, mode: PresentationMode, now_unix_seconds: i64) -> Value {
104 match mode {
105 PresentationMode::Verbose | PresentationMode::Human => value,
106 PresentationMode::Agent => {
107 let lifecycle_preserve: HashSet<&str> =
108 LIFECYCLE_NULL_PRESERVE.iter().copied().collect();
109 let score_fields: HashSet<&str> = SCORE_FIELDS.iter().copied().collect();
110 transform_agent(
111 value,
112 &lifecycle_preserve,
113 &score_fields,
114 now_unix_seconds,
115 false,
116 )
117 }
118 }
119}
120
121fn transform_agent(
127 value: Value,
128 lifecycle: &HashSet<&str>,
129 scores: &HashSet<&str>,
130 now: i64,
131 inside_properties: bool,
132) -> Value {
133 match value {
134 Value::Object(map) => {
135 let mut out = Map::new();
136 for (k, v) in map {
137 let child_inside_properties = inside_properties || k == "properties";
138 let transformed =
139 transform_field_agent(&k, v, lifecycle, scores, now, child_inside_properties);
140 match transformed {
141 None => {} Some(tv) => {
143 out.insert(k, tv);
144 }
145 }
146 }
147 Value::Object(out)
148 }
149 Value::Array(arr) => {
150 let items: Vec<Value> = arr
151 .into_iter()
152 .map(|v| transform_agent(v, lifecycle, scores, now, inside_properties))
153 .collect();
154 Value::Array(items)
155 }
156 other => other,
157 }
158}
159
160fn transform_field_agent(
168 key: &str,
169 value: Value,
170 lifecycle: &HashSet<&str>,
171 scores: &HashSet<&str>,
172 now: i64,
173 inside_properties: bool,
174) -> Option<Value> {
175 match &value {
176 Value::Null => {
178 if lifecycle.contains(key) {
179 Some(value)
180 } else {
181 None
182 }
183 }
184 Value::String(s) if s.is_empty() => None,
186 Value::Array(a) if a.is_empty() => None,
187 Value::Object(o) if o.is_empty() => None,
188 Value::Number(_) if scores.contains(key) => {
190 if let Some(f) = value.as_f64() {
191 Some(truncate_to_3_sig_figs(f))
192 } else {
193 Some(value)
194 }
195 }
196 Value::String(s) if is_canonical_uuid(s) && should_shorten_uuid_field(key) => {
198 Some(Value::String(s[..8].to_string()))
199 }
200 Value::String(s) if !inside_properties && looks_like_iso8601(s) => {
202 Some(Value::String(compact_timestamp(s, now)))
203 }
204 Value::Object(_) | Value::Array(_) => Some(transform_agent(
206 value,
207 lifecycle,
208 scores,
209 now,
210 inside_properties,
211 )),
212 _ => Some(value),
214 }
215}
216
217fn is_canonical_uuid(s: &str) -> bool {
219 if s.len() != UUID_CANONICAL_LEN {
220 return false;
221 }
222 let b = s.as_bytes();
223 b[8] == b'-'
225 && b[13] == b'-'
226 && b[18] == b'-'
227 && b[23] == b'-'
228 && b[..8].iter().all(|c| c.is_ascii_hexdigit())
229 && b[9..13].iter().all(|c| c.is_ascii_hexdigit())
230 && b[14..18].iter().all(|c| c.is_ascii_hexdigit())
231 && b[19..23].iter().all(|c| c.is_ascii_hexdigit())
232 && b[24..].iter().all(|c| c.is_ascii_hexdigit())
233}
234
235fn looks_like_iso8601(s: &str) -> bool {
239 if s.len() < 16 {
240 return false;
241 }
242 let b = s.as_bytes();
243 b[4] == b'-'
244 && b[7] == b'-'
245 && b[10] == b'T'
246 && b[13] == b':'
247 && b[..4].iter().all(|c| c.is_ascii_digit())
248 && b[5..7].iter().all(|c| c.is_ascii_digit())
249 && b[8..10].iter().all(|c| c.is_ascii_digit())
250 && b[11..13].iter().all(|c| c.is_ascii_digit())
251}
252
253fn compact_timestamp(s: &str, now: i64) -> String {
258 if let Some(unix) = parse_iso8601_unix(s) {
260 let diff = now - unix;
261 if (0..86400).contains(&diff) {
262 return relative_time(diff);
263 }
264 }
265 s.chars().take(16).collect()
267}
268
269fn parse_iso8601_unix(s: &str) -> Option<i64> {
275 if s.len() < 19 {
277 return None;
278 }
279 let b = s.as_bytes();
280 let year: i64 = parse_digits(&b[0..4])?;
281 let month: i64 = parse_digits(&b[5..7])?;
282 let day: i64 = parse_digits(&b[8..10])?;
283 let hour: i64 = parse_digits(&b[11..13])?;
284 let minute: i64 = parse_digits(&b[14..16])?;
285 let second: i64 = parse_digits(&b[17..19])?;
286
287 let days_since_epoch = days_from_civil(year, month, day);
290 Some(days_since_epoch * 86400 + hour * 3600 + minute * 60 + second)
291}
292
293fn parse_digits(b: &[u8]) -> Option<i64> {
294 let s = std::str::from_utf8(b).ok()?;
295 s.parse().ok()
296}
297
298fn days_from_civil(y: i64, m: i64, d: i64) -> i64 {
300 let y = if m <= 2 { y - 1 } else { y };
301 let era = y.div_euclid(400);
302 let yoe = y - era * 400;
303 let doy = (153 * (if m > 2 { m - 3 } else { m + 9 }) + 2) / 5 + d - 1;
304 let doe = yoe * 365 + yoe / 4 - yoe / 100 + doy;
305 era * 146097 + doe - 719468
306}
307
308fn relative_time(diff_secs: i64) -> String {
310 if diff_secs < 60 {
311 format!("{diff_secs}s ago")
312 } else if diff_secs < 3600 {
313 format!("{}m ago", diff_secs / 60)
314 } else {
315 format!("{}h ago", diff_secs / 3600)
316 }
317}
318
319fn truncate_to_3_sig_figs(f: f64) -> Value {
321 if f == 0.0 || !f.is_finite() {
322 return Value::from(f);
323 }
324 let magnitude = f.abs().log10().floor() as i32;
325 let factor = 10f64.powi(2 - magnitude);
326 let rounded = (f * factor).round() / factor;
327 serde_json::Number::from_f64(rounded)
329 .map(Value::Number)
330 .unwrap_or(Value::from(rounded))
331}
332
333#[cfg(test)]
334mod tests {
335 use super::*;
336 use serde_json::json;
337
338 const NOW: i64 = 1_748_016_480;
340
341 fn agent(v: Value) -> Value {
342 present(v, PresentationMode::Agent, NOW)
343 }
344
345 #[test]
346 fn verbose_passthrough() {
347 let v = json!({"id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890", "title": "X"});
348 let out = present(v.clone(), PresentationMode::Verbose, NOW);
349 assert_eq!(out, v);
350 }
351
352 #[test]
353 fn agent_shortens_uuid() {
354 let v = json!({"id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890"});
355 let out = agent(v);
356 assert_eq!(out["id"], json!("a1b2c3d4"));
357 }
358
359 #[test]
360 fn agent_drops_empty_string() {
361 let v = json!({"title": "ok", "description": ""});
362 let out = agent(v);
363 assert!(out.get("description").is_none());
364 assert_eq!(out["title"], json!("ok"));
365 }
366
367 #[test]
368 fn agent_drops_empty_array() {
369 let v = json!({"tags": [], "title": "ok"});
370 let out = agent(v);
371 assert!(out.get("tags").is_none());
372 }
373
374 #[test]
375 fn agent_drops_empty_object() {
376 let v = json!({"properties": {}, "title": "ok"});
377 let out = agent(v);
378 assert!(out.get("properties").is_none());
379 }
380
381 #[test]
382 fn agent_drops_non_lifecycle_null() {
383 let v = json!({"result": null, "title": "ok"});
384 let out = agent(v);
385 assert!(out.get("result").is_none());
386 }
387
388 #[test]
389 fn agent_preserves_lifecycle_null() {
390 let v = json!({"completed_at": null, "due_at": null, "title": "ok"});
391 let out = agent(v);
392 assert_eq!(out["completed_at"], json!(null));
393 assert_eq!(out["due_at"], json!(null));
394 }
395
396 #[test]
397 fn agent_preserves_relationship_null() {
398 let v = json!({"parent_id": null, "superseded_by": null});
399 let out = agent(v);
400 assert_eq!(out["parent_id"], json!(null));
401 assert_eq!(out["superseded_by"], json!(null));
402 }
403
404 #[test]
405 fn agent_truncates_score_field() {
406 let v = json!({"score": 0.12345678});
407 let out = agent(v);
408 let s = out["score"].as_f64().unwrap();
409 assert!((s - 0.123).abs() < 1e-9, "expected ~0.123, got {s}");
410 }
411
412 #[test]
413 fn agent_compacts_old_timestamp_to_minutes() {
414 let v = json!({"created_at": "2020-01-01T10:30:45.123456Z"});
416 let out = agent(v);
417 assert_eq!(out["created_at"], json!("2020-01-01T10:30"));
418 }
419
420 #[test]
421 fn agent_compacts_recent_timestamp_to_relative() {
422 let ts_unix = NOW - 180;
424 let ts = unix_to_iso8601(ts_unix);
426 let v = json!({"updated_at": ts});
427 let out = agent(v);
428 assert_eq!(out["updated_at"], json!("3m ago"));
429 }
430
431 #[test]
432 fn agent_recurses_into_nested_objects() {
433 let v = json!({
434 "items": [
435 {
436 "id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
437 "tags": [],
438 "score": 0.9999
439 }
440 ]
441 });
442 let out = agent(v);
443 let item = &out["items"][0];
444 assert_eq!(item["id"], json!("a1b2c3d4"));
445 assert!(item.get("tags").is_none());
446 let s = item["score"].as_f64().unwrap();
447 assert!((s - 1.0).abs() < 1e-9);
448 }
449
450 #[test]
452 fn agent_preserves_full_id_as_36_chars() {
453 let uuid = "a1b2c3d4-e5f6-7890-abcd-ef1234567890";
454 let v = json!({"id": uuid, "full_id": uuid, "title": "X"});
455 let out = agent(v);
456 assert_eq!(
458 out["id"],
459 json!("a1b2c3d4"),
460 "id should be 8-char short form"
461 );
462 assert_eq!(
464 out["full_id"].as_str().unwrap().len(),
465 36,
466 "full_id must be 36 chars in agent mode"
467 );
468 assert_eq!(
469 out["full_id"],
470 json!(uuid),
471 "full_id must equal the original UUID"
472 );
473 assert!(
475 out["full_id"]
476 .as_str()
477 .unwrap()
478 .starts_with(out["id"].as_str().unwrap()),
479 "full_id must start with the short id prefix"
480 );
481 }
482
483 #[test]
484 fn is_canonical_uuid_recognizes_valid() {
485 assert!(is_canonical_uuid("a1b2c3d4-e5f6-7890-abcd-ef1234567890"));
486 assert!(!is_canonical_uuid("a1b2c3d4"));
487 assert!(!is_canonical_uuid("not-a-uuid-at-all-here---------"));
488 }
489
490 #[test]
491 fn looks_like_iso8601_recognizes_valid() {
492 assert!(looks_like_iso8601("2026-05-23T16:18:15.234567Z"));
493 assert!(!looks_like_iso8601("not a timestamp"));
494 assert!(!looks_like_iso8601("2026-05-23"));
495 }
496
497 fn unix_to_iso8601(unix: i64) -> String {
499 let (y, mo, d, h, mi, s) = unix_to_civil(unix);
500 format!("{y:04}-{mo:02}-{d:02}T{h:02}:{mi:02}:{s:02}Z")
501 }
502
503 fn unix_to_civil(unix: i64) -> (i64, i64, i64, i64, i64, i64) {
504 let s = unix % 86400;
505 let days = unix / 86400;
506 let h = s / 3600;
507 let m = (s % 3600) / 60;
508 let sec = s % 60;
509 let z = days + 719468;
511 let era = z.div_euclid(146097);
512 let doe = z - era * 146097;
513 let yoe = (doe - doe / 1460 + doe / 36524 - doe / 146096) / 365;
514 let y = yoe + era * 400;
515 let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
516 let mp = (5 * doy + 2) / 153;
517 let d = doy - (153 * mp + 2) / 5 + 1;
518 let mo = if mp < 10 { mp + 3 } else { mp - 9 };
519 let y = if mo <= 2 { y + 1 } else { y };
520 (y, mo, d, h, m, sec)
521 }
522
523 #[test]
524 fn agent_does_not_shorten_uuid_shaped_content_fields() {
525 let uuid = "a1b2c3d4-e5f6-7890-abcd-ef1234567890";
526 let out = agent(json!({
527 "id": uuid,
528 "full_id": uuid,
529 "content": uuid,
530 "description": uuid,
531 "title": uuid,
532 "query": uuid,
533 }));
534
535 assert_eq!(out["id"], json!("a1b2c3d4"));
536 assert_eq!(out["full_id"], json!(uuid));
537 assert_eq!(out["content"], json!(uuid));
538 assert_eq!(out["description"], json!(uuid));
539 assert_eq!(out["title"], json!(uuid));
540 assert_eq!(out["query"], json!(uuid));
541 }
542
543 #[test]
544 fn agent_shortens_suffix_id_fields() {
545 let uuid = "a1b2c3d4-e5f6-7890-abcd-ef1234567890";
546 let out = agent(json!({
547 "note_id": uuid,
548 "source_id": uuid,
549 "target_id": uuid,
550 "proposal_id": uuid,
551 }));
552
553 assert_eq!(out["note_id"], json!("a1b2c3d4"));
554 assert_eq!(out["source_id"], json!("a1b2c3d4"));
555 assert_eq!(out["target_id"], json!("a1b2c3d4"));
556 assert_eq!(out["proposal_id"], json!("a1b2c3d4"));
557 }
558}