face-core 0.1.0

Core grouping, clustering, and paging primitives for the face CLI.
Documentation
//! JSON-family rendering: nested envelope, flat envelope, JSONL items.
//!
//! - [`render_nested`] emits `serde_json::to_string_pretty(envelope)`
//!   directly; the [`crate::Envelope`] type is the wire shape.
//! - [`render_flat`] swaps the nested `clusters` array for the §7.1
//!   flat form via [`crate::to_flat`], serializing through a private
//!   side struct so field order matches `result, meta, clusters, page`.
//! - [`render_jsonl_items`] emits one compact JSON line per
//!   `envelope.page.items` entry, ignoring clusters per §7.2.

use serde::Serialize;

use crate::{Envelope, EnvelopeCache, FaceError, FlatCluster, Page, ResultBlock, to_flat};

/// `--format=json`: serialize the nested envelope as pretty JSON.
pub(super) fn render_nested(envelope: &Envelope) -> Result<String, FaceError> {
    serde_json::to_string_pretty(envelope).map_err(map_serde)
}

/// `--format=json-flat`: §7.1 flat-envelope form.
pub(super) fn render_flat(envelope: &Envelope) -> Result<String, FaceError> {
    let flat = FlatEnvelope {
        result: &envelope.result,
        meta: &envelope.meta,
        clusters: to_flat(&envelope.clusters),
        page: &envelope.page,
        cache: &envelope.cache,
    };
    serde_json::to_string_pretty(&flat).map_err(map_serde)
}

/// `--format=jsonl-items`: emit each `page.items` entry on its own line
/// of compact JSON, with a trailing newline. Returns the empty string
/// when the page is empty (e.g. no `--cluster` was set), per §7.2.
pub(super) fn render_jsonl_items(envelope: &Envelope) -> String {
    let mut out = String::new();
    for item in &envelope.page.items {
        // `serde_json::to_string` on a `Value` is infallible.
        out.push_str(&serde_json::to_string(item).unwrap_or_default());
        out.push('\n');
    }
    out
}

/// Side struct mirroring [`Envelope`] but with the flat cluster list.
/// Field order is fixed by declaration order so the serialized JSON
/// keeps `result`, `meta`, `clusters`, `page` in that sequence —
/// matching the nested envelope's key order in §7.
#[derive(Serialize)]
struct FlatEnvelope<'a> {
    result: &'a ResultBlock,
    meta: &'a serde_json::Map<String, serde_json::Value>,
    clusters: Vec<FlatCluster>,
    page: &'a Page,
    #[serde(skip_serializing_if = "cache_ref_is_empty")]
    cache: &'a EnvelopeCache,
}

fn cache_ref_is_empty(cache: &&EnvelopeCache) -> bool {
    cache.is_empty()
}

fn map_serde(err: serde_json::Error) -> FaceError {
    // Surface a `serde_json::Error` as a generic input-parse error.
    // In practice this can only fire for non-`String` writers or
    // unsupported value types; both are impossible from this path.
    FaceError::InputParse {
        format: crate::InputFormat::Json,
        message: err.to_string(),
    }
}

#[cfg(test)]
mod tests {
    use crate::format::{OutputFormat, RenderOptions, render};
    use crate::{Cluster, ClusterId, ClusterIdSegment, Envelope};
    use serde_json::json;

    fn child(
        parent_axis: &str,
        parent_value: &str,
        axis: &str,
        value: &str,
        total: u64,
    ) -> Cluster {
        Cluster {
            id: ClusterId::new(vec![
                ClusterIdSegment {
                    axis: parent_axis.into(),
                    value: parent_value.into(),
                },
                ClusterIdSegment {
                    axis: axis.into(),
                    value: value.into(),
                },
            ]),
            label: value.to_string(),
            axis: axis.to_string(),
            value: json!(value),
            total,
            score_min: None,
            score_max: None,
            clusters: vec![],
        }
    }

    fn parent(axis: &str, value: &str, total: u64, children: Vec<Cluster>) -> Cluster {
        Cluster {
            id: ClusterId::new(vec![ClusterIdSegment {
                axis: axis.into(),
                value: value.into(),
            }]),
            label: value.to_string(),
            axis: axis.to_string(),
            value: json!(value),
            total,
            score_min: None,
            score_max: None,
            clusters: children,
        }
    }

    #[test]
    fn nested_round_trips_with_serde() {
        let env = Envelope {
            result: crate::ResultBlock {
                input_total: 38,
                ..crate::ResultBlock::default()
            },
            clusters: vec![parent(
                "file",
                "src/cli.rs",
                38,
                vec![child("file", "src/cli.rs", "score", "excellent", 12)],
            )],
            ..Envelope::default()
        };

        let out = render(&env, OutputFormat::Json, &RenderOptions::default()).unwrap();
        let back: Envelope = serde_json::from_str(&out).unwrap();
        assert_eq!(env, back);
    }

    #[test]
    fn flat_uses_parent_id() {
        let env = Envelope {
            clusters: vec![parent(
                "file",
                "src/cli.rs",
                38,
                vec![child("file", "src/cli.rs", "score", "excellent", 12)],
            )],
            ..Envelope::default()
        };

        let out = render(&env, OutputFormat::JsonFlat, &RenderOptions::default()).unwrap();
        let v: serde_json::Value = serde_json::from_str(&out).unwrap();
        let arr = v["clusters"].as_array().unwrap();
        assert_eq!(arr.len(), 2);
        assert!(arr[0]["parent_id"].is_null());
        assert_eq!(arr[1]["parent_id"], json!("file:src/cli.rs"));
        // Nested form's `clusters` key isn't present on flat entries.
        assert!(arr[0].get("clusters").is_none());
    }

    #[test]
    fn flat_keeps_top_level_envelope_keys() {
        // The flat envelope must keep `result`, `meta`, `clusters`,
        // `page` so jq pipelines that filter on those keys work
        // without re-shaping.
        let env = Envelope::default();
        let out = render(&env, OutputFormat::JsonFlat, &RenderOptions::default()).unwrap();
        let v: serde_json::Value = serde_json::from_str(&out).unwrap();
        let obj = v.as_object().unwrap();
        assert!(obj.contains_key("result"));
        assert!(obj.contains_key("meta"));
        assert!(obj.contains_key("clusters"));
        assert!(obj.contains_key("page"));
    }

    #[test]
    fn jsonl_items_one_per_line() {
        let env = Envelope {
            page: crate::Page {
                items: vec![json!({"a": 1}), json!({"a": 2}), json!({"a": 3})],
                ..crate::Page::default()
            },
            ..Envelope::default()
        };
        let out = render(&env, OutputFormat::JsonlItems, &RenderOptions::default()).unwrap();
        let lines: Vec<&str> = out.lines().collect();
        assert_eq!(lines.len(), 3);
        assert_eq!(lines[0], "{\"a\":1}");
        assert_eq!(lines[1], "{\"a\":2}");
        assert_eq!(lines[2], "{\"a\":3}");
        // Trailing newline so concatenation stays line-oriented.
        assert!(out.ends_with('\n'));
    }

    #[test]
    fn jsonl_items_empty_when_page_empty() {
        // §7.2: without `--cluster` the page block is empty, so the
        // jsonl-items format yields the empty string.
        let env = Envelope::default();
        let out = render(&env, OutputFormat::JsonlItems, &RenderOptions::default()).unwrap();
        assert_eq!(out, "");
    }
}