face-core 0.1.0

Core grouping, clustering, and paging primitives for the face CLI.
Documentation
//! Nested and flat cluster representations and conversions between them.
//!
//! [`Cluster`] is the nested form emitted by `--format=json`; each
//! cluster carries its own `clusters` array of children.
//! [`FlatCluster`] is the §7.1 flat form emitted by `--format=json-flat`;
//! each cluster carries a `parent_id` reference instead.
//!
//! [`to_flat`] flattens a nested tree; [`from_flat`] reconstructs the
//! nested tree from a flat list. The two forms are equivalent.

use std::collections::HashMap;

use serde::{Deserialize, Serialize};

use crate::{ClusterId, FaceError};

/// Nested cluster form (§7).
///
/// `clusters` is always emitted, even when empty — §7's example shows
/// `"clusters": []` on leaf clusters.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[non_exhaustive]
pub struct Cluster {
    /// Cluster id from the root to this cluster.
    pub id: ClusterId,
    /// Human-readable label (often the value itself or a band range).
    pub label: String,
    /// Field path for this cluster's axis (matches one of `result.axes[].field`).
    pub axis: String,
    /// Raw value behind this cluster (shape depends on the strategy).
    pub value: serde_json::Value,
    /// Number of input items inside this cluster (recursively).
    pub total: u64,
    /// Minimum score within this cluster, when a score path is in use.
    pub score_min: Option<f64>,
    /// Maximum score within this cluster, when a score path is in use.
    pub score_max: Option<f64>,
    /// Direct children of this cluster. Always emitted, even when empty.
    pub clusters: Vec<Cluster>,
}

/// Flat cluster form (§7.1).
///
/// Equivalent in content to [`Cluster`] but with `parent_id` replacing
/// the recursive `clusters` array.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[non_exhaustive]
pub struct FlatCluster {
    /// Cluster id from the root to this cluster.
    pub id: ClusterId,
    /// Parent id, or `None` for top-level clusters.
    pub parent_id: Option<ClusterId>,
    /// Human-readable label.
    pub label: String,
    /// Field path for this cluster's axis.
    pub axis: String,
    /// Raw value behind this cluster.
    pub value: serde_json::Value,
    /// Number of input items inside this cluster (recursively).
    pub total: u64,
    /// Minimum score within this cluster, when a score path is in use.
    pub score_min: Option<f64>,
    /// Maximum score within this cluster, when a score path is in use.
    pub score_max: Option<f64>,
}

/// Flatten a nested cluster tree into the §7.1 flat form.
///
/// The traversal is depth-first, parents emitted before children, so
/// the output is suitable for streaming to `--format=json-flat`.
///
/// # Examples
///
/// ```
/// use face_core::{Cluster, to_flat, from_flat};
///
/// let parent: Cluster = serde_json::from_value(serde_json::json!({
///     "id": "file:src/cli.rs",
///     "label": "src/cli.rs",
///     "axis": "file",
///     "value": "src/cli.rs",
///     "total": 38,
///     "score_min": null,
///     "score_max": null,
///     "clusters": []
/// })).unwrap();
///
/// let flat = to_flat(&[parent.clone()]);
/// assert_eq!(flat.len(), 1);
/// let nested = from_flat(&flat).unwrap();
/// assert_eq!(nested, vec![parent]);
/// ```
pub fn to_flat(roots: &[Cluster]) -> Vec<FlatCluster> {
    let mut out = Vec::new();
    for root in roots {
        flatten_into(root, None, &mut out);
    }
    out
}

fn flatten_into(node: &Cluster, parent_id: Option<&ClusterId>, out: &mut Vec<FlatCluster>) {
    out.push(FlatCluster {
        id: node.id.clone(),
        parent_id: parent_id.cloned(),
        label: node.label.clone(),
        axis: node.axis.clone(),
        value: node.value.clone(),
        total: node.total,
        score_min: node.score_min,
        score_max: node.score_max,
    });
    for child in &node.clusters {
        flatten_into(child, Some(&node.id), out);
    }
}

