use prov_graph::meta::{Mapping, Value};
use crate::filter::Condition;
pub const VIEWS_KEY: &str = "views";
pub const VIEW_KEYS: &[&str] = &["label", "icon", "group", "by", "under", "nest", "where"];
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum Grain {
#[default]
Year,
Month,
Day,
Initial(usize),
}
pub const GRAINS: &[&str] = &["year", "month", "day", "initial"];
impl Grain {
pub fn as_config_str(self) -> Option<&'static str> {
Some(match self {
Grain::Year => "year",
Grain::Month => "month",
Grain::Day => "day",
Grain::Initial(1) => "initial",
Grain::Initial(_) => return None,
})
}
pub fn from_config_str(text: &str) -> Option<Self> {
match text.trim() {
"year" => Some(Grain::Year),
"month" => Some(Grain::Month),
"day" => Some(Grain::Day),
"initial" => Some(Grain::Initial(1)),
_ => None,
}
}
pub fn parse(value: &Value) -> Option<Self> {
match value {
Value::String(text) => Grain::from_config_str(text),
Value::Mapping(map) => match map.iter().next() {
Some((key, arg)) if map.len() == 1 && key == "initial" => {
let n = match arg {
Value::Int(n) => *n,
Value::String(s) => s.trim().parse().ok()?,
_ => return None,
};
(n > 0).then_some(Grain::Initial(n as usize))
}
_ => None,
},
_ => None,
}
}
pub fn to_value(self) -> Value {
match self.as_config_str() {
Some(word) => Value::String(word.into()),
None => {
let Grain::Initial(n) = self else {
unreachable!("every non-parameterized grain has a bare spelling")
};
let mut map = Mapping::new();
map.insert("initial".into(), Value::Int(n as i64));
Value::Mapping(map)
}
}
}
pub fn display(self) -> String {
match self {
Grain::Initial(n) if n > 1 => format!("initial {n}"),
other => other.as_config_str().unwrap_or("initial").to_string(),
}
}
pub fn chain(self) -> Vec<Grain> {
match self {
Grain::Year => vec![Grain::Year],
Grain::Month => vec![Grain::Year, Grain::Month],
Grain::Day => vec![Grain::Year, Grain::Month, Grain::Day],
Grain::Initial(n) => (1..=n).map(Grain::Initial).collect(),
}
}
fn prefix_len(self) -> usize {
match self {
Grain::Year => 4,
Grain::Month => 7,
Grain::Day => 10,
Grain::Initial(n) => n,
}
}
pub fn cut(self, value: &str) -> Option<String> {
let text = value.trim();
if let Grain::Initial(n) = self {
let cut: String = text.chars().take(n).flat_map(char::to_uppercase).collect();
return (!cut.is_empty()).then_some(cut);
}
let bytes = text.as_bytes();
if bytes.len() < self.prefix_len() {
return None;
}
let shape_ok = bytes[..4].iter().all(u8::is_ascii_digit)
&& match self {
Grain::Month => bytes[4] == b'-' && bytes[5..7].iter().all(u8::is_ascii_digit),
Grain::Day => {
bytes[4] == b'-'
&& bytes[5..7].iter().all(u8::is_ascii_digit)
&& bytes[7] == b'-'
&& bytes[8..10].iter().all(u8::is_ascii_digit)
}
_ => true,
};
let bounded = match bytes.get(self.prefix_len()) {
Some(b) if self == Grain::Year => !b.is_ascii_digit(),
_ => true,
};
(shape_ok && bounded).then(|| text[..self.prefix_len()].to_string())
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Grouping {
pub keys: Vec<String>,
pub by: Option<Grain>,
}
impl Grouping {
pub fn field(key: impl Into<String>) -> Self {
Grouping {
keys: vec![key.into()],
by: None,
}
}
pub fn keys_of(&self, meta: &Value) -> Vec<String> {
for key in &self.keys {
let Some(value) = meta.get(key) else { continue };
let raw = scalar_texts(value);
if raw.is_empty() {
continue;
}
return match self.by {
Some(grain) => raw.iter().filter_map(|t| grain.cut(t)).collect(),
None => raw,
};
}
Vec::new()
}
fn to_value(&self) -> Value {
match self.keys.as_slice() {
[only] => Value::String(only.clone()),
many => Value::Sequence(many.iter().cloned().map(Value::String).collect()),
}
}
}
pub(crate) fn scalar_texts(value: &Value) -> Vec<String> {
match value {
Value::Sequence(items) => items.iter().filter_map(scalar_text).collect(),
other => scalar_text(other).into_iter().collect(),
}
}
fn scalar_text(value: &Value) -> Option<String> {
let text = match value {
Value::String(s) => s.trim().to_string(),
Value::Int(i) => i.to_string(),
Value::Float(f) => f.to_string(),
Value::Bool(b) => b.to_string(),
Value::Null | Value::Sequence(_) | Value::Mapping(_) => return None,
};
(!text.is_empty()).then_some(text)
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ViewSpec {
pub name: String,
pub label: Option<String>,
pub icon: Option<String>,
pub group: Grouping,
pub under: Option<String>,
pub filter: Option<Condition>,
pub nest: Option<Grain>,
}
impl ViewSpec {
pub fn parse(name: &str, value: &Value) -> Option<Self> {
let map = value.as_mapping()?;
let keys = group_keys(map.get("group"))?;
Some(ViewSpec {
name: name.to_string(),
label: non_empty(map.get("label")),
icon: non_empty(map.get("icon")),
group: Grouping {
keys,
by: map.get("by").and_then(Grain::parse),
},
under: non_empty(map.get("under")),
filter: map.get("where").and_then(Condition::parse),
nest: map.get("nest").and_then(Grain::parse),
})
}
pub fn to_mapping(&self) -> Mapping {
let mut map = Mapping::new();
if let Some(label) = &self.label {
map.insert("label".into(), Value::String(label.clone()));
}
if let Some(icon) = &self.icon {
map.insert("icon".into(), Value::String(icon.clone()));
}
map.insert("group".into(), self.group.to_value());
if let Some(by) = self.group.by {
map.insert("by".into(), by.to_value());
}
if let Some(under) = &self.under {
map.insert("under".into(), Value::String(under.clone()));
}
if let Some(filter) = &self.filter {
map.insert("where".into(), filter.to_value());
}
if let Some(nest) = self.nest {
map.insert("nest".into(), nest.to_value());
}
map
}
pub fn nest_route(&self, meta: &Value) -> Option<Vec<String>> {
let nest = self.nest?;
let raw = Grouping {
keys: self.group.keys.clone(),
by: None,
};
let values = raw.keys_of(meta);
let [value] = values.as_slice() else {
return None;
};
let route: Vec<String> = nest
.chain()
.into_iter()
.filter_map(|grain| grain.cut(value))
.collect();
(route.len() == nest.chain().len()).then_some(route)
}
pub fn display_label(&self) -> String {
match &self.label {
Some(label) => label.clone(),
None => humanize(&self.name),
}
}
}
fn group_keys(value: Option<&Value>) -> Option<Vec<String>> {
let keys: Vec<String> = match value? {
Value::String(s) => s
.trim()
.is_empty()
.then(Vec::new)
.unwrap_or_else(|| vec![s.trim().to_string()]),
Value::Sequence(items) => items.iter().filter_map(|v| non_empty(Some(v))).collect(),
_ => return None,
};
(!keys.is_empty()).then_some(keys)
}
fn non_empty(value: Option<&Value>) -> Option<String> {
let text = value?.as_str()?.trim();
(!text.is_empty()).then(|| text.to_string())
}
pub fn humanize(key: &str) -> String {
let mut words = key.split(['_', '-']).filter(|w| !w.is_empty());
let Some(first) = words.next() else {
return key.to_string();
};
let mut out = first.to_string();
if let Some(c) = out.get_mut(0..1) {
c.make_ascii_uppercase();
}
for word in words {
out.push(' ');
out.push_str(&word.to_lowercase());
}
out
}
pub fn views_from(config: &Mapping) -> Vec<ViewSpec> {
let Some(views) = config.get(VIEWS_KEY).and_then(Value::as_mapping) else {
return Vec::new();
};
views
.iter()
.filter_map(|(name, value)| ViewSpec::parse(name, value))
.collect()
}
#[cfg(test)]
mod tests {
use super::*;
fn mapping(pairs: &[(&str, Value)]) -> Value {
let mut map = Mapping::new();
for (k, v) in pairs {
map.insert((*k).into(), v.clone());
}
Value::Mapping(map)
}
fn text(pairs: &[(&str, &str)]) -> Value {
let owned: Vec<(&str, Value)> = pairs
.iter()
.map(|(k, v)| (*k, Value::String((*v).to_string())))
.collect();
mapping(&owned)
}
fn text_value(s: &str) -> Value {
Value::String(s.to_string())
}
fn seq(items: &[&str]) -> Value {
Value::Sequence(items.iter().map(|s| Value::String((*s).into())).collect())
}
#[test]
fn date_is_a_field_name_not_a_grouping_kind() {
let spec = ViewSpec::parse("daily", &text(&[("group", "date")])).expect("a view");
assert_eq!(spec.group, Grouping::field("date"));
let mut doc = Mapping::new();
doc.insert("date".into(), Value::String("2026-07-24".into()));
assert_eq!(spec.group.keys_of(&Value::Mapping(doc)), ["2026-07-24"]);
}
#[test]
fn a_chain_takes_the_first_field_that_carries_a_value() {
let spec = ViewSpec::parse(
"daily",
&mapping(&[
("group", seq(&["date_of_document", "created", "updated"])),
("by", Value::String("month".into())),
]),
)
.expect("a view");
let mut doc = Mapping::new();
doc.insert("created".into(), Value::String("2026-07-24".into()));
doc.insert("updated".into(), Value::String("2020-01-01".into()));
assert_eq!(
spec.group.keys_of(&Value::Mapping(doc)),
["2026-07"],
"created wins over updated; the grain cuts it"
);
}
#[test]
fn a_bad_value_does_not_fall_through_to_the_next_key() {
let spec = ViewSpec::parse(
"daily",
&mapping(&[
("group", seq(&["date_of_document", "created"])),
("by", Value::String("year".into())),
]),
)
.expect("a view");
let mut doc = Mapping::new();
doc.insert("date_of_document".into(), Value::String("banana".into()));
doc.insert("created".into(), Value::String("2026-07-24".into()));
assert!(spec.group.keys_of(&Value::Mapping(doc)).is_empty());
}
#[test]
fn a_sequence_field_puts_one_document_in_several_groups() {
let spec = ViewSpec::parse("who", &text(&[("group", "people")])).expect("a view");
let mut doc = Mapping::new();
doc.insert("people".into(), seq(&["Ada", "Grace"]));
assert_eq!(spec.group.keys_of(&Value::Mapping(doc)), ["Ada", "Grace"]);
}
#[test]
fn a_document_with_nothing_in_the_chain_is_ungrouped() {
let spec = ViewSpec::parse("daily", &text(&[("group", "created")])).expect("a view");
assert!(
spec.group
.keys_of(&Value::Mapping(Mapping::new()))
.is_empty()
);
let mut blank = Mapping::new();
blank.insert("created".into(), Value::String(" ".into()));
assert!(spec.group.keys_of(&Value::Mapping(blank)).is_empty());
}
#[test]
fn a_grain_cuts_an_iso_date_and_an_rfc3339_instant_alike() {
assert_eq!(Grain::Year.cut("2026-07-24"), Some("2026".into()));
assert_eq!(Grain::Month.cut("2026-07-24"), Some("2026-07".into()));
assert_eq!(Grain::Day.cut("2026-07-24"), Some("2026-07-24".into()));
assert_eq!(
Grain::Month.cut("2026-07-24T07:32:00Z"),
Some("2026-07".into())
);
assert_eq!(Grain::Year.cut(" 2026-07-24 "), Some("2026".into()));
}
#[test]
fn an_initial_grain_cuts_the_alphabet_the_way_a_date_grain_cuts_a_year() {
assert_eq!(Grain::Initial(1).cut("Ada Lovelace"), Some("A".into()));
assert_eq!(Grain::Initial(2).cut("Ada Lovelace"), Some("AD".into()));
assert_eq!(Grain::Initial(1).cut("ada"), Some("A".into()));
assert_eq!(Grain::Initial(3).cut("Bo"), Some("BO".into()));
assert_eq!(Grain::Initial(1).cut(" "), None);
}
#[test]
fn an_initial_grain_cuts_characters_not_bytes() {
assert_eq!(Grain::Initial(1).cut("Ålesund"), Some("Å".into()));
assert_eq!(Grain::Initial(2).cut("Øland"), Some("ØL".into()));
assert_eq!(Grain::Initial(1).cut("東京"), Some("東".into()));
}
#[test]
fn every_grain_chains_coarsest_first() {
assert_eq!(Grain::Day.chain(), [Grain::Year, Grain::Month, Grain::Day]);
assert_eq!(Grain::Year.chain(), [Grain::Year]);
assert_eq!(
Grain::Initial(3).chain(),
[Grain::Initial(1), Grain::Initial(2), Grain::Initial(3)]
);
}
#[test]
fn a_parameterized_grain_parses_and_round_trips() {
let mut map = Mapping::new();
map.insert("initial".into(), Value::Int(2));
let parsed = Grain::parse(&Value::Mapping(map)).expect("a grain");
assert_eq!(parsed, Grain::Initial(2));
assert_eq!(Grain::parse(&parsed.to_value()), Some(parsed));
assert_eq!(
Grain::parse(&text_value("initial")),
Some(Grain::Initial(1))
);
assert_eq!(Grain::Initial(1).to_value(), text_value("initial"));
assert_eq!(Grain::parse(&text_value("month")), Some(Grain::Month));
}
#[test]
fn a_grain_with_a_useless_parameter_does_not_parse() {
let mut zero = Mapping::new();
zero.insert("initial".into(), Value::Int(0));
assert_eq!(Grain::parse(&Value::Mapping(zero)), None);
let mut unknown = Mapping::new();
unknown.insert("bucket".into(), Value::Int(10));
assert_eq!(Grain::parse(&Value::Mapping(unknown)), None);
let mut two = Mapping::new();
two.insert("initial".into(), Value::Int(1));
two.insert("month".into(), Value::Int(1));
assert_eq!(Grain::parse(&Value::Mapping(two)), None);
}
#[test]
fn a_grain_rejects_what_is_not_a_date_at_that_grain() {
assert_eq!(Grain::Year.cut("banana"), None);
assert_eq!(Grain::Year.cut("20264"), None);
assert_eq!(Grain::Day.cut("2026-07"), None);
assert_eq!(Grain::Month.cut("2026/07"), None);
assert_eq!(Grain::Month.cut(""), None);
}
#[test]
fn grain_does_not_imply_nesting() {
let spec = ViewSpec::parse("daily", &text(&[("group", "created"), ("by", "month")]))
.expect("a view");
assert_eq!(spec.group.by, Some(Grain::Month));
assert_eq!(spec.nest, None);
let materialized = ViewSpec::parse(
"daily",
&text(&[("group", "created"), ("by", "month"), ("nest", "year")]),
)
.expect("a view");
assert_eq!(
materialized.nest,
Some(Grain::Year),
"a view may group finer than it files"
);
}
#[test]
fn an_entry_without_a_grouping_is_not_a_view() {
assert!(ViewSpec::parse("x", &text(&[("label", "Nameless")])).is_none());
assert!(ViewSpec::parse("x", &text(&[("group", " ")])).is_none());
assert!(ViewSpec::parse("x", &mapping(&[("group", seq(&[]))])).is_none());
assert!(ViewSpec::parse("x", &Value::String("created".into())).is_none());
}
#[test]
fn nest_route_gives_the_index_titles_to_file_under() {
let spec = ViewSpec::parse(
"daily",
&text(&[("group", "created"), ("by", "day"), ("nest", "month")]),
)
.expect("a view");
let mut doc = Mapping::new();
doc.insert("created".into(), Value::String("2026-07-24".into()));
assert_eq!(
spec.nest_route(&Value::Mapping(doc)),
Some(vec!["2026".to_string(), "2026-07".to_string()]),
"a month nest is a year index holding a month index"
);
}
#[test]
fn nest_route_generalizes_past_dates() {
let mut entry = Mapping::new();
entry.insert("group".into(), Value::String("surname".into()));
entry.insert("nest".into(), {
let mut g = Mapping::new();
g.insert("initial".into(), Value::Int(2));
Value::Mapping(g)
});
let spec = ViewSpec::parse("people", &Value::Mapping(entry)).expect("a view");
let mut doc = Mapping::new();
doc.insert("surname".into(), Value::String("Lovelace".into()));
assert_eq!(
spec.nest_route(&Value::Mapping(doc)),
Some(vec!["L".to_string(), "LO".to_string()])
);
}
#[test]
fn a_multi_valued_document_has_no_nest_route() {
let spec = ViewSpec::parse("who", &text(&[("group", "people"), ("nest", "initial")]))
.expect("a view");
let mut one = Mapping::new();
one.insert("people".into(), Value::String("Ada".into()));
assert_eq!(
spec.nest_route(&Value::Mapping(one)),
Some(vec!["A".to_string()]),
"one value files fine"
);
let mut two = Mapping::new();
two.insert("people".into(), seq(&["Ada", "Grace"]));
assert_eq!(
spec.nest_route(&Value::Mapping(two)),
None,
"two values are two homes, and prov's spine allows one"
);
}
#[test]
fn nest_route_ignores_how_the_view_reads() {
let spec = ViewSpec::parse(
"daily",
&text(&[("group", "created"), ("by", "year"), ("nest", "month")]),
)
.expect("a view");
let mut doc = Mapping::new();
doc.insert("created".into(), Value::String("2026-07-24".into()));
assert_eq!(
spec.nest_route(&Value::Mapping(doc)),
Some(vec!["2026".to_string(), "2026-07".to_string()]),
"grouped by year, filed by month — `by` never reaches the route"
);
}
#[test]
fn a_view_that_does_not_nest_or_cannot_file_has_no_route() {
let no_nest = ViewSpec::parse("daily", &text(&[("group", "created")])).expect("a view");
assert_eq!(no_nest.nest_route(&Value::Mapping(Mapping::new())), None);
let nests = ViewSpec::parse("daily", &text(&[("group", "created"), ("nest", "month")]))
.expect("a view");
assert_eq!(
nests.nest_route(&Value::Mapping(Mapping::new())),
None,
"nothing to file by"
);
let mut partial = Mapping::new();
partial.insert("created".into(), Value::String("2026".into()));
assert_eq!(nests.nest_route(&Value::Mapping(partial)), None);
}
#[test]
fn a_view_round_trips_through_its_mapping() {
for group in [
Grouping {
keys: vec!["created".into()],
by: Some(Grain::Month),
},
Grouping {
keys: vec!["date_of_document".into(), "created".into()],
by: Some(Grain::Day),
},
Grouping::field("people"),
] {
let spec = ViewSpec {
name: "daily".into(),
label: Some("Daily".into()),
icon: Some("calendar".into()),
group,
under: Some("[Daily](id:abc1234)".into()),
filter: Some(Condition::Not(Box::new(Condition::Has("draft".into())))),
nest: Some(Grain::Year),
};
let back =
ViewSpec::parse("daily", &Value::Mapping(spec.to_mapping())).expect("a view");
assert_eq!(back, spec);
}
}
#[test]
fn a_single_key_group_serializes_unwrapped() {
let spec = ViewSpec {
name: "who".into(),
label: None,
icon: None,
group: Grouping::field("people"),
under: None,
filter: None,
nest: None,
};
assert_eq!(
spec.to_mapping().get("group"),
Some(&Value::String("people".into()))
);
}
#[test]
fn views_read_in_declaration_order() {
let mut views = Mapping::new();
views.insert("daily".into(), text(&[("group", "created")]));
views.insert("who".into(), text(&[("group", "people")]));
let mut config = Mapping::new();
config.insert(VIEWS_KEY.into(), Value::Mapping(views));
let specs = views_from(&config);
assert_eq!(
specs.iter().map(|v| v.name.as_str()).collect::<Vec<_>>(),
["daily", "who"]
);
}
#[test]
fn a_label_falls_back_to_the_humanized_name() {
let spec = ViewSpec::parse("daily_entries", &text(&[("group", "created")])).expect("view");
assert_eq!(spec.display_label(), "Daily entries");
}
#[test]
fn a_non_string_scalar_groups_under_its_text() {
let spec = ViewSpec::parse("stars", &text(&[("group", "rating")])).expect("a view");
let mut doc = Mapping::new();
doc.insert("rating".into(), Value::Int(5));
assert_eq!(spec.group.keys_of(&Value::Mapping(doc)), ["5"]);
}
}