use std::collections::BTreeMap;
use fig::ExtKind;
use fig_schema::FieldType;
use crate::textdist::nearest;
use prov_exports::{ExportIssueKind, ExportSpec};
pub use prov_fixity::Fixity;
use prov_graph::content::ContentFormat;
use prov_graph::document::EmbedStyle;
use prov_graph::link::{Addressing, LinkStyle, Notation, PathStyle, ReferenceStyle};
use prov_graph::meta::{Mapping, Value};
use prov_graph::relation::{Cardinality, Relation, RelationSet};
use prov_identity::{Registration, Trigger};
use prov_views::{ViewIssueKind, ViewSpec};
pub use prov_graph::identity::IdStorage;
pub const SPEC_VERSION: i64 = 1;
pub const ROOT_CONFIG_KEY: &str = "prov";
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
pub struct RelationStyleConfig {
pub notation: Option<Notation>,
pub path_style: Option<PathStyle>,
pub target: Option<Addressing>,
pub label: Option<bool>,
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct RelationDef {
pub cardinality: Option<Cardinality>,
pub inverse: Option<String>,
pub means: Option<String>,
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
pub enum OpenClosed {
#[default]
Open,
Closed,
}
impl OpenClosed {
pub fn from_config_str(value: &str) -> Option<Self> {
match value {
"open" => Some(Self::Open),
"closed" => Some(Self::Closed),
_ => None,
}
}
pub fn as_config_str(self) -> &'static str {
match self {
Self::Open => "open",
Self::Closed => "closed",
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct FieldSpec {
pub ty: Option<FieldType>,
pub values: OpenClosed,
pub vocabulary: Option<String>,
pub reify: bool,
}
pub const FIELD_TYPES: &[&str] = &[
"str",
"bool",
"int",
"float",
"date",
"datetime",
"local-datetime",
"time",
"ref",
"map",
"seq",
];
pub fn field_type_from_config_str(value: &str) -> Option<FieldType> {
Some(match value {
"str" => FieldType::Str,
"bool" => FieldType::Bool,
"int" => FieldType::Int,
"float" => FieldType::Float,
"datetime" => FieldType::Extended(ExtKind::OffsetDateTime),
"local-datetime" => FieldType::Extended(ExtKind::LocalDateTime),
"date" => FieldType::Extended(ExtKind::LocalDate),
"time" => FieldType::Extended(ExtKind::LocalTime),
"ref" => FieldType::Ref,
"map" => FieldType::Map,
"seq" => FieldType::Seq,
_ => return None,
})
}
pub fn field_type_as_config_str(ty: FieldType) -> Option<&'static str> {
Some(match ty {
FieldType::Str => "str",
FieldType::Bool => "bool",
FieldType::Int => "int",
FieldType::Float => "float",
FieldType::Ref => "ref",
FieldType::Map => "map",
FieldType::Seq => "seq",
FieldType::Extended(ExtKind::OffsetDateTime) => "datetime",
FieldType::Extended(ExtKind::LocalDateTime) => "local-datetime",
FieldType::Extended(ExtKind::LocalDate) => "date",
FieldType::Extended(ExtKind::LocalTime) => "time",
FieldType::Null | FieldType::Extended(_) => return None,
_ => return None,
})
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
pub enum History {
#[default]
Off,
Manual,
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
pub enum About {
Off,
#[default]
Structure,
}
impl About {
pub fn generates(self) -> bool {
matches!(self, About::Structure)
}
pub fn from_config_str(value: &str) -> Option<Self> {
match value {
"off" => Some(Self::Off),
"structure" => Some(Self::Structure),
_ => None,
}
}
pub fn as_config_str(self) -> &'static str {
match self {
Self::Off => "off",
Self::Structure => "structure",
}
}
}
impl History {
pub fn captures(self) -> bool {
matches!(self, History::Manual)
}
pub fn from_config_str(value: &str) -> Option<Self> {
match value {
"off" => Some(Self::Off),
"manual" => Some(Self::Manual),
_ => None,
}
}
pub fn as_config_str(self) -> &'static str {
match self {
Self::Off => "off",
Self::Manual => "manual",
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct WorkspaceConfig {
pub identity: Registration,
pub notation: Notation,
pub path_style: PathStyle,
pub reference_target: Addressing,
pub reference_label: bool,
pub relation_styles: BTreeMap<String, RelationStyleConfig>,
pub spanning: Option<String>,
pub relation_defs: BTreeMap<String, RelationDef>,
pub fields: BTreeMap<String, FieldSpec>,
pub views: Vec<ViewSpec>,
pub exports: Vec<ExportSpec>,
pub id_storage: IdStorage,
pub default_embed_format: fig::Format,
pub embed_style: EmbedStyle,
pub content_format: ContentFormat,
pub recycle_bin: bool,
pub fixity: Fixity,
pub history: History,
pub about: About,
pub updated: String,
pub workspace_id: String,
}
pub use prov_graph::link::is_valid_workspace_id;
impl Default for WorkspaceConfig {
fn default() -> Self {
Self {
identity: Registration::LAZY,
notation: Notation::Markdown,
path_style: PathStyle::Root,
reference_target: Addressing::Path,
reference_label: false,
relation_styles: BTreeMap::new(),
spanning: None,
relation_defs: BTreeMap::new(),
fields: BTreeMap::new(),
views: Vec::new(),
exports: Vec::new(),
id_storage: IdStorage::Frontmatter,
default_embed_format: fig::Format::Yaml,
embed_style: EmbedStyle::Delimited,
content_format: ContentFormat::Markdown,
recycle_bin: true,
fixity: Fixity::Payloads,
history: History::Off,
about: About::Structure,
updated: String::new(),
workspace_id: String::new(),
}
}
}
impl WorkspaceConfig {
pub fn paths_only() -> Self {
Self {
identity: Registration::OFF,
id_storage: IdStorage::Registry,
..Self::default()
}
}
pub fn stable_ids() -> Self {
Self {
identity: Registration::LAZY,
reference_target: Addressing::Id,
id_storage: IdStorage::Registry,
..Self::default()
}
}
pub fn link_format(&self) -> LinkStyle {
LinkStyle::from_axes(self.notation, self.path_style)
}
pub fn reference_style(&self) -> ReferenceStyle {
ReferenceStyle {
wrapper: self.notation.wrapper(),
addressing: self.reference_target,
label: self.reference_label,
path_style: LinkStyle::from_axes(self.notation, self.path_style),
}
.normalized()
}
pub fn resolved_relation_styles(&self) -> BTreeMap<String, ReferenceStyle> {
let base = self.reference_style();
let base_notation = Notation::from_wrapper(base.wrapper, base.path_style);
let base_path = base.path_style.axes().1;
self.relation_styles
.iter()
.map(|(name, over)| {
let notation = over.notation.unwrap_or(base_notation);
let path = over.path_style.unwrap_or(base_path);
let style = ReferenceStyle {
wrapper: notation.wrapper(),
addressing: over.target.unwrap_or(base.addressing),
label: over.label.unwrap_or(base.label),
path_style: LinkStyle::from_axes(notation, path),
}
.normalized();
(name.clone(), style)
})
.collect()
}
pub fn relation_set(&self) -> RelationSet {
let mut set = if self.relation_defs.is_empty() {
RelationSet::diaryx()
} else {
let mut s = RelationSet::new();
for (name, def) in &self.relation_defs {
let mut rel = match def.cardinality.unwrap_or(Cardinality::Many) {
Cardinality::One => Relation::one(name),
Cardinality::Many => Relation::many(name),
};
if let Some(inverse) = &def.inverse {
rel = rel.inverse(inverse);
}
s = s.with(rel);
}
for pointer in ["registry", "config", "recycle_bin", "history", "about"] {
if !s.relations().iter().any(|r| r.name == pointer) {
s = s.with(Relation::one(pointer));
}
}
s.registry("registry")
.config("config")
.recycle("recycle_bin")
.history("history")
.about("about")
};
if let Some(spanning) = &self.spanning {
set = set.spanning(spanning);
}
set.with_styles(&self.resolved_relation_styles())
}
pub fn mints_on_mutation(&self) -> bool {
let link_registers = self.reference_style().registers()
|| self
.resolved_relation_styles()
.values()
.any(|s| s.registers());
(link_registers && self.identity.fires_on(Trigger::Link))
|| self.identity.fires_on(Trigger::Create)
}
pub fn apply(&mut self, meta: &Value) {
if let Some(v) = meta
.get("content_format")
.and_then(Value::as_str)
.and_then(ContentFormat::from_config_str)
{
self.content_format = v;
}
if let Some(md) = meta.get("metadata") {
if let Some(v) = md
.get("format")
.and_then(Value::as_str)
.and_then(format_from_str)
{
self.default_embed_format = v;
}
if let Some(v) = md
.get("embed")
.and_then(Value::as_str)
.and_then(EmbedStyle::from_config_str)
{
self.embed_style = v;
}
}
if let Some(rf) = meta.get("references") {
if let Some(v) = rf
.get("notation")
.and_then(Value::as_str)
.and_then(Notation::from_config_str)
{
self.notation = v;
}
if let Some(v) = rf
.get("path_style")
.and_then(Value::as_str)
.and_then(PathStyle::from_config_str)
{
self.path_style = v;
}
if let Some(v) = rf
.get("target")
.and_then(Value::as_str)
.and_then(Addressing::from_config_str)
{
self.reference_target = v;
}
if let Some(v) = rf.get("label").and_then(Value::as_bool) {
self.reference_label = v;
}
}
if let Some(v) = meta.get("spanning").and_then(Value::as_str) {
self.spanning = Some(v.to_string());
}
if let Some(v) = meta
.get("workspace_id")
.and_then(Value::as_str)
.filter(|v| is_valid_workspace_id(v))
{
self.workspace_id = v.to_string();
}
if let Some(relations) = meta.get("relations").and_then(Value::as_mapping) {
for (name, spec) in relations {
let entry = self.relation_styles.entry(name.clone()).or_default();
if let Some(v) = spec
.get("notation")
.and_then(Value::as_str)
.and_then(Notation::from_config_str)
{
entry.notation = Some(v);
}
if let Some(v) = spec
.get("path_style")
.and_then(Value::as_str)
.and_then(PathStyle::from_config_str)
{
entry.path_style = Some(v);
}
if let Some(v) = spec
.get("target")
.and_then(Value::as_str)
.and_then(Addressing::from_config_str)
{
entry.target = Some(v);
}
if let Some(v) = spec.get("label").and_then(Value::as_bool) {
entry.label = Some(v);
}
let cardinality = spec
.get("cardinality")
.and_then(Value::as_str)
.and_then(cardinality_from_str);
let inverse = spec
.get("inverse")
.and_then(Value::as_str)
.map(str::to_string);
let means = spec
.get("means")
.and_then(Value::as_str)
.map(str::to_string);
if cardinality.is_some() || inverse.is_some() || means.is_some() {
let def = self.relation_defs.entry(name.clone()).or_default();
if cardinality.is_some() {
def.cardinality = cardinality;
}
if inverse.is_some() {
def.inverse = inverse;
}
if means.is_some() {
def.means = means;
}
}
}
}
if let Some(fields) = meta.get("fields").and_then(Value::as_mapping) {
for (name, spec) in fields {
let vocabulary = spec
.get("vocabulary")
.and_then(Value::as_str)
.map(str::to_string);
let ty = spec
.get("type")
.and_then(Value::as_str)
.and_then(field_type_from_config_str);
if ty.is_none() && vocabulary.is_none() {
continue;
}
let values = spec
.get("values")
.and_then(Value::as_str)
.and_then(OpenClosed::from_config_str)
.unwrap_or_default();
let reify = spec.get("reify").and_then(Value::as_bool).unwrap_or(false);
self.fields.insert(
name.clone(),
FieldSpec {
ty,
values,
vocabulary,
reify,
},
);
}
}
if let Some(views) = meta.get(prov_views::VIEWS_KEY).and_then(Value::as_mapping) {
for (name, value) in views {
let Some(spec) = ViewSpec::parse(name, value) else {
continue;
};
match self.views.iter_mut().find(|v| v.name == spec.name) {
Some(existing) => *existing = spec,
None => self.views.push(spec),
}
}
}
if let Some(exports) = meta
.get(prov_exports::EXPORTS_KEY)
.and_then(Value::as_mapping)
{
for (name, value) in exports {
let Some(spec) = ExportSpec::parse(name, value) else {
continue;
};
match self.exports.iter_mut().find(|e| e.name == spec.name) {
Some(existing) => *existing = spec,
None => self.exports.push(spec),
}
}
}
if let Some(v) = meta
.get("id_storage")
.and_then(Value::as_str)
.and_then(IdStorage::from_config_str)
{
self.id_storage = v;
}
if let Some(v) = meta.get("updated").and_then(Value::as_str) {
self.updated = v.to_string();
}
if let Some(v) = meta
.get("identity")
.and_then(Value::as_str)
.and_then(registration_from_str)
{
self.identity = v;
}
if let Some(v) = meta
.get("fixity")
.and_then(Value::as_str)
.and_then(Fixity::from_config_str)
{
self.fixity = v;
}
if let Some(v) = meta.get("recycle_bin").and_then(Value::as_bool) {
self.recycle_bin = v;
}
if let Some(v) = meta
.get("history")
.and_then(Value::as_str)
.and_then(History::from_config_str)
{
self.history = v;
}
if let Some(v) = meta
.get("about")
.and_then(Value::as_str)
.and_then(About::from_config_str)
{
self.about = v;
}
}
pub fn from_meta(meta: &Value) -> Self {
let mut config = Self::default();
config.apply(meta);
config
}
pub fn to_mapping(&self) -> Mapping {
let mut map = Mapping::new();
map.insert("spec".into(), Value::Int(SPEC_VERSION));
map.insert(
"content_format".into(),
Value::String(self.content_format.as_config_str().into()),
);
let mut metadata = Mapping::new();
metadata.insert(
"format".into(),
Value::String(format_str(self.default_embed_format).into()),
);
metadata.insert(
"embed".into(),
Value::String(self.embed_style.as_config_str().into()),
);
map.insert("metadata".into(), Value::Mapping(metadata));
let mut references = Mapping::new();
references.insert(
"notation".into(),
Value::String(self.notation.as_config_str().into()),
);
references.insert(
"path_style".into(),
Value::String(self.path_style.as_config_str().into()),
);
references.insert(
"target".into(),
Value::String(self.reference_target.as_config_str().into()),
);
references.insert("label".into(), Value::Bool(self.reference_label));
map.insert("references".into(), Value::Mapping(references));
if let Some(spanning) = &self.spanning {
map.insert("spanning".into(), Value::String(spanning.clone()));
}
if !self.relation_styles.is_empty() || !self.relation_defs.is_empty() {
let mut names: Vec<&String> = self
.relation_styles
.keys()
.chain(self.relation_defs.keys())
.collect();
names.sort();
names.dedup();
let mut relations = Mapping::new();
for name in names {
let mut spec = Mapping::new();
if let Some(over) = self.relation_styles.get(name) {
if let Some(n) = over.notation {
spec.insert("notation".into(), Value::String(n.as_config_str().into()));
}
if let Some(p) = over.path_style {
spec.insert("path_style".into(), Value::String(p.as_config_str().into()));
}
if let Some(t) = over.target {
spec.insert("target".into(), Value::String(t.as_config_str().into()));
}
if let Some(l) = over.label {
spec.insert("label".into(), Value::Bool(l));
}
}
if let Some(def) = self.relation_defs.get(name) {
if let Some(c) = def.cardinality {
spec.insert(
"cardinality".into(),
Value::String(cardinality_str(c).into()),
);
}
if let Some(inv) = &def.inverse {
spec.insert("inverse".into(), Value::String(inv.clone()));
}
if let Some(m) = &def.means {
spec.insert("means".into(), Value::String(m.clone()));
}
}
relations.insert(name.clone(), Value::Mapping(spec));
}
map.insert("relations".into(), Value::Mapping(relations));
}
if !self.fields.is_empty() {
let mut fields = Mapping::new();
for (name, spec) in &self.fields {
let mut entry = Mapping::new();
if let Some(ty) = spec.ty.and_then(field_type_as_config_str) {
entry.insert("type".into(), Value::String(ty.into()));
}
if let Some(vocabulary) = &spec.vocabulary {
entry.insert(
"values".into(),
Value::String(spec.values.as_config_str().into()),
);
entry.insert("vocabulary".into(), Value::String(vocabulary.clone()));
}
if spec.reify {
entry.insert("reify".into(), Value::Bool(true));
}
fields.insert(name.clone(), Value::Mapping(entry));
}
map.insert("fields".into(), Value::Mapping(fields));
}
if !self.views.is_empty() {
let mut views = Mapping::new();
for spec in &self.views {
views.insert(spec.name.clone(), Value::Mapping(spec.to_mapping()));
}
map.insert(prov_views::VIEWS_KEY.into(), Value::Mapping(views));
}
if !self.exports.is_empty() {
let mut exports = Mapping::new();
for spec in &self.exports {
exports.insert(spec.name.clone(), Value::Mapping(spec.to_mapping()));
}
map.insert(prov_exports::EXPORTS_KEY.into(), Value::Mapping(exports));
}
map.insert(
"id_storage".into(),
Value::String(self.id_storage.as_config_str().into()),
);
map.insert("updated".into(), Value::String(self.updated.clone()));
map.insert(
"identity".into(),
Value::String(registration_str(self.identity).into()),
);
map.insert(
"fixity".into(),
Value::String(self.fixity.as_config_str().into()),
);
map.insert("recycle_bin".into(), Value::Bool(self.recycle_bin));
map.insert(
"history".into(),
Value::String(self.history.as_config_str().into()),
);
map.insert(
"about".into(),
Value::String(self.about.as_config_str().into()),
);
map.insert(
"workspace_id".into(),
Value::String(self.workspace_id.clone()),
);
map
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ConfigIssue {
pub key: String,
pub kind: ConfigIssueKind,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum ConfigIssueKind {
UnknownKey { suggestion: String },
InvalidValue {
value: String,
expected: Vec<String>,
},
SpanningNotSingleParent { inverse: String },
NestNotSingleValued { field: String },
MalformedWorkspaceId { value: String },
}
const TOP_KEYS: &[&str] = &[
"spec",
"content_format",
"metadata",
"references",
"relations",
"spanning",
"fields",
"views",
"exports",
"id_storage",
"updated",
"workspace_id",
"identity",
"fixity",
"recycle_bin",
"history",
"about",
];
const METADATA_KEYS: &[&str] = &["format", "embed"];
const REFERENCE_KEYS: &[&str] = &["notation", "path_style", "target", "label"];
const RELATION_DEF_KEYS: &[&str] = &["cardinality", "inverse", "means"];
const FIELD_KEYS: &[&str] = &["type", "values", "vocabulary", "reify"];
pub fn spec_ahead(meta: &Value) -> Option<i64> {
match meta.get("spec") {
Some(Value::Int(v)) if *v > SPEC_VERSION => Some(*v),
_ => None,
}
}
pub fn diagnose(meta: &Value) -> Vec<ConfigIssue> {
let mut issues = Vec::new();
let Some(map) = meta.as_mapping() else {
return issues;
};
for (key, value) in map {
match key.as_str() {
"spec" => {} "content_format" => {
enum_axis(
&mut issues,
key,
value,
|s| ContentFormat::from_config_str(s).is_some(),
&["markdown", "djot", "html"],
);
}
"id_storage" => {
enum_axis(
&mut issues,
key,
value,
|s| IdStorage::from_config_str(s).is_some(),
&["registry", "frontmatter", "both"],
);
}
"identity" => {
enum_axis(
&mut issues,
key,
value,
|s| registration_from_str(s).is_some(),
&["none", "lazy", "eager"],
);
}
"fixity" => {
enum_axis(
&mut issues,
key,
value,
|s| Fixity::from_config_str(s).is_some(),
&["off", "attachments", "all"],
);
}
"recycle_bin" => bool_axis(&mut issues, key, value),
"history" => {
enum_axis(
&mut issues,
key,
value,
|s| History::from_config_str(s).is_some(),
&["off", "manual"],
);
}
"about" => {
enum_axis(
&mut issues,
key,
value,
|s| About::from_config_str(s).is_some(),
&["off", "structure"],
);
}
"updated" => {} "workspace_id" => {
let ok = match value.as_str() {
Some(s) => s.is_empty() || is_valid_workspace_id(s),
None => false,
};
if !ok {
issues.push(ConfigIssue {
key: key.clone(),
kind: ConfigIssueKind::MalformedWorkspaceId {
value: value_summary(value),
},
});
}
}
"spanning" => {
if value.as_str().is_none() {
issues.push(ConfigIssue {
key: key.clone(),
kind: ConfigIssueKind::InvalidValue {
value: value_summary(value),
expected: vec!["a relation name".into()],
},
});
}
}
"metadata" => diagnose_metadata(&mut issues, value),
"references" => diagnose_reference_block(&mut issues, "references", value),
"relations" => diagnose_relations(&mut issues, value),
"fields" => diagnose_fields(&mut issues, value),
"views" => diagnose_views(&mut issues, value, map),
"exports" => diagnose_exports(&mut issues, value, map),
other => {
if let Some(suggestion) = nearest(other, TOP_KEYS) {
issues.push(unknown(key.clone(), suggestion));
}
}
}
}
diagnose_spanning_invariant(&mut issues, map);
issues
}
fn diagnose_spanning_invariant(issues: &mut Vec<ConfigIssue>, map: &Mapping) {
let Some(spanning) = map.get("spanning").and_then(Value::as_str) else {
return;
};
let Some(relations) = map.get("relations").and_then(Value::as_mapping) else {
return;
};
let Some(inverse) = relations
.get(spanning)
.and_then(Value::as_mapping)
.and_then(|r| r.get("inverse"))
.and_then(Value::as_str)
else {
return;
};
let inverse_cardinality = relations
.get(inverse)
.and_then(Value::as_mapping)
.and_then(|r| r.get("cardinality"))
.and_then(Value::as_str);
if inverse_cardinality == Some("many") {
issues.push(ConfigIssue {
key: "spanning".into(),
kind: ConfigIssueKind::SpanningNotSingleParent {
inverse: inverse.to_string(),
},
});
}
}
fn diagnose_metadata(issues: &mut Vec<ConfigIssue>, value: &Value) {
let Some(map) = value.as_mapping() else {
return block_shape_issue(issues, "metadata", value);
};
for (key, v) in map {
let dotted = format!("metadata.{key}");
match key.as_str() {
"format" => enum_axis(
issues,
&dotted,
v,
|s| format_from_str(s).is_some(),
&embed_format_spellings(),
),
"embed" => enum_axis(
issues,
&dotted,
v,
|s| EmbedStyle::from_config_str(s).is_some(),
&[
"delimited",
"code_block",
"html_script",
"html_code",
"separate",
],
),
other => {
if let Some(sug) = nearest(other, METADATA_KEYS) {
issues.push(unknown(dotted, format!("metadata.{sug}")));
}
}
}
}
}
fn diagnose_reference_block(issues: &mut Vec<ConfigIssue>, prefix: &str, value: &Value) {
let Some(map) = value.as_mapping() else {
return block_shape_issue(issues, prefix, value);
};
for (key, v) in map {
let dotted = format!("{prefix}.{key}");
match key.as_str() {
"notation" => enum_axis(
issues,
&dotted,
v,
|s| Notation::from_config_str(s).is_some(),
&["markdown", "wikilink", "bare"],
),
"path_style" => enum_axis(
issues,
&dotted,
v,
|s| PathStyle::from_config_str(s).is_some(),
&["root", "relative"],
),
"target" => enum_axis(
issues,
&dotted,
v,
|s| Addressing::from_config_str(s).is_some(),
&["path", "id", "alias"],
),
"label" => bool_axis(issues, &dotted, v),
other => {
if let Some(sug) = nearest(other, REFERENCE_KEYS) {
issues.push(unknown(dotted, format!("{prefix}.{sug}")));
}
}
}
}
}
fn diagnose_relations(issues: &mut Vec<ConfigIssue>, value: &Value) {
let Some(map) = value.as_mapping() else {
return block_shape_issue(issues, "relations", value);
};
for (name, spec) in map {
diagnose_relation_entry(issues, name, spec);
}
}
fn diagnose_relation_entry(issues: &mut Vec<ConfigIssue>, name: &str, value: &Value) {
let prefix = format!("relations.{name}");
let Some(map) = value.as_mapping() else {
return block_shape_issue(issues, &prefix, value);
};
for (key, v) in map {
let dotted = format!("{prefix}.{key}");
match key.as_str() {
"notation" => enum_axis(
issues,
&dotted,
v,
|s| Notation::from_config_str(s).is_some(),
&["markdown", "wikilink", "bare"],
),
"path_style" => enum_axis(
issues,
&dotted,
v,
|s| PathStyle::from_config_str(s).is_some(),
&["root", "relative"],
),
"target" => enum_axis(
issues,
&dotted,
v,
|s| Addressing::from_config_str(s).is_some(),
&["path", "id", "alias"],
),
"label" => bool_axis(issues, &dotted, v),
"cardinality" => enum_axis(
issues,
&dotted,
v,
|s| cardinality_from_str(s).is_some(),
&["one", "many"],
),
"inverse" => {
if v.as_str().is_none() {
issues.push(ConfigIssue {
key: dotted,
kind: ConfigIssueKind::InvalidValue {
value: value_summary(v),
expected: vec!["a relation name".into()],
},
});
}
}
"means" => {} other => {
let mut valid: Vec<&str> = REFERENCE_KEYS.to_vec();
valid.extend_from_slice(RELATION_DEF_KEYS);
if let Some(sug) = nearest(other, &valid) {
issues.push(unknown(dotted, format!("{prefix}.{sug}")));
}
}
}
}
}
fn diagnose_fields(issues: &mut Vec<ConfigIssue>, value: &Value) {
let Some(map) = value.as_mapping() else {
return block_shape_issue(issues, "fields", value);
};
for (name, spec) in map {
let prefix = format!("fields.{name}");
let Some(entry) = spec.as_mapping() else {
block_shape_issue(issues, &prefix, spec);
continue;
};
for (key, v) in entry {
let dotted = format!("{prefix}.{key}");
match key.as_str() {
"type" => enum_axis(
issues,
&dotted,
v,
|s| field_type_from_config_str(s).is_some(),
FIELD_TYPES,
),
"values" => enum_axis(
issues,
&dotted,
v,
|s| OpenClosed::from_config_str(s).is_some(),
&["open", "closed"],
),
"vocabulary" => {
if v.as_str().is_none() {
issues.push(ConfigIssue {
key: dotted,
kind: ConfigIssueKind::InvalidValue {
value: value_summary(v),
expected: vec!["a link to a vocabulary document".into()],
},
});
}
}
"reify" => bool_axis(issues, &dotted, v),
other => {
if let Some(sug) = nearest(other, FIELD_KEYS) {
issues.push(unknown(dotted, format!("{prefix}.{sug}")));
}
}
}
}
}
}
fn diagnose_views(issues: &mut Vec<ConfigIssue>, value: &Value, surface: &Mapping) {
let Some(map) = value.as_mapping() else {
return block_shape_issue(issues, "views", value);
};
for (name, spec) in map {
let prefix = format!("views.{name}");
diagnose_nest_is_fileable(issues, &prefix, spec, surface);
for issue in prov_views::diagnose_view(name, spec) {
let dotted = match issue.key.as_str() {
"" => prefix.clone(),
key => format!("{prefix}.{key}"),
};
let expected = || issue.kind.expected().iter().map(|s| (*s).into()).collect();
match &issue.kind {
ViewIssueKind::NotAMapping => block_shape_issue(issues, &prefix, spec),
ViewIssueKind::NoGrouping => issues.push(ConfigIssue {
key: dotted,
kind: ConfigIssueKind::InvalidValue {
value: spec
.get("group")
.map_or_else(|| "(absent)".to_string(), value_summary),
expected: vec![
"a field name, or a list of field names to try in order".into(),
],
},
}),
ViewIssueKind::BadGrain => issues.push(ConfigIssue {
key: dotted.clone(),
kind: ConfigIssueKind::InvalidValue {
value: spec
.get(&issue.key)
.map_or_else(|| "(absent)".to_string(), value_summary),
expected: expected(),
},
}),
ViewIssueKind::NoCondition => issues.push(ConfigIssue {
key: dotted,
kind: ConfigIssueKind::InvalidValue {
value: spec
.get("where")
.map_or_else(|| "(absent)".to_string(), value_summary),
expected: expected(),
},
}),
ViewIssueKind::UnknownKey => {
if let Some(sug) = nearest(&issue.key, prov_views::VIEW_KEYS) {
issues.push(unknown(dotted, format!("{prefix}.{sug}")));
}
}
}
}
}
}
fn diagnose_nest_is_fileable(
issues: &mut Vec<ConfigIssue>,
prefix: &str,
spec: &Value,
surface: &Mapping,
) {
if spec.get("nest").is_none() {
return;
}
let Some(fields) = surface.get("fields").and_then(Value::as_mapping) else {
return;
};
let Some(view) = prov_views::ViewSpec::parse("", spec) else {
return;
};
let multi: Vec<&String> = view
.group
.keys
.iter()
.filter(|key| {
fields
.get(*key)
.and_then(|f| f.get("type"))
.and_then(Value::as_str)
.and_then(field_type_from_config_str)
== Some(FieldType::Seq)
})
.collect();
if let Some(field) = multi.first() {
issues.push(ConfigIssue {
key: format!("{prefix}.nest"),
kind: ConfigIssueKind::NestNotSingleValued {
field: (*field).clone(),
},
});
}
}
fn diagnose_exports(issues: &mut Vec<ConfigIssue>, value: &Value, surface: &Mapping) {
let Some(map) = value.as_mapping() else {
return block_shape_issue(issues, "exports", value);
};
for (name, spec) in map {
let prefix = format!("exports.{name}");
diagnose_export_view_is_declared(issues, &prefix, spec, surface);
for issue in prov_exports::diagnose_export(name, spec) {
match &issue.kind {
ExportIssueKind::NotAMapping => block_shape_issue(issues, &prefix, spec),
ExportIssueKind::NoGate => issues.push(ConfigIssue {
key: format!("{prefix}.gate"),
kind: ConfigIssueKind::InvalidValue {
value: spec
.get("gate")
.map_or_else(|| "(absent)".to_string(), value_summary),
expected: vec![
"a mapping with `field` and `value` — the field a document \
declares its membership in, and the value that admits it"
.into(),
],
},
}),
ExportIssueKind::UnknownKey => {
if let Some(sug) = nearest(&issue.key, prov_exports::EXPORT_KEYS) {
issues.push(unknown(
format!("{prefix}.{}", issue.key),
format!("{prefix}.{sug}"),
));
}
}
ExportIssueKind::GateUnknownKey => {
if let Some(sug) = nearest(&issue.key, prov_exports::GATE_KEYS) {
issues.push(unknown(
format!("{prefix}.gate.{}", issue.key),
format!("{prefix}.gate.{sug}"),
));
}
}
}
}
}
}
fn diagnose_export_view_is_declared(
issues: &mut Vec<ConfigIssue>,
prefix: &str,
spec: &Value,
surface: &Mapping,
) {
let Some(named) = spec.get("view").and_then(Value::as_str).map(str::trim) else {
return;
};
let Some(views) = surface
.get(prov_views::VIEWS_KEY)
.and_then(Value::as_mapping)
else {
return;
};
if named.is_empty() || views.contains_key(named) {
return;
}
let declared: Vec<String> = views.keys().cloned().collect();
issues.push(ConfigIssue {
key: format!("{prefix}.view"),
kind: ConfigIssueKind::InvalidValue {
value: named.to_string(),
expected: declared,
},
});
}
fn block_shape_issue(issues: &mut Vec<ConfigIssue>, key: &str, value: &Value) {
issues.push(ConfigIssue {
key: key.to_string(),
kind: ConfigIssueKind::InvalidValue {
value: value_summary(value),
expected: vec!["a block of keys".into()],
},
});
}
fn enum_axis(
issues: &mut Vec<ConfigIssue>,
key: &str,
value: &Value,
parses: impl Fn(&str) -> bool,
expected: &[&str],
) {
if !value.as_str().is_some_and(parses) {
issues.push(ConfigIssue {
key: key.to_string(),
kind: ConfigIssueKind::InvalidValue {
value: value_summary(value),
expected: expected.iter().map(|s| s.to_string()).collect(),
},
});
}
}
fn bool_axis(issues: &mut Vec<ConfigIssue>, key: &str, value: &Value) {
if value.as_bool().is_none() {
issues.push(ConfigIssue {
key: key.to_string(),
kind: ConfigIssueKind::InvalidValue {
value: value_summary(value),
expected: vec!["true".into(), "false".into()],
},
});
}
}
fn unknown(key: String, suggestion: String) -> ConfigIssue {
ConfigIssue {
key,
kind: ConfigIssueKind::UnknownKey { suggestion },
}
}
fn embed_format_spellings() -> Vec<&'static str> {
#[allow(unused_mut)]
let mut v = vec!["yaml"];
#[cfg(feature = "json")]
v.push("json");
#[cfg(feature = "toml")]
v.push("toml");
#[cfg(feature = "fig-lang")]
v.push("fig");
v
}
fn value_summary(value: &Value) -> String {
match value {
Value::String(s) => s.clone(),
Value::Bool(b) => b.to_string(),
Value::Int(i) => i.to_string(),
Value::Float(f) => f.to_string(),
_ => "(non-scalar)".to_string(),
}
}
pub fn metadata_format_from_str(value: &str) -> Option<fig::Format> {
format_from_str(value)
}
pub fn metadata_format_str(format: fig::Format) -> &'static str {
format_str(format)
}
fn format_from_str(value: &str) -> Option<fig::Format> {
match value {
"yaml" | "yml" => Some(fig::Format::Yaml),
#[cfg(feature = "json")]
"json" => Some(fig::Format::Json),
#[cfg(feature = "toml")]
"toml" => Some(fig::Format::Toml),
#[cfg(feature = "fig-lang")]
"fig" => Some(fig::Format::Fig),
_ => None,
}
}
fn format_str(format: fig::Format) -> &'static str {
match format {
#[cfg(feature = "json")]
fig::Format::Json => "json",
#[cfg(feature = "toml")]
fig::Format::Toml => "toml",
#[cfg(feature = "fig-lang")]
fig::Format::Fig => "fig",
_ => "yaml",
}
}
fn cardinality_from_str(value: &str) -> Option<Cardinality> {
match value {
"one" => Some(Cardinality::One),
"many" => Some(Cardinality::Many),
_ => None,
}
}
fn cardinality_str(cardinality: Cardinality) -> &'static str {
match cardinality {
Cardinality::One => "one",
Cardinality::Many => "many",
}
}
fn registration_from_str(value: &str) -> Option<Registration> {
match value {
"none" | "off" => Some(Registration::OFF),
"lazy" => Some(Registration::LAZY),
"eager" => Some(Registration::EAGER),
_ => None,
}
}
fn registration_str(registration: Registration) -> &'static str {
match registration {
Registration::OFF => "none",
Registration::EAGER => "eager",
_ => "lazy",
}
}
#[cfg(test)]
mod tests {
use super::*;
use prov_identity::Trigger;
fn config_doc(pairs: &[(&str, &str)]) -> Value {
let mut map = Mapping::new();
for (k, v) in pairs {
let value = match *v {
"true" => Value::Bool(true),
"false" => Value::Bool(false),
other => Value::String(other.into()),
};
map.insert((*k).into(), value);
}
Value::Mapping(map)
}
#[test]
#[cfg(feature = "yaml")]
fn relation_set_builds_a_custom_vocabulary_and_falls_back_to_diaryx() {
use prov_graph::document::Document;
fn doc(text: &str) -> Document {
Document::parse("index.md", text).unwrap()
}
let default_set = WorkspaceConfig::default().relation_set();
assert_eq!(default_set.spanning_relation(), Some("contents"));
assert_eq!(default_set.registry_relation(), Some("registry"));
let config = WorkspaceConfig {
spanning: Some("part".into()),
relation_defs: BTreeMap::from([
(
"part".to_string(),
RelationDef {
cardinality: Some(Cardinality::Many),
inverse: Some("whole".to_string()),
means: None,
},
),
(
"whole".to_string(),
RelationDef {
cardinality: Some(Cardinality::One),
inverse: Some("part".to_string()),
means: None,
},
),
]),
..WorkspaceConfig::default()
};
let set = config.relation_set();
assert_eq!(set.spanning_relation(), Some("part"));
let d = doc("---\npart:\n- one.md\n- two.md\n---\nbody\n");
assert_eq!(
set.children(&fig::Value::from(&d.meta)),
vec!["one.md".to_string(), "two.md".to_string()]
);
assert_eq!(set.registry_relation(), Some("registry"));
assert!(set.relations().iter().any(|r| r.name == "recycle_bin"));
assert_eq!(set.history_relation(), Some("history"));
assert!(set.relations().iter().any(|r| r.name == "history"));
assert_eq!(set.about_relation(), Some("about"));
assert!(set.relations().iter().any(|r| r.name == "about"));
}
#[test]
fn presets_encode_the_two_styles() {
assert_eq!(WorkspaceConfig::paths_only().identity, Registration::OFF);
assert_eq!(
WorkspaceConfig::paths_only().reference_target,
Addressing::Path
);
assert!(
WorkspaceConfig::stable_ids()
.identity
.fires_on(Trigger::Link)
);
assert_eq!(
WorkspaceConfig::stable_ids().reference_target,
Addressing::Id
);
}
#[test]
fn round_trips_through_a_nested_mapping() {
let config = WorkspaceConfig {
identity: Registration::EAGER,
notation: Notation::Bare,
path_style: PathStyle::Relative,
reference_target: Addressing::Id,
reference_label: true,
relation_styles: BTreeMap::from([
(
"contents".to_string(),
RelationStyleConfig {
notation: Some(Notation::Wikilink),
path_style: None,
target: Some(Addressing::Alias),
label: None,
},
),
(
"part_of".to_string(),
RelationStyleConfig {
notation: Some(Notation::Markdown),
path_style: Some(PathStyle::Relative),
target: Some(Addressing::Id),
label: Some(false),
},
),
]),
spanning: Some("contents".to_string()),
relation_defs: BTreeMap::from([
(
"contents".to_string(),
RelationDef {
cardinality: Some(Cardinality::Many),
inverse: Some("part_of".to_string()),
means: Some("documents contained by this one".to_string()),
},
),
(
"part_of".to_string(),
RelationDef {
cardinality: Some(Cardinality::One),
inverse: Some("contents".to_string()),
means: None,
},
),
]),
fields: BTreeMap::from([
(
"audience".to_string(),
FieldSpec {
ty: Some(FieldType::Str),
values: OpenClosed::Closed,
vocabulary: Some("[Audiences](/vocab/audiences.yaml)".to_string()),
reify: true,
},
),
(
"created".to_string(),
FieldSpec {
ty: Some(FieldType::Extended(ExtKind::LocalDate)),
values: OpenClosed::default(),
vocabulary: None,
reify: false,
},
),
]),
views: vec![
ViewSpec {
name: "daily".to_string(),
label: Some("Daily".to_string()),
icon: Some("calendar".to_string()),
group: prov_views::Grouping {
keys: vec!["date_of_document".to_string(), "created".to_string()],
by: Some(prov_views::Grain::Month),
},
under: Some("[Daily](id:abc1234)".to_string()),
filter: Some(prov_views::Condition::Not(Box::new(
prov_views::Condition::Has("draft".to_string()),
))),
nest: Some(prov_views::Grain::Year),
},
ViewSpec {
name: "who".to_string(),
label: None,
icon: None,
group: prov_views::Grouping::field("people"),
under: None,
filter: None,
nest: None,
},
],
exports: vec![
ExportSpec {
name: "letters".to_string(),
label: Some("Letters home".to_string()),
gate: prov_exports::Gate {
field: "audience".to_string(),
value: "family".to_string(),
},
view: Some("daily".to_string()),
},
ExportSpec {
name: "notes".to_string(),
label: None,
gate: prov_exports::Gate {
field: "audience".to_string(),
value: "public".to_string(),
},
view: None,
},
],
id_storage: IdStorage::Frontmatter,
default_embed_format: fig::Format::Yaml,
embed_style: EmbedStyle::CodeBlock,
content_format: ContentFormat::Djot,
recycle_bin: false,
fixity: Fixity::Full,
history: History::Manual,
about: About::Off,
updated: "modified".to_string(),
workspace_id: "notes".to_string(),
};
let back = WorkspaceConfig::from_meta(&Value::Mapping(config.to_mapping()));
assert_eq!(back, config);
}
#[test]
fn per_relation_styles_resolve_over_the_workspace_default() {
let mut cfg = WorkspaceConfig::default();
cfg.apply(&config_doc_nested(
&[("target", "id")],
&[
("contents", &[("notation", "wikilink"), ("target", "alias")]),
("part_of", &[("target", "id")]),
],
));
let styles = cfg.resolved_relation_styles();
let down = styles.get("contents").expect("contents style");
assert_eq!(down.wrapper, prov_graph::link::Wrapper::Wikilink);
assert_eq!(down.addressing, Addressing::Alias);
let up = styles.get("part_of").expect("part_of style");
assert_eq!(up.wrapper, prov_graph::link::Wrapper::Markdown);
assert_eq!(up.addressing, Addressing::Id);
}
fn config_doc_nested(
references: &[(&str, &str)],
relations: &[(&str, &[(&str, &str)])],
) -> Value {
let mut top = Mapping::new();
let mut refs = Mapping::new();
for (k, v) in references {
refs.insert((*k).into(), Value::String((*v).into()));
}
top.insert("references".into(), Value::Mapping(refs));
let mut rels = Mapping::new();
for (name, axes) in relations {
let mut spec = Mapping::new();
for (k, v) in *axes {
spec.insert((*k).into(), Value::String((*v).into()));
}
rels.insert((*name).into(), Value::Mapping(spec));
}
top.insert("relations".into(), Value::Mapping(rels));
Value::Mapping(top)
}
#[test]
fn a_retired_canonical_path_style_is_reported_and_falls_back_to_root() {
let mut cfg = WorkspaceConfig::default();
let mut refs = Mapping::new();
refs.insert("path_style".into(), Value::String("canonical".into()));
let mut top = Mapping::new();
top.insert("references".into(), Value::Mapping(refs));
let meta = Value::Mapping(top);
cfg.apply(&meta);
assert_eq!(cfg.path_style, PathStyle::Root, "the resolvable spelling");
let issues = diagnose(&meta);
assert!(
issues.iter().any(|i| matches!(
&i.kind,
ConfigIssueKind::InvalidValue { value, expected }
if value.contains("canonical") && expected == &["root", "relative"]
)),
"{issues:?}"
);
}
#[test]
fn reference_axes_orthogonalize_notation_and_resolution() {
let mut cfg = WorkspaceConfig::default();
let mut refs = Mapping::new();
refs.insert("notation".into(), Value::String("bare".into()));
refs.insert("path_style".into(), Value::String("relative".into()));
let mut top = Mapping::new();
top.insert("references".into(), Value::Mapping(refs));
cfg.apply(&Value::Mapping(top));
assert_eq!(cfg.link_format(), LinkStyle::PlainRelative);
assert_eq!(cfg.notation, Notation::Bare);
assert_eq!(cfg.path_style, PathStyle::Relative);
}
#[test]
fn apply_overlays_only_present_keys_so_the_config_document_wins() {
let mut config = WorkspaceConfig::default();
config.apply(&config_doc(&[("content_format", "djot")]));
assert_eq!(config.content_format, ContentFormat::Djot);
assert_eq!(config.identity, Registration::LAZY, "identity untouched");
config.apply(&config_doc(&[("identity", "none")]));
assert_eq!(config.identity, Registration::OFF);
assert_eq!(config.content_format, ContentFormat::Djot);
}
#[test]
fn diagnose_is_silent_on_a_clean_config_and_on_user_fields() {
let doc = config_doc(&[
("title", "prov config"),
("part_of", "index.md"),
("id", "abc123"),
("spec", "1"),
("identity", "lazy"),
("fixity", "all"),
("recycle_bin", "false"),
("content_format", "djot"),
("id_storage", "both"),
("author", "someone"),
]);
assert!(diagnose(&doc).is_empty(), "flagged: {:?}", diagnose(&doc));
}
#[test]
fn diagnose_flags_a_misspelled_top_level_key_with_a_suggestion() {
let issues = diagnose(&config_doc(&[("recyle_bin", "false")]));
assert_eq!(issues.len(), 1);
assert_eq!(
issues[0].kind,
ConfigIssueKind::UnknownKey {
suggestion: "recycle_bin".into()
}
);
}
#[test]
fn workspace_id_applies_when_well_formed_and_is_ignored_when_not() {
let mut cfg = WorkspaceConfig::default();
assert_eq!(cfg.workspace_id, "", "anonymous by default");
cfg.apply(&config_doc(&[("workspace_id", "notes")]));
assert_eq!(cfg.workspace_id, "notes");
for bad in ["with/slash", "with:colon", "with space", ""] {
cfg.apply(&config_doc(&[("workspace_id", bad)]));
assert_eq!(cfg.workspace_id, "notes", "rejected {bad:?}");
}
}
#[test]
fn diagnose_flags_a_malformed_workspace_id_but_not_an_empty_one() {
for bad in ["with/slash", "with:colon", "with space"] {
let issues = diagnose(&config_doc(&[("workspace_id", bad)]));
assert_eq!(
issues.first().map(|i| &i.kind),
Some(&ConfigIssueKind::MalformedWorkspaceId {
value: bad.to_string()
}),
"{bad:?}"
);
}
assert!(
diagnose(&config_doc(&[("workspace_id", "")])).is_empty(),
"an empty name is anonymity, not an error"
);
assert!(diagnose(&config_doc(&[("workspace_id", "notes")])).is_empty());
}
#[test]
fn diagnose_flags_bad_values_and_typos_inside_nested_blocks() {
let mut refs = Mapping::new();
refs.insert("notaton".into(), Value::String("markdown".into()));
refs.insert("target".into(), Value::String("pointer".into()));
let mut top = Mapping::new();
top.insert("references".into(), Value::Mapping(refs));
let issues = diagnose(&Value::Mapping(top));
assert!(
issues.iter().any(|i| i.key == "references.notaton"
&& matches!(&i.kind, ConfigIssueKind::UnknownKey { suggestion } if suggestion == "references.notation")),
"{issues:?}"
);
assert!(
issues.iter().any(|i| i.key == "references.target"
&& matches!(&i.kind, ConfigIssueKind::InvalidValue { value, .. } if value == "pointer")),
"{issues:?}"
);
}
#[test]
fn diagnose_flags_an_unrecognized_value_on_a_real_key() {
let issues = diagnose(&config_doc(&[("fixity", "alll")]));
assert_eq!(issues.len(), 1);
match &issues[0].kind {
ConfigIssueKind::InvalidValue { value, expected } => {
assert_eq!(value, "alll");
assert!(expected.contains(&"all".to_string()), "{expected:?}");
}
other => panic!("expected InvalidValue, got {other:?}"),
}
}
#[test]
fn about_defaults_on_and_accepts_only_its_two_spellings() {
assert_eq!(WorkspaceConfig::default().about, About::Structure);
assert!(About::Structure.generates());
assert!(!About::Off.generates());
let mut cfg = WorkspaceConfig::default();
cfg.apply(&config_doc(&[("about", "off")]));
assert_eq!(cfg.about, About::Off);
let issues = diagnose(&config_doc(&[("about", "structrue")]));
assert_eq!(issues.len(), 1);
match &issues[0].kind {
ConfigIssueKind::InvalidValue { value, expected } => {
assert_eq!(value, "structrue");
assert!(expected.contains(&"structure".to_string()), "{expected:?}");
assert!(expected.contains(&"off".to_string()), "{expected:?}");
}
other => panic!("expected InvalidValue, got {other:?}"),
}
let mut unchanged = WorkspaceConfig::default();
unchanged.apply(&config_doc(&[("about", "structrue")]));
assert_eq!(unchanged.about, About::Structure);
}
#[test]
fn relation_defs_and_spanning_apply_and_round_trip() {
let mut top = Mapping::new();
top.insert("spanning".into(), Value::String("part".into()));
let mut rels = Mapping::new();
let mut part = Mapping::new();
part.insert("cardinality".into(), Value::String("many".into()));
part.insert("inverse".into(), Value::String("whole".into()));
part.insert("means".into(), Value::String("the pieces".into()));
let mut whole = Mapping::new();
whole.insert("cardinality".into(), Value::String("one".into()));
whole.insert("inverse".into(), Value::String("part".into()));
rels.insert("part".into(), Value::Mapping(part));
rels.insert("whole".into(), Value::Mapping(whole));
top.insert("relations".into(), Value::Mapping(rels));
let cfg = WorkspaceConfig::from_meta(&Value::Mapping(top));
assert_eq!(cfg.spanning.as_deref(), Some("part"));
let part_def = cfg.relation_defs.get("part").expect("part def");
assert_eq!(part_def.cardinality, Some(Cardinality::Many));
assert_eq!(part_def.inverse.as_deref(), Some("whole"));
assert_eq!(part_def.means.as_deref(), Some("the pieces"));
assert!(diagnose(&Value::Mapping(cfg.to_mapping())).is_empty());
}
#[test]
fn diagnose_flags_a_spanning_relation_whose_inverse_is_many() {
let mut top = Mapping::new();
top.insert("spanning".into(), Value::String("part".into()));
let mut rels = Mapping::new();
let mut part = Mapping::new();
part.insert("inverse".into(), Value::String("whole".into()));
let mut whole = Mapping::new();
whole.insert("cardinality".into(), Value::String("many".into()));
rels.insert("part".into(), Value::Mapping(part));
rels.insert("whole".into(), Value::Mapping(whole));
top.insert("relations".into(), Value::Mapping(rels));
let issues = diagnose(&Value::Mapping(top));
assert!(
issues.iter().any(|i| i.key == "spanning"
&& matches!(&i.kind, ConfigIssueKind::SpanningNotSingleParent { inverse } if inverse == "whole")),
"{issues:?}"
);
}
#[test]
fn a_field_may_declare_a_type_without_a_vocabulary() {
let mut created = Mapping::new();
created.insert("type".into(), Value::String("date".into()));
let mut fields = Mapping::new();
fields.insert("created".into(), Value::Mapping(created));
let mut top = Mapping::new();
top.insert("fields".into(), Value::Mapping(fields));
let config = WorkspaceConfig::from_meta(&Value::Mapping(top));
let spec = config.fields.get("created").expect("a recorded field");
assert_eq!(spec.ty, Some(FieldType::Extended(ExtKind::LocalDate)));
assert_eq!(spec.vocabulary, None);
}
#[test]
fn a_field_declaring_neither_type_nor_vocabulary_is_not_recorded() {
let mut empty = Mapping::new();
empty.insert("reify".into(), Value::Bool(true));
let mut fields = Mapping::new();
fields.insert("mystery".into(), Value::Mapping(empty));
let mut top = Mapping::new();
top.insert("fields".into(), Value::Mapping(fields));
let config = WorkspaceConfig::from_meta(&Value::Mapping(top));
assert!(config.fields.is_empty(), "{:?}", config.fields);
}
fn views_block(entries: &[(&str, &[(&str, Value)])]) -> Value {
let mut views = Mapping::new();
for (name, keys) in entries {
let mut entry = Mapping::new();
for (k, v) in *keys {
entry.insert((*k).into(), v.clone());
}
views.insert((*name).into(), Value::Mapping(entry));
}
let mut top = Mapping::new();
top.insert("views".into(), Value::Mapping(views));
Value::Mapping(top)
}
fn str_value(text: &str) -> Value {
Value::String(text.to_string())
}
#[test]
fn views_apply_in_declaration_order() {
let config = WorkspaceConfig::from_meta(&views_block(&[
("daily", &[("group", str_value("created"))]),
("who", &[("group", str_value("people"))]),
]));
assert_eq!(
config
.views
.iter()
.map(|v| v.name.as_str())
.collect::<Vec<_>>(),
["daily", "who"]
);
}
#[test]
fn a_later_surface_replaces_one_view_and_leaves_the_others() {
let mut config = WorkspaceConfig::from_meta(&views_block(&[
(
"daily",
&[
("group", str_value("created")),
("by", str_value("month")),
("icon", str_value("calendar")),
],
),
("who", &[("group", str_value("people"))]),
]));
config.apply(&views_block(&[(
"daily",
&[("group", str_value("date_of_document"))],
)]));
assert_eq!(
config
.views
.iter()
.map(|v| v.name.as_str())
.collect::<Vec<_>>(),
["daily", "who"],
"position is kept, and the untouched view survives"
);
let daily = &config.views[0];
assert_eq!(daily.group, prov_views::Grouping::field("date_of_document"));
assert_eq!(daily.group.by, None, "replaced whole, not merged key-wise");
assert_eq!(daily.icon, None);
}
#[test]
fn a_view_without_a_grouping_is_not_recorded_and_is_diagnosed() {
let meta = views_block(&[("daily", &[("label", str_value("Daily"))])]);
assert!(WorkspaceConfig::from_meta(&meta).views.is_empty());
let issues = diagnose(&meta);
assert_eq!(issues.len(), 1, "{issues:?}");
assert_eq!(issues[0].key, "views.daily.group");
assert!(matches!(
&issues[0].kind,
ConfigIssueKind::InvalidValue { value, .. } if value == "(absent)"
));
}
#[test]
fn diagnose_flags_a_misspelled_grain_and_a_misspelled_view_key() {
let issues = diagnose(&views_block(&[(
"daily",
&[
("group", str_value("created")),
("by", str_value("yearr")),
("labl", str_value("Daily")),
],
)]));
assert!(
issues.iter().any(|i| i.key == "views.daily.by"
&& matches!(&i.kind, ConfigIssueKind::InvalidValue { value, expected }
if value == "yearr" && expected.iter().any(|e| e == "year"))),
"{issues:?}"
);
assert!(
issues.iter().any(|i| i.key == "views.daily.labl"
&& i.kind
== ConfigIssueKind::UnknownKey {
suggestion: "views.daily.label".into()
}),
"{issues:?}"
);
}
fn exports_block(entries: &[(&str, &[(&str, Value)])]) -> Value {
let mut exports = Mapping::new();
for (name, keys) in entries {
let mut entry = Mapping::new();
for (k, v) in *keys {
entry.insert((*k).into(), v.clone());
}
exports.insert((*name).into(), Value::Mapping(entry));
}
let mut top = Mapping::new();
top.insert("exports".into(), Value::Mapping(exports));
Value::Mapping(top)
}
fn gate_value(field: &str, value: &str) -> Value {
let mut gate = Mapping::new();
gate.insert("field".into(), str_value(field));
gate.insert("value".into(), str_value(value));
Value::Mapping(gate)
}
#[test]
fn exports_apply_and_round_trip() {
let config = WorkspaceConfig::from_meta(&exports_block(&[
(
"letters",
&[
("gate", gate_value("audience", "family")),
("view", str_value("daily")),
],
),
("notes", &[("gate", gate_value("audience", "public"))]),
]));
assert_eq!(
config
.exports
.iter()
.map(|e| e.name.as_str())
.collect::<Vec<_>>(),
["letters", "notes"]
);
assert_eq!(config.exports[0].gate.field, "audience");
assert_eq!(config.exports[0].view.as_deref(), Some("daily"));
let written = config.to_mapping();
let reread = WorkspaceConfig::from_meta(&Value::Mapping(written));
assert_eq!(reread.exports, config.exports);
}
#[test]
fn a_later_surface_replaces_one_export_whole() {
let mut config = WorkspaceConfig::from_meta(&exports_block(&[(
"letters",
&[
("gate", gate_value("audience", "family")),
("view", str_value("daily")),
],
)]));
config.apply(&exports_block(&[(
"letters",
&[("gate", gate_value("audience", "friends"))],
)]));
assert_eq!(config.exports.len(), 1);
assert_eq!(config.exports[0].gate.value, "friends");
assert_eq!(
config.exports[0].view, None,
"replaced whole, not merged key-wise"
);
}
#[test]
fn an_export_without_a_gate_is_not_recorded_and_is_diagnosed() {
let meta = exports_block(&[("letters", &[("view", str_value("daily"))])]);
assert!(WorkspaceConfig::from_meta(&meta).exports.is_empty());
let issues = diagnose(&meta);
assert_eq!(issues.len(), 1, "{issues:?}");
assert_eq!(issues[0].key, "exports.letters.gate");
assert!(matches!(
&issues[0].kind,
ConfigIssueKind::InvalidValue { value, .. } if value == "(absent)"
));
}
#[test]
fn diagnose_flags_misspelled_export_keys_at_both_levels() {
let mut gate = Mapping::new();
gate.insert("field".into(), str_value("audience"));
gate.insert("valeu".into(), str_value("family"));
let issues = diagnose(&exports_block(&[(
"letters",
&[("gate", Value::Mapping(gate)), ("veiw", str_value("daily"))],
)]));
assert!(
issues.iter().any(|i| i.kind
== ConfigIssueKind::UnknownKey {
suggestion: "exports.letters.view".into()
}),
"{issues:?}"
);
assert!(
issues.iter().any(|i| i.kind
== ConfigIssueKind::UnknownKey {
suggestion: "exports.letters.gate.value".into()
}),
"{issues:?}"
);
}
#[test]
fn diagnose_flags_an_export_arranged_by_an_undeclared_view() {
let mut top = Mapping::new();
let Value::Mapping(views) = views_block(&[("daily", &[("group", str_value("created"))])])
else {
unreachable!()
};
let Value::Mapping(exports) = exports_block(&[(
"letters",
&[
("gate", gate_value("audience", "family")),
("view", str_value("dialy")),
],
)]) else {
unreachable!()
};
for (k, v) in views.iter().chain(exports.iter()) {
top.insert(k.clone(), v.clone());
}
let issues = diagnose(&Value::Mapping(top));
assert_eq!(issues.len(), 1, "{issues:?}");
assert_eq!(issues[0].key, "exports.letters.view");
assert!(
matches!(
&issues[0].kind,
ConfigIssueKind::InvalidValue { value, expected }
if value == "dialy" && expected == &vec!["daily".to_string()]
),
"{issues:?}"
);
let issues = diagnose(&exports_block(&[(
"letters",
&[
("gate", gate_value("audience", "family")),
("view", str_value("dialy")),
],
)]));
assert!(issues.is_empty(), "{issues:?}");
}
#[test]
fn diagnose_flags_a_nest_on_a_multi_valued_field() {
let block = |view: &[(&str, Value)]| {
let mut fields = Mapping::new();
let mut people = Mapping::new();
people.insert("type".into(), str_value("seq"));
fields.insert("people".into(), Value::Mapping(people));
let mut views = Mapping::new();
let mut entry = Mapping::new();
for (k, v) in view {
entry.insert((*k).into(), v.clone());
}
views.insert("who".into(), Value::Mapping(entry));
let mut top = Mapping::new();
top.insert("fields".into(), Value::Mapping(fields));
top.insert("views".into(), Value::Mapping(views));
Value::Mapping(top)
};
let issues = diagnose(&block(&[
("group", str_value("people")),
("nest", str_value("initial")),
]));
assert_eq!(issues.len(), 1, "{issues:?}");
assert_eq!(issues[0].key, "views.who.nest");
assert_eq!(
issues[0].kind,
ConfigIssueKind::NestNotSingleValued {
field: "people".into()
}
);
assert!(
diagnose(&block(&[("group", str_value("people"))])).is_empty(),
"grouping by a multi-valued field is not the problem"
);
}
#[test]
fn the_nest_check_is_silent_across_two_config_surfaces() {
let mut views = Mapping::new();
let mut entry = Mapping::new();
entry.insert("group".into(), str_value("people"));
entry.insert("nest".into(), str_value("initial"));
views.insert("who".into(), Value::Mapping(entry));
let mut top = Mapping::new();
top.insert("views".into(), Value::Mapping(views));
assert!(
diagnose(&Value::Mapping(top)).is_empty(),
"no `fields` in this surface to contradict it"
);
}
#[test]
fn diagnose_flags_a_views_block_that_is_not_a_block() {
let mut top = Mapping::new();
top.insert("views".into(), Value::String("daily".into()));
let issues = diagnose(&Value::Mapping(top));
assert_eq!(issues.len(), 1);
assert_eq!(issues[0].key, "views");
let issues = diagnose(&views_block(&[]));
assert!(issues.is_empty(), "an empty block is clean: {issues:?}");
}
#[test]
fn every_field_type_spelling_round_trips() {
for spelling in FIELD_TYPES {
let ty = field_type_from_config_str(spelling)
.unwrap_or_else(|| panic!("{spelling} is offered but does not parse"));
assert_eq!(field_type_as_config_str(ty), Some(*spelling));
}
}
#[test]
fn diagnose_flags_an_unknown_field_type_and_offers_the_near_miss() {
let mut created = Mapping::new();
created.insert("type".into(), Value::String("datetime2".into()));
let mut fields = Mapping::new();
fields.insert("created".into(), Value::Mapping(created));
let mut top = Mapping::new();
top.insert("fields".into(), Value::Mapping(fields));
let issues = diagnose(&Value::Mapping(top));
assert!(
issues.iter().any(|i| i.key == "fields.created.type"
&& matches!(
&i.kind,
ConfigIssueKind::InvalidValue { expected, .. }
if expected.iter().any(|e| e == "datetime")
)),
"{issues:?}"
);
}
#[test]
fn diagnose_flags_bad_field_and_relation_def_values() {
let mut top = Mapping::new();
let mut fields = Mapping::new();
let mut audience = Mapping::new();
audience.insert("values".into(), Value::String("secret".into())); audience.insert("vocabulary".into(), Value::String("/vocab/aud.yaml".into()));
fields.insert("audience".into(), Value::Mapping(audience));
top.insert("fields".into(), Value::Mapping(fields));
let mut rels = Mapping::new();
let mut c = Mapping::new();
c.insert("cardinality".into(), Value::String("two".into())); rels.insert("contents".into(), Value::Mapping(c));
top.insert("relations".into(), Value::Mapping(rels));
let issues = diagnose(&Value::Mapping(top));
assert!(
issues.iter().any(|i| i.key == "fields.audience.values"),
"{issues:?}"
);
assert!(
issues
.iter()
.any(|i| i.key == "relations.contents.cardinality"),
"{issues:?}"
);
}
#[test]
fn spec_ahead_fires_only_for_a_newer_spec() {
assert_eq!(
spec_ahead(&config_doc(&[("identity", "lazy")])),
None,
"absent spec"
);
let at = {
let mut m = Mapping::new();
m.insert("spec".into(), Value::Int(SPEC_VERSION));
Value::Mapping(m)
};
assert_eq!(spec_ahead(&at), None, "current spec is fine");
let ahead = {
let mut m = Mapping::new();
m.insert("spec".into(), Value::Int(SPEC_VERSION + 1));
Value::Mapping(m)
};
assert_eq!(spec_ahead(&ahead), Some(SPEC_VERSION + 1));
}
#[test]
fn serialized_defaults_and_presets_all_pass_diagnosis() {
for config in [
WorkspaceConfig::default(),
WorkspaceConfig::paths_only(),
WorkspaceConfig::stable_ids(),
] {
let serialized = Value::Mapping(config.to_mapping());
assert!(
diagnose(&serialized).is_empty(),
"flagged itself: {:?}",
diagnose(&serialized)
);
}
}
}