use prov_graph::meta::{Mapping, Value};
use prov_views::humanize;
pub const EXPORTS_KEY: &str = "exports";
pub const EXPORT_KEYS: &[&str] = &["label", "gate", "view"];
pub const GATE_KEYS: &[&str] = &["field", "value"];
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Gate {
pub field: String,
pub value: String,
}
impl Gate {
pub fn parse(value: &Value) -> Option<Self> {
let map = value.as_mapping()?;
Some(Gate {
field: non_empty(map.get("field"))?,
value: non_empty(map.get("value"))?,
})
}
pub fn to_value(&self) -> Value {
let mut map = Mapping::new();
map.insert("field".into(), Value::String(self.field.clone()));
map.insert("value".into(), Value::String(self.value.clone()));
Value::Mapping(map)
}
pub fn declared_in(&self, meta: &Value) -> Option<Vec<String>> {
Some(scalar_texts(meta.get(&self.field)?))
}
pub fn admits(&self, meta: &Value) -> bool {
self.declared_in(meta)
.is_some_and(|declared| declared.iter().any(|v| v == self.value.trim()))
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ExportSpec {
pub name: String,
pub label: Option<String>,
pub gate: Gate,
pub view: Option<String>,
}
impl ExportSpec {
pub fn parse(name: &str, value: &Value) -> Option<Self> {
let map = value.as_mapping()?;
let gate = Gate::parse(map.get("gate")?)?;
Some(ExportSpec {
name: name.to_string(),
label: non_empty(map.get("label")),
gate,
view: non_empty(map.get("view")),
})
}
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()));
}
map.insert("gate".into(), self.gate.to_value());
if let Some(view) = &self.view {
map.insert("view".into(), Value::String(view.clone()));
}
map
}
pub fn display_label(&self) -> String {
match &self.label {
Some(label) => label.clone(),
None => humanize(&self.name),
}
}
}
pub fn exports_from(config: &Mapping) -> Vec<ExportSpec> {
let Some(exports) = config.get(EXPORTS_KEY).and_then(Value::as_mapping) else {
return Vec::new();
};
exports
.iter()
.filter_map(|(name, value)| ExportSpec::parse(name, value))
.collect()
}
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)
}
pub(crate) fn non_empty(value: Option<&Value>) -> Option<String> {
let text = value?.as_str()?.trim();
(!text.is_empty()).then(|| text.to_string())
}
#[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 gate(field: &str, value: &str) -> Value {
text(&[("field", field), ("value", value)])
}
fn meta(pairs: &[(&str, Value)]) -> Value {
mapping(pairs)
}
fn seq(items: &[&str]) -> Value {
Value::Sequence(items.iter().map(|s| Value::String((*s).into())).collect())
}
#[test]
fn an_export_reads_its_gate_view_and_label() {
let spec = ExportSpec::parse(
"letters",
&mapping(&[
("label", Value::String("Letters home".into())),
("gate", gate("audience", "family")),
("view", Value::String("daily".into())),
]),
)
.expect("an export");
assert_eq!(spec.gate.field, "audience");
assert_eq!(spec.gate.value, "family");
assert_eq!(spec.view.as_deref(), Some("daily"));
assert_eq!(spec.display_label(), "Letters home");
}
#[test]
fn an_entry_without_a_gate_is_not_an_export() {
assert!(ExportSpec::parse("x", &text(&[("view", "daily")])).is_none());
assert!(ExportSpec::parse("x", &mapping(&[("gate", gate("audience", " "))])).is_none());
assert!(ExportSpec::parse("x", &mapping(&[("gate", gate(" ", "family"))])).is_none());
assert!(
ExportSpec::parse("x", &mapping(&[("gate", Value::String("family".into()))])).is_none()
);
assert!(ExportSpec::parse("x", &Value::String("family".into())).is_none());
}
#[test]
fn an_export_round_trips_through_its_mapping() {
let spec = ExportSpec {
name: "letters".into(),
label: Some("Letters home".into()),
gate: Gate {
field: "audience".into(),
value: "family".into(),
},
view: Some("daily".into()),
};
let back =
ExportSpec::parse("letters", &Value::Mapping(spec.to_mapping())).expect("an export");
assert_eq!(back, spec);
let minimal = ExportSpec {
name: "letters".into(),
label: None,
gate: Gate {
field: "audience".into(),
value: "family".into(),
},
view: None,
};
let map = minimal.to_mapping();
assert!(map.get("label").is_none(), "absent options are omitted");
assert!(map.get("view").is_none());
let back = ExportSpec::parse("letters", &Value::Mapping(map)).expect("an export");
assert_eq!(back, minimal);
}
#[test]
fn a_gate_admits_a_declared_value_scalar_or_sequence() {
let g = Gate {
field: "audience".into(),
value: "family".into(),
};
assert!(g.admits(&meta(&[("audience", Value::String("family".into()))])));
assert!(g.admits(&meta(&[("audience", seq(&["friends", "family"]))])));
assert!(!g.admits(&meta(&[("audience", seq(&["friends"]))])));
}
#[test]
fn an_undeclared_document_is_admitted_nowhere() {
let g = Gate {
field: "audience".into(),
value: "family".into(),
};
assert!(!g.admits(&meta(&[])));
assert!(!g.admits(&meta(&[("audience", Value::Null)])));
assert!(!g.admits(&meta(&[("audience", seq(&[]))])));
assert_eq!(g.declared_in(&meta(&[])), None, "undeclared");
assert_eq!(
g.declared_in(&meta(&[("audience", seq(&[]))])),
Some(vec![]),
"declared but empty — written, and still in no export"
);
}
#[test]
fn matching_is_exact_after_trim() {
let g = Gate {
field: "audience".into(),
value: "family".into(),
};
assert!(g.admits(&meta(&[("audience", Value::String(" family ".into()))])));
assert!(!g.admits(&meta(&[("audience", Value::String("Family".into()))])));
assert!(!g.admits(&meta(&[("audience", Value::String("FAMILY".into()))])));
}
#[test]
fn a_composite_value_declares_nothing() {
let g = Gate {
field: "audience".into(),
value: "family".into(),
};
assert!(!g.admits(&meta(&[(
"audience",
meta(&[("family", Value::Bool(true))])
)])));
assert!(!g.admits(&meta(&[(
"audience",
Value::Sequence(vec![seq(&["family"])])
)])));
}
#[test]
fn a_non_string_scalar_is_matched_by_its_text() {
let g = Gate {
field: "tier".into(),
value: "5".into(),
};
assert!(g.admits(&meta(&[("tier", Value::Int(5))])));
}
#[test]
fn exports_read_in_declaration_order() {
let mut exports = Mapping::new();
exports.insert(
"letters".into(),
mapping(&[("gate", gate("audience", "family"))]),
);
exports.insert(
"notes".into(),
mapping(&[("gate", gate("audience", "public"))]),
);
let mut config = Mapping::new();
config.insert(EXPORTS_KEY.into(), Value::Mapping(exports));
let specs = exports_from(&config);
assert_eq!(
specs.iter().map(|s| s.name.as_str()).collect::<Vec<_>>(),
["letters", "notes"]
);
}
#[test]
fn a_label_falls_back_to_the_humanized_name() {
let spec = ExportSpec::parse(
"letters_home",
&mapping(&[("gate", gate("audience", "family"))]),
)
.expect("an export");
assert_eq!(spec.display_label(), "Letters home");
}
}