use std::collections::BTreeMap;
use crate::config::OpenClosed;
use crate::identity::Id;
use crate::meta::Value;
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Term {
pub id: Option<Id>,
pub means: Option<String>,
pub retired: bool,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Vocabulary {
pub field: String,
pub values: OpenClosed,
pub terms: BTreeMap<String, Term>,
}
impl Vocabulary {
pub fn from_meta(meta: &Value) -> Option<Self> {
let marker = meta.get("vocabulary")?;
let field = marker.get("field").and_then(Value::as_str)?.to_string();
let values = marker
.get("values")
.and_then(Value::as_str)
.and_then(OpenClosed::from_config_str)
.unwrap_or_default();
let mut terms = BTreeMap::new();
if let Some(map) = meta.get("terms").and_then(Value::as_mapping) {
for (name, spec) in map {
let term = match spec.as_mapping() {
Some(entry) => Term {
id: entry
.get("id")
.and_then(Value::as_str)
.map(|s| Id(s.to_string())),
means: entry.get("means").and_then(Value::as_str).map(str::to_string),
retired: entry.get("retired").and_then(Value::as_bool).unwrap_or(false),
},
None => Term {
id: None,
means: None,
retired: false,
},
};
terms.insert(name.clone(), term);
}
}
Some(Self {
field,
values,
terms,
})
}
pub fn accepts(&self, value: &str) -> bool {
self.terms.get(value).is_some_and(|t| !t.retired)
}
pub fn is_retired(&self, value: &str) -> bool {
self.terms.get(value).is_some_and(|t| t.retired)
}
pub fn live_term_names(&self) -> Vec<String> {
self.terms
.iter()
.filter(|(_, t)| !t.retired)
.map(|(name, _)| name.clone())
.collect()
}
}
#[cfg(all(test, feature = "yaml"))]
mod tests {
use super::*;
use crate::document::Document;
fn vocab(text: &str) -> Vocabulary {
let doc = Document::parse("vocab/audiences.yaml", text).unwrap();
Vocabulary::from_meta(&doc.meta).expect("a vocabulary store")
}
#[test]
fn parses_a_closed_vocabulary_with_terms() {
let v = vocab(
"title: Audiences\n\
part_of: /README.md\n\
vocabulary:\n field: audience\n values: closed\n\
terms:\n public:\n means: Anyone\n friends:\n id: aud_k9fp\n",
);
assert_eq!(v.field, "audience");
assert_eq!(v.values, OpenClosed::Closed);
assert!(v.accepts("public"));
assert!(v.accepts("friends"));
assert!(!v.accepts("colleagues"));
assert_eq!(v.terms["friends"].id, Some(Id("aud_k9fp".into())));
}
#[test]
fn a_retired_term_is_known_but_not_accepted() {
let v = vocab(
"vocabulary:\n field: status\n values: closed\n\
terms:\n active: {}\n archived_2024:\n retired: true\n",
);
assert!(v.accepts("active"));
assert!(!v.accepts("archived_2024"), "retired is not a valid value");
assert!(v.is_retired("archived_2024"), "but it is still known");
assert_eq!(v.live_term_names(), vec!["active".to_string()]);
}
#[test]
fn a_document_without_the_marker_is_not_a_vocabulary() {
let doc = Document::parse("notes.md", "---\ntitle: Notes\n---\nbody\n").unwrap();
assert!(Vocabulary::from_meta(&doc.meta).is_none());
}
}