use std::collections::BTreeMap;
use serde::Deserialize;
#[derive(Debug, Clone, Default, Deserialize)]
#[serde(default)]
pub struct BaseFile {
pub filters: Option<Filter>,
pub formulas: BTreeMap<String, String>,
pub properties: BTreeMap<String, PropertyConfig>,
pub summaries: BTreeMap<String, String>,
pub views: Vec<View>,
}
#[derive(Debug, Clone, Default, Deserialize)]
#[serde(default)]
pub struct PropertyConfig {
#[serde(rename = "displayName")]
pub display_name: Option<String>,
}
#[derive(Debug, Clone, Deserialize)]
#[serde(untagged)]
pub enum Filter {
Expr(String),
Logic(LogicFilter),
List(Vec<Filter>),
}
#[derive(Debug, Clone, Default, Deserialize)]
#[serde(default)]
pub struct LogicFilter {
pub and: Option<Vec<Filter>>,
pub or: Option<Vec<Filter>>,
pub not: Option<Box<NotFilter>>,
}
#[derive(Debug, Clone, Deserialize)]
#[serde(untagged)]
pub enum NotFilter {
One(Filter),
Many(Vec<Filter>),
}
#[derive(Debug, Clone, Default, Deserialize)]
#[serde(default)]
pub struct View {
#[serde(rename = "type")]
pub view_type: String,
pub name: Option<String>,
pub filters: Option<Filter>,
pub order: Vec<String>,
pub sort: Vec<SortKey>,
#[serde(rename = "groupBy")]
pub group_by: Option<GroupBy>,
pub limit: Option<usize>,
#[serde(rename = "columnSize")]
pub column_size: BTreeMap<String, u32>,
pub summaries: BTreeMap<String, String>,
pub image: Option<String>,
}
#[derive(Debug, Clone, Deserialize)]
#[serde(untagged)]
pub enum SortKey {
Property(String),
Full {
property: String,
#[serde(default)]
direction: Option<String>,
},
}
impl SortKey {
pub fn property(&self) -> &str {
match self {
SortKey::Property(p) => p,
SortKey::Full { property, .. } => property,
}
}
pub fn descending(&self) -> bool {
match self {
SortKey::Property(_) => false,
SortKey::Full { direction, .. } => direction
.as_deref()
.map(|d| d.eq_ignore_ascii_case("desc"))
.unwrap_or(false),
}
}
}
#[derive(Debug, Clone, Deserialize)]
#[serde(untagged)]
pub enum GroupBy {
Property(String),
Full {
property: String,
#[serde(default)]
direction: Option<String>,
},
}
impl GroupBy {
pub fn property(&self) -> &str {
match self {
GroupBy::Property(p) => p,
GroupBy::Full { property, .. } => property,
}
}
pub fn descending(&self) -> bool {
match self {
GroupBy::Property(_) => false,
GroupBy::Full { direction, .. } => direction
.as_deref()
.map(|d| d.eq_ignore_ascii_case("desc"))
.unwrap_or(false),
}
}
}
pub fn parse_base(yaml: &str) -> Result<BaseFile, serde_yml::Error> {
if yaml.trim().is_empty() {
return Ok(BaseFile::default());
}
serde_yml::from_str(yaml)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parses_real_vault_shape() {
let yaml = r#"
filters:
and:
- categories.contains(link("Categories/Books", "Books"))
- '!file.inFolder("Misc")'
views:
- type: table
name: Table
order:
- file.name
- file.size
sort:
- property: file.name
direction: DESC
columnSize:
file.name: 381
"#;
let base = parse_base(yaml).unwrap();
assert!(base.filters.is_some());
assert_eq!(base.views.len(), 1);
let v = &base.views[0];
assert_eq!(v.view_type, "table");
assert_eq!(v.name.as_deref(), Some("Table"));
assert_eq!(v.order, vec!["file.name", "file.size"]);
assert_eq!(v.sort.len(), 1);
assert_eq!(v.sort[0].property(), "file.name");
assert!(v.sort[0].descending());
assert_eq!(v.column_size.get("file.name"), Some(&381));
}
#[test]
fn parses_formulas_and_summaries() {
let yaml = r#"
formulas:
formatted_price: 'if(price, price.toFixed(2) + " dollars")'
ppu: "(price / age).toFixed(2)"
summaries:
customAverage: 'values.mean().round(3)'
properties:
status:
displayName: Status
views:
- type: table
summaries:
formula.ppu: Average
"#;
let base = parse_base(yaml).unwrap();
assert_eq!(base.formulas.len(), 2);
assert!(base.formulas.contains_key("ppu"));
assert_eq!(
base.summaries.get("customAverage").map(String::as_str),
Some("values.mean().round(3)")
);
assert_eq!(
base.properties
.get("status")
.and_then(|p| p.display_name.as_deref()),
Some("Status")
);
assert_eq!(
base.views[0]
.summaries
.get("formula.ppu")
.map(String::as_str),
Some("Average")
);
}
#[test]
fn tolerates_unknown_keys() {
let yaml = "unknownTop: 1\nviews:\n - type: table\n bogusField: x\n";
let base = parse_base(yaml).unwrap();
assert_eq!(base.views.len(), 1);
}
#[test]
fn empty_base_is_valid() {
assert_eq!(parse_base("").unwrap().views.len(), 0);
assert_eq!(parse_base(" \n").unwrap().views.len(), 0);
}
#[test]
fn shorthand_sort_and_group() {
let yaml =
"views:\n - type: table\n sort:\n - file.name\n groupBy: note.status\n";
let base = parse_base(yaml).unwrap();
assert_eq!(base.views[0].sort[0].property(), "file.name");
assert!(!base.views[0].sort[0].descending());
assert_eq!(
base.views[0].group_by.as_ref().unwrap().property(),
"note.status"
);
}
}