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            // Redundancy-reduction pre-pass (§7): skipped in Verbose mode.
66            let reduced = if presentation == PresentationMode::Verbose {
67                value
68            } else {
69                apply_redundancy_drop(value)
70            };
71            match format {
72                OutputFormat::Auto => render_auto(reduced),
73                OutputFormat::Table => render_table_forced(reduced),
74                OutputFormat::Json => unreachable!(),
75            }
76        }
77    }
78}
79
80// ── Redundancy-reduction pre-pass (ADR-078 §7) ──────────────────────────────
81
82/// Apply the view-only redundancy-reduction pre-pass (ADR-078 §7) to a value.
83///
84/// Applies at most ONE pass over the value. This function is the canonical
85/// entry for the pre-pass; the per-record logic lives in `drop_record`.
86///
87/// Applied only when `format` ∈ {`auto`, `table`} AND `presentation` ≠ `Verbose`.
88/// Callers are responsible for checking those conditions; this function applies
89/// unconditionally.
90pub fn apply_redundancy_drop(value: Value) -> Value {
91    match value {
92        Value::Object(_) => drop_record(value),
93        Value::Array(arr) => Value::Array(
94            arr.into_iter()
95                .map(|v| if v.is_object() { drop_record(v) } else { v })
96                .collect(),
97        ),
98        other => other,
99    }
100}
101
102/// Apply per-record redundancy rules (§7.1, §7.2, §7.3) to a single record object.
103fn drop_record(value: Value) -> Value {
104    let Value::Object(mut map) = value else {
105        return value;
106    };
107
108    // §7.1: suppress `full_id`.
109    map.remove("full_id");
110
111    // §7.3: elide `namespace` when its value is `"local"`.
112    if map.get("namespace").and_then(Value::as_str) == Some("local") {
113        map.remove("namespace");
114    }
115
116    // §7.2: properties dedup — remove key-value pairs from `properties` that
117    // have an identical counterpart at the top level of this record.
118    let props_val = map.remove("properties");
119    if let Some(Value::Object(props)) = props_val {
120        let mut new_props = Map::new();
121        for (k, v) in props {
122            if map.get(&k) != Some(&v) {
123                new_props.insert(k, v);
124            }
125        }
126        if !new_props.is_empty() {
127            map.insert("properties".to_string(), Value::Object(new_props));
128        }
129    } else if let Some(other) = props_val {
130        map.insert("properties".to_string(), other);
131    }
132
133    // Recurse into array values so nested record arrays are also reduced.
134    let out: Map<String, Value> = map
135        .into_iter()
136        .map(|(k, v)| {
137            let v = match v {
138                Value::Array(arr) => Value::Array(
139                    arr.into_iter()
140                        .map(|item| {
141                            if item.is_object() {
142                                drop_record(item)
143                            } else {
144                                item
145                            }
146                        })
147                        .collect(),
148                ),
149                other => other,
150            };
151            (k, v)
152        })
153        .collect();
154    Value::Object(out)
155}
156
157// ── Shape-aware rendering (`auto`) ──────────────────────────────────────────
158
159/// Render a value using shape-aware dispatch (ADR-078 §3).
160fn render_auto(value: Value) -> String {
161    // Shape (a): homogeneous record array.
162    if let Some((records, keys)) = find_record_array(&value) {
163        return render_table(&records, &keys);
164    }
165    // Shape (b): single record / heterogeneous object.
166    if value.is_object() {
167        return render_kv_block(&value, 0);
168    }
169    // Shape (c): compact-JSON fallback.
170    serde_json::to_string(&value).unwrap_or_else(|_| "null".to_string())
171}
172
173/// Force the markdown-table renderer regardless of detected shape (ADR-078 §6).
174fn render_table_forced(value: Value) -> String {
175    if let Some((records, keys)) = find_record_array(&value) {
176        return render_table(&records, &keys);
177    }
178    // No record array detected — fallback to compact JSON per §8.3.
179    serde_json::to_string(&value).unwrap_or_else(|_| "null".to_string())
180}
181
182/// Find the first homogeneous record array in `value`.
183///
184/// Checks:
185/// 1. `value` itself is an array of 2+ objects.
186/// 2. `value` is an object with a key whose value is an array of 2+ objects.
187///
188/// Returns `(records_cloned, ordered_column_keys)` when found.
189fn find_record_array(value: &Value) -> Option<(Vec<Value>, Vec<String>)> {
190    match value {
191        Value::Array(arr) if arr.len() >= 2 && arr.iter().all(Value::is_object) => {
192            let keys = collect_keys(arr);
193            Some((arr.clone(), keys))
194        }
195        Value::Object(map) => {
196            for v in map.values() {
197                if let Value::Array(arr) = v {
198                    if arr.len() >= 2 && arr.iter().all(Value::is_object) {
199                        let keys = collect_keys(arr);
200                        return Some((arr.clone(), keys));
201                    }
202                }
203            }
204            None
205        }
206        _ => None,
207    }
208}
209
210/// Collect column names in first-seen order across all records.
211fn collect_keys(records: &[Value]) -> Vec<String> {
212    let mut seen = HashSet::new();
213    let mut keys = Vec::new();
214    for record in records {
215        if let Value::Object(map) = record {
216            for k in map.keys() {
217                if seen.insert(k.clone()) {
218                    keys.push(k.clone());
219                }
220            }
221        }
222    }
223    keys
224}
225
226// ── Markdown table renderer (ADR-078 §3a) ───────────────────────────────────
227
228/// Render a record array as a GitHub-Flavored Markdown table.
229fn render_table(records: &[Value], keys: &[String]) -> String {
230    let mut out = String::new();
231
232    // Header row.
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    // Separator row.
242    out.push('|');
243    for _ in keys {
244        out.push_str("---|");
245    }
246    out.push('\n');
247
248    // Data rows.
249    for record in records {
250        out.push('|');
251        for k in keys {
252            let cell = record.get(k).unwrap_or(&Value::Null);
253            let text = cell_text(cell);
254            out.push(' ');
255            out.push_str(&text);
256            out.push_str(" |");
257        }
258        out.push('\n');
259    }
260
261    out
262}
263
264/// Format a cell value: escape `|`, collapse newlines, truncate to ~120 chars.
265fn cell_text(value: &Value) -> String {
266    let raw = match value {
267        Value::Null => String::new(),
268        Value::Bool(b) => b.to_string(),
269        Value::Number(n) => n.to_string(),
270        Value::String(s) => s.clone(),
271        // Arrays and nested objects use compact JSON in the cell.
272        other => serde_json::to_string(other).unwrap_or_default(),
273    };
274
275    // Escape literal `|` and collapse embedded newlines to a space.
276    let escaped = raw.replace('|', "\\|").replace(['\n', '\r'], " ");
277
278    // Truncate to approximately CELL_TRUNCATE *characters* (char boundary,
279    // not byte index — slicing on a byte offset can panic on multi-byte chars).
280    let char_count = escaped.chars().count();
281    if char_count > CELL_TRUNCATE {
282        let truncated: String = escaped.chars().take(CELL_TRUNCATE).collect();
283        format!("{truncated}...")
284    } else {
285        escaped
286    }
287}
288
289// ── Flat key-value block renderer (ADR-078 §3b) ─────────────────────────────
290
291/// Render a single record or heterogeneous object as a flat key-value block.
292fn render_kv_block(value: &Value, depth: usize) -> String {
293    let indent = "  ".repeat(depth);
294    match value {
295        Value::Object(map) => {
296            let mut out = String::new();
297            for (k, v) in map {
298                match v {
299                    Value::Object(_) => {
300                        out.push_str(&format!("{}{}:\n", indent, k));
301                        out.push_str(&render_kv_block(v, depth + 1));
302                    }
303                    Value::Array(arr) if arr.iter().any(Value::is_object) => {
304                        out.push_str(&format!("{}{}:\n", indent, k));
305                        for item in arr {
306                            if item.is_object() {
307                                out.push_str(&render_kv_block(item, depth + 1));
308                            } else {
309                                out.push_str(&format!("{}  - {}\n", indent, cell_text(item)));
310                            }
311                        }
312                    }
313                    _ => {
314                        out.push_str(&format!("{}{}: {}\n", indent, k, cell_text(v)));
315                    }
316                }
317            }
318            out
319        }
320        other => format!("{}{}\n", indent, cell_text(other)),
321    }
322}
323
324/// Convert a microsecond epoch `i64` to an RFC 3339 / ISO-8601 string.
325///
326/// Entity and Note storage uses `i64` microseconds internally; this is the
327/// single conversion point before any field reaches the MCP boundary.
328///
329/// Format: `YYYY-MM-DDTHH:MM:SS.ffffffZ` (SecondsFormat::Micros, UTC `Z`).
330pub fn micros_to_iso(micros: i64) -> String {
331    chrono::DateTime::<chrono::Utc>::from_timestamp_micros(micros)
332        .unwrap_or_else(chrono::Utc::now)
333        .to_rfc3339_opts(chrono::SecondsFormat::Micros, true)
334}
335
336/// How the response envelope is presented to the caller.
337#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
338#[serde(rename_all = "snake_case")]
339pub enum PresentationMode {
340    /// Token-efficient. Default for MCP callers (agents).
341    ///
342    /// Short UUIDs (8-char), compact timestamps (minute granularity or
343    /// relative), empty fields dropped, lifecycle nulls preserved, score
344    /// fields truncated to 3 significant figures.
345    #[default]
346    Agent,
347    /// Full canonical shape. Default for `kkernel exec` and CI/scripted callers.
348    ///
349    /// No transformation — handler output passes through as-is.
350    Verbose,
351    /// Pretty-printed terminal output. Default for `khive` CLI.
352    ///
353    /// **At the MCP runtime level this is identical to `Verbose`** — the
354    /// canonical JSON is returned unchanged. Terminal formatting (relative
355    /// timestamps, glyph substitution, table layout) is applied by the CLI
356    /// layer (`khive-cli::format::pretty`), not the MCP response pipeline.
357    Human,
358}
359
360/// Lifecycle `null` fields that are PRESERVED in Agent mode even when null.
361///
362/// These fields carry lifecycle meaning (absent ≠ null) and must not be dropped.
363const LIFECYCLE_NULL_PRESERVE: &[&str] = &[
364    "completed_at",
365    "deleted_at",
366    "due_at",
367    "read_at",
368    "started_at",
369    "superseded_at",
370    "applied_at",
371    "withdrawn_at",
372    "reviewed_at",
373    "parent_id",
374    "superseded_by",
375    "replaced_by",
376];
377
378/// Score field names that are truncated to 3 significant figures in Agent mode.
379const SCORE_FIELDS: &[&str] = &[
380    "score",
381    "salience",
382    "decay_factor",
383    "rrf_score",
384    "similarity",
385    "cross_encoder_score",
386    "graph_proximity_score",
387];
388
389/// UUID v4 canonical string length (8-4-4-4-12 = 32 hex + 4 dashes = 36).
390const UUID_CANONICAL_LEN: usize = 36;
391
392/// Return true for fields whose whole-string UUID values may be shortened in
393/// Agent mode. Content-like fields are intentionally excluded even when their
394/// value happens to be UUID-shaped.
395///
396/// `full_id` is explicitly excluded (P-C1): its purpose is to give callers a
397/// stable chaining handle, so shortening it makes it identical to `id` and
398/// defeats the field entirely.
399fn should_shorten_uuid_field(key: &str) -> bool {
400    if key == "full_id" {
401        return false;
402    }
403    key == "id" || key.ends_with("_id") || matches!(key, "superseded_by" | "replaced_by")
404}
405
406/// Transform a successful verb result value according to the given
407/// [`PresentationMode`].
408///
409/// - `Verbose` / `Human`: returns `value` unchanged.
410/// - `Agent`: applies UUID shortening, timestamp compaction, empty-field
411///   dropping, lifecycle-null preservation, and score truncation.
412///
413/// `now_unix_seconds` is sampled once per response and passed through so all
414/// relative datetime renderings within a response use the same instant.
415pub fn present(value: Value, mode: PresentationMode, now_unix_seconds: i64) -> Value {
416    match mode {
417        PresentationMode::Verbose | PresentationMode::Human => value,
418        PresentationMode::Agent => {
419            let lifecycle_preserve: HashSet<&str> =
420                LIFECYCLE_NULL_PRESERVE.iter().copied().collect();
421            let score_fields: HashSet<&str> = SCORE_FIELDS.iter().copied().collect();
422            transform_agent(
423                value,
424                &lifecycle_preserve,
425                &score_fields,
426                now_unix_seconds,
427                false,
428            )
429        }
430    }
431}
432
433/// Apply the Agent-mode transform to an arbitrary JSON value.
434///
435/// `inside_properties` is `true` when recursing inside a `"properties"` object.
436/// Caller-supplied payload timestamps (e.g. `trigger_at`) must not be compacted
437/// because they encode domain semantics the agent may need to round-trip (#546).
438fn transform_agent(
439    value: Value,
440    lifecycle: &HashSet<&str>,
441    scores: &HashSet<&str>,
442    now: i64,
443    inside_properties: bool,
444) -> Value {
445    match value {
446        Value::Object(map) => {
447            let mut out = Map::new();
448            for (k, v) in map {
449                let child_inside_properties = inside_properties || k == "properties";
450                let transformed =
451                    transform_field_agent(&k, v, lifecycle, scores, now, child_inside_properties);
452                match transformed {
453                    None => {} // drop
454                    Some(tv) => {
455                        out.insert(k, tv);
456                    }
457                }
458            }
459            Value::Object(out)
460        }
461        Value::Array(arr) => {
462            let items: Vec<Value> = arr
463                .into_iter()
464                .map(|v| transform_agent(v, lifecycle, scores, now, inside_properties))
465                .collect();
466            Value::Array(items)
467        }
468        other => other,
469    }
470}
471
472/// Transform a single named field value under Agent mode.
473///
474/// Returns `None` if the field should be dropped.
475///
476/// `inside_properties` suppresses timestamp compaction for caller-submitted
477/// payload values (e.g. `trigger_at` stored under `"properties"`). Metadata
478/// timestamps at the top level (`created_at`, `updated_at`) are still compacted.
479fn transform_field_agent(
480    key: &str,
481    value: Value,
482    lifecycle: &HashSet<&str>,
483    scores: &HashSet<&str>,
484    now: i64,
485    inside_properties: bool,
486) -> Option<Value> {
487    match &value {
488        // Preserve lifecycle nulls; drop other nulls.
489        Value::Null => {
490            if lifecycle.contains(key) {
491                Some(value)
492            } else {
493                None
494            }
495        }
496        // Drop empty strings, arrays, objects.
497        Value::String(s) if s.is_empty() => None,
498        Value::Array(a) if a.is_empty() => None,
499        Value::Object(o) if o.is_empty() => None,
500        // Truncate score fields.
501        Value::Number(_) if scores.contains(key) => {
502            if let Some(f) = value.as_f64() {
503                Some(truncate_to_3_sig_figs(f))
504            } else {
505                Some(value)
506            }
507        }
508        // Shorten UUIDs only in fields whose names carry ID semantics.
509        Value::String(s) if is_canonical_uuid(s) && should_shorten_uuid_field(key) => {
510            Some(Value::String(s[..8].to_string()))
511        }
512        // Compact ISO-8601 timestamps only outside caller-supplied payload objects.
513        Value::String(s) if !inside_properties && looks_like_iso8601(s) => {
514            Some(Value::String(compact_timestamp(s, now)))
515        }
516        // Recurse into objects and arrays.
517        Value::Object(_) | Value::Array(_) => Some(transform_agent(
518            value,
519            lifecycle,
520            scores,
521            now,
522            inside_properties,
523        )),
524        // Everything else passes through.
525        _ => Some(value),
526    }
527}
528
529/// Returns `true` if `s` looks like a canonical UUID (36 chars, standard form).
530fn is_canonical_uuid(s: &str) -> bool {
531    if s.len() != UUID_CANONICAL_LEN {
532        return false;
533    }
534    let b = s.as_bytes();
535    // Pattern: xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx
536    b[8] == b'-'
537        && b[13] == b'-'
538        && b[18] == b'-'
539        && b[23] == b'-'
540        && b[..8].iter().all(|c| c.is_ascii_hexdigit())
541        && b[9..13].iter().all(|c| c.is_ascii_hexdigit())
542        && b[14..18].iter().all(|c| c.is_ascii_hexdigit())
543        && b[19..23].iter().all(|c| c.is_ascii_hexdigit())
544        && b[24..].iter().all(|c| c.is_ascii_hexdigit())
545}
546
547/// Returns `true` if `s` looks like an ISO-8601 datetime string.
548///
549/// Heuristic: starts with `YYYY-MM-DDTHH:` (16 chars, proper digit positions).
550fn looks_like_iso8601(s: &str) -> bool {
551    if s.len() < 16 {
552        return false;
553    }
554    let b = s.as_bytes();
555    b[4] == b'-'
556        && b[7] == b'-'
557        && b[10] == b'T'
558        && b[13] == b':'
559        && b[..4].iter().all(|c| c.is_ascii_digit())
560        && b[5..7].iter().all(|c| c.is_ascii_digit())
561        && b[8..10].iter().all(|c| c.is_ascii_digit())
562        && b[11..13].iter().all(|c| c.is_ascii_digit())
563}
564
565/// Compact an ISO-8601 timestamp for Agent mode.
566///
567/// - Within the last 24 hours: relative form (e.g. `"3m ago"`, `"2h ago"`).
568/// - Older: minute-granularity absolute form `"YYYY-MM-DDTHH:MM"`.
569fn compact_timestamp(s: &str, now: i64) -> String {
570    // Parse Unix seconds from the timestamp if possible; fall back to truncation.
571    if let Some(unix) = parse_iso8601_unix(s) {
572        let diff = now - unix;
573        if (0..86400).contains(&diff) {
574            return relative_time(diff);
575        }
576    }
577    // Minute granularity: take the first 16 chars.
578    s.chars().take(16).collect()
579}
580
581/// Attempt to parse an ISO-8601 datetime string to Unix seconds.
582///
583/// Only handles the subset produced by khive handlers:
584/// `YYYY-MM-DDTHH:MM:SS[.frac][Z]`. Returns `None` for anything we can't parse
585/// (graceful degradation — the timestamp is still compacted by truncation).
586fn parse_iso8601_unix(s: &str) -> Option<i64> {
587    // Minimum parseable: "YYYY-MM-DDTHH:MM:SS"
588    if s.len() < 19 {
589        return None;
590    }
591    let b = s.as_bytes();
592    let year: i64 = parse_digits(&b[0..4])?;
593    let month: i64 = parse_digits(&b[5..7])?;
594    let day: i64 = parse_digits(&b[8..10])?;
595    let hour: i64 = parse_digits(&b[11..13])?;
596    let minute: i64 = parse_digits(&b[14..16])?;
597    let second: i64 = parse_digits(&b[17..19])?;
598
599    // Simple Gregorian → Unix seconds (no timezone offsets other than 'Z').
600    // Close enough for relative-time comparisons; not for calendar correctness.
601    let days_since_epoch = days_from_civil(year, month, day);
602    Some(days_since_epoch * 86400 + hour * 3600 + minute * 60 + second)
603}
604
605fn parse_digits(b: &[u8]) -> Option<i64> {
606    let s = std::str::from_utf8(b).ok()?;
607    s.parse().ok()
608}
609
610/// Gregorian date → days since 1970-01-01. Algorithm: Howard Hinnant's civil.
611fn days_from_civil(y: i64, m: i64, d: i64) -> i64 {
612    let y = if m <= 2 { y - 1 } else { y };
613    let era = y.div_euclid(400);
614    let yoe = y - era * 400;
615    let doy = (153 * (if m > 2 { m - 3 } else { m + 9 }) + 2) / 5 + d - 1;
616    let doe = yoe * 365 + yoe / 4 - yoe / 100 + doy;
617    era * 146097 + doe - 719468
618}
619
620/// Format a duration in seconds as a relative time string (e.g. `"3m ago"`).
621fn relative_time(diff_secs: i64) -> String {
622    if diff_secs < 60 {
623        format!("{diff_secs}s ago")
624    } else if diff_secs < 3600 {
625        format!("{}m ago", diff_secs / 60)
626    } else {
627        format!("{}h ago", diff_secs / 3600)
628    }
629}
630
631/// Truncate a float to 3 significant figures, returning a `serde_json::Value`.
632fn truncate_to_3_sig_figs(f: f64) -> Value {
633    if f == 0.0 || !f.is_finite() {
634        return Value::from(f);
635    }
636    let magnitude = f.abs().log10().floor() as i32;
637    let factor = 10f64.powi(2 - magnitude);
638    let rounded = (f * factor).round() / factor;
639    // Re-serialize through serde_json to avoid floating-point noise.
640    serde_json::Number::from_f64(rounded)
641        .map(Value::Number)
642        .unwrap_or(Value::from(rounded))
643}
644
645#[cfg(test)]
646mod tests {
647    use super::*;
648    use serde_json::json;
649
650    /// A fixed "now" for deterministic tests: 2026-05-23T16:18:00Z ≈ 1748016480.
651    const NOW: i64 = 1_748_016_480;
652
653    fn agent(v: Value) -> Value {
654        present(v, PresentationMode::Agent, NOW)
655    }
656
657    #[test]
658    fn verbose_passthrough() {
659        let v = json!({"id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890", "title": "X"});
660        let out = present(v.clone(), PresentationMode::Verbose, NOW);
661        assert_eq!(out, v);
662    }
663
664    #[test]
665    fn agent_shortens_uuid() {
666        let v = json!({"id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890"});
667        let out = agent(v);
668        assert_eq!(out["id"], json!("a1b2c3d4"));
669    }
670
671    #[test]
672    fn agent_drops_empty_string() {
673        let v = json!({"title": "ok", "description": ""});
674        let out = agent(v);
675        assert!(out.get("description").is_none());
676        assert_eq!(out["title"], json!("ok"));
677    }
678
679    #[test]
680    fn agent_drops_empty_array() {
681        let v = json!({"tags": [], "title": "ok"});
682        let out = agent(v);
683        assert!(out.get("tags").is_none());
684    }
685
686    #[test]
687    fn agent_drops_empty_object() {
688        let v = json!({"properties": {}, "title": "ok"});
689        let out = agent(v);
690        assert!(out.get("properties").is_none());
691    }
692
693    #[test]
694    fn agent_drops_non_lifecycle_null() {
695        let v = json!({"result": null, "title": "ok"});
696        let out = agent(v);
697        assert!(out.get("result").is_none());
698    }
699
700    #[test]
701    fn agent_preserves_lifecycle_null() {
702        let v = json!({"completed_at": null, "due_at": null, "title": "ok"});
703        let out = agent(v);
704        assert_eq!(out["completed_at"], json!(null));
705        assert_eq!(out["due_at"], json!(null));
706    }
707
708    #[test]
709    fn agent_preserves_relationship_null() {
710        let v = json!({"parent_id": null, "superseded_by": null});
711        let out = agent(v);
712        assert_eq!(out["parent_id"], json!(null));
713        assert_eq!(out["superseded_by"], json!(null));
714    }
715
716    #[test]
717    fn agent_truncates_score_field() {
718        let v = json!({"score": 0.12345678});
719        let out = agent(v);
720        let s = out["score"].as_f64().unwrap();
721        assert!((s - 0.123).abs() < 1e-9, "expected ~0.123, got {s}");
722    }
723
724    #[test]
725    fn agent_compacts_old_timestamp_to_minutes() {
726        // Far past — not within 24h of NOW. Should be truncated to 16 chars.
727        let v = json!({"created_at": "2020-01-01T10:30:45.123456Z"});
728        let out = agent(v);
729        assert_eq!(out["created_at"], json!("2020-01-01T10:30"));
730    }
731
732    #[test]
733    fn agent_compacts_recent_timestamp_to_relative() {
734        // 3 minutes before NOW: diff = 180s.
735        let ts_unix = NOW - 180;
736        // Format as ISO-8601.
737        let ts = unix_to_iso8601(ts_unix);
738        let v = json!({"updated_at": ts});
739        let out = agent(v);
740        assert_eq!(out["updated_at"], json!("3m ago"));
741    }
742
743    #[test]
744    fn agent_recurses_into_nested_objects() {
745        let v = json!({
746            "items": [
747                {
748                    "id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
749                    "tags": [],
750                    "score": 0.9999
751                }
752            ]
753        });
754        let out = agent(v);
755        let item = &out["items"][0];
756        assert_eq!(item["id"], json!("a1b2c3d4"));
757        assert!(item.get("tags").is_none());
758        let s = item["score"].as_f64().unwrap();
759        assert!((s - 1.0).abs() < 1e-9);
760    }
761
762    // P-C1 regression: full_id must never be shortened in Agent mode.
763    #[test]
764    fn agent_preserves_full_id_as_36_chars() {
765        let uuid = "a1b2c3d4-e5f6-7890-abcd-ef1234567890";
766        let v = json!({"id": uuid, "full_id": uuid, "title": "X"});
767        let out = agent(v);
768        // `id` is shortened to 8 chars
769        assert_eq!(
770            out["id"],
771            json!("a1b2c3d4"),
772            "id should be 8-char short form"
773        );
774        // `full_id` must remain the full 36-char UUID
775        assert_eq!(
776            out["full_id"].as_str().unwrap().len(),
777            36,
778            "full_id must be 36 chars in agent mode"
779        );
780        assert_eq!(
781            out["full_id"],
782            json!(uuid),
783            "full_id must equal the original UUID"
784        );
785        // Verify the invariant: full_id starts with the short id prefix
786        assert!(
787            out["full_id"]
788                .as_str()
789                .unwrap()
790                .starts_with(out["id"].as_str().unwrap()),
791            "full_id must start with the short id prefix"
792        );
793    }
794
795    #[test]
796    fn is_canonical_uuid_recognizes_valid() {
797        assert!(is_canonical_uuid("a1b2c3d4-e5f6-7890-abcd-ef1234567890"));
798        assert!(!is_canonical_uuid("a1b2c3d4"));
799        assert!(!is_canonical_uuid("not-a-uuid-at-all-here---------"));
800    }
801
802    #[test]
803    fn looks_like_iso8601_recognizes_valid() {
804        assert!(looks_like_iso8601("2026-05-23T16:18:15.234567Z"));
805        assert!(!looks_like_iso8601("not a timestamp"));
806        assert!(!looks_like_iso8601("2026-05-23"));
807    }
808
809    /// Format Unix seconds as ISO-8601 for test construction.
810    fn unix_to_iso8601(unix: i64) -> String {
811        let (y, mo, d, h, mi, s) = unix_to_civil(unix);
812        format!("{y:04}-{mo:02}-{d:02}T{h:02}:{mi:02}:{s:02}Z")
813    }
814
815    fn unix_to_civil(unix: i64) -> (i64, i64, i64, i64, i64, i64) {
816        let s = unix % 86400;
817        let days = unix / 86400;
818        let h = s / 3600;
819        let m = (s % 3600) / 60;
820        let sec = s % 60;
821        // Howard Hinnant civil_from_days
822        let z = days + 719468;
823        let era = z.div_euclid(146097);
824        let doe = z - era * 146097;
825        let yoe = (doe - doe / 1460 + doe / 36524 - doe / 146096) / 365;
826        let y = yoe + era * 400;
827        let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
828        let mp = (5 * doy + 2) / 153;
829        let d = doy - (153 * mp + 2) / 5 + 1;
830        let mo = if mp < 10 { mp + 3 } else { mp - 9 };
831        let y = if mo <= 2 { y + 1 } else { y };
832        (y, mo, d, h, m, sec)
833    }
834
835    #[test]
836    fn agent_does_not_shorten_uuid_shaped_content_fields() {
837        let uuid = "a1b2c3d4-e5f6-7890-abcd-ef1234567890";
838        let out = agent(json!({
839            "id": uuid,
840            "full_id": uuid,
841            "content": uuid,
842            "description": uuid,
843            "title": uuid,
844            "query": uuid,
845        }));
846
847        assert_eq!(out["id"], json!("a1b2c3d4"));
848        assert_eq!(out["full_id"], json!(uuid));
849        assert_eq!(out["content"], json!(uuid));
850        assert_eq!(out["description"], json!(uuid));
851        assert_eq!(out["title"], json!(uuid));
852        assert_eq!(out["query"], json!(uuid));
853    }
854
855    #[test]
856    fn agent_shortens_suffix_id_fields() {
857        let uuid = "a1b2c3d4-e5f6-7890-abcd-ef1234567890";
858        let out = agent(json!({
859            "note_id": uuid,
860            "source_id": uuid,
861            "target_id": uuid,
862        }));
863
864        assert_eq!(out["note_id"], json!("a1b2c3d4"));
865        assert_eq!(out["source_id"], json!("a1b2c3d4"));
866        assert_eq!(out["target_id"], json!("a1b2c3d4"));
867    }
868
869    // ── ADR-078: OutputFormat tests ───────────────────────────────────────────
870
871    /// (a) json format preserves full shape (no field dropped, no transformation).
872    #[test]
873    fn format_json_preserves_full_shape() {
874        let v = json!({
875            "full_id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
876            "namespace": "local",
877            "properties": {"k": "v"},
878            "title": "test"
879        });
880        let rendered = render_format(v.clone(), OutputFormat::Json, PresentationMode::Agent);
881        let parsed: Value = serde_json::from_str(&rendered).unwrap();
882        // full_id must NOT be dropped in json mode (§P-C1, §4).
883        assert!(
884            parsed.get("full_id").is_some(),
885            "json mode must keep full_id"
886        );
887        // namespace must NOT be elided in json mode.
888        assert_eq!(
889            parsed.get("namespace").and_then(Value::as_str),
890            Some("local")
891        );
892        // properties must NOT be deduped in json mode.
893        assert!(parsed.get("properties").is_some());
894    }
895
896    /// (a-vs-auto) auto mode drops redundant fields that json mode preserves.
897    #[test]
898    fn format_auto_drops_versus_json_keeps() {
899        let v = json!({
900            "full_id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
901            "namespace": "local",
902            "title": "test"
903        });
904        let json_rendered = render_format(v.clone(), OutputFormat::Json, PresentationMode::Agent);
905        let auto_rendered = render_format(v.clone(), OutputFormat::Auto, PresentationMode::Agent);
906        // json keeps both; auto drops namespace="local" and full_id.
907        let json_parsed: Value = serde_json::from_str(&json_rendered).unwrap();
908        assert!(
909            json_parsed.get("full_id").is_some(),
910            "json must keep full_id"
911        );
912        assert_eq!(
913            json_parsed.get("namespace").and_then(Value::as_str),
914            Some("local")
915        );
916        // Auto mode: namespace should be elided (redundancy §7.3), full_id dropped (§7.1).
917        // The value itself is a single record → rendered as kv block.
918        assert!(
919            !auto_rendered.contains("full_id"),
920            "auto kv block must drop full_id"
921        );
922        assert!(
923            !auto_rendered.contains("namespace"),
924            "auto kv block must elide namespace=local"
925        );
926    }
927
928    /// (b1) homogeneous record array → markdown table with header + separator + rows.
929    #[test]
930    fn format_auto_homogeneous_array_renders_markdown_table() {
931        let v = json!([
932            {"id": "abc", "title": "First"},
933            {"id": "def", "title": "Second"}
934        ]);
935        let rendered = render_format(v, OutputFormat::Auto, PresentationMode::Agent);
936        // Header row.
937        assert!(rendered.starts_with('|'), "must start with |");
938        assert!(
939            rendered.contains("| id |") || rendered.contains("| id"),
940            "must have id column"
941        );
942        assert!(rendered.contains("title"), "must have title column");
943        // Separator row.
944        assert!(rendered.contains("|---|"), "must have separator row");
945        // Data rows.
946        assert!(rendered.contains("abc"), "must have first row data");
947        assert!(rendered.contains("Second"), "must have second row data");
948    }
949
950    /// (b2) single record → flat kv block.
951    #[test]
952    fn format_auto_single_record_renders_kv_block() {
953        let v = json!({"id": "abc", "title": "Hello World"});
954        let rendered = render_format(v, OutputFormat::Auto, PresentationMode::Agent);
955        // kv block uses "key: value\n" format.
956        assert!(rendered.contains("id: abc"), "must have id: abc");
957        assert!(
958            rendered.contains("title: Hello World"),
959            "must have title line"
960        );
961        // Must NOT contain markdown table markers.
962        assert!(
963            !rendered.starts_with('|'),
964            "single record must not be a markdown table"
965        );
966    }
967
968    /// (b3) fallback: auto on heterogeneous/scalar value falls back to compact json.
969    #[test]
970    fn format_auto_scalar_fallback_compact_json() {
971        let v = json!(42);
972        let rendered = render_format(v, OutputFormat::Auto, PresentationMode::Agent);
973        assert_eq!(rendered, "42");
974    }
975
976    /// (c) table format forces markdown table even when shape would normally be kv.
977    #[test]
978    fn format_table_forces_markdown_when_array() {
979        let v = json!({
980            "items": [
981                {"name": "A", "score": 1},
982                {"name": "B", "score": 2}
983            ]
984        });
985        let rendered = render_format(v, OutputFormat::Table, PresentationMode::Agent);
986        assert!(
987            rendered.contains("|"),
988            "table format must produce markdown table"
989        );
990        assert!(rendered.contains("name"), "must have name column");
991        assert!(rendered.contains("score"), "must have score column");
992    }
993
994    /// (c-fallback) table format falls back to compact json when no record array found.
995    #[test]
996    fn format_table_falls_back_to_json_when_no_array() {
997        let v = json!({"single": "value"});
998        let rendered = render_format(v, OutputFormat::Table, PresentationMode::Agent);
999        // No record array → compact JSON fallback.
1000        let parsed: Value = serde_json::from_str(&rendered).unwrap();
1001        assert_eq!(parsed["single"], json!("value"));
1002    }
1003
1004    /// (d) redundancy-drop: auto/table skipped in Verbose mode (§7).
1005    #[test]
1006    fn format_auto_verbose_skips_redundancy_drop() {
1007        let v = json!({
1008            "full_id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
1009            "namespace": "local",
1010            "title": "test"
1011        });
1012        // In Verbose mode, redundancy drop must be skipped.
1013        // The value is a single object → kv block, but full_id and namespace stay.
1014        let rendered = render_format(v, OutputFormat::Auto, PresentationMode::Verbose);
1015        assert!(
1016            rendered.contains("full_id"),
1017            "verbose must preserve full_id"
1018        );
1019        assert!(
1020            rendered.contains("namespace"),
1021            "verbose must preserve namespace"
1022        );
1023    }
1024
1025    /// (e) error envelope invariant: ok=false entries must stay compact json under auto.
1026    ///
1027    /// This tests the render_result logic via the redundancy-drop + render path.
1028    /// The server-level render_result function is tested indirectly: we verify that
1029    /// apply_redundancy_drop does NOT touch "ok: false" envelopes (the actual
1030    /// invariant is enforced by render_result checking the ok flag before dispatching).
1031    #[test]
1032    fn redundancy_drop_does_not_corrupt_error_shape() {
1033        // An error envelope that might be passed to the pre-pass in a batch.
1034        let v = json!({"ok": false, "error": "something failed", "namespace": "local"});
1035        // apply_redundancy_drop is a pure value transform; it doesn't know about ok.
1036        // The caller (render_result in server.rs) is responsible for bypassing
1037        // auto-render on ok=false entries. Here we just verify the pre-pass
1038        // doesn't lose the error field.
1039        let reduced = apply_redundancy_drop(v.clone());
1040        assert!(
1041            reduced.get("error").is_some(),
1042            "redundancy drop must preserve error field"
1043        );
1044        assert_eq!(
1045            reduced.get("ok").and_then(Value::as_bool),
1046            Some(false),
1047            "redundancy drop must preserve ok=false"
1048        );
1049    }
1050
1051    /// Properties dedup removes only keys that match a top-level sibling exactly.
1052    #[test]
1053    fn redundancy_drop_properties_dedup() {
1054        let v = json!({
1055            "id": "abc",
1056            "title": "Same",
1057            "properties": {
1058                "title": "Same",  // duplicate → removed
1059                "extra": "unique" // not at top level → kept
1060            }
1061        });
1062        let reduced = apply_redundancy_drop(v);
1063        let props = reduced.get("properties").expect("properties must remain");
1064        assert!(props.get("extra").is_some(), "unique property must be kept");
1065        assert!(
1066            props.get("title").is_none(),
1067            "duplicate top-level property must be removed"
1068        );
1069    }
1070
1071    /// Cell truncation: text > 120 chars gets `...` appended.
1072    #[test]
1073    fn cell_text_truncates_long_values() {
1074        let long = "X".repeat(200);
1075        let v = json!([
1076            {"col": long.clone()},
1077            {"col": "short"}
1078        ]);
1079        let rendered = render_format(v, OutputFormat::Auto, PresentationMode::Agent);
1080        // Cell must be truncated to ~120 chars + "..."
1081        assert!(
1082            rendered.contains("..."),
1083            "long cell must be truncated with ..."
1084        );
1085        assert!(
1086            !rendered.contains(&long),
1087            "full long string must not appear in table"
1088        );
1089    }
1090
1091    /// Cell truncation must not panic on multi-byte UTF-8 characters (High 3).
1092    ///
1093    /// A string of 119 ASCII bytes followed by a 3-byte CJK character and more
1094    /// text has `len() > 120` but byte index 120 falls inside the CJK char.
1095    /// The old byte-slice truncation would panic; char-boundary truncation is safe.
1096    #[test]
1097    fn cell_text_truncation_is_utf8_safe() {
1098        // 119 ASCII 'a' bytes, then CJK char U+4E2D (3 bytes each), then more text.
1099        // Total byte length: 119 + 3 * 10 + 5 > 120, but byte 120 is inside a CJK char.
1100        let prefix = "a".repeat(119);
1101        let suffix = "中".repeat(10); // each '中' is 3 bytes
1102        let long_multibyte = format!("{prefix}{suffix}trailing");
1103        let v = json!([
1104            {"col": long_multibyte.clone()},
1105            {"col": "ok"}
1106        ]);
1107        // Must not panic — this was the bug.
1108        let rendered = render_format(v, OutputFormat::Auto, PresentationMode::Agent);
1109        assert!(
1110            rendered.contains("..."),
1111            "multibyte cell must be truncated with ..."
1112        );
1113        // The rendered string must be valid UTF-8 (no partial char slicing).
1114        assert!(
1115            std::str::from_utf8(rendered.as_bytes()).is_ok(),
1116            "rendered output must be valid UTF-8"
1117        );
1118    }
1119}