use std::collections::BTreeSet;
use indexmap::IndexMap;
use serde::{Deserialize, Serialize};
use serde_yaml::{Mapping, Value};
use thiserror::Error;
use crate::identifier::{EntryAddress, EntryAddressError};
use crate::meta::MetaRegistry;
use crate::structural::{StructuralEdgeDirection, StructuralTideSettings};
pub const NAME_FIELD: &str = "name";
pub const DESC_FIELD: &str = "desc";
pub const META_FIELD: &str = "meta";
pub const FROZEN_FIELD: &str = "frozen";
pub const META_TYPE_FIELD: &str = "meta.type";
pub const META_RIPPLE_LAKE_FIELD: &str = "meta.ripple.lake";
pub const META_RIPPLE_ANCHOR_FIELD: &str = "meta.ripple.anchor";
pub const STRUCTURAL_META_TYPE: &str = "structural";
pub const INTRINSIC_META_TYPE: &str = "intrinsic";
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct Entry {
pub id: EntryAddress,
pub metadata: EntryMetadata,
pub body: String,
}
impl Entry {
pub fn new(
id: impl Into<EntryAddress>, metadata: EntryMetadata, body: impl Into<String>,
) -> Self {
Self { id: id.into(), metadata, body: body.into() }
}
pub fn from_markdown(
id: impl Into<EntryAddress>, source: &str,
) -> Result<Self, EntryParseError> {
Self::from_markdown_with_registry(id, source, &MetaRegistry::standard())
}
pub fn from_markdown_with_registry(
id: impl Into<EntryAddress>, source: &str, registry: &MetaRegistry,
) -> Result<Self, EntryParseError> {
RawEntry::from_markdown(id, source)?.into_entry(registry)
}
pub fn to_markdown(&self) -> Result<String, EntryRenderError> {
Ok(format!("---\n{}---\n\n{}", self.metadata.to_yaml_source()?, self.body))
}
pub fn replace_markdown_body(source: &str, body: &str) -> Result<String, EntryParseError> {
let body_start = frontmatter_body_start(source)?;
Ok(format!("{}{}", &source[..body_start], body))
}
pub fn default_seed_entries() -> Result<Vec<Self>, EntryParseError> {
let mut name =
EntryMetadata::new("Name", "The required plain-string title field for entries.")?;
name.meta.entry_type = Some(EntryMetaType::Intrinsic);
let mut desc = EntryMetadata::new(
"Description",
"The required plain-string summary field for entries.",
)?;
desc.meta.entry_type = Some(EntryMetaType::Intrinsic);
let category =
EntryMetadata::new("Category", "An entry that other entries can be categorized by.")?;
let meta = EntryMetadata::new(
"Meta",
"An entry that defines the project's principles, vocabulary, and documentation method.",
)?;
let concept =
EntryMetadata::new("Concept", "A named idea that compresses project knowledge.")?;
let narrative = EntryMetadata::new("Narrative", "A route through concepts for a reader.")?;
Ok(vec![
Self::new(
seed_id("name"),
name,
"The required `name` metadata field gives an entry its reader-facing title.\n",
),
Self::new(
seed_id("desc"),
desc,
"The required `desc` metadata field gives an entry its compact summary.\n",
),
Self::new(
seed_id("category"),
category,
"Categorize an entry by this entry to use it as a category target.\n",
),
Self::new(
seed_id("meta"),
meta,
"Defines how this project should be understood and developed.\n",
),
Self::new(
seed_id("concept"),
concept,
"A concept gives a stable name to compressed project knowledge.\n",
),
Self::new(
seed_id("narrative"),
narrative,
"A narrative records an order in which a reader can understand concepts.\n",
),
])
}
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct RawEntry {
pub id: EntryAddress,
metadata: Mapping,
pub body: String,
}
impl RawEntry {
pub fn from_markdown(
id: impl Into<EntryAddress>, source: &str,
) -> Result<Self, EntryParseError> {
let (metadata_source, body) = split_frontmatter(source)?;
let metadata = parse_metadata_mapping(metadata_source)?;
Ok(Self { id: id.into(), metadata, body })
}
pub fn meta_type(&self) -> Result<Option<EntryMetaType>, EntryParseError> {
parse_flat_meta_type_value(self.metadata.get(Value::String(META_TYPE_FIELD.to_owned())))
}
pub fn entry_meta(&self) -> Result<EntryMeta, EntryParseError> {
let mut mapping = self.metadata.clone();
if mapping.contains_key(Value::String(FROZEN_FIELD.to_owned())) {
return Err(EntryParseError::TopLevelFrozenMarker);
}
let mut meta = take_entry_meta(&mut mapping)?;
meta.entry_type = take_flat_meta_type(&mut mapping)?;
meta.tide = take_flat_structural_tide_settings(&mut mapping, meta.entry_type)?;
Ok(meta)
}
pub fn into_entry(self, registry: &MetaRegistry) -> Result<Entry, EntryParseError> {
let metadata = EntryMetadata::from_mapping(self.metadata, registry)?;
Ok(Entry::new(self.id, metadata, self.body))
}
}
pub type EntryStructuralFields = IndexMap<String, Vec<EntryAddress>>;
pub type EntryIntrinsicFields = IndexMap<String, String>;
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct EntryMetadata {
pub intrinsic: EntryIntrinsicFields,
pub meta: EntryMeta,
pub structural: EntryStructuralFields,
}
impl EntryMetadata {
pub fn new(name: impl Into<String>, desc: impl Into<String>) -> Result<Self, EntryParseError> {
let name = name.into();
let desc = desc.into();
validate_plain_string(NAME_FIELD, &name)?;
validate_plain_string(DESC_FIELD, &desc)?;
let mut intrinsic = EntryIntrinsicFields::new();
intrinsic.insert(NAME_FIELD.to_owned(), name);
intrinsic.insert(DESC_FIELD.to_owned(), desc);
Ok(Self { intrinsic, meta: EntryMeta::default(), structural: EntryStructuralFields::new() })
}
pub fn from_yaml_source(source: &str) -> Result<Self, EntryParseError> {
Self::from_yaml_source_with_registry(source, &MetaRegistry::standard())
}
pub fn from_yaml_source_with_registry(
source: &str, registry: &MetaRegistry,
) -> Result<Self, EntryParseError> {
let mapping = parse_metadata_mapping(source)?;
Self::from_mapping(mapping, registry)
}
pub(crate) fn from_mapping(
mut mapping: Mapping, registry: &MetaRegistry,
) -> Result<Self, EntryParseError> {
let mut intrinsic = EntryIntrinsicFields::new();
for (field, _) in registry.intrinsic_fields() {
let value = take_required_string(&mut mapping, field)?;
validate_plain_string(field, &value)?;
intrinsic.insert(field.to_owned(), value);
}
if mapping.contains_key(Value::String(FROZEN_FIELD.to_owned())) {
return Err(EntryParseError::TopLevelFrozenMarker);
}
let mut meta = take_entry_meta(&mut mapping)?;
meta.entry_type = take_flat_meta_type(&mut mapping)?;
meta.tide = take_flat_structural_tide_settings(&mut mapping, meta.entry_type)?;
let structural = take_structural_fields(mapping)?;
Ok(Self { intrinsic, meta, structural })
}
pub fn to_yaml_source(&self) -> Result<String, EntryRenderError> {
for (field, value) in &self.intrinsic {
validate_plain_string(field, value)?;
}
let mut out = String::new();
for (field, value) in &self.intrinsic {
out.push_str(&format!("{field}: {}\n", render_yaml_scalar(value)?));
}
render_entry_meta(&mut out, &self.meta);
render_structural_fields(&mut out, &self.structural)?;
Ok(out)
}
pub fn structural_targets(&self) -> impl Iterator<Item = (&str, &EntryAddress)> {
self.structural
.iter()
.flat_map(|(field, targets)| targets.iter().map(move |id| (field.as_str(), id)))
}
pub fn structural_fields(&self) -> impl Iterator<Item = (&str, &[EntryAddress])> {
self.structural.iter().map(|(field, targets)| (field.as_str(), targets.as_slice()))
}
pub fn structural_targets_for(&self, field: &str) -> &[EntryAddress] {
self.structural.get(field).map(Vec::as_slice).unwrap_or_default()
}
pub fn structural_field(&self, field: &str) -> Option<&[EntryAddress]> {
self.structural.get(field).map(Vec::as_slice)
}
pub fn structural_targets_for_mut(
&mut self, field: impl Into<String>,
) -> &mut Vec<EntryAddress> {
self.structural.entry(field.into()).or_default()
}
pub fn intrinsic_field(&self, field: &str) -> Option<&str> {
self.intrinsic.get(field).map(String::as_str)
}
pub fn intrinsic_fields(&self) -> impl Iterator<Item = (&str, &str)> {
self.intrinsic.iter().map(|(field, value)| (field.as_str(), value.as_str()))
}
pub fn name(&self) -> &str {
self.intrinsic_field(NAME_FIELD).unwrap_or("")
}
pub fn desc(&self) -> &str {
self.intrinsic_field(DESC_FIELD).unwrap_or("")
}
pub fn set_structural_targets(
&mut self, field: impl Into<String>, targets: impl IntoIterator<Item = EntryAddress>,
) {
self.structural.insert(field.into(), targets.into_iter().collect::<Vec<_>>());
}
pub fn push_structural_target(
&mut self, field: impl Into<String>, target: impl Into<EntryAddress>,
) {
self.structural_targets_for_mut(field).push(target.into());
}
pub fn rename_structural_target(
&mut self, old_id: &EntryAddress, new_id: &EntryAddress,
) -> bool {
let mut changed = false;
for targets in self.structural.values_mut() {
for target in targets {
if target == old_id {
*target = new_id.clone();
changed = true;
}
}
}
changed
}
pub fn rename_structural_field(
&mut self, old_id: &EntryAddress, new_id: &EntryAddress,
) -> bool {
let old_field = old_id.as_str();
if !self.structural.contains_key(old_field) {
return false;
}
let mut renamed = EntryStructuralFields::with_capacity(self.structural.len());
for (field, targets) in std::mem::take(&mut self.structural) {
if field == old_field {
renamed.insert(new_id.as_str().to_owned(), targets);
} else {
renamed.insert(field, targets);
}
}
self.structural = renamed;
true
}
}
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct EntryMeta {
pub frozen: Option<FrozenMarker>,
pub entry_type: Option<EntryMetaType>,
pub tide: Option<StructuralTideSettings>,
}
impl EntryMeta {
pub fn is_empty(&self) -> bool {
self.frozen.is_none() && self.entry_type.is_none() && self.tide.is_none()
}
pub fn is_structural_relation(&self) -> bool {
self.entry_type == Some(EntryMetaType::Structural)
}
pub fn is_intrinsic_field(&self) -> bool {
self.entry_type == Some(EntryMetaType::Intrinsic)
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case")]
pub enum EntryMetaType {
Structural,
Intrinsic,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct FrozenMarker {
reasons: BTreeSet<FrozenReason>,
}
impl FrozenMarker {
pub fn reviewed() -> Self {
Self::from_reason(FrozenReason::Reviewed)
}
pub fn managed() -> Self {
Self::from_reason(FrozenReason::Managed)
}
pub fn is_reviewed(&self) -> bool {
self.reasons.contains(&FrozenReason::Reviewed)
}
pub fn is_managed(&self) -> bool {
self.reasons.contains(&FrozenReason::Managed)
}
pub fn insert_reviewed(&mut self) {
self.reasons.insert(FrozenReason::Reviewed);
}
pub fn insert_managed(&mut self) {
self.reasons.insert(FrozenReason::Managed);
}
pub fn remove_reviewed(&mut self) -> bool {
self.reasons.remove(&FrozenReason::Reviewed);
!self.reasons.is_empty()
}
pub fn reasons(&self) -> impl Iterator<Item = FrozenReason> + '_ {
[FrozenReason::Reviewed, FrozenReason::Managed]
.into_iter()
.filter(|reason| self.reasons.contains(reason))
}
fn from_reason(reason: FrozenReason) -> Self {
let mut reasons = BTreeSet::new();
reasons.insert(reason);
Self { reasons }
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case")]
pub enum FrozenReason {
Reviewed,
Managed,
}
fn seed_id(raw: &str) -> EntryAddress {
EntryAddress::new(raw)
.unwrap_or_else(|error| panic!("invalid built-in seed path `{raw}`: {error}"))
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
struct FrontmatterBounds {
metadata_start: usize,
metadata_end: usize,
body_start: usize,
}
impl FrontmatterBounds {
fn parse(source: &str) -> Result<Self, EntryParseError> {
let opening_line =
source.split_inclusive('\n').next().ok_or(EntryParseError::MissingFrontmatter)?;
if !opening_line.ends_with('\n') || line_text(opening_line) != "---" {
return Err(EntryParseError::MissingFrontmatter);
}
let metadata_start = opening_line.len();
let mut metadata_end = metadata_start;
let mut cursor = metadata_start;
for line in source[metadata_start..].split_inclusive('\n') {
if line.ends_with('\n') && line_text(line) == "---" {
let body_start =
cursor + line.len() + line_break_len_at(&source[cursor + line.len()..]);
return Ok(Self { metadata_start, metadata_end, body_start });
}
if !line.ends_with('\n') {
break;
}
metadata_end = cursor + line.len() - line_ending_len(line);
cursor += line.len();
}
Err(EntryParseError::UnterminatedFrontmatter)
}
}
fn split_frontmatter(source: &str) -> Result<(&str, String), EntryParseError> {
let bounds = FrontmatterBounds::parse(source)?;
Ok((bounds.metadata(source), bounds.body(source).to_owned()))
}
fn frontmatter_body_start(source: &str) -> Result<usize, EntryParseError> {
Ok(FrontmatterBounds::parse(source)?.body_start)
}
impl FrontmatterBounds {
fn metadata(self, source: &str) -> &str {
&source[self.metadata_start..self.metadata_end]
}
fn body(self, source: &str) -> &str {
&source[self.body_start..]
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
enum LineEnding {
Lf,
Crlf,
}
pub(crate) fn has_mixed_line_endings(source: &str) -> bool {
let mut first = None;
for line in source.split_inclusive('\n') {
let Some(ending) = line_ending(line) else {
continue;
};
if let Some(first) = first {
if first != ending {
return true;
}
} else {
first = Some(ending);
}
}
false
}
fn line_text(line: &str) -> &str {
match line_ending(line) {
| Some(LineEnding::Crlf) => &line[..line.len() - "\r\n".len()],
| Some(LineEnding::Lf) => &line[..line.len() - "\n".len()],
| None => line,
}
}
fn line_ending_len(line: &str) -> usize {
match line_ending(line) {
| Some(LineEnding::Crlf) => "\r\n".len(),
| Some(LineEnding::Lf) => "\n".len(),
| None => 0,
}
}
fn line_break_len_at(source: &str) -> usize {
if source.starts_with("\r\n") {
"\r\n".len()
} else if source.starts_with('\n') {
"\n".len()
} else {
0
}
}
fn line_ending(line: &str) -> Option<LineEnding> {
if line.ends_with("\r\n") {
Some(LineEnding::Crlf)
} else if line.ends_with('\n') {
Some(LineEnding::Lf)
} else {
None
}
}
fn parse_metadata_mapping(source: &str) -> Result<Mapping, EntryParseError> {
let value: Value = serde_yaml::from_str(source).map_err(EntryParseError::Yaml)?;
match value {
| Value::Mapping(mapping) => Ok(mapping),
| _ => Err(EntryParseError::MetadataMustBeMapping),
}
}
fn take_required_string(mapping: &mut Mapping, field: &str) -> Result<String, EntryParseError> {
let value = mapping
.shift_remove(Value::String(field.to_owned()))
.ok_or_else(|| EntryParseError::MissingField(field.to_owned()))?;
match value {
| Value::String(value) => Ok(value),
| _ => Err(EntryParseError::FieldMustBeString(field.to_owned())),
}
}
fn take_structural_fields(mapping: Mapping) -> Result<EntryStructuralFields, EntryParseError> {
let mut structural = EntryStructuralFields::new();
for (key, value) in mapping {
let Value::String(field) = key else {
return Err(EntryParseError::MetadataKeyMustBeString);
};
structural.insert(field.clone(), parse_id_list(field, value)?);
}
Ok(structural)
}
fn take_entry_meta(mapping: &mut Mapping) -> Result<EntryMeta, EntryParseError> {
let Some(value) = mapping.shift_remove(Value::String(META_FIELD.to_owned())) else {
return Ok(EntryMeta::default());
};
let Value::Mapping(mut meta_mapping) = value else {
return Err(EntryParseError::MetaMustBeMapping);
};
let frozen = take_meta_frozen_marker(&mut meta_mapping)?;
if let Some((key, _)) = meta_mapping.into_iter().next() {
let Value::String(field) = key else {
return Err(EntryParseError::MetaKeyMustBeString);
};
return Err(EntryParseError::UnknownMetaField(field));
}
Ok(EntryMeta { frozen, entry_type: None, tide: None })
}
fn parse_id_list(field: String, value: Value) -> Result<Vec<EntryAddress>, EntryParseError> {
let Value::Sequence(values) = value else {
return Err(EntryParseError::FieldMustBeList(field));
};
values
.into_iter()
.map(|value| match value {
| Value::String(raw) => EntryAddress::new(&raw).map_err(|source| {
EntryParseError::InvalidStructuralId { field: field.clone(), value: raw, source }
}),
| _ => Err(EntryParseError::ListItemMustBeString(field.clone())),
})
.collect()
}
fn take_meta_frozen_marker(mapping: &mut Mapping) -> Result<Option<FrozenMarker>, EntryParseError> {
let Some(value) = mapping.shift_remove(Value::String(FROZEN_FIELD.to_owned())) else {
return Ok(None);
};
parse_frozen_marker_value(value).map(Some)
}
fn take_flat_meta_type(mapping: &mut Mapping) -> Result<Option<EntryMetaType>, EntryParseError> {
let Some(value) = mapping.shift_remove(Value::String(META_TYPE_FIELD.to_owned())) else {
return Ok(None);
};
parse_flat_meta_type_value(Some(&value))
}
fn parse_flat_meta_type_value(
value: Option<&Value>,
) -> Result<Option<EntryMetaType>, EntryParseError> {
match value {
| None => Ok(None),
| Some(Value::String(raw)) if raw == STRUCTURAL_META_TYPE => {
Ok(Some(EntryMetaType::Structural))
}
| Some(Value::String(raw)) if raw == INTRINSIC_META_TYPE => {
Ok(Some(EntryMetaType::Intrinsic))
}
| _ => Err(EntryParseError::InvalidMetaType),
}
}
fn take_flat_structural_tide_settings(
mapping: &mut Mapping, entry_type: Option<EntryMetaType>,
) -> Result<Option<StructuralTideSettings>, EntryParseError> {
let keys = mapping
.keys()
.filter_map(|key| match key {
| Value::String(field) if field.starts_with("meta.") => Some(field.clone()),
| _ => None,
})
.collect::<Vec<_>>();
let mut settings = None;
for field in keys {
let value = mapping
.shift_remove(Value::String(field.clone()))
.expect("metadata key was collected from this mapping");
parse_flat_structural_tide_field(&mut settings, &field, value, entry_type)?;
}
Ok(settings)
}
fn parse_flat_structural_tide_field(
settings: &mut Option<StructuralTideSettings>, field: &str, value: Value,
entry_type: Option<EntryMetaType>,
) -> Result<(), EntryParseError> {
let meta_field =
field.strip_prefix("meta.").expect("flat structural tide field has meta prefix");
let parts = meta_field.split('.').collect::<Vec<_>>();
match parts.as_slice() {
| ["ripple", line @ ("lake" | "anchor")] => {
if entry_type != Some(EntryMetaType::Structural) {
return Err(EntryParseError::StructuralTideWithoutType(field.to_owned()));
}
for direction in parse_tide_direction_list_field(field, value)? {
set_structural_tide_setting(
settings.get_or_insert_with(StructuralTideSettings::default),
line,
direction,
true,
);
}
settings.get_or_insert_with(StructuralTideSettings::default);
Ok(())
}
| _ => Err(EntryParseError::UnknownMetaField(meta_field.to_owned())),
}
}
fn parse_tide_direction_list_field(
field: &str, value: Value,
) -> Result<Vec<StructuralEdgeDirection>, EntryParseError> {
let Value::Sequence(values) = value else {
return Err(EntryParseError::InvalidStructuralTideField(field.to_owned()));
};
let mut directions = Vec::new();
for value in values {
let Value::String(raw) = value else {
return Err(EntryParseError::InvalidStructuralTideField(field.to_owned()));
};
let direction = raw
.parse::<StructuralEdgeDirection>()
.map_err(|_| EntryParseError::InvalidStructuralTideField(field.to_owned()))?;
if directions.contains(&direction) {
return Err(EntryParseError::InvalidStructuralTideField(field.to_owned()));
}
directions.push(direction);
}
Ok(directions)
}
fn set_structural_tide_setting(
settings: &mut StructuralTideSettings, line: &str, direction: StructuralEdgeDirection,
enabled: bool,
) {
let ripple = match direction {
| StructuralEdgeDirection::To => &mut settings.to,
| StructuralEdgeDirection::From => &mut settings.from,
| StructuralEdgeDirection::Clique => &mut settings.clique,
};
match line {
| "lake" => ripple.lake = enabled,
| "anchor" => ripple.anchor = enabled,
| _ => unreachable!("line was parsed before setting tide value"),
}
}
fn parse_frozen_marker_value(value: Value) -> Result<FrozenMarker, EntryParseError> {
let Value::Sequence(values) = value else {
return Err(EntryParseError::InvalidFrozenMarker);
};
if values.is_empty() {
return Err(EntryParseError::InvalidFrozenMarker);
}
let mut reasons = BTreeSet::new();
for value in values {
let Value::String(raw) = value else {
return Err(EntryParseError::InvalidFrozenMarker);
};
let reason = match raw.as_str() {
| "reviewed" => FrozenReason::Reviewed,
| "managed" => FrozenReason::Managed,
| _ => return Err(EntryParseError::InvalidFrozenMarker),
};
if !reasons.insert(reason) {
return Err(EntryParseError::InvalidFrozenMarker);
}
}
Ok(FrozenMarker { reasons })
}
fn validate_plain_string(field: &str, value: &str) -> Result<(), EntryParseError> {
if value.contains('\n') || value.contains('\r') {
return Err(EntryParseError::FieldMustBePlainString(field.to_owned()));
}
Ok(())
}
fn render_id_list(
out: &mut String, field: &str, values: &[EntryAddress],
) -> Result<(), EntryRenderError> {
if values.is_empty() {
out.push_str(field);
out.push_str(": []\n");
return Ok(());
}
out.push_str(field);
out.push_str(":\n");
for id in values {
out.push_str(" - ");
out.push_str(&render_yaml_scalar(id.as_str())?);
out.push('\n');
}
Ok(())
}
fn render_structural_fields(
out: &mut String, structural: &EntryStructuralFields,
) -> Result<(), EntryRenderError> {
for (field, values) in structural {
render_id_list(out, field, values)?;
}
Ok(())
}
fn render_entry_meta(out: &mut String, meta: &EntryMeta) {
if meta.is_empty() {
return;
}
if let Some(marker) = &meta.frozen {
out.push_str("meta:\n");
render_meta_frozen_marker(out, marker);
}
if let Some(entry_type) = meta.entry_type {
render_flat_meta_type(out, entry_type);
}
if let Some(tide) = &meta.tide {
render_flat_structural_tide_settings(out, tide);
}
}
fn render_meta_frozen_marker(out: &mut String, marker: &FrozenMarker) {
out.push_str(" frozen:\n");
for reason in marker.reasons() {
out.push_str(" - ");
out.push_str(match reason {
| FrozenReason::Reviewed => "reviewed",
| FrozenReason::Managed => "managed",
});
out.push('\n');
}
}
fn render_flat_meta_type(out: &mut String, entry_type: EntryMetaType) {
out.push_str(META_TYPE_FIELD);
out.push_str(": ");
out.push_str(match entry_type {
| EntryMetaType::Structural => "\"structural\"",
| EntryMetaType::Intrinsic => "\"intrinsic\"",
});
out.push('\n');
}
fn render_flat_structural_tide_settings(out: &mut String, settings: &StructuralTideSettings) {
render_flat_structural_tide_line(
out,
"lake",
settings.to.lake,
settings.from.lake,
settings.clique.lake,
);
render_flat_structural_tide_line(
out,
"anchor",
settings.to.anchor,
settings.from.anchor,
settings.clique.anchor,
);
}
fn render_flat_structural_tide_line(
out: &mut String, line: &str, to: bool, from: bool, clique: bool,
) {
out.push_str("meta.ripple.");
out.push_str(line);
out.push_str(": [");
let mut first = true;
for (direction, enabled) in [("to", to), ("from", from), ("clique", clique)] {
if enabled {
if !first {
out.push_str(", ");
}
first = false;
out.push('"');
out.push_str(direction);
out.push('"');
}
}
out.push_str("]\n");
}
fn render_yaml_scalar(value: &str) -> Result<String, EntryRenderError> {
let mut rendered = serde_yaml::to_string(value).map_err(EntryRenderError::Yaml)?;
if let Some(stripped) = rendered.strip_suffix("\n...\n") {
rendered = stripped.to_owned();
}
Ok(rendered.trim_end_matches('\n').to_owned())
}
#[derive(Debug, Error)]
pub enum EntryParseError {
#[error("entry is missing a YAML metadata block")]
MissingFrontmatter,
#[error("entry metadata block is not closed")]
UnterminatedFrontmatter,
#[error("invalid YAML metadata: {0}")]
Yaml(serde_yaml::Error),
#[error("entry metadata must be a mapping")]
MetadataMustBeMapping,
#[error("entry metadata keys must be strings")]
MetadataKeyMustBeString,
#[error("metadata field `meta` must be a mapping")]
MetaMustBeMapping,
#[error("metadata field `meta` keys must be strings")]
MetaKeyMustBeString,
#[error("unknown Sirno-managed metadata field `meta.{0}`")]
UnknownMetaField(String),
#[error("metadata field `meta.type` must be \"structural\" or \"intrinsic\"")]
InvalidMetaType,
#[error("metadata field `{0}` requires `meta.type: \"structural\"`")]
StructuralTideWithoutType(String),
#[error("missing required metadata field `{0}`")]
MissingField(String),
#[error("metadata field `{0}` must be a string")]
FieldMustBeString(String),
#[error("metadata field `{0}` must be a single-line plain string")]
FieldMustBePlainString(String),
#[error("metadata field `{0}` must be a list")]
FieldMustBeList(String),
#[error("items in metadata field `{0}` must be strings")]
ListItemMustBeString(String),
#[error("metadata field `{field}` contains invalid entry address `{value}`")]
InvalidStructuralId {
field: String,
value: String,
#[source]
source: EntryAddressError,
},
#[error("metadata field `meta.frozen` must be a non-empty list of reviewed or managed reasons")]
InvalidFrozenMarker,
#[error("metadata field `{0}` must be a list of to, from, and clique tide directions")]
InvalidStructuralTideField(String),
#[error("metadata field `frozen` moved to `meta.frozen`")]
TopLevelFrozenMarker,
}
#[derive(Debug, Error)]
pub enum EntryRenderError {
#[error(transparent)]
InvalidMetadata(#[from] EntryParseError),
#[error("failed to render YAML scalar: {0}")]
Yaml(serde_yaml::Error),
}
#[cfg(test)]
mod tests {
use super::*;
fn entry_id() -> EntryAddress {
EntryAddress::new("witness").unwrap()
}
#[test]
fn parses_canonical_entry_metadata() {
let source = "\
---
name: Witness
desc: An entry whose claim is evidenced by repository artifacts.
topic:
- concept
---
Body.
";
let entry = Entry::from_markdown(entry_id(), source).unwrap();
assert_eq!(entry.metadata.name(), "Witness");
assert_eq!(
entry.metadata.structural_targets_for("topic"),
&[EntryAddress::new("concept").unwrap()]
);
assert_eq!(entry.body, "Body.\n");
}
#[test]
fn parses_crlf_entry_metadata() {
let source = concat!(
"---\r\n",
"name: Witness\r\n",
"desc: An entry whose claim is evidenced by repository artifacts.\r\n",
"topic:\r\n",
" - concept\r\n",
"---\r\n",
"\r\n",
"Body.\r\n",
);
let entry = Entry::from_markdown(entry_id(), source).unwrap();
assert_eq!(entry.metadata.name(), "Witness");
assert_eq!(
entry.metadata.structural_targets_for("topic"),
&[EntryAddress::new("concept").unwrap()]
);
assert_eq!(entry.body, "Body.\r\n");
}
#[test]
fn rejects_scalar_structural_link_relation() {
let source = "\
---
name: Bad
desc: Bad structural metadata.
topic: concept
---
";
let error = Entry::from_markdown(entry_id(), source).unwrap_err();
assert!(matches!(error, EntryParseError::FieldMustBeList(field) if field == "topic"));
}
#[test]
fn parses_extra_list_metadata_as_structural_link_relation() {
let source = "\
---
name: Evidence
desc: Metadata with a project-defined structural link relation.
witness:
- repository-evidence
---
";
let entry = Entry::from_markdown(entry_id(), source).unwrap();
assert_eq!(
entry.metadata.structural_targets_for("witness"),
&[EntryAddress::new("repository-evidence").unwrap()]
);
}
#[test]
fn preserves_structural_link_relation_order_when_rendering() {
let source = "\
---
name: Ordered
desc: Metadata with user-authored structural link relation order.
zeta:
- concept
alpha:
- meta
---
Body.
";
let entry = Entry::from_markdown(entry_id(), source).unwrap();
let fields = entry.metadata.structural_fields().map(|(field, _)| field).collect::<Vec<_>>();
let rendered = entry.to_markdown().unwrap();
assert_eq!(fields, ["zeta", "alpha"]);
assert!(rendered.find("zeta:\n").unwrap() < rendered.find("alpha:\n").unwrap());
}
#[test]
fn preserves_present_empty_structural_link_relation_when_rendering() {
let source = "\
---
name: Empty Field
desc: Metadata with a present empty structural link relation.
topic: []
---
Body.
";
let entry = Entry::from_markdown(entry_id(), source).unwrap();
let rendered = entry.to_markdown().unwrap();
let reparsed = Entry::from_markdown(entry_id(), &rendered).unwrap();
assert!(
matches!(entry.metadata.structural_field("topic"), Some(targets) if targets.is_empty())
);
assert!(
matches!(reparsed.metadata.structural_field("topic"), Some(targets) if targets.is_empty())
);
assert!(rendered.contains("topic: []\n"));
}
#[test]
fn renders_structural_ids_as_yaml_scalars() {
let target = EntryAddress::new("Design Note #1").unwrap();
let mut metadata =
EntryMetadata::new("Evidence", "Metadata with a quoted target.").unwrap();
metadata.push_structural_target("witness", target.clone());
let entry = Entry::new(entry_id(), metadata, "Body.\n");
let rendered = entry.to_markdown().unwrap();
let reparsed = Entry::from_markdown(entry_id(), &rendered).unwrap();
assert_eq!(reparsed.metadata.structural_targets_for("witness"), &[target]);
}
#[test]
fn renames_structural_targets() {
let old_id = EntryAddress::new("old-entry").unwrap();
let new_id = EntryAddress::new("new-entry").unwrap();
let mut metadata = EntryMetadata::new("Concept", "A named idea.").unwrap();
metadata.push_structural_target("belongs", old_id.clone());
metadata.push_structural_target("belongs", EntryAddress::new("other-entry").unwrap());
metadata.push_structural_target("refines", old_id.clone());
assert!(metadata.rename_structural_target(&old_id, &new_id));
assert_eq!(
metadata.structural_targets_for("belongs"),
&[new_id.clone(), EntryAddress::new("other-entry").unwrap()]
);
assert_eq!(metadata.structural_targets_for("refines"), &[new_id]);
}
#[test]
fn renames_structural_fields() {
let old_id = EntryAddress::new("refines").unwrap();
let new_id = EntryAddress::new("prerequisite").unwrap();
let mut metadata = EntryMetadata::new("Concept", "A named idea.").unwrap();
metadata.push_structural_target("category", EntryAddress::new("concept").unwrap());
metadata.push_structural_target("refines", EntryAddress::new("broader").unwrap());
metadata.push_structural_target("belongs", EntryAddress::new("area").unwrap());
assert!(metadata.rename_structural_field(&old_id, &new_id));
let fields = metadata.structural_fields().map(|(field, _)| field).collect::<Vec<_>>();
assert_eq!(fields, ["category", "prerequisite", "belongs"]);
assert_eq!(
metadata.structural_targets_for("prerequisite"),
&[EntryAddress::new("broader").unwrap()]
);
assert!(metadata.structural_field("refines").is_none());
}
#[test]
fn parses_canonical_frozen_marker() {
let source = "\
---
name: Frozen
desc: A protected entry.
meta:
frozen:
- reviewed
---
Body.
";
let entry = Entry::from_markdown(entry_id(), source).unwrap();
assert_eq!(entry.metadata.meta.frozen, Some(FrozenMarker::reviewed()));
assert!(entry.metadata.meta.frozen.as_ref().unwrap().is_reviewed());
}
#[test]
fn parses_entry_without_managed_meta() {
let source = "\
---
name: Plain
desc: No managed metadata.
topic:
- concept
---
Body.
";
let entry = Entry::from_markdown(entry_id(), source).unwrap();
assert!(entry.metadata.meta.is_empty());
assert_eq!(
entry.metadata.structural_targets_for("topic"),
&[EntryAddress::new("concept").unwrap()]
);
}
#[test]
fn rejects_top_level_frozen_marker() {
let source = "\
---
name: Old
desc: Old frozen marker.
frozen:
- reviewed
---
";
let error = Entry::from_markdown(entry_id(), source).unwrap_err();
assert!(matches!(error, EntryParseError::TopLevelFrozenMarker));
}
#[test]
fn rejects_noncanonical_frozen_value() {
let source = "\
---
name: Bad
desc: Bad frozen marker.
meta:
frozen: true
---
";
let error = Entry::from_markdown(entry_id(), source).unwrap_err();
assert!(matches!(error, EntryParseError::InvalidFrozenMarker));
}
#[test]
fn rejects_explicit_null_frozen_value() {
let source = "\
---
name: Bad
desc: Bad frozen marker.
meta:
frozen: null
---
";
let error = Entry::from_markdown(entry_id(), source).unwrap_err();
assert!(matches!(error, EntryParseError::InvalidFrozenMarker));
}
#[test]
fn parses_managed_frozen_reason() {
let source = "\
---
name: Managed
desc: A managed entry.
meta:
frozen:
- reviewed
- managed
---
Body.
";
let entry = Entry::from_markdown(entry_id(), source).unwrap();
let frozen = entry.metadata.meta.frozen.as_ref().unwrap();
assert!(frozen.is_reviewed());
assert!(frozen.is_managed());
}
#[test]
fn parses_structural_tide_settings() {
let source = "\
---
name: Belongs
desc: A structural relation.
meta.type: \"structural\"
meta.ripple.lake: [\"to\", \"from\", \"clique\"]
meta.ripple.anchor: [\"from\"]
---
Body.
";
let entry = Entry::from_markdown(entry_id(), source).unwrap();
let structural = entry.metadata.meta.tide.unwrap();
assert_eq!(
structural,
StructuralTideSettings::new(
crate::structural::StructuralRippleSettings::new(true, false),
crate::structural::StructuralRippleSettings::new(true, true),
crate::structural::StructuralRippleSettings::new(true, false),
)
);
}
#[test]
fn renders_empty_structural_tide_settings() {
let mut metadata = EntryMetadata::new("Category", "A structural relation.").unwrap();
metadata.meta.entry_type = Some(EntryMetaType::Structural);
metadata.meta.tide = Some(StructuralTideSettings::default());
let entry = Entry::new(entry_id(), metadata, "Body.\n");
let rendered = entry.to_markdown().unwrap();
let reparsed = Entry::from_markdown(entry_id(), &rendered).unwrap();
assert!(
rendered.contains(
"meta.type: \"structural\"\nmeta.ripple.lake: []\nmeta.ripple.anchor: []\n"
)
);
assert_eq!(reparsed.metadata.meta.tide, Some(StructuralTideSettings::default()));
}
#[test]
fn parses_structural_type_without_tide_settings() {
let source = "\
---
name: Category
desc: A structural relation.
meta.type: \"structural\"
---
Body.
";
let entry = Entry::from_markdown(entry_id(), source).unwrap();
assert_eq!(entry.metadata.meta.entry_type, Some(EntryMetaType::Structural));
assert_eq!(entry.metadata.meta.tide, None);
}
#[test]
fn parses_intrinsic_meta_type() {
let source = "\
---
name: Name
desc: A required metadata field.
meta.type: \"intrinsic\"
---
Body.
";
let entry = Entry::from_markdown(entry_id(), source).unwrap();
assert_eq!(entry.metadata.meta.entry_type, Some(EntryMetaType::Intrinsic));
assert!(entry.metadata.meta.is_intrinsic_field());
assert_eq!(entry.metadata.meta.tide, None);
}
#[test]
fn renders_intrinsic_meta_type() {
let mut metadata = EntryMetadata::new("Name", "A required metadata field.").unwrap();
metadata.meta.entry_type = Some(EntryMetaType::Intrinsic);
let entry = Entry::new(entry_id(), metadata, "Body.\n");
let rendered = entry.to_markdown().unwrap();
assert!(rendered.contains("meta.type: \"intrinsic\"\n"));
}
#[test]
fn renders_structural_tide_settings() {
let mut metadata = EntryMetadata::new("Belongs", "A structural relation.").unwrap();
metadata.meta.entry_type = Some(EntryMetaType::Structural);
metadata.meta.tide = Some(StructuralTideSettings::new(
crate::structural::StructuralRippleSettings::new(true, false),
crate::structural::StructuralRippleSettings::new(true, true),
crate::structural::StructuralRippleSettings::new(true, false),
));
let entry = Entry::new(entry_id(), metadata, "Body.\n");
let rendered = entry.to_markdown().unwrap();
assert!(rendered.contains(
"\
meta.ripple.lake: [\"to\", \"from\", \"clique\"]
meta.ripple.anchor: [\"from\"]
"
));
}
#[test]
fn renders_canonical_frozen_marker() {
let mut metadata = EntryMetadata::new("Frozen", "Protected entry.").unwrap();
metadata.meta.frozen = Some(FrozenMarker::reviewed());
let entry = Entry::new(entry_id(), metadata, "Body.\n");
let rendered = entry.to_markdown().unwrap();
assert!(rendered.contains("meta:\n frozen:\n - reviewed\n"));
assert!(
rendered.find("desc: Protected entry.\n").unwrap() < rendered.find("meta:\n").unwrap()
);
assert!(!rendered.contains("frozen: null"));
assert!(!rendered.contains("frozen: true"));
}
#[test]
fn omits_empty_managed_meta_when_rendering() {
let metadata = EntryMetadata::new("Plain", "No managed metadata.").unwrap();
let entry = Entry::new(entry_id(), metadata, "Body.\n");
let rendered = entry.to_markdown().unwrap();
assert!(!rendered.contains("meta:\n"));
}
#[test]
fn rejects_non_mapping_meta() {
let source = "\
---
name: Bad
desc: Bad meta.
meta: true
---
";
let error = Entry::from_markdown(entry_id(), source).unwrap_err();
assert!(matches!(error, EntryParseError::MetaMustBeMapping));
}
#[test]
fn rejects_unknown_meta_field() {
let source = "\
---
name: Bad
desc: Bad meta.
meta:
owner: sirno
---
";
let error = Entry::from_markdown(entry_id(), source).unwrap_err();
assert!(matches!(error, EntryParseError::UnknownMetaField(field) if field == "owner"));
}
#[test]
fn rejects_old_structural_tide_block() {
let source = "\
---
name: Bad
desc: Bad tide metadata.
meta:
structural: {}
---
";
let error = Entry::from_markdown(entry_id(), source).unwrap_err();
assert!(matches!(error, EntryParseError::UnknownMetaField(field) if field == "structural"));
}
#[test]
fn rejects_old_dotted_tide_field() {
let source = "\
---
name: Bad
desc: Bad tide metadata.
meta.ripple.lake.to: true
---
";
let error = Entry::from_markdown(entry_id(), source).unwrap_err();
assert!(
matches!(error, EntryParseError::UnknownMetaField(field) if field == "ripple.lake.to")
);
}
#[test]
fn rejects_old_flat_tide_field() {
let source = "\
---
name: Bad
desc: Bad tide metadata.
meta.type: \"structural\"
meta.lake: [\"to\"]
---
";
let error = Entry::from_markdown(entry_id(), source).unwrap_err();
assert!(matches!(error, EntryParseError::UnknownMetaField(field) if field == "lake"));
}
#[test]
fn rejects_non_list_flat_tide_field() {
let source = "\
---
name: Bad
desc: Bad tide metadata.
meta.type: \"structural\"
meta.ripple.lake: true
---
";
let error = Entry::from_markdown(entry_id(), source).unwrap_err();
assert!(
matches!(error, EntryParseError::InvalidStructuralTideField(field) if field == "meta.ripple.lake")
);
}
#[test]
fn rejects_unknown_flat_tide_direction() {
let source = "\
---
name: Bad
desc: Bad tide metadata.
meta.type: \"structural\"
meta.ripple.lake: [\"around\"]
---
";
let error = Entry::from_markdown(entry_id(), source).unwrap_err();
assert!(
matches!(error, EntryParseError::InvalidStructuralTideField(field) if field == "meta.ripple.lake")
);
}
#[test]
fn rejects_tide_field_without_structural_type() {
let source = "\
---
name: Bad
desc: Bad tide metadata.
meta.ripple.lake: [\"to\"]
---
";
let error = Entry::from_markdown(entry_id(), source).unwrap_err();
assert!(
matches!(error, EntryParseError::StructuralTideWithoutType(field) if field == "meta.ripple.lake")
);
}
#[test]
fn rejects_tide_field_with_intrinsic_type() {
let source = "\
---
name: Bad
desc: Bad tide metadata.
meta.type: \"intrinsic\"
meta.ripple.lake: [\"to\"]
---
";
let error = Entry::from_markdown(entry_id(), source).unwrap_err();
assert!(
matches!(error, EntryParseError::StructuralTideWithoutType(field) if field == "meta.ripple.lake")
);
}
#[test]
fn rejects_unknown_flat_meta_type() {
let source = "\
---
name: Bad
desc: Bad type metadata.
meta.type: \"concept\"
---
";
let error = Entry::from_markdown(entry_id(), source).unwrap_err();
assert!(matches!(error, EntryParseError::InvalidMetaType));
}
#[test]
fn replaces_body_without_rewriting_frontmatter() {
let source = "\
---
name: Old
desc: Existing desc.
---
Old body.
";
let replaced = Entry::replace_markdown_body(source, "New body.\n").unwrap();
assert!(replaced.starts_with("---\nname: Old\ndesc: Existing desc.\n---\n\n"));
assert!(replaced.ends_with("New body.\n"));
assert!(!replaced.contains("Old body."));
}
#[test]
fn replaces_crlf_body_without_rewriting_frontmatter() {
let source = "---\r\nname: Old\r\ndesc: Existing desc.\r\n---\r\n\r\nOld body.\r\n";
let replaced = Entry::replace_markdown_body(source, "New body.\n").unwrap();
assert!(replaced.starts_with("---\r\nname: Old\r\ndesc: Existing desc.\r\n---\r\n\r\n"));
assert!(replaced.ends_with("New body.\n"));
assert!(!replaced.contains("Old body."));
}
#[test]
fn detects_mixed_line_endings() {
assert!(!has_mixed_line_endings("---\nname: Entry\n---\n"));
assert!(!has_mixed_line_endings("---\r\nname: Entry\r\n---\r\n"));
assert!(has_mixed_line_endings("---\r\nname: Entry\n---\r\n"));
}
}