1use std::collections::HashSet;
48
49use serde::{Deserialize, Serialize};
50use serde_json::{Map, Value};
51
52pub fn micros_to_iso(micros: i64) -> String {
60 chrono::DateTime::<chrono::Utc>::from_timestamp_micros(micros)
61 .unwrap_or_else(chrono::Utc::now)
62 .to_rfc3339_opts(chrono::SecondsFormat::Micros, true)
63}
64
65#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
67#[serde(rename_all = "snake_case")]
68pub enum PresentationMode {
69 #[default]
75 Agent,
76 Verbose,
80 Human,
89}
90
91const LIFECYCLE_NULL_PRESERVE: &[&str] = &[
96 "completed_at",
97 "deleted_at",
98 "due_at",
99 "read_at",
100 "started_at",
101 "superseded_at",
102 "applied_at",
103 "withdrawn_at",
104 "reviewed_at",
105 "parent_id",
106 "superseded_by",
107 "replaced_by",
108];
109
110const SCORE_FIELDS: &[&str] = &[
114 "score",
115 "salience",
116 "decay_factor",
117 "rrf_score",
118 "similarity",
119 "cross_encoder_score",
120 "graph_proximity_score",
121];
122
123const UUID_CANONICAL_LEN: usize = 36;
125
126fn should_shorten_uuid_field(key: &str) -> bool {
134 if key == "full_id" {
135 return false;
136 }
137 key == "id" || key.ends_with("_id") || matches!(key, "superseded_by" | "replaced_by")
138}
139
140pub fn present(value: Value, mode: PresentationMode, now_unix_seconds: i64) -> Value {
150 match mode {
151 PresentationMode::Verbose | PresentationMode::Human => value,
152 PresentationMode::Agent => {
153 let lifecycle_preserve: HashSet<&str> =
154 LIFECYCLE_NULL_PRESERVE.iter().copied().collect();
155 let score_fields: HashSet<&str> = SCORE_FIELDS.iter().copied().collect();
156 transform_agent(
157 value,
158 &lifecycle_preserve,
159 &score_fields,
160 now_unix_seconds,
161 false,
162 )
163 }
164 }
165}
166
167fn transform_agent(
173 value: Value,
174 lifecycle: &HashSet<&str>,
175 scores: &HashSet<&str>,
176 now: i64,
177 inside_properties: bool,
178) -> Value {
179 match value {
180 Value::Object(map) => {
181 let mut out = Map::new();
182 for (k, v) in map {
183 let child_inside_properties = inside_properties || k == "properties";
184 let transformed =
185 transform_field_agent(&k, v, lifecycle, scores, now, child_inside_properties);
186 match transformed {
187 None => {} Some(tv) => {
189 out.insert(k, tv);
190 }
191 }
192 }
193 Value::Object(out)
194 }
195 Value::Array(arr) => {
196 let items: Vec<Value> = arr
197 .into_iter()
198 .map(|v| transform_agent(v, lifecycle, scores, now, inside_properties))
199 .collect();
200 Value::Array(items)
201 }
202 other => other,
203 }
204}
205
206fn transform_field_agent(
214 key: &str,
215 value: Value,
216 lifecycle: &HashSet<&str>,
217 scores: &HashSet<&str>,
218 now: i64,
219 inside_properties: bool,
220) -> Option<Value> {
221 match &value {
222 Value::Null => {
224 if lifecycle.contains(key) {
225 Some(value)
226 } else {
227 None
228 }
229 }
230 Value::String(s) if s.is_empty() => None,
232 Value::Array(a) if a.is_empty() => None,
233 Value::Object(o) if o.is_empty() => None,
234 Value::Number(_) if scores.contains(key) => {
236 if let Some(f) = value.as_f64() {
237 Some(truncate_to_3_sig_figs(f))
238 } else {
239 Some(value)
240 }
241 }
242 Value::String(s) if is_canonical_uuid(s) && should_shorten_uuid_field(key) => {
244 Some(Value::String(s[..8].to_string()))
245 }
246 Value::String(s) if !inside_properties && looks_like_iso8601(s) => {
248 Some(Value::String(compact_timestamp(s, now)))
249 }
250 Value::Object(_) | Value::Array(_) => Some(transform_agent(
252 value,
253 lifecycle,
254 scores,
255 now,
256 inside_properties,
257 )),
258 _ => Some(value),
260 }
261}
262
263fn is_canonical_uuid(s: &str) -> bool {
265 if s.len() != UUID_CANONICAL_LEN {
266 return false;
267 }
268 let b = s.as_bytes();
269 b[8] == b'-'
271 && b[13] == b'-'
272 && b[18] == b'-'
273 && b[23] == b'-'
274 && b[..8].iter().all(|c| c.is_ascii_hexdigit())
275 && b[9..13].iter().all(|c| c.is_ascii_hexdigit())
276 && b[14..18].iter().all(|c| c.is_ascii_hexdigit())
277 && b[19..23].iter().all(|c| c.is_ascii_hexdigit())
278 && b[24..].iter().all(|c| c.is_ascii_hexdigit())
279}
280
281fn looks_like_iso8601(s: &str) -> bool {
285 if s.len() < 16 {
286 return false;
287 }
288 let b = s.as_bytes();
289 b[4] == b'-'
290 && b[7] == b'-'
291 && b[10] == b'T'
292 && b[13] == b':'
293 && b[..4].iter().all(|c| c.is_ascii_digit())
294 && b[5..7].iter().all(|c| c.is_ascii_digit())
295 && b[8..10].iter().all(|c| c.is_ascii_digit())
296 && b[11..13].iter().all(|c| c.is_ascii_digit())
297}
298
299fn compact_timestamp(s: &str, now: i64) -> String {
304 if let Some(unix) = parse_iso8601_unix(s) {
306 let diff = now - unix;
307 if (0..86400).contains(&diff) {
308 return relative_time(diff);
309 }
310 }
311 s.chars().take(16).collect()
313}
314
315fn parse_iso8601_unix(s: &str) -> Option<i64> {
321 if s.len() < 19 {
323 return None;
324 }
325 let b = s.as_bytes();
326 let year: i64 = parse_digits(&b[0..4])?;
327 let month: i64 = parse_digits(&b[5..7])?;
328 let day: i64 = parse_digits(&b[8..10])?;
329 let hour: i64 = parse_digits(&b[11..13])?;
330 let minute: i64 = parse_digits(&b[14..16])?;
331 let second: i64 = parse_digits(&b[17..19])?;
332
333 let days_since_epoch = days_from_civil(year, month, day);
336 Some(days_since_epoch * 86400 + hour * 3600 + minute * 60 + second)
337}
338
339fn parse_digits(b: &[u8]) -> Option<i64> {
340 let s = std::str::from_utf8(b).ok()?;
341 s.parse().ok()
342}
343
344fn days_from_civil(y: i64, m: i64, d: i64) -> i64 {
346 let y = if m <= 2 { y - 1 } else { y };
347 let era = y.div_euclid(400);
348 let yoe = y - era * 400;
349 let doy = (153 * (if m > 2 { m - 3 } else { m + 9 }) + 2) / 5 + d - 1;
350 let doe = yoe * 365 + yoe / 4 - yoe / 100 + doy;
351 era * 146097 + doe - 719468
352}
353
354fn relative_time(diff_secs: i64) -> String {
356 if diff_secs < 60 {
357 format!("{diff_secs}s ago")
358 } else if diff_secs < 3600 {
359 format!("{}m ago", diff_secs / 60)
360 } else {
361 format!("{}h ago", diff_secs / 3600)
362 }
363}
364
365fn truncate_to_3_sig_figs(f: f64) -> Value {
367 if f == 0.0 || !f.is_finite() {
368 return Value::from(f);
369 }
370 let magnitude = f.abs().log10().floor() as i32;
371 let factor = 10f64.powi(2 - magnitude);
372 let rounded = (f * factor).round() / factor;
373 serde_json::Number::from_f64(rounded)
375 .map(Value::Number)
376 .unwrap_or(Value::from(rounded))
377}
378
379#[cfg(test)]
380mod tests {
381 use super::*;
382 use serde_json::json;
383
384 const NOW: i64 = 1_748_016_480;
386
387 fn agent(v: Value) -> Value {
388 present(v, PresentationMode::Agent, NOW)
389 }
390
391 #[test]
392 fn verbose_passthrough() {
393 let v = json!({"id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890", "title": "X"});
394 let out = present(v.clone(), PresentationMode::Verbose, NOW);
395 assert_eq!(out, v);
396 }
397
398 #[test]
399 fn agent_shortens_uuid() {
400 let v = json!({"id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890"});
401 let out = agent(v);
402 assert_eq!(out["id"], json!("a1b2c3d4"));
403 }
404
405 #[test]
406 fn agent_drops_empty_string() {
407 let v = json!({"title": "ok", "description": ""});
408 let out = agent(v);
409 assert!(out.get("description").is_none());
410 assert_eq!(out["title"], json!("ok"));
411 }
412
413 #[test]
414 fn agent_drops_empty_array() {
415 let v = json!({"tags": [], "title": "ok"});
416 let out = agent(v);
417 assert!(out.get("tags").is_none());
418 }
419
420 #[test]
421 fn agent_drops_empty_object() {
422 let v = json!({"properties": {}, "title": "ok"});
423 let out = agent(v);
424 assert!(out.get("properties").is_none());
425 }
426
427 #[test]
428 fn agent_drops_non_lifecycle_null() {
429 let v = json!({"result": null, "title": "ok"});
430 let out = agent(v);
431 assert!(out.get("result").is_none());
432 }
433
434 #[test]
435 fn agent_preserves_lifecycle_null() {
436 let v = json!({"completed_at": null, "due_at": null, "title": "ok"});
437 let out = agent(v);
438 assert_eq!(out["completed_at"], json!(null));
439 assert_eq!(out["due_at"], json!(null));
440 }
441
442 #[test]
443 fn agent_preserves_relationship_null() {
444 let v = json!({"parent_id": null, "superseded_by": null});
445 let out = agent(v);
446 assert_eq!(out["parent_id"], json!(null));
447 assert_eq!(out["superseded_by"], json!(null));
448 }
449
450 #[test]
451 fn agent_truncates_score_field() {
452 let v = json!({"score": 0.12345678});
453 let out = agent(v);
454 let s = out["score"].as_f64().unwrap();
455 assert!((s - 0.123).abs() < 1e-9, "expected ~0.123, got {s}");
456 }
457
458 #[test]
459 fn agent_compacts_old_timestamp_to_minutes() {
460 let v = json!({"created_at": "2020-01-01T10:30:45.123456Z"});
462 let out = agent(v);
463 assert_eq!(out["created_at"], json!("2020-01-01T10:30"));
464 }
465
466 #[test]
467 fn agent_compacts_recent_timestamp_to_relative() {
468 let ts_unix = NOW - 180;
470 let ts = unix_to_iso8601(ts_unix);
472 let v = json!({"updated_at": ts});
473 let out = agent(v);
474 assert_eq!(out["updated_at"], json!("3m ago"));
475 }
476
477 #[test]
478 fn agent_recurses_into_nested_objects() {
479 let v = json!({
480 "items": [
481 {
482 "id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
483 "tags": [],
484 "score": 0.9999
485 }
486 ]
487 });
488 let out = agent(v);
489 let item = &out["items"][0];
490 assert_eq!(item["id"], json!("a1b2c3d4"));
491 assert!(item.get("tags").is_none());
492 let s = item["score"].as_f64().unwrap();
493 assert!((s - 1.0).abs() < 1e-9);
494 }
495
496 #[test]
498 fn agent_preserves_full_id_as_36_chars() {
499 let uuid = "a1b2c3d4-e5f6-7890-abcd-ef1234567890";
500 let v = json!({"id": uuid, "full_id": uuid, "title": "X"});
501 let out = agent(v);
502 assert_eq!(
504 out["id"],
505 json!("a1b2c3d4"),
506 "id should be 8-char short form"
507 );
508 assert_eq!(
510 out["full_id"].as_str().unwrap().len(),
511 36,
512 "full_id must be 36 chars in agent mode"
513 );
514 assert_eq!(
515 out["full_id"],
516 json!(uuid),
517 "full_id must equal the original UUID"
518 );
519 assert!(
521 out["full_id"]
522 .as_str()
523 .unwrap()
524 .starts_with(out["id"].as_str().unwrap()),
525 "full_id must start with the short id prefix"
526 );
527 }
528
529 #[test]
530 fn is_canonical_uuid_recognizes_valid() {
531 assert!(is_canonical_uuid("a1b2c3d4-e5f6-7890-abcd-ef1234567890"));
532 assert!(!is_canonical_uuid("a1b2c3d4"));
533 assert!(!is_canonical_uuid("not-a-uuid-at-all-here---------"));
534 }
535
536 #[test]
537 fn looks_like_iso8601_recognizes_valid() {
538 assert!(looks_like_iso8601("2026-05-23T16:18:15.234567Z"));
539 assert!(!looks_like_iso8601("not a timestamp"));
540 assert!(!looks_like_iso8601("2026-05-23"));
541 }
542
543 fn unix_to_iso8601(unix: i64) -> String {
545 let (y, mo, d, h, mi, s) = unix_to_civil(unix);
546 format!("{y:04}-{mo:02}-{d:02}T{h:02}:{mi:02}:{s:02}Z")
547 }
548
549 fn unix_to_civil(unix: i64) -> (i64, i64, i64, i64, i64, i64) {
550 let s = unix % 86400;
551 let days = unix / 86400;
552 let h = s / 3600;
553 let m = (s % 3600) / 60;
554 let sec = s % 60;
555 let z = days + 719468;
557 let era = z.div_euclid(146097);
558 let doe = z - era * 146097;
559 let yoe = (doe - doe / 1460 + doe / 36524 - doe / 146096) / 365;
560 let y = yoe + era * 400;
561 let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
562 let mp = (5 * doy + 2) / 153;
563 let d = doy - (153 * mp + 2) / 5 + 1;
564 let mo = if mp < 10 { mp + 3 } else { mp - 9 };
565 let y = if mo <= 2 { y + 1 } else { y };
566 (y, mo, d, h, m, sec)
567 }
568
569 #[test]
570 fn agent_does_not_shorten_uuid_shaped_content_fields() {
571 let uuid = "a1b2c3d4-e5f6-7890-abcd-ef1234567890";
572 let out = agent(json!({
573 "id": uuid,
574 "full_id": uuid,
575 "content": uuid,
576 "description": uuid,
577 "title": uuid,
578 "query": uuid,
579 }));
580
581 assert_eq!(out["id"], json!("a1b2c3d4"));
582 assert_eq!(out["full_id"], json!(uuid));
583 assert_eq!(out["content"], json!(uuid));
584 assert_eq!(out["description"], json!(uuid));
585 assert_eq!(out["title"], json!(uuid));
586 assert_eq!(out["query"], json!(uuid));
587 }
588
589 #[test]
590 fn agent_shortens_suffix_id_fields() {
591 let uuid = "a1b2c3d4-e5f6-7890-abcd-ef1234567890";
592 let out = agent(json!({
593 "note_id": uuid,
594 "source_id": uuid,
595 "target_id": uuid,
596 "proposal_id": uuid,
597 }));
598
599 assert_eq!(out["note_id"], json!("a1b2c3d4"));
600 assert_eq!(out["source_id"], json!("a1b2c3d4"));
601 assert_eq!(out["target_id"], json!("a1b2c3d4"));
602 assert_eq!(out["proposal_id"], json!("a1b2c3d4"));
603 }
604}