use flower_core::schema::{FieldRule, Schema};
use flower_core::{Consequence, FieldType, Icon, Severity, Term, Tint};
use prov::{FIELD_TYPES, WorkspaceConfig};
use crate::rules::{
choice, choice_terms, costly, costly_when, open_choice, open_choice_terms, path, present, term,
text, toggle,
};
pub const CONFIG_READONLY_KEYS: &[&str] = &["spec"];
pub const VIEW_GRAINS: &[(&str, &str)] = &[
("year", "One group per year"),
("month", "One group per month"),
("day", "One group per day"),
("initial", "One group per first letter"),
];
pub fn config_schema(config: &WorkspaceConfig) -> Schema {
Schema::new(config_rules(config))
}
pub fn config_rules(config: &WorkspaceConfig) -> Vec<FieldRule> {
let mut rules = vec![
text(path(&["title"]), "Title", Icon::Text),
FieldRule::new(path(&["spec"]))
.ty(FieldType::Int)
.present(present("Config format version", Icon::Lock)),
choice(
path(&["content_format"]),
"Content format",
Icon::Enum,
&[
("markdown", "Markdown documents"),
("djot", "Djot documents"),
("html", "HTML documents"),
],
),
costly(
open_choice(
path(&["metadata", "format"]),
"Metadata format",
Icon::Enum,
&[
("yaml", "YAML frontmatter"),
("json", "JSON metadata"),
("toml", "TOML metadata"),
("fig", "fig metadata"),
],
),
Tint::Warning,
"Rewrites the metadata of every document in the workspace.",
),
costly(
choice(
path(&["metadata", "embed"]),
"How metadata is embedded",
Icon::Enum,
&[
("delimited", "Fenced frontmatter (`---`)"),
("code_block", "A fenced code block"),
("html_script", "A <script> tag"),
("html_code", "An HTML <code> block"),
("separate", "A sidecar file beside the document"),
],
),
Tint::Warning,
"Rewrites the metadata of every document in the workspace.",
),
];
for prefix in [&["references"][..], &["relations", "*"][..]] {
let at = |leaf: &str| {
let mut segs = prefix.to_vec();
segs.push(leaf);
path(&segs)
};
rules.push(costly(
choice(
at("notation"),
"Link notation",
Icon::Link,
&[
("markdown", "[Title](/path.md)"),
("wikilink", "[[path]]"),
("bare", "A bare path, unwrapped"),
],
),
Tint::Warning,
"Rewrites every link in the workspace.",
));
rules.push(costly(
choice(
at("path_style"),
"Link paths",
Icon::Link,
&[
("root", "From the workspace root (/notes/a.md)"),
("relative", "Relative to the linking document"),
],
),
Tint::Warning,
"Rewrites every link in the workspace.",
));
rules.push(costly(
choice(
at("target"),
"What a link addresses",
Icon::Link,
&[
("path", "The target's path — renames rewrite links"),
("id", "The target's id — renames rewrite nothing"),
("alias", "A human alias"),
],
),
Tint::Warning,
"Rewrites every link in the workspace.",
));
rules.push(toggle(at("label"), "Carry the target's title as a label"));
}
rules.push(costly(
text(path(&["spanning"]), "The containment relation", Icon::Link),
Tint::Warning,
"The relation the whole workspace is organised by. Changing it rebuilds the tree.",
));
rules.push(text(
path(&["workspace_id"]),
"This workspace's id",
Icon::Lock,
));
rules.push(choice(
path(&["relations", "*", "cardinality"]),
"How many targets",
Icon::Enum,
&[("one", "At most one"), ("many", "A list")],
));
rules.push(text(
path(&["relations", "*", "inverse"]),
"The relation pointing back",
Icon::Link,
));
rules.push(text(
path(&["relations", "*", "means"]),
"What this relation means",
Icon::Text,
));
rules.push(choice_terms(
path(&["fields", "*", "type"]),
"Value type",
Icon::Enum,
FIELD_TYPES
.iter()
.map(|t| term(t, field_type_gloss(t)))
.collect(),
));
rules.push(choice(
path(&["fields", "*", "values"]),
"Which values are legal",
Icon::Enum,
&[
("open", "Anything — the field is free text"),
("closed", "Only terms the vocabulary lists"),
],
));
rules.push(text(
path(&["fields", "*", "vocabulary"]),
"Vocabulary document",
Icon::Link,
));
rules.push(costly_when(
toggle(
path(&["fields", "*", "reify"]),
"Give each value its own document",
),
true,
Severity::Confirm,
"Creates a document for every distinct value of this field across the workspace.",
));
let id_storage = choice(
path(&["id_storage"]),
"Where ids live",
Icon::Lock,
&[
("registry", "The registry only"),
("frontmatter", "Each document only"),
("both", "Both — the registry stays rebuildable"),
],
);
rules.push(
id_storage
.on_change(Consequence::when(
"registry",
"Ids leave the documents. The registry becomes the only copy.",
))
.on_change(Consequence::when(
"frontmatter",
"Ids leave the registry, so it can no longer be rebuilt from itself.",
)),
);
rules.push(text(
path(&["updated"]),
"Field stamped on save (empty turns it off)",
Icon::Clock,
));
rules.push(costly_when(
choice(
path(&["identity"]),
"When a document earns an id",
Icon::Lock,
&[
("none", "Never"),
("lazy", "When something first needs one"),
("eager", "At creation"),
],
),
"none",
Severity::Confirm,
"New documents stop earning ids, so nothing can link to them by id.",
));
rules.push(choice(
path(&["fixity"]),
"Content checksums",
Icon::Lock,
&[
("off", "No checksums"),
("attachments", "Attachments only"),
("all", "Every document body"),
],
));
rules.push(costly_when(
toggle(path(&["recycle_bin"]), "Recoverable delete (recycle bin)"),
false,
Severity::ConfirmExplicitly,
"Deleting stops being recoverable. Anything deleted afterwards is gone.",
));
rules.push(choice(
path(&["about"]),
"Generated “how to read this” page",
Icon::Text,
&[
("off", "Don't generate one"),
("structure", "Describe this workspace's structure"),
],
));
rules.push(text(
path(&["views", "*", "label"]),
"View name",
Icon::Text,
));
rules.push(text(
path(&["views", "*", "icon"]),
"View glyph",
Icon::Text,
));
rules.push(open_choice_terms(
path(&["views", "*", "group"]),
"Groups by",
Icon::Enum,
group_terms(config),
));
rules.push(open_choice_terms(
path(&["views", "*", "group", "[]"]),
"Groups by",
Icon::Enum,
group_terms(config),
));
rules.push(choice(
path(&["views", "*", "by"]),
"Grain",
Icon::Clock,
VIEW_GRAINS,
));
rules.push(text(
path(&["views", "*", "under"]),
"Filed under (empty covers the whole workspace)",
Icon::Link,
));
rules.push(choice(
path(&["views", "*", "nest"]),
"New entries nest by (empty files them flat)",
Icon::Link,
VIEW_GRAINS,
));
rules.push(open_choice_terms(
path(&["views", "*", "where", "has"]),
"Only documents that have",
Icon::Enum,
group_terms(config),
));
rules.push(open_choice_terms(
path(&["views", "*", "where", "has", "[]"]),
"Only documents that have",
Icon::Enum,
group_terms(config),
));
rules.push(text(
path(&["views", "*", "where", "equals", "*"]),
"…and whose value is",
Icon::Text,
));
rules
}
fn group_terms(config: &WorkspaceConfig) -> Vec<Term> {
config
.fields
.keys()
.map(|name| Term::value(name.clone()).description(format!("The document's {name}")))
.collect()
}
fn field_type_gloss(ty: &str) -> &'static str {
match ty {
"str" => "Text",
"bool" => "True or false",
"int" => "A whole number",
"float" => "A number",
"date" => "A calendar day",
"datetime" => "An instant with a time zone",
"local-datetime" => "A date and time, no zone",
"time" => "A time of day",
"ref" => "A link to another document",
"map" => "A block of keys",
"seq" => "A list",
_ => "",
}
}
#[cfg(test)]
mod costly_tests {
use super::*;
use fig::Value;
use flower_core::{FieldRuleExt, Seg};
fn schema() -> Schema {
config_schema(&WorkspaceConfig::default())
}
fn warned(path: &[Seg]) -> Option<(String, String)> {
let schema = schema();
let rule = schema.rule_for(path)?;
let tint = rule.present.tint?;
Some((format!("{tint:?}"), rule.present.description.clone()?))
}
fn key(k: &str) -> Seg {
Seg::Key(k.into())
}
#[test]
fn a_field_that_rewrites_the_workspace_carries_the_warning_and_the_sentence() {
let schema = schema();
for path in [
vec![key("metadata"), key("format")],
vec![key("metadata"), key("embed")],
vec![key("references"), key("notation")],
vec![key("references"), key("path_style")],
vec![key("references"), key("target")],
vec![key("spanning")],
] {
let (tint, why) = warned(&path).unwrap_or_else(|| panic!("{path:?} should warn"));
assert_eq!(tint, "Warning", "{path:?}");
assert!(!why.is_empty(), "{path:?} warns without saying why");
let rule = schema.rule_for(&path).expect("rule");
assert!(
rule.severity_of(&Value::Str("anything".into())).is_some(),
"{path:?} is tinted but declares no consequence"
);
}
}
#[test]
fn a_field_costly_in_one_direction_warns_on_that_direction_only() {
let schema = schema();
let cases: &[(&str, Value, Value)] = &[
("recycle_bin", Value::Bool(false), Value::Bool(true)),
(
"identity",
Value::Str("none".into()),
Value::Str("eager".into()),
),
];
for (field, costly, safe) in cases {
let rule = schema
.rule_for(&[key(field)])
.unwrap_or_else(|| panic!("no rule for {field}"));
assert!(
rule.severity_of(costly).is_some(),
"{field} should warn on {costly:?}"
);
assert!(
rule.severity_of(safe).is_none(),
"{field} warns on {safe:?}, which costs nothing"
);
}
}
#[test]
fn only_the_unrecoverable_change_asks_for_a_deliberate_yes() {
let schema = schema();
let strongest = |field: &str, value: Value| {
schema
.rule_for(&[key(field)])
.and_then(|r| r.severity_of(&value))
};
assert_eq!(
strongest("recycle_bin", Value::Bool(false)),
Some(Severity::ConfirmExplicitly)
);
assert_eq!(
strongest("identity", Value::Str("none".into())),
Some(Severity::Confirm)
);
}
#[test]
fn a_narrowing_declares_its_destinations_and_leaves_the_no_op_to_the_host() {
let schema = schema();
let rule = schema.rule_for(&[key("id_storage")]).expect("rule");
for narrow in ["registry", "frontmatter"] {
assert!(
rule.severity_of(&Value::Str(narrow.into())).is_some(),
"narrowing to {narrow} should say what is lost"
);
}
assert!(
rule.severity_of(&Value::Str("both".into())).is_none(),
"widening to `both` loses nothing"
);
}
#[test]
fn no_guard_names_a_value_the_vocabulary_does_not_have() {
let schema = schema();
for field in ["identity", "id_storage", "about", "fixity"] {
let rule = schema.rule_for(&[key(field)]).expect("rule");
let terms = match rule.enum_constraint() {
Some((terms, _)) => terms.to_vec(),
None => continue,
};
let orphans = flower_core::guards_without_terms(&rule.on_change, &terms);
assert!(
orphans.is_empty(),
"{field} guards values its vocabulary does not offer: {orphans:?}"
);
}
}
#[test]
fn an_ordinary_field_carries_no_warning() {
assert!(warned(&[key("title")]).is_none());
}
}
#[cfg(test)]
mod tests {
use super::*;
use flower_core::{FieldRuleExt, PathPat, Seg, SegPat};
use prov::config::{FieldSpec, OpenClosed};
use prov::meta::{Mapping, Value};
use prov::{ConfigIssueKind, FieldType as ProvFieldType};
fn config_with(fields: &[(&str, Option<ProvFieldType>)]) -> WorkspaceConfig {
let mut config = WorkspaceConfig::default();
for (name, ty) in fields {
config.fields.insert(
(*name).to_string(),
FieldSpec {
ty: *ty,
values: OpenClosed::default(),
vocabulary: None,
reify: false,
},
);
}
config
}
fn concrete(rule: &FieldRule) -> Option<Vec<String>> {
rule.at
.0
.iter()
.map(|seg| match seg {
SegPat::Key(k) => Some(k.clone()),
SegPat::AnyKey => Some("x".to_string()),
_ => None,
})
.collect()
}
fn nested(path: &[String], value: &str) -> Value {
let mut current = Value::String(value.to_string());
for key in path.iter().rev() {
let mut map = Mapping::new();
map.insert(key.clone(), current);
current = Value::Mapping(map);
}
current
}
#[test]
fn every_offered_term_is_one_prov_accepts() {
let schema = config_schema(&config_with(&[]));
let mut checked = 0;
for rule in schema.rules() {
let Some((terms, closed)) = rule.enum_constraint() else {
continue;
};
if !closed {
continue; }
let Some(path) = concrete(rule) else { continue };
let dotted = path.join(".");
for term in terms {
let issues = prov::diagnose(&nested(&path, &term.value));
let bad: Vec<_> = issues
.iter()
.filter(|i| i.key == dotted)
.filter(|i| matches!(i.kind, ConfigIssueKind::InvalidValue { .. }))
.collect();
assert!(
bad.is_empty(),
"schema offers `{dotted}: {}`, which prov rejects: {bad:?}",
term.value
);
checked += 1;
}
}
assert!(
checked > 20,
"expected to have checked real terms, got {checked}"
);
}
#[test]
fn field_types_come_from_prov() {
let schema = config_schema(&config_with(&[]));
let rule = schema
.rule_for(&[
Seg::Key("fields".into()),
Seg::Key("people".into()),
Seg::Key("type".into()),
])
.expect("fields.<name>.type should be governed");
let (terms, closed) = rule.enum_constraint().expect("a closed type picker");
assert!(closed);
let offered: Vec<&str> = terms.iter().map(|t| t.value.as_str()).collect();
assert_eq!(offered, FIELD_TYPES);
}
#[test]
fn every_key_prov_writes_is_governed() {
let mut config = config_with(&[("audience", Some(ProvFieldType::Str))]);
config.fields.get_mut("audience").unwrap().vocabulary = Some("audiences.yaml".into());
let schema = config_schema(&config);
fn walk(schema: &Schema, path: &mut Vec<Seg>, value: &Value, ungoverned: &mut Vec<String>) {
match value {
Value::Mapping(map) => {
for (key, child) in map {
path.push(Seg::Key(key.clone()));
walk(schema, path, child, ungoverned);
path.pop();
}
}
_ => {
if schema.rule_for(path).is_none() {
ungoverned.push(
path.iter()
.map(|s| match s {
Seg::Key(k) => k.clone(),
other => format!("{other:?}"),
})
.collect::<Vec<_>>()
.join("."),
);
}
}
}
}
let mut ungoverned = Vec::new();
walk(
&schema,
&mut Vec::new(),
&Value::Mapping(config.to_mapping()),
&mut ungoverned,
);
assert!(
ungoverned.is_empty(),
"prov writes these keys and the schema does not govern them: {ungoverned:?}"
);
}
#[test]
fn policy_axes_are_typed() {
let schema = config_schema(&config_with(&[]));
let recycle = schema
.rule_for(&[Seg::Key("recycle_bin".into())])
.expect("recycle_bin should be governed");
assert_eq!(recycle.ty, Some(FieldType::Bool));
let fixity = schema
.rule_for(&[Seg::Key("fixity".into())])
.expect("fixity should be governed");
let (terms, closed) = fixity.enum_constraint().expect("a closed picker");
assert!(closed, "an unparseable fixity silently keeps the default");
assert_eq!(
terms.iter().map(|t| t.value.as_str()).collect::<Vec<_>>(),
["off", "attachments", "all"]
);
}
#[test]
fn the_workspace_id_is_governed_at_the_top_level() {
let schema = config_schema(&config_with(&[]));
let id = schema
.rule_for(&[Seg::Key("workspace_id".into())])
.expect("the workspace's id should be governed");
assert_eq!(id.present.title.as_deref(), Some("This workspace's id"));
assert_eq!(id.present.icon, Some(Icon::Lock));
}
#[test]
fn a_relation_entry_is_governed_like_the_references_block() {
let schema = config_schema(&config_with(&[]));
for path in [
vec![Seg::Key("references".into()), Seg::Key("target".into())],
vec![
Seg::Key("relations".into()),
Seg::Key("contents".into()),
Seg::Key("target".into()),
],
] {
let rule = schema.rule_for(&path).expect("target should be governed");
let (terms, _) = rule.enum_constraint().expect("a picker");
assert_eq!(
terms.iter().map(|t| t.value.as_str()).collect::<Vec<_>>(),
["path", "id", "alias"]
);
}
}
#[test]
fn a_view_entry_is_governed_and_its_grouping_stays_open() {
let schema = config_schema(&config_with(&[("people", Some(ProvFieldType::Str))]));
let view = |leaf: &str| {
vec![
Seg::Key("views".into()),
Seg::Key("daily".into()),
Seg::Key(leaf.into()),
]
};
let by = schema.rule_for(&view("by")).expect("views.*.by");
let (grains, closed) = by.enum_constraint().expect("a grain picker");
assert!(closed, "a grain prov cannot parse is not a grain");
assert_eq!(
grains.iter().map(|t| t.value.as_str()).collect::<Vec<_>>(),
VIEW_GRAINS.iter().map(|(v, _)| *v).collect::<Vec<_>>()
);
let group = schema.rule_for(&view("group")).expect("views.*.group");
let (fields, closed) = group.enum_constraint().expect("a field picker");
assert!(!closed, "prov imposes no vocabulary on `group:`");
assert_eq!(
fields.iter().map(|t| t.value.as_str()).collect::<Vec<_>>(),
["people"]
);
assert!(
schema
.rule_for(&[
Seg::Key("views".into()),
Seg::Key("daily".into()),
Seg::Key("group".into()),
Seg::Index(0),
])
.is_some(),
"the list form of `group:` should be governed"
);
assert!(schema.rule_for(&view("label")).is_some());
assert!(schema.rule_for(&view("under")).is_some());
assert!(schema.rule_for(&view("nest")).is_some());
}
#[test]
fn a_prepended_rule_shadows_the_generic_one() {
let config = config_with(&[("people", Some(ProvFieldType::Str))]);
let group = vec![
Seg::Key("views".into()),
Seg::Key("daily".into()),
Seg::Key("group".into()),
];
let mut overlaid = vec![open_choice_terms(
PathPat(vec![
SegPat::Key("views".into()),
SegPat::AnyKey,
SegPat::Key("group".into()),
]),
"Groups by",
Icon::Enum,
vec![term("date", "The document's date")],
)];
overlaid.extend(config_rules(&config));
let schema = Schema::new(overlaid);
let (terms, _) = schema
.rule_for(&group)
.and_then(|r| r.enum_constraint())
.expect("the overlay rule");
assert_eq!(
terms.iter().map(|t| t.value.as_str()).collect::<Vec<_>>(),
["date"],
"the prepended rule should win"
);
assert!(
config_schema(&config)
.rule_for(&[Seg::Key("myapp".into()), Seg::Key("default_view".into())])
.is_none()
);
}
}