use regex::Regex;
use serde::Serialize;
use std::path::Path;
#[derive(Debug, Serialize)]
pub struct Node {
pub title: String,
pub ontology: Option<String>,
pub axiology: Option<String>,
pub ethics: Option<String>,
pub epistemology: Option<String>,
#[serde(skip)]
#[allow(dead_code)]
pub raw: String,
}
impl Node {
pub fn parse(content: &str) -> Result<Self, String> {
let title = extract_title(content).ok_or("No title (# heading) found")?;
let ontology = extract_section(content, "Ontology");
let axiology = extract_section(content, "Axiology");
let ethics = extract_section(content, "Ethics");
let epistemology = extract_section(content, "Epistemology");
Ok(Node {
title,
ontology,
axiology,
ethics,
epistemology,
raw: content.to_string(),
})
}
}
fn extract_title(content: &str) -> Option<String> {
for line in content.lines() {
let trimmed = line.trim();
if trimmed.starts_with("# ") && !trimmed.starts_with("## ") {
return Some(trimmed.trim_start_matches("# ").to_string());
}
}
None
}
fn extract_section(content: &str, section_name: &str) -> Option<String> {
let lines: Vec<&str> = content.lines().collect();
let mut start = None;
let mut end = None;
for (i, line) in lines.iter().enumerate() {
let trimmed = line.trim();
if trimmed.starts_with("## ") && !trimmed.starts_with("### ") {
let heading_text = trimmed.trim_start_matches("## ");
if heading_text.contains(section_name) {
start = Some(i + 1);
} else if start.is_some() && end.is_none() {
end = Some(i);
}
}
}
let start = start?;
let end = end.unwrap_or(lines.len());
let section: String = lines[start..end].join("\n").trim().to_string();
if section.is_empty() {
None
} else {
Some(section)
}
}
pub fn extract_links(content: &str) -> Vec<String> {
let re = Regex::new(r"\[([^\]]+)\]\(\./([a-z0-9_-]+)\.md\)").unwrap();
re.captures_iter(content)
.map(|cap| cap[2].to_string())
.collect()
}
pub fn extract_unique_links(content: &str) -> Vec<String> {
let mut links = extract_links(content);
links.sort();
links.dedup();
links
}
pub fn list_terms(src_dir: &Path) -> Result<Vec<String>, String> {
let mut terms = Vec::new();
let entries = std::fs::read_dir(src_dir)
.map_err(|e| format!("Cannot read {}: {e}", src_dir.display()))?;
for entry in entries.flatten() {
let path = entry.path();
if path.extension().and_then(|e| e.to_str()) == Some("md")
&& let Some(stem) = path.file_stem().and_then(|s| s.to_str())
{
terms.push(stem.to_string());
}
}
terms.sort();
Ok(terms)
}
#[cfg(test)]
mod tests {
use super::*;
const SAMPLE: &str = r#"# Existence
## [Ontology](./ontology.md)
Everything that 'is', or more simply, everything.
A [scoped](./scope.md) Existence, representing an [Entity's](./entity.md) [perspective](./perspective.md).
## [Axiology](./axiology.md)
Existence is the Universal Set of everything, including itself.
## [Epistemology](./epistemology.md)
Contrary to some [cultural](./culture.md) [definitions](./definition.md), Existence includes all thoughts.
"#;
#[test]
fn test_parse_node() {
let node = Node::parse(SAMPLE).unwrap();
assert_eq!(node.title, "Existence");
assert!(node.ontology.is_some());
assert!(node.axiology.is_some());
assert!(node.epistemology.is_some());
assert!(node.ethics.is_none());
}
#[test]
fn test_extract_title() {
assert_eq!(
extract_title("# Existence\n\nstuff"),
Some("Existence".to_string())
);
assert_eq!(
extract_title("## Not Title\n# Real Title"),
Some("Real Title".to_string())
);
assert_eq!(extract_title("no heading"), None);
}
#[test]
fn test_extract_links() {
let links = extract_links(
"[scope](./scope.md) and [entity](./entity.md) plus [pattern](./pattern.md)",
);
assert_eq!(links, vec!["scope", "entity", "pattern"]);
}
#[test]
fn test_extract_unique_links() {
let links = extract_unique_links("[a](./scope.md) [b](./scope.md) [c](./entity.md)");
assert_eq!(links, vec!["entity", "scope"]);
}
}