/// Reconstruct a nested cluster tree from the §7.1 flat form.
///
/// # Errors
///
/// Returns [`FaceError::InvalidClusterId`] if any `parent_id`
/// references an id not present in the input list (a dangling parent).
pub fn from_flat(flat: &[FlatCluster]) -> Result<Vec<Cluster>, FaceError> {
    // Validate parent refs up front. Every non-None parent_id must be
    // present as some entry's `id`.
    let id_set: std::collections::HashSet<&ClusterId> = flat.iter().map(|c| &c.id).collect();
    for c in flat {
        if let Some(p) = &c.parent_id
            && !id_set.contains(p)
        {
            return Err(FaceError::InvalidClusterId {
                segment: 0,
                reason: "parent_id references unknown id".to_string(),
            });
        }
    }

    // Group child indices by parent id so we can rebuild bottom-up.
    let mut children_of: HashMap<Option<ClusterId>, Vec<usize>> = HashMap::new();
    for (i, c) in flat.iter().enumerate() {
        children_of.entry(c.parent_id.clone()).or_default().push(i);
    }

    fn build(
        idx: usize,
        flat: &[FlatCluster],
        children_of: &HashMap<Option<ClusterId>, Vec<usize>>,
    ) -> Cluster {
        let f = &flat[idx];
        let kids = children_of
            .get(&Some(f.id.clone()))
            .map(|v| v.iter().map(|&i| build(i, flat, children_of)).collect())
            .unwrap_or_default();
        Cluster {
            id: f.id.clone(),
            label: f.label.clone(),
            axis: f.axis.clone(),
            value: f.value.clone(),
            total: f.total,
            score_min: f.score_min,
            score_max: f.score_max,
            clusters: kids,
        }
    }

    let roots = children_of
        .get(&None)
        .map(|v| v.iter().map(|&i| build(i, flat, &children_of)).collect())
        .unwrap_or_default();
    Ok(roots)
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::ClusterIdSegment;
    use serde_json::Value;

    fn seg(axis: &str, value: &str) -> ClusterIdSegment {
        ClusterIdSegment {
            axis: axis.into(),
            value: value.into(),
        }
    }

    fn leaf(id: ClusterId, axis: &str, value: &str, total: u64) -> Cluster {
        Cluster {
            id,
            label: value.to_string(),
            axis: axis.to_string(),
            value: Value::String(value.to_string()),
            total,
            score_min: None,
            score_max: None,
            clusters: vec![],
        }
    }

    #[test]
    fn one_deep_tree_round_trips() {
        let parent_id = ClusterId::new(vec![seg("file", "src/cli.rs")]);
        let child_id = ClusterId::new(vec![seg("file", "src/cli.rs"), seg("score", "excellent")]);

        let child = leaf(child_id, "score", "excellent", 12);
        let parent = Cluster {
            clusters: vec![child],
            ..leaf(parent_id, "file", "src/cli.rs", 38)
        };

        let flat = to_flat(std::slice::from_ref(&parent));
        assert_eq!(flat.len(), 2);
        assert!(flat[0].parent_id.is_none());
        assert_eq!(flat[1].parent_id.as_ref(), Some(&parent.id));

        let nested = from_flat(&flat).unwrap();
        assert_eq!(nested, vec![parent]);
    }

    #[test]
    fn dangling_parent_id_is_an_error() {
        let id = ClusterId::new(vec![seg("score", "excellent")]);
        let dangling_parent = ClusterId::new(vec![seg("file", "missing")]);
        let flat = vec![FlatCluster {
            id,
            parent_id: Some(dangling_parent),
            label: "excellent".into(),
            axis: "score".into(),
            value: Value::String("excellent".into()),
            total: 1,
            score_min: None,
            score_max: None,
        }];
        let err = from_flat(&flat).unwrap_err();
        assert!(matches!(
            err,
            FaceError::InvalidClusterId { segment: 0, .. }
        ));
    }
}