1326 lines
40 KiB
Rust
1326 lines
40 KiB
Rust
use std::{collections::HashMap, path::PathBuf};
|
|
|
|
pub use edge::Edge;
|
|
pub use meta::{Config, Meta};
|
|
pub use node::Node;
|
|
use serde::{Deserialize, Serialize};
|
|
|
|
use crate::{
|
|
prelude::*,
|
|
syntax::{
|
|
command::Arguments,
|
|
content::{
|
|
self,
|
|
parser::{Token, flatten, token::Anchor},
|
|
},
|
|
},
|
|
};
|
|
|
|
pub mod edge;
|
|
pub mod meta;
|
|
pub mod node;
|
|
|
|
#[derive(Serialize, Deserialize, Clone, Default, PartialEq, Eq, Debug)]
|
|
pub struct Graph {
|
|
#[serde(default)]
|
|
pub nodes: HashMap<String, Node>,
|
|
#[serde(default)]
|
|
pub root_node: String,
|
|
#[serde(skip_deserializing)]
|
|
pub incoming: HashMap<String, Vec<Edge>>,
|
|
#[serde(skip_deserializing)]
|
|
pub lowercase_keymap: HashMap<String, String>,
|
|
#[serde(default)]
|
|
pub meta: Meta,
|
|
#[serde(default)]
|
|
pub stats: Stats,
|
|
}
|
|
|
|
#[derive(Serialize, Deserialize, Clone, Default, PartialEq, Eq, Debug)]
|
|
pub struct Stats {
|
|
pub detached: HashMap<String, u32>,
|
|
pub detached_total: u32,
|
|
}
|
|
|
|
impl Graph {
|
|
pub fn with_message(message: &str) -> Graph {
|
|
let graph = Graph::default();
|
|
let mut messages = graph.meta.messages;
|
|
messages.push(message.to_string());
|
|
Graph {
|
|
meta: Meta {
|
|
messages,
|
|
..graph.meta
|
|
},
|
|
..graph
|
|
}
|
|
}
|
|
|
|
pub fn malformed(message: Option<&str>) -> Graph {
|
|
let mut graph = if let Some(m) = message {
|
|
Graph::with_message(m)
|
|
} else {
|
|
Graph::default()
|
|
};
|
|
graph.meta.malformed = true;
|
|
graph
|
|
}
|
|
|
|
/// Loads a Graph TOML file from CLI arguments or their defaults and
|
|
/// returns a modulated Graph.
|
|
///
|
|
/// Returns a graph with an error message if any errors are propagated.
|
|
pub fn load() -> Graph {
|
|
let result = Graph::load_file(None);
|
|
match result {
|
|
Ok(graph) => graph,
|
|
Err(error) => Graph::malformed(Some(&error)),
|
|
}
|
|
}
|
|
|
|
/// Takes a file path to a TOML file and returns a modulated Graph.
|
|
///
|
|
/// If `path` is None, it will fallback to CLI arguments or their defaults.
|
|
///
|
|
/// # Errors
|
|
/// Propagates errors from `Graph::read_file`.
|
|
pub fn load_file(path: Option<&str>) -> Result<Graph, String> {
|
|
let mut graph = Graph::from_file(path)?;
|
|
graph.modulate();
|
|
Ok(graph)
|
|
}
|
|
|
|
/// Reads a TOML file into a Graph without modulating it.
|
|
///
|
|
/// # Errors
|
|
/// Returns Err if it can't read the contents of `in_path`.
|
|
/// Propagates errors from `Graph::from_serial`.
|
|
pub fn from_file(in_path: Option<&str>) -> Result<Graph, String> {
|
|
let cli_path = Arguments::default().parse().graph_path;
|
|
let path = in_path.map_or(cli_path, PathBuf::from);
|
|
|
|
let toml_source = match std::fs::read_to_string(&path) {
|
|
Ok(s) => s,
|
|
Err(e) => {
|
|
log!(
|
|
ERROR,
|
|
"Error reading path {}: {e}",
|
|
path.as_path().display(),
|
|
);
|
|
return Err(format!(
|
|
"Failed reading file at {}",
|
|
path.as_path().display(),
|
|
));
|
|
},
|
|
};
|
|
|
|
let result = Graph::from_serial(&toml_source, &Format::TOML)?;
|
|
|
|
Ok(result)
|
|
}
|
|
|
|
/// Deserializes the given format into a graph.
|
|
///
|
|
/// # Errors
|
|
/// Errors on unsupported formats.
|
|
/// Propagates serialization errors.
|
|
pub fn from_serial(
|
|
serial: &str,
|
|
format: &Format,
|
|
) -> Result<Graph, SerialError> {
|
|
let result = match *format {
|
|
Format::TOML => match toml::from_str::<Graph>(serial) {
|
|
Ok(graph) => Ok(graph),
|
|
Err(error) => Err(SerialError {
|
|
cause: SerialErrorCause::MalformedInput,
|
|
message: error.to_string(),
|
|
}),
|
|
},
|
|
Format::JSON => match serde_json::from_str::<Graph>(serial) {
|
|
Ok(graph) => Ok(graph),
|
|
Err(error) => Err(SerialError {
|
|
cause: SerialErrorCause::MalformedInput,
|
|
message: error.to_string(),
|
|
}),
|
|
},
|
|
Format::Unsupported => Err(SerialError {
|
|
cause: SerialErrorCause::UnsupportedFormat,
|
|
message: "Unsupported format".to_string(),
|
|
}),
|
|
};
|
|
|
|
let graph = result?;
|
|
Graph::print_warnings(&graph);
|
|
Ok(graph)
|
|
}
|
|
|
|
fn print_warnings(graph: &Graph) {
|
|
if graph.meta.config.serve_fonts && !graph.meta.config.footer {
|
|
log!(
|
|
WARN,
|
|
"Ignoring 'footer' value of false (hidden) because \
|
|
'serve_fonts' is set to true (by default or explicitly). \
|
|
This is necessary for compliance with the font licenses. \
|
|
Either set 'serve_fonts' to false to disable serving fonts \
|
|
or reenable the footer to suppress this warning."
|
|
);
|
|
}
|
|
}
|
|
|
|
/// Serializes a graph to the given format.
|
|
///
|
|
/// # Errors
|
|
/// Errors on unsupported formats.
|
|
/// Propagates serialization errors.
|
|
pub fn to_serial(&self, format: &Format) -> Result<String, SerialError> {
|
|
match *format {
|
|
Format::TOML => match toml::to_string(self) {
|
|
Ok(s) => Ok(s),
|
|
Err(e) => Err(SerialError {
|
|
cause: SerialErrorCause::MalformedInput,
|
|
message: e.to_string(),
|
|
}),
|
|
},
|
|
Format::JSON => match serde_json::to_string(self) {
|
|
Ok(s) => Ok(s),
|
|
Err(e) => Err(SerialError {
|
|
cause: SerialErrorCause::MalformedInput,
|
|
message: e.to_string(),
|
|
}),
|
|
},
|
|
Format::Unsupported => Err(SerialError {
|
|
cause: SerialErrorCause::UnsupportedFormat,
|
|
message: "Unsupported format".to_string(),
|
|
}),
|
|
}
|
|
}
|
|
|
|
fn gather_stats(&mut self) {
|
|
let detached = self.stats.detached.values();
|
|
self.stats.detached_total = detached.sum();
|
|
}
|
|
|
|
pub fn modulate(&mut self) {
|
|
let mut instant = now();
|
|
instant = tlog!(&instant, "Started node modulation");
|
|
self.map_lowercase_keys();
|
|
instant = tlog!(&instant, "Mapped lowercase keys");
|
|
self.modulate_nodes();
|
|
instant = tlog!(&instant, "Modulated nodes");
|
|
self.modulate_edges();
|
|
instant = tlog!(&instant, "Modulated edges");
|
|
self.map_incoming();
|
|
instant = tlog!(&instant, "Mapped incoming edges");
|
|
self.gather_stats();
|
|
instant = tlog!(&instant, "Gathered stats");
|
|
self.parse_config();
|
|
tlog!(&instant, "Parsed configuration");
|
|
}
|
|
|
|
/// Construct a `HashMap` with incoming connections (reversed edges).
|
|
fn map_incoming(&mut self) {
|
|
for node in self.nodes.clone().into_values() {
|
|
for edge in node.connections.clone().values() {
|
|
let mut edges = self
|
|
.incoming
|
|
.get(&edge.to.clone())
|
|
.unwrap_or(&vec![])
|
|
.clone();
|
|
edges.extend_from_slice(std::slice::from_ref(edge));
|
|
self.incoming.insert(edge.to.clone(), edges.clone());
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Modulates nodes that have been deserialized and are still unprocessed.
|
|
///
|
|
/// Performs checked arithmetic to the following effect:
|
|
/// - Stats will saturate at `u32::MAX` (`increment_detached` calls)
|
|
fn modulate_nodes(&mut self) {
|
|
let in_nodes = self.nodes.clone();
|
|
|
|
for (key, node) in in_nodes.clone() {
|
|
let connections = node.connections.clone();
|
|
let mut new_edges = connections.clone();
|
|
|
|
// Modulate connections
|
|
for (connection_key, edge) in connections {
|
|
let mut new_edge = edge.clone();
|
|
|
|
// Populate empty "to" and "from" IDs
|
|
if edge.from.is_empty() {
|
|
new_edge.from.clone_from(&key);
|
|
}
|
|
|
|
if edge.to.is_empty() {
|
|
new_edge.to.clone_from(&connection_key);
|
|
}
|
|
|
|
// Flag detached edges
|
|
if (!edge.to.is_empty() && in_nodes.contains_key(&edge.to))
|
|
|| (edge.to.is_empty()
|
|
&& in_nodes.contains_key(&new_edge.to))
|
|
{
|
|
new_edge.detached = false;
|
|
} else {
|
|
new_edge.detached = true;
|
|
self.increment_detached(&edge.to);
|
|
}
|
|
|
|
if let Some(e) = new_edges.get_mut(&connection_key) {
|
|
*e = new_edge;
|
|
}
|
|
}
|
|
|
|
// Create connections for each link
|
|
for link in &node.links {
|
|
let detached = !in_nodes.contains_key(link);
|
|
new_edges.insert(
|
|
link.clone(),
|
|
Edge {
|
|
from: key.clone(),
|
|
to: link.clone(),
|
|
detached,
|
|
},
|
|
);
|
|
|
|
if detached {
|
|
self.increment_detached(link);
|
|
}
|
|
}
|
|
|
|
// Populate empty titles with IDs
|
|
let new_title = if node.title.is_empty() {
|
|
key.clone()
|
|
} else {
|
|
node.title.clone()
|
|
};
|
|
|
|
// Populate empty summaries with the leading part of the node text
|
|
let summary = if node.summary.is_empty() {
|
|
let first_line = if let Some(first) =
|
|
node.text.split("\n\n").find(|s| !s.is_empty())
|
|
{
|
|
String::from(first)
|
|
} else {
|
|
node.text.clone()
|
|
};
|
|
|
|
let mut candidate =
|
|
if let Some(dot_split) = first_line.split_once('.') {
|
|
format!("{}.", dot_split.0)
|
|
} else {
|
|
first_line
|
|
};
|
|
|
|
if candidate.len() > 300 {
|
|
candidate.truncate(300);
|
|
candidate.push('…');
|
|
}
|
|
candidate
|
|
} else {
|
|
node.summary.clone()
|
|
};
|
|
|
|
// Assemble new node
|
|
let new_node = Node {
|
|
id: key.clone(),
|
|
title: new_title,
|
|
summary: flatten(&summary, self),
|
|
connections: new_edges,
|
|
..node.clone()
|
|
};
|
|
|
|
self.nodes.insert(key.clone(), new_node);
|
|
}
|
|
}
|
|
|
|
/// Modulates edges that have been deserialized and are still unprocessed.
|
|
///
|
|
/// Performs checked arithmetic to the following effect:
|
|
/// - Stats will saturate at `u32::MAX`
|
|
fn modulate_edges(&mut self) {
|
|
let graph = self.clone();
|
|
let iterator = self.nodes.iter_mut();
|
|
for (key, node) in iterator {
|
|
// Parse node text
|
|
let parse_output = content::rich_parse(&node.text, &graph);
|
|
node.text
|
|
.clone_from(&parse_output.text.clone().unwrap_or_default());
|
|
|
|
// Create connections for each anchor
|
|
let parsed_anchors =
|
|
parse_output.only(&Token::Anchor(Box::default()));
|
|
let mut anchors: Vec<Anchor> = vec![];
|
|
for token in parsed_anchors {
|
|
if let Token::Anchor(token_data) = token {
|
|
anchors.push(*token_data.clone());
|
|
}
|
|
}
|
|
|
|
for anchor in anchors {
|
|
if let Some(anchor_node) = anchor.node() {
|
|
node.connections.insert(
|
|
anchor_node.id.clone(),
|
|
Edge {
|
|
from: key.clone(),
|
|
to: anchor_node.id,
|
|
detached: false,
|
|
},
|
|
);
|
|
} else {
|
|
if let Some(destination) = anchor.destination()
|
|
&& !anchor.external()
|
|
{
|
|
let trimmed_destination = destination
|
|
.trim_start_matches("/node/")
|
|
.to_string();
|
|
node.connections.insert(
|
|
trimmed_destination.clone(),
|
|
Edge {
|
|
from: key.clone(),
|
|
to: trimmed_destination.clone(),
|
|
detached: true,
|
|
},
|
|
);
|
|
|
|
self.stats
|
|
.detached
|
|
.entry(trimmed_destination)
|
|
.and_modify(|count| {
|
|
*count = count.saturating_add(1);
|
|
})
|
|
.or_insert(1);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Increments detached node statistics for the given node ID.
|
|
///
|
|
/// Performs checked arithmetic to the following effect:
|
|
/// - Stats will saturate at `u32::MAX`
|
|
fn increment_detached(&mut self, node_id: &str) {
|
|
self.stats
|
|
.detached
|
|
.entry(node_id.to_string())
|
|
.and_modify(|count| *count = count.saturating_add(1))
|
|
.or_insert(1);
|
|
}
|
|
|
|
pub fn map_lowercase_keys(&mut self) {
|
|
for key in self.nodes.keys() {
|
|
self.lowercase_keymap
|
|
.insert(key.clone().to_lowercase(), key.clone());
|
|
}
|
|
}
|
|
|
|
pub fn find_node(&self, query: &str) -> QueryResult {
|
|
let collapsed_query = query.trim().replace(' ', "");
|
|
|
|
if query == collapsed_query {
|
|
log!(VERBOSE, "Chasing candidate for query {query}");
|
|
} else {
|
|
log!(
|
|
VERBOSE,
|
|
"Chasing candidate: query {query}, collapsed {collapsed_query}"
|
|
);
|
|
}
|
|
|
|
let candidate = if let Some(exact_match) = self.nodes.get(query) {
|
|
log!(VERBOSE, "Elected exact match {exact_match}");
|
|
QueryResult {
|
|
node: Some(exact_match.clone()),
|
|
exact: true,
|
|
redirect: false,
|
|
}
|
|
} else if let Some(lower_key) =
|
|
self.lowercase_keymap.get(&collapsed_query.to_lowercase())
|
|
{
|
|
log!(
|
|
VERBOSE,
|
|
"Elected non-exact match through lower key {lower_key}"
|
|
);
|
|
QueryResult {
|
|
node: self.nodes.get(lower_key).cloned(),
|
|
exact: false,
|
|
redirect: false,
|
|
}
|
|
} else {
|
|
log!(VERBOSE, "No candidate found");
|
|
QueryResult::default()
|
|
};
|
|
|
|
if let Some(candidate_node) = &candidate.node
|
|
&& !candidate_node.redirect.is_empty()
|
|
{
|
|
log!(VERBOSE, "Recursing: candidate is a redirect");
|
|
if let Some(recursive_match) =
|
|
self.find_node(&candidate_node.redirect).node
|
|
{
|
|
QueryResult {
|
|
node: Some(recursive_match),
|
|
exact: false,
|
|
redirect: true,
|
|
}
|
|
} else {
|
|
QueryResult {
|
|
node: None,
|
|
exact: false,
|
|
redirect: true,
|
|
}
|
|
}
|
|
} else {
|
|
log!(VERBOSE, "Returning candidate {candidate}");
|
|
candidate
|
|
}
|
|
}
|
|
|
|
pub fn get_root(&self) -> Option<Node> {
|
|
self.nodes.get(&self.root_node).cloned()
|
|
}
|
|
|
|
pub fn parse_config(&mut self) {
|
|
self.meta.config.footer_text =
|
|
content::parse(&self.meta.config.footer_text, self);
|
|
self.meta.config.about_text =
|
|
content::parse(&self.meta.config.about_text, self);
|
|
}
|
|
}
|
|
|
|
pub enum Format {
|
|
TOML,
|
|
JSON,
|
|
Unsupported,
|
|
}
|
|
|
|
#[derive(Serialize, Deserialize, Clone, Debug)]
|
|
pub enum SerialErrorCause {
|
|
UnsupportedFormat,
|
|
MalformedInput,
|
|
}
|
|
|
|
impl std::fmt::Display for SerialErrorCause {
|
|
fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
|
|
let text = match self {
|
|
SerialErrorCause::MalformedInput => "Malformed Input",
|
|
SerialErrorCause::UnsupportedFormat => "Unsupported Format",
|
|
};
|
|
write!(f, "{text}")
|
|
}
|
|
}
|
|
|
|
#[derive(Serialize, Deserialize, Clone, Debug)]
|
|
pub struct SerialError {
|
|
pub cause: SerialErrorCause,
|
|
pub message: String,
|
|
}
|
|
|
|
impl From<SerialError> for String {
|
|
fn from(error: SerialError) -> String {
|
|
format!("{}: {}", error.cause, error.message)
|
|
}
|
|
}
|
|
|
|
impl From<&str> for Format {
|
|
fn from(s: &str) -> Format {
|
|
if s.to_lowercase() == "toml" {
|
|
Format::TOML
|
|
} else if s.to_lowercase() == "json" {
|
|
Format::JSON
|
|
} else {
|
|
Format::Unsupported
|
|
}
|
|
}
|
|
}
|
|
|
|
impl std::fmt::Display for Format {
|
|
fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
|
|
match self {
|
|
Format::TOML => write!(f, "TOML"),
|
|
Format::JSON => write!(f, "JSON"),
|
|
Format::Unsupported => write!(f, "Unsupported format"),
|
|
}
|
|
}
|
|
}
|
|
|
|
#[derive(Clone, Default, Debug)]
|
|
pub struct QueryResult {
|
|
pub node: Option<Node>,
|
|
pub redirect: bool,
|
|
pub exact: bool,
|
|
}
|
|
|
|
impl std::fmt::Display for QueryResult {
|
|
fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
|
|
let meta = if self.redirect && self.exact {
|
|
"[exact redirect] "
|
|
} else if !self.redirect && self.exact {
|
|
"[exact] "
|
|
} else if self.redirect && !self.exact {
|
|
"[redirect] "
|
|
} else {
|
|
""
|
|
};
|
|
let node = if let Some(n) = &self.node {
|
|
n.id.clone()
|
|
} else {
|
|
String::from("No Match")
|
|
};
|
|
write!(f, "QueryResult: {meta}{node}")
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
#[test]
|
|
fn empty_graph() {
|
|
let graph = Graph::with_message("ISryQFd9peG6eYz9CFRQFWeD1GnPo0oj");
|
|
assert!(graph.nodes.is_empty());
|
|
assert!(graph.incoming.is_empty());
|
|
assert_eq!(
|
|
graph.meta.messages.first().unwrap(),
|
|
"ISryQFd9peG6eYz9CFRQFWeD1GnPo0oj"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn good_json() {
|
|
let json = r#"
|
|
{
|
|
"nodes": {
|
|
"JSON": {
|
|
"text": "",
|
|
"title": "JSON",
|
|
"links": [],
|
|
"id": "JSON",
|
|
"hidden": false,
|
|
"connections": {}
|
|
}
|
|
},
|
|
"root_node": "JSON"
|
|
}
|
|
"#;
|
|
|
|
let deserialize_result = Graph::from_serial(json, &Format::JSON);
|
|
println!("{deserialize_result:?}");
|
|
assert!(deserialize_result.is_ok());
|
|
}
|
|
|
|
#[test]
|
|
fn bad_json() {
|
|
assert!(Graph::from_serial(":::", &Format::JSON).is_err_and(|e| {
|
|
e.message.contains("expected value at line 1 column 1")
|
|
},));
|
|
}
|
|
|
|
#[test]
|
|
fn with_message() {
|
|
let payload = "QmMxohuLe9DZCOzcaxH2wzZGqOot1In6";
|
|
let graph = Graph::with_message(payload);
|
|
assert_eq!(payload, graph.meta.messages.first().unwrap());
|
|
}
|
|
|
|
#[test]
|
|
fn malformed_without_message() {
|
|
let graph = Graph::malformed(None);
|
|
assert!(graph.meta.messages.is_empty());
|
|
}
|
|
|
|
#[test]
|
|
fn malformed_with_message() {
|
|
let payload = "s8LuGwRQA4GdNGvAlaIUrryZYBGkY5Ev";
|
|
let graph = Graph::malformed(Some(payload));
|
|
assert_eq!(payload, graph.meta.messages.first().unwrap());
|
|
}
|
|
|
|
#[test]
|
|
fn bad_deserial_input() {
|
|
let result = Graph::from_serial("not toml", &Format::TOML);
|
|
assert!(result.is_err());
|
|
assert!(matches!(
|
|
result.unwrap_err().cause,
|
|
SerialErrorCause::MalformedInput
|
|
));
|
|
}
|
|
|
|
#[test]
|
|
fn bad_deserial_format() {
|
|
let result = Graph::from_serial("not toml", &Format::Unsupported);
|
|
assert!(result.is_err());
|
|
assert!(matches!(
|
|
result.unwrap_err().cause,
|
|
SerialErrorCause::UnsupportedFormat
|
|
));
|
|
}
|
|
|
|
#[test]
|
|
fn bad_serial_format() {
|
|
let result = Graph::load().to_serial(&Format::Unsupported);
|
|
assert!(result.is_err());
|
|
assert!(matches!(
|
|
result.unwrap_err().cause,
|
|
SerialErrorCause::UnsupportedFormat
|
|
));
|
|
}
|
|
|
|
#[test]
|
|
fn empty_modulated_graph_is_empty() {
|
|
let mut graph = Graph::from_serial("", &Format::TOML).unwrap();
|
|
graph.modulate();
|
|
|
|
assert!(graph.nodes.is_empty());
|
|
assert!(graph.incoming.is_empty());
|
|
}
|
|
|
|
#[test]
|
|
fn title_population_from_id() {
|
|
let mut graph = Graph::from_serial(
|
|
concat!("[nodes.TitlelessNode]\n", r#"text = "Some text""#,),
|
|
&Format::TOML,
|
|
)
|
|
.unwrap();
|
|
graph.modulate();
|
|
|
|
let node = graph.nodes.get("TitlelessNode");
|
|
assert_eq!(node.unwrap().title, "TitlelessNode");
|
|
}
|
|
|
|
#[test]
|
|
fn no_title_population_from_id_if_title_set() {
|
|
let mut graph = Graph::from_serial(
|
|
concat!(
|
|
"[nodes.TitlefulNode]\n",
|
|
r#"title = "A Title""#,
|
|
"\n",
|
|
r#"text = "Some text""#,
|
|
),
|
|
&Format::TOML,
|
|
)
|
|
.unwrap();
|
|
graph.modulate();
|
|
|
|
let node = graph.nodes.get("TitlefulNode");
|
|
assert_eq!(node.unwrap().title, "A Title");
|
|
}
|
|
|
|
#[test]
|
|
fn detached_edge_is_flagged() {
|
|
let mut graph = Graph::from_serial(
|
|
concat!(
|
|
"[nodes.Node]\n",
|
|
r#"text = "Some text here""#,
|
|
"\n\n",
|
|
"[nodes.Node.connections.Nowhere]\n",
|
|
),
|
|
&Format::TOML,
|
|
)
|
|
.unwrap();
|
|
graph.modulate();
|
|
|
|
let node = &graph.nodes["Node"];
|
|
let connection = &node.connections["Nowhere"];
|
|
assert!(connection.detached);
|
|
}
|
|
|
|
#[test]
|
|
fn attached_edge_is_not_flagged() {
|
|
let mut graph = Graph::from_serial(
|
|
concat!(
|
|
"[nodes.NodeOne]\n",
|
|
r#"text = "Some text here""#,
|
|
"\n\n",
|
|
"[nodes.NodeOne.connections.NodeTwo]\n\n",
|
|
"[nodes.NodeTwo]\n",
|
|
r#"text = "Some other text here""#,
|
|
"\n\n",
|
|
),
|
|
&Format::TOML,
|
|
)
|
|
.unwrap();
|
|
graph.modulate();
|
|
|
|
let node = &graph.nodes["NodeOne"];
|
|
let connection = &node.connections["NodeTwo"];
|
|
println!("{connection:#?}");
|
|
assert!(!connection.detached);
|
|
}
|
|
|
|
#[test]
|
|
fn to_and_from_population() {
|
|
let mut graph = Graph::from_serial(
|
|
concat!(
|
|
"[nodes.n01]\n",
|
|
"[nodes.n01.connections.n02]\n\n",
|
|
"[nodes.n02]\n",
|
|
"[nodes.n02.connections.n03]\n\n",
|
|
),
|
|
&Format::TOML,
|
|
)
|
|
.unwrap();
|
|
graph.modulate();
|
|
|
|
let n01 = &graph.nodes["n01"];
|
|
let n02 = &graph.nodes["n02"];
|
|
let n01_to_n02 = &n01.connections["n02"];
|
|
let n02_to_n03 = &n02.connections["n03"];
|
|
|
|
assert_eq!(n01_to_n02.from, "n01");
|
|
assert_eq!(n01_to_n02.to, "n02");
|
|
assert!(!n01_to_n02.detached);
|
|
assert_eq!(n02_to_n03.from, "n02");
|
|
assert_eq!(n02_to_n03.to, "n03");
|
|
assert!(n02_to_n03.detached);
|
|
}
|
|
|
|
#[test]
|
|
fn links_become_connections() {
|
|
let mut graph = Graph::from_serial(
|
|
concat!(
|
|
"[nodes.n01]\n",
|
|
r#"links = [ "n02", "n03", "n04" ]"#,
|
|
"\n\n",
|
|
"[nodes.n02]\n",
|
|
"[nodes.n04]\n",
|
|
r#"links = [ "n01", "n03" ]"#,
|
|
"\n\n",
|
|
),
|
|
&Format::TOML,
|
|
)
|
|
.unwrap();
|
|
graph.modulate();
|
|
|
|
let n01 = &graph.nodes["n01"];
|
|
let n02 = &graph.nodes["n02"];
|
|
let n04 = &graph.nodes["n04"];
|
|
|
|
let n01_to_n02 = &n01.connections["n02"];
|
|
let n01_to_n03 = &n01.connections["n03"];
|
|
let n01_to_n04 = &n01.connections["n04"];
|
|
|
|
let n04_to_n01 = &n04.connections["n01"];
|
|
let n04_to_n03 = &n04.connections["n03"];
|
|
|
|
assert_eq!(n01_to_n02.from, "n01");
|
|
assert_eq!(n01_to_n02.to, "n02");
|
|
assert!(!n01_to_n02.detached);
|
|
|
|
assert_eq!(n01_to_n03.from, "n01");
|
|
assert_eq!(n01_to_n03.to, "n03");
|
|
assert!(n01_to_n03.detached);
|
|
|
|
assert_eq!(n01_to_n04.from, "n01");
|
|
assert_eq!(n01_to_n04.to, "n04");
|
|
assert!(!n01_to_n04.detached);
|
|
|
|
assert!(n02.connections.is_empty());
|
|
|
|
assert_eq!(n04_to_n01.from, "n04");
|
|
assert_eq!(n04_to_n01.to, "n01");
|
|
assert!(!n04_to_n01.detached);
|
|
|
|
assert_eq!(n04_to_n03.from, "n04");
|
|
assert_eq!(n04_to_n03.to, "n03");
|
|
assert!(n04_to_n03.detached);
|
|
}
|
|
|
|
#[test]
|
|
fn detached_count_increments() {
|
|
let mut graph = Graph::from_serial(
|
|
concat!(
|
|
"[nodes.n01]\n",
|
|
r#"links = [ "n02", "n03", "n04", "n05", "n06", "n10" ]"#,
|
|
"\n\n",
|
|
"[nodes.n02]\n",
|
|
"[nodes.n04]\n",
|
|
r#"links = [ "n01", "n02", "n03", "n06", "n11", "n15" ]"#,
|
|
"\n\n"
|
|
),
|
|
&Format::TOML,
|
|
)
|
|
.unwrap();
|
|
graph.modulate();
|
|
|
|
assert_eq!(graph.stats.detached_total, 8);
|
|
}
|
|
|
|
#[test]
|
|
fn populated_summary() {
|
|
let text = "vh18qEUN22X2SxLj6lpOOzMBB4N6S0UG";
|
|
let mut graph = Graph::from_serial(
|
|
&format!(
|
|
"[nodes.n01]\n\
|
|
text = \"{text}\"\n\
|
|
"
|
|
),
|
|
&Format::TOML,
|
|
)
|
|
.unwrap();
|
|
graph.modulate();
|
|
|
|
assert!(&graph.nodes["n01"].summary.contains(text));
|
|
assert!(&graph.nodes["n01"].text.contains(text));
|
|
}
|
|
|
|
#[test]
|
|
fn supplied_summary() {
|
|
let text = "vh18qEUN22X2SxLj6lpOOzMBB4N6S0UG";
|
|
let summary = "W5dhPgNs7S1Zsq6uPK47MAw8xXyNxwep";
|
|
let mut graph = Graph::from_serial(
|
|
&format!(
|
|
"[nodes.n01]\n\
|
|
summary = \"{summary}\"\n\
|
|
text = \"{text}\"\n\
|
|
",
|
|
),
|
|
&Format::TOML,
|
|
)
|
|
.unwrap();
|
|
graph.modulate();
|
|
|
|
assert_eq!(&graph.nodes["n01"].summary, summary);
|
|
assert!(&graph.nodes["n01"].text.contains(text));
|
|
}
|
|
|
|
#[test]
|
|
fn summary_from_first_sentence() {
|
|
let first_sentence = "zTWFX0a8 tYTO2g.";
|
|
let text = format!(
|
|
"{first_sentence} QoGa PtDsJ vh18qE U N22 X2S. MBB4N6S0UG\n\n\
|
|
6FokUX o OCEc LzZFfR1nkqa hWIF LdrtD3G. PDQwv Ba2PnZ yEBVpqQdt\n\n\
|
|
Py6aoPK FV7iU UdrYB vD UeMvvg u 5kbt 9ZW9x7MR"
|
|
);
|
|
let mut graph = Graph::from_serial(
|
|
&format!("[nodes.n01]\ntext = \"\"\"{text}\"\"\"\n"),
|
|
&Format::TOML,
|
|
)
|
|
.unwrap();
|
|
graph.modulate();
|
|
|
|
assert_eq!(&graph.nodes["n01"].summary, first_sentence);
|
|
}
|
|
|
|
#[test]
|
|
fn summary_from_first_paragraph() {
|
|
let first_paragraph = "EGR6IS fTQsRp rv7 g jvItnYU 2HNciS MID\n\
|
|
iz3vx vXxa vW4JI6l E5itd6qm2Yx gFw 1D Nq0805 bXHe3h iqABe ilnHKl\n\
|
|
F4AHvMLto cz3C Z279r9 jtIbBnY JqjwZPQdepf cdv6";
|
|
let text = format!(
|
|
"{first_paragraph}\n\nn0D CvHcIU7R oPcZy V1Iy9PgXO gOw lfeDy\n\n\
|
|
jrOSq 0uVtJLd Idx08Bpy BBj 4PVS R9lt RqjTs s AURUx93 Xu9WiI0rP.\n"
|
|
);
|
|
let mut graph = Graph::from_serial(
|
|
format!("[nodes.n01]\ntext = \"\"\"{text}\"\"\"\n").as_str(),
|
|
&Format::TOML,
|
|
)
|
|
.unwrap();
|
|
graph.modulate();
|
|
|
|
assert_eq!(&graph.nodes["n01"].summary, first_paragraph);
|
|
}
|
|
|
|
#[test]
|
|
fn summary_from_first_300_chars() {
|
|
let first_300 = concat!(
|
|
"Primis, quod, cum in rerum natura duo ",
|
|
"quaerenda sint, unum, quae materia sit, ex qua quaeque res ",
|
|
"efficiatur, alterum, quae naturales essent nec tamen id, cuius ",
|
|
"causa haec finxerat, assecutus est: Nam si omnes veri erunt, ut ",
|
|
"Epicuri ratio docet, tum denique poterit aliquid cognosci et ",
|
|
"percipi? Quos q"
|
|
);
|
|
let tail = concat!(
|
|
"uam autem et praeterita grate meminit et ",
|
|
"praesentibus ita potitur, ut animadvertat quanta sint ea ",
|
|
"quamque iucunda, neque pendet ex futuris, sed expectat illa, ",
|
|
"fruitur praesentibus ab iisque vitii"
|
|
);
|
|
let text = format!("{first_300}{tail}");
|
|
let summary = format!("{first_300}…");
|
|
|
|
let mut graph = Graph::from_serial(
|
|
format!("[nodes.n01]\ntext = \"\"\"{text}\"\"\"\n").as_str(),
|
|
&Format::TOML,
|
|
)
|
|
.unwrap();
|
|
graph.modulate();
|
|
|
|
assert_eq!(graph.nodes["n01"].summary, summary);
|
|
}
|
|
|
|
#[test]
|
|
fn anchors_become_connections() {
|
|
let mut graph = Graph::from_serial(
|
|
"\
|
|
[nodes.n1]
|
|
text = 'an anchor to |n2|, the existing node'
|
|
|
|
[nodes.n2]
|
|
text = 'an anchor to |n0|, the nonexistent node'
|
|
|
|
",
|
|
&Format::TOML,
|
|
)
|
|
.unwrap();
|
|
graph.modulate();
|
|
|
|
let n1_to_n2 = &graph.nodes["n1"].connections.get("n2");
|
|
|
|
let n2_to_n0 = &graph.nodes["n2"].connections.get("n0");
|
|
|
|
println!("{n1_to_n2:#?}");
|
|
println!("{n2_to_n0:#?}");
|
|
|
|
assert!(!n1_to_n2.unwrap().detached);
|
|
assert!(n2_to_n0.unwrap().detached);
|
|
}
|
|
|
|
#[test]
|
|
fn detached_anchors_increase_counts() {
|
|
let mut graph = Graph::from_serial(
|
|
"\
|
|
[nodes.n1]\n\
|
|
text = 'this |anchor| is detached, as is |this one|.'\n\
|
|
",
|
|
&Format::TOML,
|
|
)
|
|
.unwrap();
|
|
graph.modulate();
|
|
|
|
assert_eq!(graph.stats.detached_total, 2);
|
|
assert_eq!(graph.stats.detached["anchor"], 1);
|
|
assert_eq!(graph.stats.detached["this one"], 1);
|
|
}
|
|
|
|
#[test]
|
|
fn repeated_detached_anchors_increase_counts() {
|
|
let mut graph = Graph::from_serial(
|
|
"\
|
|
[nodes.n1]\n\
|
|
text = 'this |anchor| is detached, as appears twice: |anchor|.'\n\
|
|
",
|
|
&Format::TOML,
|
|
)
|
|
.unwrap();
|
|
graph.modulate();
|
|
|
|
assert_eq!(graph.stats.detached_total, 2);
|
|
assert_eq!(graph.stats.detached["anchor"], 2);
|
|
}
|
|
|
|
#[test]
|
|
fn find_exact() {
|
|
let mut graph = Graph::from_serial(
|
|
"\
|
|
[nodes.n1]\n\
|
|
",
|
|
&Format::TOML,
|
|
)
|
|
.unwrap();
|
|
graph.modulate();
|
|
|
|
let query_result = graph.find_node("n1");
|
|
assert_eq!(query_result.node.unwrap().id, "n1");
|
|
assert!(query_result.exact);
|
|
assert!(!query_result.redirect);
|
|
}
|
|
|
|
#[test]
|
|
fn find_inexact() {
|
|
let mut graph = Graph::from_serial(
|
|
"\
|
|
[nodes.n1]\n\
|
|
",
|
|
&Format::TOML,
|
|
)
|
|
.unwrap();
|
|
graph.modulate();
|
|
|
|
let query_result = graph.find_node("n 1");
|
|
println!("{query_result}");
|
|
|
|
assert_eq!(query_result.node.unwrap().id, "n1");
|
|
assert!(!query_result.exact);
|
|
assert!(!query_result.redirect);
|
|
}
|
|
|
|
#[test]
|
|
fn find_inexact_to_redirect() {
|
|
let mut graph = Graph::from_serial(
|
|
"\
|
|
[nodes.n1]\n\
|
|
redirect = 'n3'
|
|
\n\
|
|
[nodes.n3]
|
|
",
|
|
&Format::TOML,
|
|
)
|
|
.unwrap();
|
|
graph.modulate();
|
|
|
|
let query_result = graph.find_node("n 1");
|
|
println!("{query_result}");
|
|
|
|
assert_eq!(query_result.node.unwrap().id, "n3");
|
|
assert!(!query_result.exact);
|
|
assert!(query_result.redirect);
|
|
}
|
|
|
|
#[test]
|
|
fn double_recursion_to_redirect() {
|
|
let mut graph = Graph::from_serial(
|
|
"\
|
|
[nodes.n1]\n\
|
|
redirect = 'n2'
|
|
\n\
|
|
[nodes.n2]\n\
|
|
redirect = 'n 3'
|
|
\n\
|
|
[nodes.n3]
|
|
",
|
|
&Format::TOML,
|
|
)
|
|
.unwrap();
|
|
graph.modulate();
|
|
|
|
let query_result = graph.find_node("n 1");
|
|
println!("{query_result}");
|
|
|
|
assert_eq!(query_result.node.unwrap().id, "n3");
|
|
assert!(!query_result.exact);
|
|
assert!(query_result.redirect);
|
|
}
|
|
|
|
#[test]
|
|
fn find_redirect_to_inexisting() {
|
|
let mut graph = Graph::from_serial(
|
|
"\
|
|
[nodes.n1]\n\
|
|
redirect = 'n0'\n\
|
|
",
|
|
&Format::TOML,
|
|
)
|
|
.unwrap();
|
|
graph.modulate();
|
|
|
|
let query_result = graph.find_node("n1");
|
|
println!("{query_result}");
|
|
|
|
assert!(query_result.node.is_none());
|
|
assert!(query_result.redirect);
|
|
assert!(!query_result.exact);
|
|
}
|
|
|
|
#[test]
|
|
fn find_redirect_to_existing() {
|
|
let mut graph = Graph::from_serial(
|
|
"\
|
|
[nodes.n1]\n\
|
|
redirect = 'n2'\n\
|
|
\n\
|
|
[nodes.n2]
|
|
",
|
|
&Format::TOML,
|
|
)
|
|
.unwrap();
|
|
graph.modulate();
|
|
|
|
let query_result = graph.find_node("n1");
|
|
assert_eq!(query_result.node.unwrap().id, "n2");
|
|
assert!(query_result.redirect);
|
|
assert!(!query_result.exact);
|
|
}
|
|
|
|
#[test]
|
|
fn serial_error_display() {
|
|
let bad_input = SerialErrorCause::MalformedInput;
|
|
let bad_format = SerialErrorCause::UnsupportedFormat;
|
|
|
|
assert_eq!(format!("{bad_input}"), "Malformed Input");
|
|
assert_eq!(format!("{bad_format}"), "Unsupported Format");
|
|
}
|
|
|
|
#[test]
|
|
fn string_from_serial_error() {
|
|
let bad_input_error_message = "denSehpfhCjr05gUd7TgYLb8veJHAMZW";
|
|
let bad_input_cause = SerialErrorCause::MalformedInput;
|
|
let bad_input = SerialError {
|
|
cause: bad_input_cause.clone(),
|
|
message: bad_input_error_message.to_string(),
|
|
};
|
|
|
|
let bad_format_error_message = "4brcCkWOgLHBvhLk2OcgTOKQgpKrc1bB";
|
|
let bad_format_cause = SerialErrorCause::UnsupportedFormat;
|
|
let bad_format = SerialError {
|
|
cause: bad_format_cause.clone(),
|
|
message: bad_format_error_message.to_string(),
|
|
};
|
|
|
|
let s_bad_input = String::from(bad_input.clone());
|
|
let s_bad_format = String::from(bad_format.clone());
|
|
|
|
assert!(s_bad_input.contains(bad_input_error_message));
|
|
assert!(s_bad_input.contains(bad_input.message.as_str()));
|
|
assert!(s_bad_input.contains(format!("{bad_input_cause}").as_str()));
|
|
|
|
assert!(s_bad_format.contains(bad_format_error_message));
|
|
assert!(s_bad_format.contains(bad_format.message.as_str()));
|
|
assert!(s_bad_format.contains(format!("{bad_format_cause}").as_str()));
|
|
}
|
|
|
|
#[test]
|
|
fn format_from_str() {
|
|
let uppercase_toml = Format::from("TOML");
|
|
let lowercase_toml = Format::from("toml");
|
|
let mixed_case_toml = Format::from("tOmL");
|
|
|
|
assert!(matches!(uppercase_toml, Format::TOML));
|
|
assert!(matches!(lowercase_toml, Format::TOML));
|
|
assert!(matches!(mixed_case_toml, Format::TOML));
|
|
|
|
let uppercase_json = Format::from("JSON");
|
|
let lowercase_json = Format::from("json");
|
|
let mixed_case_json = Format::from("JsoN");
|
|
|
|
assert!(matches!(uppercase_json, Format::JSON));
|
|
assert!(matches!(lowercase_json, Format::JSON));
|
|
assert!(matches!(mixed_case_json, Format::JSON));
|
|
|
|
let unsupported = [
|
|
Format::from("j son"),
|
|
Format::from(""),
|
|
Format::from("strawberry"),
|
|
Format::from(" "),
|
|
Format::from("\n"),
|
|
];
|
|
|
|
assert!(unsupported.iter().all(|f| matches!(f, Format::Unsupported)));
|
|
}
|
|
|
|
#[test]
|
|
fn format_display() {
|
|
let toml = format!("{}", Format::TOML);
|
|
let json = format!("{}", Format::JSON);
|
|
let unsupported = format!("{}", Format::Unsupported);
|
|
|
|
assert_eq!(toml, "TOML");
|
|
assert_eq!(json, "JSON");
|
|
assert_eq!(unsupported, "Unsupported format");
|
|
}
|
|
|
|
#[test]
|
|
fn query_result_display() {
|
|
let mut node = Node::default();
|
|
let node_id = "nv00qmO6PDrqJheUHOONlCVpuceefS30";
|
|
node.id = String::from(node_id);
|
|
|
|
let none_exact = QueryResult {
|
|
node: None,
|
|
exact: true,
|
|
redirect: false,
|
|
};
|
|
|
|
assert!(!format!("{none_exact}").contains(node_id));
|
|
assert!(format!("{none_exact}").contains("No Match"));
|
|
assert!(format!("{none_exact}").contains("exact"));
|
|
assert!(!format!("{none_exact}").contains("redirect"));
|
|
|
|
let some_exact = QueryResult {
|
|
node: Some(node.clone()),
|
|
exact: true,
|
|
redirect: false,
|
|
};
|
|
|
|
assert!(format!("{some_exact}").contains(node_id));
|
|
assert!(!format!("{some_exact}").contains("No Match"));
|
|
assert!(format!("{some_exact}").contains("exact"));
|
|
assert!(!format!("{some_exact}").contains("redirect"));
|
|
|
|
let none_redirect = QueryResult {
|
|
node: None,
|
|
exact: false,
|
|
redirect: true,
|
|
};
|
|
|
|
assert!(!format!("{none_redirect}").contains(node_id));
|
|
assert!(format!("{none_redirect}").contains("No Match"));
|
|
assert!(format!("{none_redirect}").contains("redirect"));
|
|
assert!(!format!("{none_redirect}").contains("exact"));
|
|
|
|
let some_redirect = QueryResult {
|
|
node: Some(node.clone()),
|
|
exact: false,
|
|
redirect: true,
|
|
};
|
|
|
|
assert!(format!("{some_redirect}").contains(node_id));
|
|
assert!(!format!("{some_redirect}").contains("No Match"));
|
|
assert!(format!("{some_redirect}").contains("redirect"));
|
|
assert!(!format!("{some_redirect}").contains("exact"));
|
|
|
|
let some_exact_redirect = QueryResult {
|
|
node: Some(node.clone()),
|
|
exact: true,
|
|
redirect: true,
|
|
};
|
|
|
|
assert!(format!("{some_exact_redirect}").contains(node_id));
|
|
assert!(!format!("{some_exact_redirect}").contains("No Match"));
|
|
assert!(format!("{some_exact_redirect}").contains("redirect"));
|
|
assert!(format!("{some_exact_redirect}").contains("exact"));
|
|
|
|
let none_exact_redirect = QueryResult {
|
|
node: None,
|
|
exact: true,
|
|
redirect: true,
|
|
};
|
|
|
|
assert!(!format!("{none_exact_redirect}").contains(node_id));
|
|
assert!(format!("{none_exact_redirect}").contains("No Match"));
|
|
assert!(format!("{none_exact_redirect}").contains("redirect"));
|
|
assert!(format!("{none_exact_redirect}").contains("exact"));
|
|
|
|
let none = QueryResult {
|
|
node: None,
|
|
exact: false,
|
|
redirect: false,
|
|
};
|
|
|
|
assert!(!format!("{none}").contains(node_id));
|
|
assert!(format!("{none}").contains("No Match"));
|
|
assert!(!format!("{none}").contains("redirect"));
|
|
assert!(!format!("{none}").contains("exact"));
|
|
|
|
let some = QueryResult {
|
|
node: Some(node),
|
|
exact: false,
|
|
redirect: false,
|
|
};
|
|
|
|
assert!(format!("{some}").contains(node_id));
|
|
assert!(!format!("{some}").contains("No Match"));
|
|
assert!(!format!("{some}").contains("redirect"));
|
|
assert!(!format!("{some}").contains("exact"));
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
#[expect(clippy::panic_in_result_fn)]
|
|
mod serial_tests {
|
|
use super::*;
|
|
use crate::dev::test::{Directories, Error};
|
|
|
|
#[test]
|
|
fn bad_graph_path() -> Result<(), Error> {
|
|
let _dirs = Directories::setup("bad_graph_path")?;
|
|
|
|
let graph = Graph::load();
|
|
let message = graph.meta.messages.first().unwrap();
|
|
assert!(message.contains("Failed reading file at"));
|
|
|
|
Ok(())
|
|
}
|
|
}
|