use std::collections::BTreeMap;
use crate::select::{Row, Selection};
use crate::spec::Grouping;
#[derive(Debug, Clone, PartialEq)]
pub struct Group<'a> {
pub key: String,
pub rows: Vec<&'a Row>,
}
#[derive(Debug, Clone, PartialEq)]
pub struct RowSet<'a> {
pub view: String,
pub groups: Vec<Group<'a>>,
pub ungrouped: Vec<&'a Row>,
}
impl RowSet<'_> {
pub fn len(&self) -> usize {
let mut paths: Vec<_> = self
.groups
.iter()
.flat_map(|g| &g.rows)
.chain(&self.ungrouped)
.map(|r| &r.path)
.collect();
paths.sort();
paths.dedup();
paths.len()
}
pub fn is_empty(&self) -> bool {
self.groups.is_empty() && self.ungrouped.is_empty()
}
pub fn placements(&self) -> usize {
self.groups.iter().map(|g| g.rows.len()).sum::<usize>() + self.ungrouped.len()
}
}
pub fn group<'a>(selection: &'a Selection, grouping: &Grouping) -> RowSet<'a> {
let mut grouped: BTreeMap<String, Vec<&'a Row>> = BTreeMap::new();
let mut ungrouped: Vec<&'a Row> = Vec::new();
for row in &selection.rows {
let keys = grouping.keys_of(&row.meta);
if keys.is_empty() {
ungrouped.push(row);
continue;
}
for key in keys {
let bucket = grouped.entry(key).or_default();
if !bucket.iter().any(|r| r.path == row.path) {
bucket.push(row);
}
}
}
let groups = grouped
.into_iter()
.map(|(key, rows)| Group { key, rows })
.collect();
RowSet {
view: selection.view.clone(),
groups,
ungrouped,
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::spec::Grain;
use prov_graph::meta::{Mapping, Value};
use std::path::PathBuf;
fn selection(rows: &[(&str, &[(&str, Value)])]) -> Selection {
Selection {
view: "v".into(),
rows: rows
.iter()
.map(|(path, fields)| {
let mut meta = Mapping::new();
for (k, v) in *fields {
meta.insert((*k).into(), v.clone());
}
Row {
path: PathBuf::from(path),
meta: Value::Mapping(meta),
}
})
.collect(),
}
}
fn text(s: &str) -> Value {
Value::String(s.to_string())
}
fn seq(items: &[&str]) -> Value {
Value::Sequence(items.iter().map(|s| text(s)).collect())
}
#[test]
fn groups_are_ascending_and_rows_stay_in_path_order() {
let sel = selection(&[
("b.md", &[("created", text("2026-08-01"))]),
("a.md", &[("created", text("2026-07-24"))]),
("c.md", &[("created", text("2026-07-30"))]),
]);
let rows = group(
&sel,
&Grouping {
keys: vec!["created".into()],
by: Some(Grain::Month),
},
);
assert_eq!(
rows.groups
.iter()
.map(|g| g.key.as_str())
.collect::<Vec<_>>(),
["2026-07", "2026-08"]
);
assert_eq!(
rows.groups[0]
.rows
.iter()
.map(|r| r.path.to_str().unwrap())
.collect::<Vec<_>>(),
["a.md", "c.md"]
);
}
#[test]
fn len_counts_documents_and_placements_counts_rows() {
let sel = selection(&[
("letter.md", &[("people", seq(&["Ada", "Grace"]))]),
("note.md", &[("people", seq(&["Ada"]))]),
("bare.md", &[]),
]);
let rows = group(&sel, &Grouping::field("people"));
assert_eq!(rows.len(), 3, "three documents");
assert_eq!(
rows.placements(),
4,
"Ada twice, Grace once, ungrouped once"
);
assert_eq!(rows.len(), sel.len(), "nothing selected went missing");
}
#[test]
fn a_repeated_value_does_not_double_a_row_within_its_group() {
let sel = selection(&[("letter.md", &[("people", seq(&["Ada", "Ada"]))])]);
let rows = group(&sel, &Grouping::field("people"));
assert_eq!(rows.groups.len(), 1);
assert_eq!(rows.groups[0].rows.len(), 1);
}
#[test]
fn every_selected_row_lands_somewhere() {
let sel = selection(&[
("a.md", &[("created", text("2026-07-24"))]),
("b.md", &[("created", text("banana"))]),
("c.md", &[]),
]);
let rows = group(
&sel,
&Grouping {
keys: vec!["created".into()],
by: Some(Grain::Year),
},
);
assert_eq!(rows.len(), 3);
assert_eq!(rows.ungrouped.len(), 2, "the unparseable and the absent");
}
#[test]
fn one_selection_groups_several_ways_at_once() {
let sel = selection(&[(
"letter.md",
&[("people", seq(&["Ada"])), ("created", text("2026-07-24"))],
)]);
let by_people = group(&sel, &Grouping::field("people"));
let by_year = group(
&sel,
&Grouping {
keys: vec!["created".into()],
by: Some(Grain::Year),
},
);
assert_eq!(by_people.groups[0].key, "Ada");
assert_eq!(by_year.groups[0].key, "2026");
assert_eq!(
by_people.groups[0].rows[0].path,
by_year.groups[0].rows[0].path
);
}
}