Skip to main content

khive_runtime/
presentation.rs

1//! Verb response presentation modes and transformation.
2//!
3//! Transforms canonical handler output into caller-appropriate form after dispatch
4//! and before wire serialization. `Agent` mode abbreviates UUIDs/timestamps and drops
5//! empty fields; `Verbose` and `Human` pass through canonical JSON unchanged.
6//!
7//! This module also contains the `OutputFormat` axis (ADR-078) which governs how
8//! the resulting `serde_json::Value` is serialized or rendered to an output string.
9//! `PresentationMode` and `OutputFormat` compose independently.
10
11use std::collections::HashSet;
12
13use serde::{Deserialize, Serialize};
14use serde_json::{Map, Value};
15
16// ── OutputFormat ─────────────────────────────────────────────────────────────
17
18/// Output serialization format for verb results (ADR-078).
19///
20/// Orthogonal to [`PresentationMode`]: `PresentationMode` controls field-level
21/// transforms (UUID shortening, timestamp compaction, empty-field dropping);
22/// `OutputFormat` controls how the resulting `serde_json::Value` is serialized
23/// or rendered to the wire string.
24///
25/// Default is [`OutputFormat::Json`] on every surface: compact, lossless,
26/// shape-stable machine contract.
27///
28/// Note: `Yaml` is a clean follow-up — implemented as a 3-variant enum
29/// (`Json`, `Auto`, `Table`) per ADR-078 §"yaml" which permits omission when
30/// the in-tree emitter would balloon.
31#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
32#[serde(rename_all = "snake_case")]
33pub enum OutputFormat {
34    /// Compact JSON (`serde_json::to_string`). Lossless machine contract. Default.
35    #[default]
36    Json,
37    /// Shape-aware: markdown table for homogeneous record arrays,
38    /// flat key-value block for single records, compact-JSON fallback.
39    Auto,
40    /// Force the markdown-table renderer regardless of detected shape.
41    Table,
42}
43
44/// Cell truncation limit for markdown-table rendering (ADR-078 §3a).
45const CELL_TRUNCATE: usize = 120;
46
47// ── Public render entry point ────────────────────────────────────────────────
48
49/// Render a successful verb result value to a wire string using the given format.
50///
51/// Called at the single serialization seam (ADR-078 §9) AFTER all `$prev` chain
52/// resolution and AFTER the [`PresentationMode`] transform.
53///
54/// Error envelopes (`ok=false`) are never passed here — the caller must handle
55/// them as compact JSON directly (ADR-078 §8.2).
56///
57/// When `format` is [`OutputFormat::Json`], returns compact JSON (`serde_json::to_string`).
58/// When `format` is [`OutputFormat::Auto`] or [`OutputFormat::Table`], applies the
59/// redundancy-reduction pre-pass (§7) — unless `presentation` is [`PresentationMode::Verbose`]
60/// — then dispatches to the shape-aware renderer.
61pub 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            // Skip the redundancy-reduction pre-pass in Verbose mode: full
66            // canonical shape must pass through unchanged.
67            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
81// ── Redundancy-reduction pre-pass (ADR-078 §7) ──────────────────────────────
82
83/// Apply the view-only redundancy-reduction pre-pass (ADR-078 §7) to a value.
84///
85/// Applies at most ONE pass over the value. This function is the canonical
86/// entry for the pre-pass; the per-record logic lives in `drop_record`.
87///
88/// Applied only when `format` ∈ {`auto`, `table`} AND `presentation` ≠ `Verbose`.
89/// Callers are responsible for checking those conditions; this function applies
90/// unconditionally.
91pub 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
103/// Apply per-record redundancy rules (§7.1, §7.2, §7.3) to a single record object.
104fn drop_record(value: Value) -> Value {
105    let Value::Object(mut map) = value else {
106        return value;
107    };
108
109    // `full_id` is a chaining handle, not needed in view-only output.
110    map.remove("full_id");
111
112    // `"local"` is the common case, so only surface `namespace` when it isn't.
113    if map.get("namespace").and_then(Value::as_str) == Some("local") {
114        map.remove("namespace");
115    }
116
117    // Properties dedup: drop key-value pairs from `properties` that
118    // duplicate an identical top-level sibling.
119    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    // Recurse into array values so nested record arrays are also reduced.
135    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
158// ── Shape-aware rendering (`auto`) ──────────────────────────────────────────
159
160/// Render a value using shape-aware dispatch (ADR-078 §3).
161fn render_auto(value: Value) -> String {
162    // Shape (a): homogeneous record array.
163    if let Some((records, keys)) = find_record_array(&value) {
164        return render_table(&records, &keys);
165    }
166    // Shape (b): single record / heterogeneous object.
167    if value.is_object() {
168        return render_kv_block(&value, 0);
169    }
170    // Shape (c): compact-JSON fallback.
171    serde_json::to_string(&value).unwrap_or_else(|_| "null".to_string())
172}
173
174/// Force the markdown-table renderer regardless of detected shape (ADR-078 §6).
175fn render_table_forced(value: Value) -> String {
176    if let Some((records, keys)) = find_record_array(&value) {
177        return render_table(&records, &keys);
178    }
179    // No record array detected: fall back to compact JSON.
180    serde_json::to_string(&value).unwrap_or_else(|_| "null".to_string())
181}
182
183/// Find the first homogeneous record array in `value`.
184///
185/// Checks:
186/// 1. `value` itself is an array of 2+ objects.
187/// 2. `value` is an object with a key whose value is an array of 2+ objects.
188///
189/// Returns `(records_cloned, ordered_column_keys)` when found.
190fn 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
211/// Collect column names in first-seen order across all records.
212fn 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
227// ── Markdown table renderer (ADR-078 §3a) ───────────────────────────────────
228
229/// Render a record array as a GitHub-Flavored Markdown table.
230fn 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
262/// Format a cell value: escape `|`, collapse newlines, truncate to ~120 chars.
263fn 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        // Arrays and nested objects use compact JSON in the cell.
270        other => serde_json::to_string(other).unwrap_or_default(),
271    };
272
273    // Escape literal `|` and collapse embedded newlines to a space.
274    let escaped = raw.replace('|', "\\|").replace(['\n', '\r'], " ");
275
276    // Truncate to approximately CELL_TRUNCATE *characters* (char boundary,
277    // not byte index — slicing on a byte offset can panic on multi-byte chars).
278    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
287// ── Flat key-value block renderer (ADR-078 §3b) ─────────────────────────────
288
289/// Render a single record or heterogeneous object as a flat key-value block.
290fn 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
322/// Convert a microsecond epoch `i64` to an RFC 3339 / ISO-8601 string.
323///
324/// Entity and Note storage uses `i64` microseconds internally; this is the
325/// single conversion point before any field reaches the MCP boundary.
326///
327/// Format: `YYYY-MM-DDTHH:MM:SS.ffffffZ` (SecondsFormat::Micros, UTC `Z`).
328pub 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/// How the response envelope is presented to the caller.
335#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
336#[serde(rename_all = "snake_case")]
337pub enum PresentationMode {
338    /// Token-efficient. Default for MCP callers (agents).
339    ///
340    /// Short UUIDs (8-char), compact timestamps (minute granularity or
341    /// relative), empty fields dropped, lifecycle nulls preserved, score
342    /// fields truncated to 3 significant figures.
343    #[default]
344    Agent,
345    /// Full canonical shape. Default for `kkernel exec` and CI/scripted callers.
346    ///
347    /// No transformation — handler output passes through as-is.
348    Verbose,
349    /// Pretty-printed terminal output. Default for `khive` CLI.
350    ///
351    /// **At the MCP runtime level this is identical to `Verbose`** — the
352    /// canonical JSON is returned unchanged. Terminal formatting (relative
353    /// timestamps, glyph substitution, table layout) is applied by the CLI
354    /// layer (`khive-cli::format::pretty`), not the MCP response pipeline.
355    Human,
356}
357
358/// Lifecycle `null` fields that are PRESERVED in Agent mode even when null.
359///
360/// These fields carry lifecycle meaning (absent ≠ null) and must not be dropped.
361const 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
376/// Score field names that are truncated to 3 significant figures in Agent mode.
377const 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
387/// UUID v4 canonical string length (8-4-4-4-12 = 32 hex + 4 dashes = 36).
388const UUID_CANONICAL_LEN: usize = 36;
389
390/// Return true for fields whose whole-string UUID values may be shortened in
391/// Agent mode. Content-like fields are intentionally excluded even when their
392/// value happens to be UUID-shaped.
393///
394/// `full_id` is explicitly excluded: its purpose is to give callers a
395/// stable chaining handle, so shortening it makes it identical to `id` and
396/// defeats the field entirely.
397fn 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
404/// Transform a successful verb result value according to the given
405/// [`PresentationMode`].
406///
407/// - `Verbose` / `Human`: returns `value` unchanged.
408/// - `Agent`: applies UUID shortening, timestamp compaction, empty-field
409///   dropping, lifecycle-null preservation, and score truncation.
410///
411/// `now_unix_seconds` is sampled once per response and passed through so all
412/// relative datetime renderings within a response use the same instant.
413pub 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
431/// Apply the Agent-mode transform to an arbitrary JSON value.
432///
433/// `inside_properties` is `true` when recursing inside a `"properties"` object.
434/// Caller-supplied payload timestamps (e.g. `trigger_at`) must not be compacted
435/// because they encode domain semantics the agent may need to round-trip.
436fn 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 => {} // drop
452                    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
470/// Transform a single named field value under Agent mode.
471///
472/// Returns `None` if the field should be dropped.
473///
474/// `inside_properties` suppresses timestamp compaction for caller-submitted
475/// payload values (e.g. `trigger_at` stored under `"properties"`). Metadata
476/// timestamps at the top level (`created_at`, `updated_at`) are still compacted.
477fn 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        // Preserve lifecycle nulls; drop other nulls.
487        Value::Null => {
488            if lifecycle.contains(key) {
489                Some(value)
490            } else {
491                None
492            }
493        }
494        // Drop empty strings, arrays, objects.
495        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        // Truncate score fields.
499        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        // Shorten UUIDs only in fields whose names carry ID semantics.
507        Value::String(s) if is_canonical_uuid(s) && should_shorten_uuid_field(key) => {
508            Some(Value::String(s[..8].to_string()))
509        }
510        // Compact ISO-8601 timestamps only outside caller-supplied payload objects.
511        Value::String(s) if !inside_properties && looks_like_iso8601(s) => {
512            Some(Value::String(compact_timestamp(s, now)))
513        }
514        // Recurse into objects and arrays.
515        Value::Object(_) | Value::Array(_) => Some(transform_agent(
516            value,
517            lifecycle,
518            scores,
519            now,
520            inside_properties,
521        )),
522        // Everything else passes through.
523        _ => Some(value),
524    }
525}
526
527/// Returns `true` if `s` looks like a canonical UUID (36 chars, standard form).
528fn is_canonical_uuid(s: &str) -> bool {
529    if s.len() != UUID_CANONICAL_LEN {
530        return false;
531    }
532    let b = s.as_bytes();
533    // Pattern: xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx
534    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
545/// Returns `true` if `s` looks like an ISO-8601 datetime string.
546///
547/// Heuristic: starts with `YYYY-MM-DDTHH:` (16 chars, proper digit positions).
548fn 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
563/// Compact an ISO-8601 timestamp for Agent mode.
564///
565/// - Within the last 24 hours: relative form (e.g. `"3m ago"`, `"2h ago"`).
566/// - Older: minute-granularity absolute form `"YYYY-MM-DDTHH:MM"`.
567fn compact_timestamp(s: &str, now: i64) -> String {
568    // Parse Unix seconds from the timestamp if possible; fall back to truncation.
569    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    // Minute granularity: take the first 16 chars.
576    s.chars().take(16).collect()
577}
578
579/// Attempt to parse an ISO-8601 datetime string to Unix seconds.
580///
581/// Only handles the subset produced by khive handlers:
582/// `YYYY-MM-DDTHH:MM:SS[.frac][Z|±HH:MM|±HHMM]`. Returns `None` for anything
583/// we can't parse (graceful degradation — the timestamp is still compacted
584/// by truncation).
585fn parse_iso8601_unix(s: &str) -> Option<i64> {
586    // Minimum parseable: "YYYY-MM-DDTHH:MM:SS"
587    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    // Simple Gregorian → local-wall-clock Unix seconds, then adjust for any
599    // trailing timezone offset (see `parse_tz_offset_secs`) to get the
600    // actual UTC instant.
601    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
607/// Parse the tail of an ISO-8601 timestamp (everything from byte index 19
608/// onward, i.e. after the whole-seconds field) into a UTC offset in seconds.
609///
610/// Handles, in order: optional fractional seconds (`.nnn`, skipped — this
611/// parser only has whole-second precision), then one of:
612/// - empty string or `"Z"` → offset 0
613/// - `±HH:MM` or the compact `±HHMM` form → `sign * (hh*3600 + mm*60)`
614///
615/// Returns `None` for anything else (malformed tail).
616fn 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        // "HH:MM"
638        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        // "HHMM"
643        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
660/// Gregorian date → days since 1970-01-01. Algorithm: Howard Hinnant's civil.
661fn 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
670/// Format a duration in seconds as a relative time string (e.g. `"3m ago"`).
671fn 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
681/// Truncate a float to 3 significant figures, returning a `serde_json::Value`.
682fn 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    // Re-serialize through serde_json to avoid floating-point noise.
690    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    /// A fixed "now" for deterministic tests: 2026-05-23T16:18:00Z ≈ 1748016480.
701    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        // Far past — not within 24h of NOW. Should be truncated to 16 chars.
777        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        // 3 minutes before NOW: diff = 180s.
785        let ts_unix = NOW - 180;
786        // Format as ISO-8601.
787        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    // full_id must never be shortened in Agent mode: it's the caller's
813    // stable chaining handle.
814    #[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        // `id` is shortened to 8 chars
820        assert_eq!(
821            out["id"],
822            json!("a1b2c3d4"),
823            "id should be 8-char short form"
824        );
825        // `full_id` must remain the full 36-char UUID
826        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        // Verify the invariant: full_id starts with the short id prefix
837        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    /// Format Unix seconds as ISO-8601 for test construction.
861    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        // Howard Hinnant civil_from_days
873        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    // ── ADR-078: OutputFormat tests ───────────────────────────────────────────
921
922    /// (a) json format preserves full shape (no field dropped, no transformation).
923    #[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        // full_id must not be dropped in json mode.
934        assert!(
935            parsed.get("full_id").is_some(),
936            "json mode must keep full_id"
937        );
938        // namespace must NOT be elided in json mode.
939        assert_eq!(
940            parsed.get("namespace").and_then(Value::as_str),
941            Some("local")
942        );
943        // properties must NOT be deduped in json mode.
944        assert!(parsed.get("properties").is_some());
945    }
946
947    /// (a-vs-auto) auto mode drops redundant fields that json mode preserves.
948    #[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        // json keeps both; auto drops namespace="local" and full_id.
958        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        // Auto mode elides namespace=local and drops full_id.
968        // The value itself is a single record → rendered as kv block.
969        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    /// (b1) homogeneous record array → markdown table with header + separator + rows.
980    #[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    /// (b2) single record → flat kv block.
999    #[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        // kv block uses "key: value\n" format.
1004        assert!(rendered.contains("id: abc"), "must have id: abc");
1005        assert!(
1006            rendered.contains("title: Hello World"),
1007            "must have title line"
1008        );
1009        // Must NOT contain markdown table markers.
1010        assert!(
1011            !rendered.starts_with('|'),
1012            "single record must not be a markdown table"
1013        );
1014    }
1015
1016    /// (b3) fallback: auto on heterogeneous/scalar value falls back to compact json.
1017    #[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    /// (c) table format forces markdown table even when shape would normally be kv.
1025    #[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    /// (c-fallback) table format falls back to compact json when no record array found.
1043    #[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        // No record array → compact JSON fallback.
1048        let parsed: Value = serde_json::from_str(&rendered).unwrap();
1049        assert_eq!(parsed["single"], json!("value"));
1050    }
1051
1052    /// (d) redundancy-drop: auto/table skipped in Verbose mode (§7).
1053    #[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        // In Verbose mode, redundancy drop must be skipped.
1061        // The value is a single object → kv block, but full_id and namespace stay.
1062        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    /// Indirect check that the redundancy pre-pass doesn't corrupt an error
1074    /// envelope's shape; the actual ok=false bypass is enforced by
1075    /// `render_result`, not by this pre-pass.
1076    #[test]
1077    fn redundancy_drop_does_not_corrupt_error_shape() {
1078        let v = json!({"ok": false, "error": "something failed", "namespace": "local"});
1079        // apply_redundancy_drop is a pure value transform with no knowledge of
1080        // `ok`: bypassing it for error envelopes is the caller's job
1081        // (render_result in server.rs). This only checks the pre-pass itself
1082        // doesn't lose the error field.
1083        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    /// Properties dedup removes only keys that match a top-level sibling exactly.
1096    #[test]
1097    fn redundancy_drop_properties_dedup() {
1098        let v = json!({
1099            "id": "abc",
1100            "title": "Same",
1101            "properties": {
1102                "title": "Same",  // duplicate → removed
1103                "extra": "unique" // not at top level → kept
1104            }
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    /// Cell truncation: text > 120 chars gets `...` appended.
1116    #[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        // Cell must be truncated to ~120 chars + "..."
1125        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    /// Cell truncation must not panic on multi-byte UTF-8 characters.
1136    ///
1137    /// A string of 119 ASCII bytes followed by a 3-byte CJK character and more
1138    /// text has `len() > 120` but byte index 120 falls inside the CJK char.
1139    /// The old byte-slice truncation would panic; char-boundary truncation is safe.
1140    #[test]
1141    fn cell_text_truncation_is_utf8_safe() {
1142        // 119 ASCII 'a' bytes, then CJK char U+4E2D (3 bytes each), then more text.
1143        // Total byte length: 119 + 3 * 10 + 5 > 120, but byte 120 is inside a CJK char.
1144        let prefix = "a".repeat(119);
1145        let suffix = "中".repeat(10); // each '中' is 3 bytes
1146        let long_multibyte = format!("{prefix}{suffix}trailing");
1147        let v = json!([
1148            {"col": long_multibyte.clone()},
1149            {"col": "ok"}
1150        ]);
1151        // Must not panic — this was the bug.
1152        let rendered = render_format(v, OutputFormat::Auto, PresentationMode::Agent);
1153        assert!(
1154            rendered.contains("..."),
1155            "multibyte cell must be truncated with ..."
1156        );
1157        // The rendered string must be valid UTF-8 (no partial char slicing).
1158        assert!(
1159            std::str::from_utf8(rendered.as_bytes()).is_ok(),
1160            "rendered output must be valid UTF-8"
1161        );
1162    }
1163
1164    // --- parse_iso8601_unix / relative-time offset handling ---
1165
1166    #[test]
1167    fn parse_iso8601_unix_negative_offset_matches_equivalent_utc() {
1168        // "-04:00" is 4 hours behind UTC, so 11:55 local == 15:55Z.
1169        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        // "+04:00" is 4 hours ahead of UTC, so 20:15 local == 16:15Z.
1178        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        // Fractional seconds are dropped (whole-second precision only) but
1203        // must not prevent the trailing offset from being applied.
1204        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        // No trailing Z/offset at all: existing "no offset" behavior preserved.
1221        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        // Hour out of range (>23), colon and compact forms.
1237        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        // Minute out of range (>59), colon and compact forms.
1240        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        // +23:59 / -23:59 are the largest valid offsets and must still parse.
1247        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        // A wall-clock-identical-to-NOW timestamp carrying a "-02:00" offset
1255        // is actually 2h in the future; an offset-naive parser would misread
1256        // the wall-clock digits as UTC and report "0s ago".
1257        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        // "20:05+04:00" == "16:05Z", which is 3 minutes before NOW
1265        // (2025-05-23T16:08:00Z). Correct offset handling must produce
1266        // "3m ago"; the old offset-naive parser would compare wall-clock
1267        // 20:05 against NOW directly, landing outside the 24h window and
1268        // falling back to truncated absolute form instead.
1269        let out = compact_timestamp("2025-05-23T20:05:00+04:00", NOW);
1270        assert_eq!(out, "3m ago");
1271    }
1272}