use indexmap::IndexMap;
use serde::{Deserialize, Serialize};
use serde_yaml::{Mapping, Value};
use thiserror::Error;
use crate::id::{EntryId, EntryIdError};
pub const NAME_FIELD: &str = "name";
pub const DESC_FIELD: &str = "desc";
pub const FROZEN_FIELD: &str = "frozen";
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct Entry {
pub id: EntryId,
pub metadata: EntryMetadata,
pub body: String,
}
impl Entry {
pub fn new(id: EntryId, metadata: EntryMetadata, body: impl Into<String>) -> Self {
Self { id, metadata, body: body.into() }
}
pub fn from_markdown(id: EntryId, source: &str) -> Result<Self, EntryParseError> {
let (metadata_source, body) = split_frontmatter(source)?;
let metadata = EntryMetadata::from_yaml_source(metadata_source)?;
Ok(Self::new(id, metadata, body))
}
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 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("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",
),
])
}
}
pub type EntryStructuralFields = IndexMap<String, Vec<EntryId>>;
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct EntryMetadata {
pub name: String,
pub desc: String,
pub structural: EntryStructuralFields,
pub frozen: Option<FrozenMarker>,
}
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)?;
Ok(Self { name, desc, structural: EntryStructuralFields::new(), frozen: None })
}
pub fn from_yaml_source(source: &str) -> Result<Self, EntryParseError> {
let canonical_frozen = has_canonical_marker(source, FROZEN_FIELD);
let value: Value = serde_yaml::from_str(source).map_err(EntryParseError::Yaml)?;
let mut mapping = match value {
| Value::Mapping(mapping) => mapping,
| _ => return Err(EntryParseError::MetadataMustBeMapping),
};
let name = take_required_string(&mut mapping, NAME_FIELD)?;
let desc = take_required_string(&mut mapping, DESC_FIELD)?;
validate_plain_string(NAME_FIELD, &name)?;
validate_plain_string(DESC_FIELD, &desc)?;
let frozen = take_frozen_marker(&mut mapping, canonical_frozen)?;
let structural = take_structural_fields(mapping)?;
Ok(Self { name, desc, structural, frozen })
}
pub fn to_yaml_source(&self) -> Result<String, EntryRenderError> {
validate_plain_string(NAME_FIELD, &self.name)?;
validate_plain_string(DESC_FIELD, &self.desc)?;
let mut out = String::new();
out.push_str(&format!("name: {}\n", render_yaml_scalar(&self.name)?));
out.push_str(&format!("desc: {}\n", render_yaml_scalar(&self.desc)?));
render_structural_fields(&mut out, &self.structural)?;
if self.frozen.is_some() {
out.push_str("frozen:\n");
}
Ok(out)
}
pub fn structural_targets(&self) -> impl Iterator<Item = (&str, &EntryId)> {
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, &[EntryId])> {
self.structural.iter().map(|(field, targets)| (field.as_str(), targets.as_slice()))
}
pub fn structural_targets_for(&self, field: &str) -> &[EntryId] {
self.structural.get(field).map(Vec::as_slice).unwrap_or_default()
}
pub fn structural_field(&self, field: &str) -> Option<&[EntryId]> {
self.structural.get(field).map(Vec::as_slice)
}
pub fn structural_targets_for_mut(&mut self, field: impl Into<String>) -> &mut Vec<EntryId> {
self.structural.entry(field.into()).or_default()
}
pub fn set_structural_targets(
&mut self, field: impl Into<String>, targets: impl IntoIterator<Item = EntryId>,
) {
self.structural.insert(field.into(), targets.into_iter().collect::<Vec<_>>());
}
pub fn push_structural_target(&mut self, field: impl Into<String>, target: EntryId) {
self.structural_targets_for_mut(field).push(target);
}
pub fn rename_structural_target(&mut self, old_id: &EntryId, new_id: &EntryId) -> 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
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub enum FrozenMarker {
Present,
}
fn seed_id(raw: &str) -> EntryId {
EntryId::new(raw).unwrap_or_else(|error| panic!("invalid built-in seed id `{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 take_required_string(
mapping: &mut Mapping, field: &'static str,
) -> Result<String, EntryParseError> {
let value = mapping
.shift_remove(Value::String(field.to_owned()))
.ok_or(EntryParseError::MissingField(field))?;
match value {
| Value::String(value) => Ok(value),
| _ => Err(EntryParseError::FieldMustBeString(field)),
}
}
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 parse_id_list(field: String, value: Value) -> Result<Vec<EntryId>, EntryParseError> {
let Value::Sequence(values) = value else {
return Err(EntryParseError::FieldMustBeList(field));
};
values
.into_iter()
.map(|value| match value {
| Value::String(raw) => EntryId::new(&raw).map_err(|source| {
EntryParseError::InvalidStructuralId { field: field.clone(), value: raw, source }
}),
| _ => Err(EntryParseError::ListItemMustBeString(field.clone())),
})
.collect()
}
fn take_frozen_marker(
mapping: &mut Mapping, canonical_frozen: bool,
) -> Result<Option<FrozenMarker>, EntryParseError> {
let Some(value) = mapping.shift_remove(Value::String(FROZEN_FIELD.to_owned())) else {
return Ok(None);
};
if value != Value::Null || !canonical_frozen {
return Err(EntryParseError::InvalidFrozenMarker);
}
Ok(Some(FrozenMarker::Present))
}
fn has_canonical_marker(source: &str, field: &'static str) -> bool {
source.lines().any(|line| {
let line = line.trim_end();
line.strip_suffix(':').is_some_and(|prefix| prefix == field)
})
}
fn validate_plain_string(field: &'static str, value: &str) -> Result<(), EntryParseError> {
if value.contains('\n') || value.contains('\r') {
return Err(EntryParseError::FieldMustBePlainString(field));
}
Ok(())
}
fn render_id_list(
out: &mut String, field: &str, values: &[EntryId],
) -> 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_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("missing required metadata field `{0}`")]
MissingField(&'static str),
#[error("metadata field `{0}` must be a string")]
FieldMustBeString(&'static str),
#[error("metadata field `{0}` must be a single-line plain string")]
FieldMustBePlainString(&'static str),
#[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 id `{value}`")]
InvalidStructuralId {
field: String,
value: String,
#[source]
source: EntryIdError,
},
#[error("metadata field `frozen` must be written as canonical marker `frozen:`")]
InvalidFrozenMarker,
}
#[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() -> EntryId {
EntryId::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"),
&[EntryId::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"),
&[EntryId::new("concept").unwrap()]
);
assert_eq!(entry.body, "Body.\r\n");
}
#[test]
fn rejects_scalar_structural_field() {
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_field() {
let source = "\
---
name: Evidence
desc: Metadata with a project-defined structural field.
witness:
- repository-evidence
---
";
let entry = Entry::from_markdown(entry_id(), source).unwrap();
assert_eq!(
entry.metadata.structural_targets_for("witness"),
&[EntryId::new("repository-evidence").unwrap()]
);
}
#[test]
fn preserves_structural_field_order_when_rendering() {
let source = "\
---
name: Ordered
desc: Metadata with user-authored structural field 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_field_when_rendering() {
let source = "\
---
name: Empty Field
desc: Metadata with a present empty structural field.
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 = EntryId::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 = EntryId::new("old-entry").unwrap();
let new_id = EntryId::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", EntryId::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(), EntryId::new("other-entry").unwrap()]
);
assert_eq!(metadata.structural_targets_for("refines"), &[new_id]);
}
#[test]
fn parses_canonical_frozen_marker() {
let source = "\
---
name: Frozen
desc: A protected entry.
frozen:
---
Body.
";
let entry = Entry::from_markdown(entry_id(), source).unwrap();
assert_eq!(entry.metadata.frozen, Some(FrozenMarker::Present));
}
#[test]
fn rejects_noncanonical_frozen_value() {
let source = "\
---
name: Bad
desc: Bad frozen marker.
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.
frozen: null
---
";
let error = Entry::from_markdown(entry_id(), source).unwrap_err();
assert!(matches!(error, EntryParseError::InvalidFrozenMarker));
}
#[test]
fn renders_canonical_frozen_marker() {
let mut metadata = EntryMetadata::new("Frozen", "Protected entry.").unwrap();
metadata.frozen = Some(FrozenMarker::Present);
let entry = Entry::new(entry_id(), metadata, "Body.\n");
let rendered = entry.to_markdown().unwrap();
assert!(rendered.contains("frozen:\n"));
assert!(!rendered.contains("frozen: null"));
assert!(!rendered.contains("frozen: true"));
}
#[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"));
}
}