use std::collections::BTreeMap;
use flower_core::schema::{Constraint, FieldRule, Schema};
use flower_core::{Cardinality, FieldType, Icon, PathPat, Presentation, SegPat, Term, Tint};
use prov::{Cardinality as ProvCardinality, OpenClosed, Vocabulary, WorkspaceConfig};
use crate::facets::{self, Facets};
use crate::rules::{path, text, toggle};
pub fn schema_from_config(
config: &WorkspaceConfig,
vocabularies: &BTreeMap<String, Vocabulary>,
) -> Schema {
Schema::new(document_rules(config, vocabularies))
}
pub fn document_rules(
config: &WorkspaceConfig,
vocabularies: &BTreeMap<String, Vocabulary>,
) -> Vec<FieldRule> {
let mut rules = Vec::new();
for (field, spec) in &config.fields {
let ty = spec
.ty
.or_else(|| spec.vocabulary.as_ref().map(|_| FieldType::Str));
let constraint = spec.vocabulary.as_ref().map(|_| Constraint::Enum {
values: vocabularies.get(field).map(vocab_terms).unwrap_or_default(),
closed: matches!(spec.values, OpenClosed::Closed),
});
let icon = if constraint.is_some() {
Icon::Tag
} else {
icon_for(ty)
};
let rule = |at: PathPat| {
FieldRule::new(at)
.ty(ty)
.constraint_opt(constraint.clone())
.present(Presentation::default().icon(icon.clone()))
};
rules.push(rule(PathPat::key(field.clone())));
rules.push(rule(PathPat::each_item_of(field.clone())));
}
let facets = Facets::from_config(config);
let relations = config.relation_set();
let spanning = relations.spanning_relation().map(str::to_string);
for rel in relations.relations() {
let is_spanning = spanning.as_deref() == Some(rel.name.as_str());
let means = facets
.of_key(&rel.name)
.relation()
.and_then(|r| r.means.clone());
let cardinality = match rel.cardinality {
ProvCardinality::One => Cardinality::One,
ProvCardinality::Many => Cardinality::Many,
};
let reference = |at: PathPat| {
FieldRule::new(at)
.ty(FieldType::Ref)
.constraint(Constraint::Reference {
relation: rel.name.clone(),
cardinality,
spanning: is_spanning,
})
.present(
Presentation::default()
.icon(Icon::Link)
.tint(is_spanning.then_some(Tint::Accent))
.description_opt(means.clone()),
)
};
rules.push(reference(PathPat::key(rel.name.clone())));
if matches!(rel.cardinality, ProvCardinality::Many) {
rules.push(reference(PathPat::each_item_of(rel.name.clone())));
}
}
rules.extend(kernel_rules(config));
rules
}
pub fn kernel_rules(config: &WorkspaceConfig) -> Vec<FieldRule> {
let described = |mut rule: FieldRule, why: &str| {
rule.present = std::mem::take(&mut rule.present).description(why);
rule
};
let mut rules = vec![
described(
text(path(&[facets::TITLE_KEY]), "Title", Icon::Text),
"The name a nominal reference resolves against.",
),
described(
text(path(&[facets::IDENTITY_KEY]), "Identity", Icon::Lock),
"Minted by the workspace; references depend on it.",
),
described(
text(path(&[facets::CONTENT_KEY]), "Payload", Icon::Link),
"The opaque file whose bytes are this node's body.",
),
described(
text(path(&[facets::MANIFEST_KEY]), "Manifest", Icon::Link),
"The store listing every opaque file under the directory this node claims.",
),
described(
toggle(path(&[facets::ATTACHMENT_KEY]), "Opaque payload"),
"Read the payload as bytes, not as a document.",
),
described(
text(
path(&[facets::CONTENT_HASH_KEY]),
"Content digest",
Icon::Lock,
),
"Recorded by prov's fixity pass; not typed.",
),
];
if !config.updated.is_empty() {
rules.push(described(
text(path(&[&config.updated]), "Last updated", Icon::Clock),
"Stamped in RFC 3339 UTC when the content changes; prov reads it back.",
));
}
rules.extend(nested_under(
facets::POLICY_KEY,
crate::config_schema::config_rules(config),
));
rules
}
fn nested_under(key: &str, rules: Vec<FieldRule>) -> Vec<FieldRule> {
rules
.into_iter()
.map(|mut rule| {
let mut segments = vec![SegPat::Key(key.to_string())];
segments.extend(rule.at.0);
rule.at = PathPat(segments);
rule
})
.collect()
}
fn icon_for(ty: Option<FieldType>) -> Icon {
use fig::ExtKind::{LocalDate, LocalDateTime, LocalTime, OffsetDateTime};
match ty {
Some(FieldType::Extended(OffsetDateTime | LocalDateTime | LocalDate | LocalTime)) => {
Icon::Clock
}
Some(FieldType::Bool) => Icon::Toggle,
Some(FieldType::Ref) => Icon::Link,
_ => Icon::Text,
}
}
fn vocab_terms(vocab: &Vocabulary) -> Vec<Term> {
vocab
.terms
.iter()
.map(|(name, term)| {
Term::value(name.clone())
.description_opt(term.means.clone())
.retired(term.retired)
})
.collect()
}
#[cfg(test)]
mod tests {
use super::*;
use flower_core::{FieldRuleExt, Seg};
fn audience_config() -> (WorkspaceConfig, BTreeMap<String, Vocabulary>) {
let mut config = WorkspaceConfig::default();
config.fields.insert(
"audience".to_string(),
prov::FieldSpec {
ty: None,
values: OpenClosed::Closed,
vocabulary: Some("audiences.yaml".to_string()),
reify: false,
},
);
config.fields.insert(
"created".to_string(),
prov::FieldSpec {
ty: Some(prov::FieldType::Extended(prov::ExtKind::LocalDate)),
values: OpenClosed::default(),
vocabulary: None,
reify: false,
},
);
let mut terms = BTreeMap::new();
terms.insert(
"public".to_string(),
prov::Term {
id: None,
means: Some("Anyone".to_string()),
retired: false,
},
);
terms.insert(
"private".to_string(),
prov::Term {
id: None,
means: None,
retired: false,
},
);
let mut vocabs = BTreeMap::new();
vocabs.insert(
"audience".to_string(),
Vocabulary {
field: "audience".to_string(),
values: OpenClosed::Closed,
terms,
},
);
(config, vocabs)
}
#[test]
fn a_closed_field_becomes_a_closed_enum_over_each_item() {
let (config, vocabs) = audience_config();
let schema = schema_from_config(&config, &vocabs);
let rule = schema
.rule_for(&[Seg::Key("audience".into()), Seg::Index(0)])
.expect("an each-item rule for audience");
let (terms, closed) = rule.enum_constraint().expect("an enum constraint");
assert!(closed, "the field declares `values: closed`");
assert!(terms.iter().any(|t| t.value == "public"));
assert!(terms.iter().any(|t| t.value == "private"));
assert!(
schema
.rule_for(&[Seg::Key("audience".into())])
.and_then(|r| r.enum_constraint())
.is_some()
);
}
#[test]
fn a_typed_field_without_a_vocabulary_yields_a_typed_unconstrained_rule() {
let (config, vocabs) = audience_config();
let schema = schema_from_config(&config, &vocabs);
let rule = schema
.rule_for(&[Seg::Key("created".into())])
.expect("a rule for created");
assert_eq!(
rule.ty,
Some(FieldType::Extended(fig::ExtKind::LocalDate)),
"the declared type reaches the editor"
);
assert!(
rule.constraint.is_none(),
"a type is not a claim about which values are legal"
);
assert_eq!(rule.present.icon, Some(Icon::Clock));
}
#[test]
fn the_keys_prov_always_reads_are_governed_even_when_nothing_is_declared() {
let schema = schema_from_config(&WorkspaceConfig::default(), &BTreeMap::new());
for (key, icon) in [
("title", Icon::Text),
("id", Icon::Lock),
("content", Icon::Link),
("content_hash", Icon::Lock),
] {
let rule = schema
.rule_for(&[Seg::Key(key.into())])
.unwrap_or_else(|| panic!("a rule for {key}"));
assert_eq!(rule.present.icon.as_ref(), Some(&icon), "{key}");
assert!(rule.present.description.is_some(), "{key} says what it is");
}
let marker = schema
.rule_for(&[Seg::Key("attachment".into())])
.expect("a rule for attachment");
assert_eq!(marker.ty, Some(FieldType::Bool));
}
#[test]
fn the_stamped_field_is_governed_under_the_name_the_workspace_gave_it() {
let bare = schema_from_config(&WorkspaceConfig::default(), &BTreeMap::new());
assert!(bare.rule_for(&[Seg::Key("modified".into())]).is_none());
let config = WorkspaceConfig {
updated: "modified".to_string(),
..WorkspaceConfig::default()
};
let schema = schema_from_config(&config, &BTreeMap::new());
let rule = schema
.rule_for(&[Seg::Key("modified".into())])
.expect("the workspace's own stamp name");
assert_eq!(rule.present.icon, Some(Icon::Clock));
}
#[test]
fn the_roots_inline_policy_block_is_governed_by_the_config_documents_rules() {
let schema = schema_from_config(&WorkspaceConfig::default(), &BTreeMap::new());
let inline = schema
.rule_for(&[Seg::Key("prov".into()), Seg::Key("fixity".into())])
.expect("prov.fixity");
let (terms, closed) = inline.enum_constraint().expect("a picker, not a text box");
assert!(closed);
assert!(terms.iter().any(|t| t.value == "attachments"));
assert!(schema.rule_for(&[Seg::Key("fixity".into())]).is_none());
}
#[test]
fn a_declared_field_shadows_the_kernel_rule_of_the_same_name() {
let mut config = WorkspaceConfig::default();
config.fields.insert(
"title".to_string(),
prov::FieldSpec {
ty: None,
values: OpenClosed::Closed,
vocabulary: Some("titles.yaml".to_string()),
reify: false,
},
);
let schema = schema_from_config(&config, &BTreeMap::new());
let rule = schema
.rule_for(&[Seg::Key("title".into())])
.expect("a rule for title");
assert!(
rule.enum_constraint().is_some(),
"the workspace's declaration, not prov's kernel rule"
);
}
#[test]
fn a_relations_gloss_travels_onto_its_row() {
let schema = schema_from_config(&WorkspaceConfig::default(), &BTreeMap::new());
let rule = schema
.rule_for(&[Seg::Key("part_of".into())])
.expect("a rule for part_of");
assert_eq!(
rule.present.description.as_deref(),
Some("the document that contains this one")
);
}
#[test]
fn the_spanning_relation_becomes_a_spanning_reference() {
let (config, vocabs) = audience_config();
let schema = schema_from_config(&config, &vocabs);
let rule = schema
.rule_for(&[Seg::Key("contents".into())])
.expect("a rule for contents");
match &rule.constraint {
Some(Constraint::Reference {
relation, spanning, ..
}) => {
assert_eq!(relation, "contents");
assert!(*spanning, "contents is the spanning backbone");
}
other => panic!("expected a spanning reference, got {other:?}"),
}
let part_of = schema
.rule_for(&[Seg::Key("part_of".into())])
.expect("a rule for part_of");
assert_eq!(part_of.reference(), Some("part_of"));
assert!(matches!(
part_of.constraint,
Some(Constraint::Reference {
spanning: false,
..
})
));
}
}