use std::collections::BTreeSet;
use std::fmt::Write;
use thiserror::Error;
use crate::entry::Entry;
use crate::id::EntryId;
use crate::structural::{StructuralEdgeDirection, StructuralEdgeIndex, StructuralSettings};
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct GeneratedLinkBody<'a> {
body: &'a str,
}
impl<'a> GeneratedLinkBody<'a> {
pub fn new(body: &'a str) -> Self {
Self { body }
}
pub fn validate(&self) -> Result<(), GeneratedLinkError> {
self.bounds().map(|_| ())
}
pub fn is_stale(&self, expected: &str) -> Result<bool, GeneratedLinkError> {
let Some(bounds) = self.bounds()? else {
return Ok(false);
};
Ok(&self.body[bounds.region_start..bounds.region_end] != expected)
}
pub fn apply(&self, footer: &str) -> Result<String, GeneratedLinkError> {
let Some(bounds) = self.bounds()? else {
return Ok(self.append_footer(footer));
};
let region_end = bounds.next_line_start(self.body);
let before = self.body[..bounds.region_start].trim_end_matches('\n');
let after = self.body[region_end..].trim_start_matches('\n');
let mut out = String::new();
if !before.is_empty() {
out.push_str(before);
out.push_str("\n\n");
}
out.push_str(footer);
out.push('\n');
if !after.is_empty() {
out.push('\n');
out.push_str(after);
}
Ok(out)
}
pub fn delete(&self) -> Result<String, GeneratedLinkError> {
let Some(bounds) = self.bounds()? else {
return Ok(self.body.to_owned());
};
let region_end = bounds.next_line_start(self.body);
let before = self.body[..bounds.region_start].trim_end_matches('\n');
let after = self.body[region_end..].trim_start_matches('\n');
let mut out = String::new();
if !before.is_empty() {
out.push_str(before);
}
if !before.is_empty() && !after.is_empty() {
out.push_str("\n\n");
}
if !after.is_empty() {
out.push_str(after);
}
if after.is_empty() && self.body.ends_with('\n') && !out.is_empty() {
out.push('\n');
}
Ok(out)
}
pub fn mask(&self) -> Result<String, GeneratedLinkError> {
let Some(bounds) = self.bounds()? else {
return Ok(self.body.to_owned());
};
let region_end = bounds.next_line_start(self.body);
let body = self.body.as_bytes();
let mut out = Vec::with_capacity(body.len());
out.extend_from_slice(&body[..bounds.region_start]);
for byte in &body[bounds.region_start..region_end] {
out.push(if *byte == b'\n' { b'\n' } else { b' ' });
}
out.extend_from_slice(&body[region_end..]);
Ok(String::from_utf8(out).expect("masked generated-link body remains UTF-8"))
}
fn bounds(&self) -> Result<Option<GeneratedLinkBounds>, GeneratedLinkError> {
GeneratedLinkBounds::find(self.body)
}
fn append_footer(&self, footer: &str) -> String {
let before = self.body.trim_end_matches('\n');
let mut out = String::new();
if !before.is_empty() {
out.push_str(before);
out.push_str("\n\n");
if !Self::ends_with_divider(before) {
out.push_str(GENERATED_LINK_DIVIDER);
out.push_str("\n\n");
}
}
out.push_str(footer);
out.push('\n');
out
}
fn ends_with_divider(body: &str) -> bool {
body.lines()
.rev()
.find(|line| !line.trim().is_empty())
.is_some_and(|line| line.trim() == GENERATED_LINK_DIVIDER)
}
}
impl StructuralEdgeIndex {
pub fn render_entry(&self, entry: &Entry, settings: &StructuralSettings) -> String {
let mut out = String::new();
out.push_str(BEGIN_LINKS_GUARD);
out.push_str("\n\n");
let mut sections = Vec::new();
for (field, field_settings) in settings.fields() {
for direction in StructuralEdgeDirection::ORDER {
if field_settings.edge(direction).render {
sections.push(GeneratedLinkSection::new(
section_title(field, direction),
self.edge_targets(field, direction, entry),
));
}
}
}
if sections.is_empty() {
out.push_str("(none)\n\n");
} else {
for section in §ions {
section.render(&mut out);
}
out.push('\n');
}
out.push_str(END_LINKS_GUARD);
out
}
}
fn section_title(field: &str, direction: StructuralEdgeDirection) -> String {
format!("{field} ({direction})")
}
#[derive(Debug)]
struct GeneratedLinkSection {
title: String,
targets: BTreeSet<EntryId>,
}
impl GeneratedLinkSection {
fn new(title: String, targets: BTreeSet<EntryId>) -> Self {
Self { title, targets }
}
fn render(&self, out: &mut String) {
out.push_str("- ");
out.push_str(&self.title);
out.push(':');
if self.targets.is_empty() {
out.push_str(" (none)\n");
return;
}
out.push('\n');
for id in &self.targets {
out.push_str(" - ");
out.push_str(&render_markdown_entry_link(id));
out.push('\n');
}
}
}
fn render_markdown_entry_link(id: &EntryId) -> String {
format!(
"[{}]({}.md)",
escape_markdown_link_label(id.as_str()),
percent_encode_path_segment(id.as_str())
)
}
fn escape_markdown_link_label(value: &str) -> String {
let mut out = String::new();
for character in value.chars() {
if matches!(character, '[' | ']') {
out.push('\\');
}
out.push(character);
}
out
}
fn percent_encode_path_segment(value: &str) -> String {
let mut out = String::new();
for byte in value.bytes() {
if byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'.' | b'_' | b'~') {
out.push(char::from(byte));
} else {
write!(&mut out, "%{byte:02X}").expect("writing to a string cannot fail");
}
}
out
}
pub const BEGIN_LINKS_GUARD: &str = "> **Sirno generated links begin. Do not edit this section.**";
pub const END_LINKS_GUARD: &str = "> **Sirno generated links end.**";
const GENERATED_LINK_DIVIDER: &str = "---";
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
struct GeneratedLinkBounds {
region_start: usize,
region_end: usize,
}
impl GeneratedLinkBounds {
fn find(body: &str) -> Result<Option<Self>, GeneratedLinkError> {
let begin = Self::guard_positions(body, BEGIN_LINKS_GUARD);
let end = Self::guard_positions(body, END_LINKS_GUARD);
let bounds = match (begin.as_slice(), end.as_slice()) {
| ([], []) => Ok(()),
| ([begin], [end]) if begin < end => Ok(()),
| ([begin], [end]) if begin > end => Err(GeneratedLinkError::EndBeforeBegin),
| ([], [_]) => Err(GeneratedLinkError::MissingBegin),
| ([_], []) => Err(GeneratedLinkError::MissingEnd),
| (_, _) if begin.len() > 1 => Err(GeneratedLinkError::DuplicateBegin),
| (_, _) if end.len() > 1 => Err(GeneratedLinkError::DuplicateEnd),
| _ => Err(GeneratedLinkError::Malformed),
};
bounds?;
if begin.is_empty() {
return Ok(None);
}
let begin = begin[0];
let end = end[0] + END_LINKS_GUARD.len();
Ok(Some(Self { region_start: Self::line_start(body, begin), region_end: end }))
}
fn next_line_start(self, body: &str) -> usize {
body[self.region_end..]
.find('\n')
.map_or(body.len(), |position| self.region_end + position + 1)
}
fn guard_positions(body: &str, guard: &str) -> Vec<usize> {
body.match_indices(guard).map(|(index, _)| index).collect()
}
fn line_start(body: &str, index: usize) -> usize {
body[..index].rfind('\n').map_or(0, |position| position + 1)
}
}
#[derive(Debug, Error, PartialEq, Eq)]
pub enum GeneratedLinkError {
#[error("generated-link footer is missing its opening guard")]
MissingBegin,
#[error("generated-link footer is missing its closing guard")]
MissingEnd,
#[error("generated-link footer has duplicate opening guards")]
DuplicateBegin,
#[error("generated-link footer has duplicate closing guards")]
DuplicateEnd,
#[error("generated-link footer closing guard appears before opening guard")]
EndBeforeBegin,
#[error("generated-link footer boundaries are malformed")]
Malformed,
}
#[cfg(test)]
mod tests {
use super::*;
use crate::structural::{StructuralFieldSettings, StructuralSettings};
use crate::{Entry, EntryId, EntryMetadata};
const FIELD_KIND: &str = "kind";
const FIELD_AREA: &str = "area";
const FIELD_PARENT: &str = "parent";
fn id(raw: &str) -> EntryId {
EntryId::new(raw).unwrap()
}
fn entry() -> Entry {
let mut metadata = EntryMetadata::new("Concept", "A named idea.").unwrap();
metadata.push_structural_target(FIELD_KIND, id("meta"));
metadata.push_structural_target(FIELD_AREA, id("core"));
metadata.push_structural_target(FIELD_PARENT, id("metadata"));
Entry::new(id("concept"), metadata, "Body.\n")
}
fn structural_settings(
fields: impl IntoIterator<Item = (&'static str, StructuralFieldSettings)>,
) -> StructuralSettings {
StructuralSettings::from_fields(fields)
}
fn area_settings() -> StructuralSettings {
structural_settings([(FIELD_AREA, render_settings(true, true, false))])
}
fn render_settings(to: bool, from: bool, clique: bool) -> StructuralFieldSettings {
StructuralFieldSettings::render_only(to, from, clique)
}
fn render_entry(entry: &Entry, settings: &StructuralSettings) -> String {
StructuralEdgeIndex::from_entries(std::slice::from_ref(entry)).render_entry(entry, settings)
}
#[test]
fn default_settings_render_no_sections() {
let footer = render_entry(&entry(), &StructuralSettings::default());
assert_eq!(footer, format!("{BEGIN_LINKS_GUARD}\n\n(none)\n\n{END_LINKS_GUARD}"));
}
#[test]
fn configured_settings_render_selected_field_links() {
let footer = render_entry(&entry(), &area_settings());
assert!(!footer.contains("[meta](meta.md)"));
assert!(footer.contains(" - [core](core.md)"));
assert!(!footer.contains("[metadata](metadata.md)"));
assert!(!footer.contains("## Sirno Links"));
assert!(footer.contains("- area (from): (none)"));
assert!(footer.contains("- area (to):\n - [core](core.md)"));
assert!(footer.contains(BEGIN_LINKS_GUARD));
assert!(footer.contains(END_LINKS_GUARD));
assert!(footer.contains("> **Sirno generated links begin."));
}
#[test]
fn quoted_guards_are_separated_from_link_list() {
let footer = render_entry(&entry(), &area_settings());
assert!(footer.contains(&format!(
"{BEGIN_LINKS_GUARD}\n\n- area (to):\n - [core](core.md)\n- area (from): (none)"
)));
assert!(footer.contains(&format!("- area (from): (none)\n\n{END_LINKS_GUARD}")));
}
#[test]
fn settings_can_enable_each_structural_field() {
let settings = structural_settings([
(FIELD_KIND, render_settings(true, true, false)),
(FIELD_AREA, render_settings(true, true, false)),
(FIELD_PARENT, render_settings(true, true, false)),
]);
let footer = render_entry(&entry(), &settings);
assert!(footer.contains(" - [meta](meta.md)"));
assert!(footer.contains(" - [core](core.md)"));
assert!(footer.contains(" - [metadata](metadata.md)"));
assert!(footer.contains("- kind (from): (none)"));
assert!(footer.contains("- kind (to):"));
assert!(footer.contains("- area (from): (none)"));
assert!(footer.contains("- area (to):"));
assert!(footer.contains("- parent (from): (none)"));
assert!(footer.contains("- parent (to):"));
}
#[test]
fn repeated_targets_render_once() {
let mut entry = entry();
entry.metadata.push_structural_target(FIELD_KIND, id("meta"));
let settings = structural_settings([(FIELD_KIND, render_settings(true, true, false))]);
let footer = render_entry(&entry, &settings);
assert_eq!(footer.matches("[meta](meta.md)").count(), 1);
}
#[test]
fn generated_links_escape_filename_like_entry_ids() {
let target = id("Spec [A] #1");
let mut metadata = EntryMetadata::new("Concept", "A named idea.").unwrap();
metadata.push_structural_target(FIELD_AREA, target);
let entry = Entry::new(id("concept"), metadata, "Body.\n");
let footer = render_entry(&entry, &area_settings());
assert!(footer.contains(" - [Spec \\[A\\] #1](Spec%20%5BA%5D%20%231.md)"));
}
#[test]
fn boolean_field_settings_render_to_and_from_edges() {
let settings = structural_settings([(FIELD_KIND, render_settings(true, true, false))]);
let target_entry =
Entry::new(id("meta"), EntryMetadata::new("Meta", "A kind.").unwrap(), "Body.\n");
let mut member_metadata = EntryMetadata::new("Member", "A kind member.").unwrap();
member_metadata.push_structural_target(FIELD_KIND, id("meta"));
let member = Entry::new(id("member"), member_metadata, "Body.\n");
let entries = vec![target_entry.clone(), member.clone()];
let index = StructuralEdgeIndex::from_entries(&entries);
let target_footer = index.render_entry(&target_entry, &settings);
let member_footer = index.render_entry(&member, &settings);
assert!(target_footer.contains("- kind (from):"));
assert!(target_footer.contains(" - [member](member.md)"));
assert!(target_footer.contains("- kind (to): (none)"));
assert!(member_footer.contains("- kind (from): (none)"));
assert!(member_footer.contains("- kind (to):"));
assert!(member_footer.contains(" - [meta](meta.md)"));
}
#[test]
fn table_field_settings_can_choose_one_side() {
let settings = structural_settings([(FIELD_KIND, render_settings(false, true, false))]);
let target_entry =
Entry::new(id("meta"), EntryMetadata::new("Meta", "A kind.").unwrap(), "Body.\n");
let mut member_metadata = EntryMetadata::new("Member", "A kind member.").unwrap();
member_metadata.push_structural_target(FIELD_KIND, id("meta"));
let member = Entry::new(id("member"), member_metadata, "Body.\n");
let entries = vec![target_entry.clone(), member.clone()];
let index = StructuralEdgeIndex::from_entries(&entries);
let target_footer = index.render_entry(&target_entry, &settings);
let member_footer = index.render_entry(&member, &settings);
assert!(target_footer.contains("- kind (from):"));
assert!(target_footer.contains(" - [member](member.md)"));
assert!(member_footer.contains("- kind (from): (none)"));
assert!(!member_footer.contains("[meta](meta.md)"));
}
#[test]
fn clique_setting_expands_field_targets_to_edges() {
let settings = structural_settings([(FIELD_AREA, render_settings(false, false, true))]);
let closure = Entry::new(
id("core"),
EntryMetadata::new("Core", "A review neighborhood.").unwrap(),
"Body.\n",
);
let mut left_metadata = EntryMetadata::new("Left", "A neighborhood member.").unwrap();
left_metadata.push_structural_target(FIELD_AREA, id("core"));
let left = Entry::new(id("left"), left_metadata, "Body.\n");
let mut right_metadata = EntryMetadata::new("Right", "A neighborhood member.").unwrap();
right_metadata.push_structural_target(FIELD_AREA, id("core"));
let right = Entry::new(id("right"), right_metadata, "Body.\n");
let mut outside_metadata = EntryMetadata::new("Outside", "Another member.").unwrap();
outside_metadata.push_structural_target(FIELD_AREA, id("other"));
let outside = Entry::new(id("outside"), outside_metadata, "Body.\n");
let entries = vec![closure.clone(), left.clone(), right.clone(), outside];
let index = StructuralEdgeIndex::from_entries(&entries);
let closure_footer = index.render_entry(&closure, &settings);
let left_footer = index.render_entry(&left, &settings);
assert!(closure_footer.contains("- area (clique):"));
assert!(!closure_footer.contains("area (from)"));
assert!(closure_footer.contains(" - [left](left.md)"));
assert!(closure_footer.contains(" - [right](right.md)"));
assert!(!closure_footer.contains("[core](core.md)"));
assert!(!closure_footer.contains("[outside](outside.md)"));
assert!(left_footer.contains("- area (clique):"));
assert!(!left_footer.contains("area (to)"));
assert!(left_footer.contains(" - [core](core.md)"));
assert!(left_footer.contains(" - [right](right.md)"));
assert!(!left_footer.contains("[left](left.md)"));
assert!(!left_footer.contains("[outside](outside.md)"));
}
#[test]
fn direct_sections_remain_when_clique_is_enabled() {
let settings = structural_settings([(FIELD_AREA, render_settings(true, true, true))]);
let closure = Entry::new(
id("core"),
EntryMetadata::new("Core", "A review neighborhood.").unwrap(),
"Body.\n",
);
let mut left_metadata = EntryMetadata::new("Left", "A neighborhood member.").unwrap();
left_metadata.push_structural_target(FIELD_AREA, id("core"));
let left = Entry::new(id("left"), left_metadata, "Body.\n");
let mut right_metadata = EntryMetadata::new("Right", "A neighborhood member.").unwrap();
right_metadata.push_structural_target(FIELD_AREA, id("core"));
let right = Entry::new(id("right"), right_metadata, "Body.\n");
let entries = vec![closure, left.clone(), right];
let index = StructuralEdgeIndex::from_entries(&entries);
let left_footer = index.render_entry(&left, &settings);
assert!(left_footer.contains("- area (to):\n - [core](core.md)"));
assert!(left_footer.contains("- area (clique):"));
assert!(left_footer.contains(" - [right](right.md)"));
}
#[test]
fn sections_render_to_from_clique_order() {
let settings = structural_settings([(FIELD_AREA, render_settings(true, true, true))]);
let closure = Entry::new(
id("core"),
EntryMetadata::new("Core", "A review neighborhood.").unwrap(),
"Body.\n",
);
let mut left_metadata = EntryMetadata::new("Left", "A neighborhood member.").unwrap();
left_metadata.push_structural_target(FIELD_AREA, id("core"));
let left = Entry::new(id("left"), left_metadata, "Body.\n");
let mut right_metadata = EntryMetadata::new("Right", "A neighborhood member.").unwrap();
right_metadata.push_structural_target(FIELD_AREA, id("core"));
let right = Entry::new(id("right"), right_metadata, "Body.\n");
let entries = vec![closure, left.clone(), right];
let index = StructuralEdgeIndex::from_entries(&entries);
let footer = index.render_entry(&left, &settings);
let to = footer.find("area (to):").unwrap();
let from = footer.find("area (from):").unwrap();
let clique = footer.find("area (clique):").unwrap();
assert!(to < from);
assert!(from < clique);
}
#[test]
fn renders_empty_enabled_sections_when_entry_has_no_structural_targets() {
let metadata = EntryMetadata::new("Meta", "A kind.").unwrap();
let entry = Entry::new(EntryId::new("meta").unwrap(), metadata, "Body.\n");
let footer = render_entry(&entry, &area_settings());
assert!(footer.contains("- area (from): (none)"));
assert!(footer.contains("- area (to): (none)"));
assert!(!footer.contains(&format!("{BEGIN_LINKS_GUARD}\n\n(none)\n\n{END_LINKS_GUARD}")));
assert!(!footer.contains("- none"));
}
#[test]
fn renders_region_none_when_no_sections_are_enabled() {
let metadata = EntryMetadata::new("Meta", "A kind.").unwrap();
let entry = Entry::new(EntryId::new("meta").unwrap(), metadata, "Body.\n");
let settings = StructuralSettings::from_fields([
(FIELD_KIND, StructuralFieldSettings::default()),
(FIELD_AREA, StructuralFieldSettings::default()),
(FIELD_PARENT, StructuralFieldSettings::default()),
]);
let footer = render_entry(&entry, &settings);
assert_eq!(footer, format!("{BEGIN_LINKS_GUARD}\n\n(none)\n\n{END_LINKS_GUARD}"));
assert!(!footer.contains("- none"));
}
#[test]
fn appends_footer_when_missing() {
let footer = render_entry(&entry(), &StructuralSettings::default());
let body = GeneratedLinkBody::new("Body.\n").apply(&footer).unwrap();
assert_eq!(body, format!("Body.\n\n---\n\n{footer}\n"));
assert_eq!(body.matches(BEGIN_LINKS_GUARD).count(), 1);
}
#[test]
fn appends_footer_without_duplicate_divider() {
let footer = render_entry(&entry(), &StructuralSettings::default());
let body = GeneratedLinkBody::new("Body.\n\n---\n").apply(&footer).unwrap();
assert_eq!(body, format!("Body.\n\n---\n\n{footer}\n"));
}
#[test]
fn replaces_only_existing_footer_region() {
let old = format!("{BEGIN_LINKS_GUARD}\nold\n{END_LINKS_GUARD}\n");
let body = format!("Before.\n\n{old}\nAfter.\n");
let footer = render_entry(&entry(), &StructuralSettings::default());
let body = GeneratedLinkBody::new(&body).apply(&footer).unwrap();
assert!(body.starts_with("Before.\n\n"));
assert!(body.ends_with("After.\n"));
assert!(!body.contains("old"));
assert_eq!(body.matches(BEGIN_LINKS_GUARD).count(), 1);
}
#[test]
fn deletes_existing_footer_region() {
let footer = render_entry(&entry(), &StructuralSettings::default());
let body = GeneratedLinkBody::new("Body.\n").apply(&footer).unwrap();
let body = GeneratedLinkBody::new(&body).delete().unwrap();
assert_eq!(body, "Body.\n\n---\n");
assert!(!body.contains(BEGIN_LINKS_GUARD));
}
#[test]
fn deletes_footer_without_touching_following_body() {
let footer = render_entry(&entry(), &StructuralSettings::default());
let body = format!("Before.\n\n{footer}\nAfter.\n");
let body = GeneratedLinkBody::new(&body).delete().unwrap();
assert_eq!(body, "Before.\n\nAfter.\n");
}
#[test]
fn delete_is_noop_when_footer_is_missing() {
let body = GeneratedLinkBody::new("Body.\n").delete().unwrap();
assert_eq!(body, "Body.\n");
}
#[test]
fn masks_existing_footer_region() {
let footer = render_entry(&entry(), &StructuralSettings::default());
let body = format!("Before.\n\n{footer}\nAfter.\n");
let masked = GeneratedLinkBody::new(&body).mask().unwrap();
assert_eq!(masked.len(), body.len());
assert_eq!(masked.lines().count(), body.lines().count());
assert!(masked.contains("Before."));
assert!(masked.contains("After."));
assert!(!masked.contains(BEGIN_LINKS_GUARD));
assert!(!masked.contains("(none)"));
}
#[test]
fn mask_is_noop_when_footer_is_missing() {
let body = GeneratedLinkBody::new("Body.\n").mask().unwrap();
assert_eq!(body, "Body.\n");
}
#[test]
fn reports_generated_links_staleness() {
let expected = render_entry(&entry(), &StructuralSettings::default());
let current = GeneratedLinkBody::new("Body.\n").apply(&expected).unwrap();
let stale = GeneratedLinkBody::new("Body.\n")
.apply(&render_entry(
&entry(),
&structural_settings([
(FIELD_KIND, render_settings(true, true, false)),
(FIELD_AREA, render_settings(true, true, false)),
(FIELD_PARENT, render_settings(true, true, false)),
]),
))
.unwrap();
assert!(!GeneratedLinkBody::new("Body.\n").is_stale(&expected).unwrap());
assert!(!GeneratedLinkBody::new(¤t).is_stale(&expected).unwrap());
assert!(GeneratedLinkBody::new(&stale).is_stale(&expected).unwrap());
}
#[test]
fn rejects_missing_end_guard() {
let error = GeneratedLinkBody::new(BEGIN_LINKS_GUARD).validate().unwrap_err();
assert_eq!(error, GeneratedLinkError::MissingEnd);
}
#[test]
fn rejects_duplicate_begin_guard() {
let body = format!("{BEGIN_LINKS_GUARD}\n{BEGIN_LINKS_GUARD}\n{END_LINKS_GUARD}\n");
let error = GeneratedLinkBody::new(&body).validate().unwrap_err();
assert_eq!(error, GeneratedLinkError::DuplicateBegin);
}
}