Skip to main content

face_core/
cluster.rs

1//! Nested and flat cluster representations and conversions between them.
2//!
3//! [`Cluster`] is the nested form emitted by `--format=json`; each
4//! cluster carries its own `clusters` array of children.
5//! [`FlatCluster`] is the §7.1 flat form emitted by `--format=json-flat`;
6//! each cluster carries a `parent_id` reference instead.
7//!
8//! [`to_flat`] flattens a nested tree; [`from_flat`] reconstructs the
9//! nested tree from a flat list. The two forms are equivalent.
10
11use std::collections::HashMap;
12
13use serde::{Deserialize, Serialize};
14
15use crate::{ClusterId, FaceError};
16
17/// Nested cluster form (§7).
18///
19/// `clusters` is always emitted, even when empty — §7's example shows
20/// `"clusters": []` on leaf clusters.
21#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
22#[non_exhaustive]
23pub struct Cluster {
24    /// Cluster id from the root to this cluster.
25    pub id: ClusterId,
26    /// Human-readable label (often the value itself or a band range).
27    pub label: String,
28    /// Field path for this cluster's axis (matches one of `result.axes[].field`).
29    pub axis: String,
30    /// Raw value behind this cluster (shape depends on the strategy).
31    pub value: serde_json::Value,
32    /// Number of input items inside this cluster (recursively).
33    pub total: u64,
34    /// Minimum score within this cluster, when a score path is in use.
35    pub score_min: Option<f64>,
36    /// Maximum score within this cluster, when a score path is in use.
37    pub score_max: Option<f64>,
38    /// Direct children of this cluster. Always emitted, even when empty.
39    pub clusters: Vec<Cluster>,
40}
41
42/// Flat cluster form (§7.1).
43///
44/// Equivalent in content to [`Cluster`] but with `parent_id` replacing
45/// the recursive `clusters` array.
46#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
47#[non_exhaustive]
48pub struct FlatCluster {
49    /// Cluster id from the root to this cluster.
50    pub id: ClusterId,
51    /// Parent id, or `None` for top-level clusters.
52    pub parent_id: Option<ClusterId>,
53    /// Human-readable label.
54    pub label: String,
55    /// Field path for this cluster's axis.
56    pub axis: String,
57    /// Raw value behind this cluster.
58    pub value: serde_json::Value,
59    /// Number of input items inside this cluster (recursively).
60    pub total: u64,
61    /// Minimum score within this cluster, when a score path is in use.
62    pub score_min: Option<f64>,
63    /// Maximum score within this cluster, when a score path is in use.
64    pub score_max: Option<f64>,
65}
66
67/// Flatten a nested cluster tree into the §7.1 flat form.
68///
69/// The traversal is depth-first, parents emitted before children, so
70/// the output is suitable for streaming to `--format=json-flat`.
71///
72/// # Examples
73///
74/// ```
75/// use face_core::{Cluster, to_flat, from_flat};
76///
77/// let parent: Cluster = serde_json::from_value(serde_json::json!({
78///     "id": "file:src/cli.rs",
79///     "label": "src/cli.rs",
80///     "axis": "file",
81///     "value": "src/cli.rs",
82///     "total": 38,
83///     "score_min": null,
84///     "score_max": null,
85///     "clusters": []
86/// })).unwrap();
87///
88/// let flat = to_flat(&[parent.clone()]);
89/// assert_eq!(flat.len(), 1);
90/// let nested = from_flat(&flat).unwrap();
91/// assert_eq!(nested, vec![parent]);
92/// ```
93pub fn to_flat(roots: &[Cluster]) -> Vec<FlatCluster> {
94    let mut out = Vec::new();
95    for root in roots {
96        flatten_into(root, None, &mut out);
97    }
98    out
99}
100
101fn flatten_into(node: &Cluster, parent_id: Option<&ClusterId>, out: &mut Vec<FlatCluster>) {
102    out.push(FlatCluster {
103        id: node.id.clone(),
104        parent_id: parent_id.cloned(),
105        label: node.label.clone(),
106        axis: node.axis.clone(),
107        value: node.value.clone(),
108        total: node.total,
109        score_min: node.score_min,
110        score_max: node.score_max,
111    });
112    for child in &node.clusters {
113        flatten_into(child, Some(&node.id), out);
114    }
115}
116
117/// Reconstruct a nested cluster tree from the §7.1 flat form.
118///
119/// # Errors
120///
121/// Returns [`FaceError::InvalidClusterId`] if any `parent_id`
122/// references an id not present in the input list (a dangling parent).
123pub fn from_flat(flat: &[FlatCluster]) -> Result<Vec<Cluster>, FaceError> {
124    // Validate parent refs up front. Every non-None parent_id must be
125    // present as some entry's `id`.
126    let id_set: std::collections::HashSet<&ClusterId> = flat.iter().map(|c| &c.id).collect();
127    for c in flat {
128        if let Some(p) = &c.parent_id
129            && !id_set.contains(p)
130        {
131            return Err(FaceError::InvalidClusterId {
132                segment: 0,
133                reason: "parent_id references unknown id".to_string(),
134            });
135        }
136    }
137
138    // Group child indices by parent id so we can rebuild bottom-up.
139    let mut children_of: HashMap<Option<ClusterId>, Vec<usize>> = HashMap::new();
140    for (i, c) in flat.iter().enumerate() {
141        children_of.entry(c.parent_id.clone()).or_default().push(i);
142    }
143
144    fn build(
145        idx: usize,
146        flat: &[FlatCluster],
147        children_of: &HashMap<Option<ClusterId>, Vec<usize>>,
148    ) -> Cluster {
149        let f = &flat[idx];
150        let kids = children_of
151            .get(&Some(f.id.clone()))
152            .map(|v| v.iter().map(|&i| build(i, flat, children_of)).collect())
153            .unwrap_or_default();
154        Cluster {
155            id: f.id.clone(),
156            label: f.label.clone(),
157            axis: f.axis.clone(),
158            value: f.value.clone(),
159            total: f.total,
160            score_min: f.score_min,
161            score_max: f.score_max,
162            clusters: kids,
163        }
164    }
165
166    let roots = children_of
167        .get(&None)
168        .map(|v| v.iter().map(|&i| build(i, flat, &children_of)).collect())
169        .unwrap_or_default();
170    Ok(roots)
171}
172
173#[cfg(test)]
174mod tests {
175    use super::*;
176    use crate::ClusterIdSegment;
177    use serde_json::Value;
178
179    fn seg(axis: &str, value: &str) -> ClusterIdSegment {
180        ClusterIdSegment {
181            axis: axis.into(),
182            value: value.into(),
183        }
184    }
185
186    fn leaf(id: ClusterId, axis: &str, value: &str, total: u64) -> Cluster {
187        Cluster {
188            id,
189            label: value.to_string(),
190            axis: axis.to_string(),
191            value: Value::String(value.to_string()),
192            total,
193            score_min: None,
194            score_max: None,
195            clusters: vec![],
196        }
197    }
198
199    #[test]
200    fn one_deep_tree_round_trips() {
201        let parent_id = ClusterId::new(vec![seg("file", "src/cli.rs")]);
202        let child_id = ClusterId::new(vec![seg("file", "src/cli.rs"), seg("score", "excellent")]);
203
204        let child = leaf(child_id, "score", "excellent", 12);
205        let parent = Cluster {
206            clusters: vec![child],
207            ..leaf(parent_id, "file", "src/cli.rs", 38)
208        };
209
210        let flat = to_flat(std::slice::from_ref(&parent));
211        assert_eq!(flat.len(), 2);
212        assert!(flat[0].parent_id.is_none());
213        assert_eq!(flat[1].parent_id.as_ref(), Some(&parent.id));
214
215        let nested = from_flat(&flat).unwrap();
216        assert_eq!(nested, vec![parent]);
217    }
218
219    #[test]
220    fn dangling_parent_id_is_an_error() {
221        let id = ClusterId::new(vec![seg("score", "excellent")]);
222        let dangling_parent = ClusterId::new(vec![seg("file", "missing")]);
223        let flat = vec![FlatCluster {
224            id,
225            parent_id: Some(dangling_parent),
226            label: "excellent".into(),
227            axis: "score".into(),
228            value: Value::String("excellent".into()),
229            total: 1,
230            score_min: None,
231            score_max: None,
232        }];
233        let err = from_flat(&flat).unwrap_err();
234        assert!(matches!(
235            err,
236            FaceError::InvalidClusterId { segment: 0, .. }
237        ));
238    }
239}