pub mod generator;
pub mod id;
pub mod loader;
pub mod parser;
pub mod source;
pub mod store_builder;
pub(crate) mod wikilink_rewrite;
pub mod writer;
use indexmap::IndexMap;
use schemars::JsonSchema;
use serde::{Deserialize, Serialize};
use std::collections::{HashMap, HashSet};
use std::fmt;
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize, JsonSchema)]
pub struct EntityId(pub String);
impl EntityId {
pub fn new(mem: &str, slug: &str) -> Self {
use unicode_normalization::UnicodeNormalization;
let mem_nfc: String = mem.nfc().collect();
let slug_nfc: String = slug.nfc().collect();
Self(format!("{mem_nfc}--{slug_nfc}"))
}
pub fn canonical(id: &str) -> Self {
use unicode_normalization::UnicodeNormalization;
Self(id.nfc().collect())
}
pub fn mem(&self) -> &str {
match self.0.find("--") {
Some(idx) => &self.0[..idx],
None => "",
}
}
pub fn name(&self) -> &str {
let path = self.path();
match path.rfind('/') {
Some(i) => &path[i + 1..],
None => path,
}
}
pub fn path(&self) -> &str {
match self.0.find("--") {
Some(idx) => &self.0[idx + 2..],
None => &self.0,
}
}
}
impl fmt::Display for EntityId {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "{}", self.0)
}
}
impl AsRef<str> for EntityId {
fn as_ref(&self) -> &str {
&self.0
}
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(untagged)]
pub enum MetadataValue {
Bool(bool),
Integer(i64),
Float(f64),
String(String),
}
impl MetadataValue {
pub fn to_frontmatter_string(&self) -> String {
match self {
Self::String(s) => s.clone(),
Self::Integer(n) => n.to_string(),
Self::Float(v) => format!("{v}"),
Self::Bool(b) => b.to_string(),
}
}
pub fn as_str(&self) -> Option<&str> {
match self {
Self::String(s) => Some(s),
_ => None,
}
}
pub fn is_falsy(&self) -> bool {
match self {
Self::Bool(b) => !b,
Self::Integer(n) => *n == 0,
Self::Float(f) => *f == 0.0,
Self::String(s) => s.is_empty(),
}
}
}
impl fmt::Display for MetadataValue {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "{}", self.to_frontmatter_string())
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Entity {
pub id: EntityId,
pub title: String,
pub entity_type: String,
pub mem: String,
pub file_path: String,
pub metadata: IndexMap<String, MetadataValue>,
pub sections: IndexMap<String, String>,
pub relationships: Vec<Relationship>,
pub content_hash: String,
pub stub: bool,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub stub_kind: Option<StubKind>,
#[serde(skip, default)]
pub heading_spans: HashMap<String, Vec<HeadingSpan>>,
#[serde(skip, default)]
pub raw_section_headings: Vec<String>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(tag = "kind", rename_all = "snake_case")]
pub enum StubKind {
ForwardReference,
LoadTime,
Residual {
since_commit: String,
readonly_referrers: Vec<EntityId>,
},
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct HeadingSpan {
pub level: u8,
pub title: String,
pub start_offset: usize,
pub end_offset: usize,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Relationship {
pub rel_type: String,
pub target: EntityId,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub description: Option<String>,
}
impl Relationship {
pub fn new(rel_type: impl Into<String>, target: EntityId) -> Self {
Self {
rel_type: rel_type.into(),
target,
description: None,
}
}
}
pub fn normalise_description(description: Option<&str>) -> Option<String> {
description
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty())
}
pub struct ParseResult {
pub entity: Entity,
pub inline_links: Vec<EntityId>,
pub parse_warnings: Vec<crate::ops::WarningHint>,
}
pub fn resolve_cross_mem_refs(
relationships: &mut [Relationship],
inline_links: &mut [EntityId],
current_mem: &str,
visible_writable: &HashSet<String>,
) {
for rel in relationships.iter_mut() {
if let Some(resolved) = rewrite_target(&rel.target, current_mem, visible_writable) {
rel.target = resolved;
}
}
for link in inline_links.iter_mut() {
if let Some(resolved) = rewrite_target(link, current_mem, visible_writable) {
*link = resolved;
}
}
}
fn rewrite_target(
target: &EntityId,
current_mem: &str,
visible_writable: &HashSet<String>,
) -> Option<EntityId> {
if target.mem() != current_mem {
return None;
}
let path = target.path();
let (prefix, rest) = path.split_once("--")?;
if prefix == current_mem {
return None;
}
if visible_writable.contains(prefix) {
Some(EntityId::new(prefix, rest))
} else {
None
}
}
#[cfg(test)]
mod resolve_tests {
use super::*;
fn roster(names: &[&str]) -> HashSet<String> {
names.iter().map(|s| s.to_string()).collect()
}
fn rel(rel_type: &str, mem: &str, slug: &str) -> Relationship {
Relationship {
rel_type: rel_type.to_string(),
target: EntityId::new(mem, slug),
description: None,
}
}
#[test]
fn rewrites_cross_mem_relationship_when_prefix_is_known() {
let mut rels = vec![rel("USES", "plan", "main--foo")];
let mut inline: Vec<EntityId> = Vec::new();
resolve_cross_mem_refs(&mut rels, &mut inline, "plan", &roster(&["main", "plan"]));
assert_eq!(rels[0].target.mem(), "main");
assert_eq!(rels[0].target.path(), "foo");
}
#[test]
fn leaves_relationship_unchanged_when_prefix_not_in_roster() {
let mut rels = vec![rel("USES", "plan", "main--foo")];
let mut inline: Vec<EntityId> = Vec::new();
resolve_cross_mem_refs(&mut rels, &mut inline, "plan", &roster(&["plan"]));
assert_eq!(rels[0].target.mem(), "plan");
assert_eq!(rels[0].target.path(), "main--foo");
}
#[test]
fn target_without_double_dash_is_unchanged() {
let mut rels = vec![rel("USES", "plan", "foo")];
let mut inline: Vec<EntityId> = Vec::new();
resolve_cross_mem_refs(&mut rels, &mut inline, "plan", &roster(&["main", "plan"]));
assert_eq!(rels[0].target.mem(), "plan");
assert_eq!(rels[0].target.path(), "foo");
}
#[test]
fn legacy_slug_with_unknown_prefix_stays_same_mem() {
let mut rels = vec![rel("USES", "plan", "some-legacy--slug")];
let mut inline: Vec<EntityId> = Vec::new();
resolve_cross_mem_refs(&mut rels, &mut inline, "plan", &roster(&["main", "plan"]));
assert_eq!(rels[0].target.mem(), "plan");
assert_eq!(rels[0].target.path(), "some-legacy--slug");
}
#[test]
fn rewrites_inline_links_identically_to_relationships() {
let mut rels = vec![rel("USES", "plan", "main--foo")];
let mut inline: Vec<EntityId> = vec![EntityId::new("plan", "main--bar")];
resolve_cross_mem_refs(&mut rels, &mut inline, "plan", &roster(&["main", "plan"]));
assert_eq!(rels[0].target.mem(), "main");
assert_eq!(rels[0].target.path(), "foo");
assert_eq!(inline[0].mem(), "main");
assert_eq!(inline[0].path(), "bar");
}
#[test]
fn self_prefix_is_same_mem_noop() {
let mut rels = vec![rel("USES", "plan", "plan--foo")];
let mut inline: Vec<EntityId> = Vec::new();
resolve_cross_mem_refs(&mut rels, &mut inline, "plan", &roster(&["main", "plan"]));
assert_eq!(rels[0].target.mem(), "plan");
assert_eq!(rels[0].target.path(), "plan--foo");
}
#[test]
fn split_on_first_double_dash() {
let mut rels = vec![rel("USES", "plan", "main--foo--bar")];
let mut inline: Vec<EntityId> = Vec::new();
resolve_cross_mem_refs(&mut rels, &mut inline, "plan", &roster(&["main", "plan"]));
assert_eq!(rels[0].target.mem(), "main");
assert_eq!(rels[0].target.path(), "foo--bar");
}
#[test]
fn target_already_in_another_mem_is_untouched() {
let mut rels = vec![rel("USES", "main", "foo")];
let mut inline: Vec<EntityId> = Vec::new();
resolve_cross_mem_refs(&mut rels, &mut inline, "plan", &roster(&["main", "plan"]));
assert_eq!(rels[0].target.mem(), "main");
assert_eq!(rels[0].target.path(), "foo");
}
}