Further centralize state, return result from serial methods

This commit is contained in:
Juno Takano 2026-01-17 04:01:03 -03:00
commit c23d35217d
15 changed files with 471 additions and 244 deletions

View file

@ -39,100 +39,149 @@ pub struct Stats {
pub detached: HashMap<String, u32>,
}
#[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 { "[redirect] " } else { "" };
let node = if let Some(n) = &self.node {
n.id.clone()
} else {
String::from("No Match")
};
write!(f, "QueryResult: {meta}{node}")
}
}
impl Graph {
pub fn error(message: Option<&str>) -> 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: message.map_or(vec![], |m| vec![m.to_string()]),
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 TOML file from the default location and returns a modulated Graph
///
/// Returns a graph with an error message if any errors are propagated to it.
pub fn load() -> Graph {
Self::load_file("")
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 an empty string, it will fallback to CLI arguments
pub fn load_file(path: &str) -> Graph {
let mut graph = if path.is_empty() {
Self::read_file(None)
} else {
Self::read_file(Some(path))
};
///
/// # Errors
/// Propagates errors from `Graph::read_file`.
pub fn load_file(path: Option<&str>) -> Result<Graph, String> {
let mut graph = Graph::read_file(path)?;
graph.modulate();
graph
Ok(graph)
}
/// Reads a TOML fie into a Graph without modulating it
pub fn read_file(in_path: Option<&str>) -> 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 read_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) => format!("Error: {e}"),
Err(e) => {
log!(ERROR, "Failed reading {e}");
return Err("Failed reading file at {path}".to_string());
},
};
Self::from_serial(&toml_source, &Format::TOML)
let result = Graph::from_serial(&toml_source, &Format::TOML)?;
Ok(result)
}
pub fn from_serial(serial: &str, format: &Format) -> Graph {
/// 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> {
match *format {
Format::TOML => match toml::from_str(serial) {
Ok(g) => g,
Err(error) => Graph::error(Some(&error.to_string())),
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(serial) {
Ok(g) => g,
Err(error) => Graph::error(Some(&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(),
}),
}
}
pub fn to_serial(graph: &Graph, format: &Format) -> String {
/// Serializes a graph to the given format.
///
/// # Errors
/// Errors on unsupported formats.
/// Propagates serialization errors.
pub fn to_serial(
graph: &Graph,
format: &Format,
) -> Result<String, SerialError> {
match *format {
Format::TOML => match toml::to_string(graph) {
Ok(s) => s,
Err(e) => e.to_string(),
Ok(s) => Ok(s),
Err(e) => Err(SerialError {
cause: SerialErrorCause::MalformedInput,
message: e.to_string(),
}),
},
Format::JSON => match serde_json::to_string(graph) {
Ok(s) => s,
Err(e) => e.to_string(),
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(),
}),
}
}
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.parse_config();
tlog!(&instant, "Parsed configuration");
}
// Construct a HashMap with incoming connections (reversed edges)
@ -392,6 +441,76 @@ impl Graph {
pub enum Format {
TOML,
JSON,
Unsupported,
}
#[derive(Serialize, Deserialize, 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, 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"),
}
}
}
#[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 { "[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)]
@ -400,7 +519,7 @@ mod tests {
#[test]
fn empty_graph() {
let graph = Graph::error(Some("ISryQFd9peG6eYz9CFRQFWeD1GnPo0oj"));
let graph = Graph::with_message("ISryQFd9peG6eYz9CFRQFWeD1GnPo0oj");
assert!(graph.nodes.is_empty());
assert!(graph.incoming.is_empty());
assert_eq!(
@ -427,15 +546,16 @@ mod tests {
}
"#;
let graph = Graph::from_serial(json, &Format::JSON);
assert!(graph.meta.messages.is_empty());
let deserialize_result = Graph::from_serial(json, &Format::JSON);
println!("{deserialize_result:?}");
assert!(deserialize_result.is_ok());
}
#[test]
fn bad_json() {
let graph = Graph::from_serial(":::", &Format::JSON);
let message = graph.meta.messages.first().unwrap();
assert!(message.contains("expected value at line 1 column 1"));
assert!(Graph::from_serial(":::", &Format::JSON).is_err_and(|e| {
e.message.contains("expected value at line 1 column 1")
},));
}
}
@ -456,8 +576,7 @@ mod serial_tests {
let graph = Graph::load();
let message = graph.meta.messages.first().unwrap();
assert!(message.contains("TOML parse error"));
assert!(message.contains("No such file or directory"));
assert!(message.contains("Failed reading file at"));
assert!(std::env::set_current_dir(original_working_directory).is_ok());
}

View file

@ -7,6 +7,8 @@ pub struct Meta {
pub version: Option<Version>,
#[serde(default)]
pub messages: Vec<String>,
#[serde(default)]
pub malformed: bool,
}
impl Default for Meta {
@ -15,6 +17,7 @@ impl Default for Meta {
config: Config::default(),
version: Version::from_env(),
messages: vec![],
malformed: false,
}
}
}
@ -109,6 +112,7 @@ fn mkfalse() -> bool {
fn mk8() -> u16 {
8
}
#[cfg(test)]
mod tests {
use crate::graph::Graph;
@ -186,7 +190,7 @@ pub struct Version {
impl Version {
pub fn from_env() -> Option<Version> {
Self::from(env!("CARGO_PKG_VERSION"))
Version::from(env!("CARGO_PKG_VERSION"))
}
pub fn from(version: &str) -> Option<Version> {

View file

@ -27,7 +27,7 @@ impl Data {
let trace_string = format!("{trace:?}");
let filter = env::var("DEBUG_FILTER").unwrap_or_default();
let exclude = env::var("DEBUG_EXCLUDE").unwrap_or_default();
let env_level = Data::env_level();
let env_level = env_level();
let message_level = message_level_opt.unwrap_or(MESSAGE_DEFAULT);
let path = make_display_path(captured_path, &env_level);
@ -69,19 +69,19 @@ impl Data {
trace,
}
}
}
pub fn env_level() -> Level {
if let Ok(level) = env::var("DEBUG") {
Level::from(level.as_str())
} else {
ENV_DEFAULT
}
pub fn env_level() -> Level {
if let Ok(level) = env::var("DEBUG") {
Level::from(level.as_str())
} else {
ENV_DEFAULT
}
}
#[allow(clippy::print_stderr)]
pub fn print_state() {
let env_level = Data::env_level();
let env_level = env_level();
let version = env!("CARGO_PKG_VERSION");
if env_level == ENV_DEFAULT {
eprintln!("en {version}");
@ -93,16 +93,19 @@ pub fn print_state() {
#[allow(clippy::print_stderr)]
pub fn timed(past: &Instant, message: &str) -> Instant {
let now = Instant::now();
let level = Data::env_level();
let env_level = env_level();
let duration = now.duration_since(*past);
let display_duration = if duration.as_millis() > 1000 {
format!("{}s {}ms", duration.as_secs(), duration.subsec_millis())
} else if duration.as_millis() == 0 {
} else if duration.as_millis() <= 1 {
if env_level < Level::VERBOSE {
return now;
}
format!("{}ns", duration.as_nanos())
} else {
format!("{}ms", duration.as_millis())
};
if !message.is_empty() && Level::DEBUG <= level {
if !message.is_empty() && Level::DEBUG <= env_level {
eprintln!("[tlog] +{display_duration} {message}");
}
now

View file

@ -1,6 +1,6 @@
use std::{backtrace, io, panic};
use en::{prelude::*, log, ONSET, graph::Graph, syntax};
use en::{ONSET, graph::Graph, log, prelude::*, syntax};
#[tokio::main]
#[allow(clippy::print_stderr, clippy::print_stdout)]
@ -40,7 +40,7 @@ async fn main() -> io::Result<()> {
let graph = Graph::load();
instant = tlog!(&instant, "Loaded graph");
let router = en::router::new(&graph);
let router = en::router::new(graph);
tlog!(&instant, "Initialized router");
let listener =

View file

@ -1,6 +1,6 @@
use axum::{routing::get, Router};
use axum::{Router, routing::get};
use crate::{graph::Format, graph::Graph};
use crate::graph::Graph;
mod handlers {
pub mod graph;
@ -11,57 +11,68 @@ mod handlers {
pub mod error;
}
pub fn new(graph: &Graph) -> Router {
#[derive(Clone)]
pub struct GlobalState {
pub graph: Graph,
}
pub fn new(graph: Graph) -> Router {
let state = GlobalState { graph };
let mut router = Router::default()
.route(
"/",
get(|| handlers::navigation::page("index.html"))
.post(handlers::navigation::search),
get(handlers::navigation::index).post(handlers::navigation::search),
)
.route(
"/node/{node_id}",
get(handlers::graph::node).post(handlers::graph::node),
)
.route("/data", get(handlers::navigation::data))
.route("/graph/{format}", get(handlers::fixed::serial))
.route("/search", get(handlers::navigation::search))
.route("/redirect", get(handlers::navigation::redirect))
.route(
"/static/style.css",
get(|| handlers::fixed::file("./static/style.css", "text/css")),
)
.route(
"/static/fonts/sans",
get(|| handlers::fixed::file("./static/fonts/sans", "")),
)
.route(
"/static/fonts/didone",
get(|| handlers::fixed::file("./static/fonts/didone", "")),
)
.route(
"/static/fonts/mono",
get(|| handlers::fixed::file("./static/fonts/mono", "")),
)
.route(
"/static/fonts/title",
get(|| handlers::fixed::file("./static/fonts/title", "")),
)
.route(
"/static/fonts/prose",
get(|| handlers::fixed::file("./static/fonts/prose", "")),
)
.route(
"/static/favicon.svg",
get(|| {
handlers::fixed::file("./static/favicon.svg", "image/svg+xml")
}),
)
.fallback(handlers::error::not_found);
);
if graph.meta.config.about {
router = router
.route("/about", get(|| handlers::navigation::page("about.html")));
}
if graph.meta.config.tree {
if state.graph.meta.config.tree {
router = router.route("/tree", get(handlers::navigation::tree));
}
if graph.meta.config.raw {
if graph.meta.config.raw_json {
router = router.route(
"/graph/json",
get(|| handlers::fixed::serial(&Format::JSON)),
);
}
if graph.meta.config.raw_toml {
router = router.route(
"/graph/toml",
get(|| handlers::fixed::serial(&Format::TOML)),
);
}
if state.graph.meta.config.about {
router = router.route("/about", get(handlers::navigation::about));
}
router
.fallback(handlers::error::not_found)
.with_state(state)
}
#[cfg(test)]
@ -89,7 +100,7 @@ mod tests {
},
..default_graph
};
let router = new(&graph);
let router = new(graph);
router
.oneshot(Request::builder().uri(uri).body(Body::empty()).unwrap())
@ -152,7 +163,7 @@ mod tests {
config.raw_toml = false;
let response = request("/graph/toml", Some(&config)).await;
assert_eq!(response.status(), StatusCode::NOT_FOUND);
assert_eq!(response.status(), StatusCode::FORBIDDEN);
}
#[tokio::test]
@ -161,7 +172,7 @@ mod tests {
config.raw_json = false;
let response = request("/graph/json", Some(&config)).await;
assert_eq!(response.status(), StatusCode::NOT_FOUND);
assert_eq!(response.status(), StatusCode::FORBIDDEN);
}
#[tokio::test]
@ -170,8 +181,8 @@ mod tests {
config.raw = false;
let toml_response = request("/graph/toml", Some(&config)).await;
assert_eq!(toml_response.status(), StatusCode::NOT_FOUND);
assert_eq!(toml_response.status(), StatusCode::FORBIDDEN);
let json_response = request("/graph/json", Some(&config)).await;
assert_eq!(json_response.status(), StatusCode::NOT_FOUND);
assert_eq!(json_response.status(), StatusCode::FORBIDDEN);
}
}

View file

@ -1,18 +1,23 @@
use axum::{
body::Body,
extract::State,
http::{Response, StatusCode, header},
};
use crate::{graph::Graph, router::handlers};
use crate::{
graph::Graph,
router::{GlobalState, handlers},
};
pub(in crate::router::handlers) fn by_code(
code: Option<u16>,
message: Option<&str>,
graph: &Graph,
) -> Response<Body> {
let out_code = code.unwrap_or(500);
let out_message = &message.unwrap_or("Unknown error");
let body = make_body(Some(out_code), Some(out_message));
let body = make_body(Some(out_code), Some(out_message), graph);
handlers::raw::make_response(
&body,
@ -21,10 +26,13 @@ pub(in crate::router::handlers) fn by_code(
)
}
fn make_body(code: Option<u16>, message: Option<&str>) -> String {
fn make_body(
code: Option<u16>,
message: Option<&str>,
graph: &Graph,
) -> String {
let mut context = tera::Context::default();
let graph = Graph::load();
let out_code = code.unwrap_or(500);
let out_message = &message.unwrap_or("Unknown error");
@ -35,12 +43,12 @@ fn make_body(code: Option<u16>, message: Option<&str>) -> String {
.to_string(),
);
context.insert("graph", &graph);
context.insert("graph", graph);
context.insert("message", out_message);
context.insert("status_code", &out_code.to_string());
handlers::template::render(
"error.html",
"error",
&context,
Some(&format!(
"Failed to render template for Error {out_code}: {out_message}"
@ -50,10 +58,11 @@ fn make_body(code: Option<u16>, message: Option<&str>) -> String {
.0
}
pub async fn not_found() -> Response<Body> {
pub async fn not_found(State(state): State<GlobalState>) -> Response<Body> {
by_code(
Some(404),
Some("The page you tried to access could not be found."),
&state.graph,
)
}
@ -61,27 +70,32 @@ pub async fn not_found() -> Response<Body> {
mod tests {
use axum::{
http::{StatusCode},
extract::State,
};
use super::*;
#[tokio::test]
async fn not_found() {
let response = super::not_found().await;
let state = State(GlobalState {
graph: Graph::load(),
});
let response = super::not_found(state).await;
assert_eq!(response.status(), StatusCode::NOT_FOUND);
}
#[tokio::test]
async fn internal_error() {
assert!(by_code(Some(201), None).status() == 201);
assert!(by_code(Some(304), None).status() == 304);
assert!(by_code(Some(418), None).status() == 418);
assert!(by_code(Some(505), None).status() == 505);
let graph = Graph::load();
assert!(by_code(Some(201), None, &graph).status() == 201);
assert!(by_code(Some(304), None, &graph).status() == 304);
assert!(by_code(Some(418), None, &graph).status() == 418);
assert!(by_code(Some(505), None, &graph).status() == 505);
}
#[test]
fn custom_message() {
let pattern = "sibPtt0mvHPWS9HQ0YBQfGu8cUs954LZ";
let body = make_body(Some(501), Some(pattern));
let body = make_body(Some(501), Some(pattern), &Graph::load());
assert!(body.contains(pattern));
assert!(!body.contains(&pattern.chars().rev().collect::<String>()));
}

View file

@ -1,12 +1,13 @@
use axum::{
body::Body,
http::{Response, StatusCode, header, HeaderValue},
extract::{Path, State},
http::{HeaderValue, Response, StatusCode, header},
};
use crate::prelude::*;
use crate::{
router::handlers,
graph::{Graph, Format},
graph::{Format, Graph, SerialErrorCause},
router::{GlobalState, handlers},
};
/// # Panics
@ -41,22 +42,68 @@ pub async fn file(file_path: &str, content_type: &str) -> Response<Body> {
response
}
#[expect(clippy::unused_async)]
pub async fn serial(format: &Format) -> Response<Body> {
let graph = Graph::load();
let body = Graph::to_serial(&graph, format);
pub async fn serial(
Path(format): Path<String>,
State(state): State<GlobalState>,
) -> Response<Body> {
let config = &state.graph.meta.config;
match *format {
Format::TOML => handlers::raw::make_response(
&body,
200,
&[(header::CONTENT_TYPE, "text/plain")],
),
Format::JSON => handlers::raw::make_response(
&body,
200,
&[(header::CONTENT_TYPE, "application/json")],
),
let make_error = |code: u16, message: &str| -> Response<Body> {
handlers::error::by_code(
Some(code),
Some(
format!(
"<p>{message}</p>\n\
<p>Check the <a href=/data>data</a> \n\
page for the available formats.</p>"
)
.as_str(),
),
&state.graph,
)
};
let forbidden_response =
make_error(403, "This graph format is not available.");
let unsupported_response =
make_error(400, "This graph format is not supported.");
let parse_failure = make_error(505, "The graph has failed to parse.");
let body =
match Graph::to_serial(&state.graph, &Format::from(format.as_str())) {
Ok(serial) => serial,
Err(error) => match error.cause {
SerialErrorCause::MalformedInput => return parse_failure,
SerialErrorCause::UnsupportedFormat => {
return unsupported_response;
},
},
};
match Format::from(format.as_str()) {
Format::TOML => {
if config.raw && config.raw_toml {
handlers::raw::make_response(
&body,
200,
&[(header::CONTENT_TYPE, "text/plain")],
)
} else {
forbidden_response
}
},
Format::JSON => {
if config.raw && config.raw_json {
handlers::raw::make_response(
&body,
200,
&[(header::CONTENT_TYPE, "application/json")],
)
} else {
forbidden_response
}
},
Format::Unsupported => unsupported_response,
}
}
@ -64,15 +111,28 @@ pub async fn serial(format: &Format) -> Response<Body> {
mod tests {
use super::*;
async fn wrap_serial(format: &str) -> Response<Body> {
let state = GlobalState {
graph: Graph::load(),
};
serial(Path(format.to_string()), State(state)).await
}
#[tokio::test]
async fn serial_toml() {
let response = serial(&Format::TOML).await;
let response = wrap_serial("toml").await;
assert!(response.status() == 200);
}
#[tokio::test]
async fn serial_json() {
let response = wrap_serial("json").await;
assert!(response.status() == 200);
}
#[tokio::test]
async fn serial_toml_content_type() {
let response = serial(&Format::TOML).await;
let response = wrap_serial("TOML").await;
assert!(
response.headers().get(header::CONTENT_TYPE).unwrap()
== "text/plain"
@ -81,7 +141,7 @@ mod tests {
#[tokio::test]
async fn serial_json_content_type() {
let response = serial(&Format::JSON).await;
let response = wrap_serial("json").await;
assert!(
response.headers().get(header::CONTENT_TYPE).unwrap()
== "application/json"

View file

@ -1,12 +1,21 @@
use axum::response::IntoResponse as _;
use axum::{body::Body, extract::Path, http::Response, response::Redirect};
use axum::{
extract::State,
response::IntoResponse as _,
{body::Body, extract::Path, http::Response, response::Redirect},
};
use crate::{prelude::*, graph::Graph, router::handlers, graph::Node};
use crate::{
graph::Node,
prelude::*,
router::{GlobalState, handlers},
};
pub async fn node(Path(id): Path<String>) -> Response<Body> {
pub async fn node(
Path(id): Path<String>,
State(state): State<GlobalState>,
) -> Response<Body> {
let instant = now();
let graph = Graph::load();
let result = graph.find_node(&id);
let result = state.graph.find_node(&id);
let found = result.node.is_some();
let node = result
.node
@ -25,20 +34,19 @@ pub async fn node(Path(id): Path<String>) -> Response<Body> {
}
let mut context = tera::Context::default();
context.insert("graph", &graph);
context.insert("graph", &state.graph);
context.insert("node", &node);
context.insert("incoming", &graph.incoming.get(&id));
context.insert("incoming", &state.graph.incoming.get(&id));
tlog!(&instant, "Assembled response for node {}", node.id);
handlers::template::by_filename(
"node.html",
handlers::template::with_context(
"node",
&context,
if found { 500 } else { 404 },
Some(
format!(
"Failed to generate page for node {} (ID {}).\n\
Node struct: <pre>{:#?}</pre>",
node.title, id, node
"Failed to generate page for node {} (ID {}).",
node.title, id
)
.to_owned(),
),
@ -52,17 +60,26 @@ mod tests {
http::{HeaderName, StatusCode},
};
use crate::graph::Graph;
use super::*;
async fn wrap_node(query: &str) -> Response<Body> {
let state = GlobalState {
graph: Graph::load(),
};
node(Path(query.to_string()), axum::extract::State(state)).await
}
#[tokio::test]
async fn syntax() {
let response = node(Path("Syntax".to_string())).await;
let response = wrap_node("Syntax").await;
assert_eq!(response.status(), StatusCode::OK);
}
#[tokio::test]
async fn syntax_content_type() {
let response = node(Path("Syntax".to_string())).await;
let response = wrap_node("Syntax").await;
assert!(
response
.headers()
@ -76,19 +93,19 @@ mod tests {
#[tokio::test]
async fn not_found() {
let response = node(Path("InexistentNode".to_string())).await;
let response = wrap_node("InexistentNode").await;
assert_eq!(response.status(), StatusCode::NOT_FOUND);
}
#[tokio::test]
async fn redirect() {
let response = node(Path("syntax".to_string())).await;
let response = wrap_node("syntax").await;
assert_eq!(response.status(), StatusCode::PERMANENT_REDIRECT);
}
#[tokio::test]
async fn docs_redirect() {
let response = node(Path("docs".to_string())).await;
let response = wrap_node("docs").await;
assert_eq!(response.status(), StatusCode::PERMANENT_REDIRECT);
}
}

View file

@ -1,67 +1,45 @@
use axum::{
body::Body,
http::{Response},
response::Redirect,
Form,
};
use axum::{Form, body::Body, extract::State, http::Response, response::Redirect};
use crate::{
prelude::*,
graph::{Graph, Node},
router::handlers,
router::{GlobalState, handlers},
};
#[expect(clippy::unused_async)]
pub async fn page(template: &str) -> Response<Body> {
let instant = now();
let mut context = tera::Context::default();
let graph = Graph::load();
context.insert("graph", &graph);
tlog!(&instant, "Assembled response for template {template}");
handlers::template::by_filename(template, &context, 500, None, false)
pub async fn index(State(state): State<GlobalState>) -> Response<Body> {
handlers::template::with_graph("index", state).await
}
pub async fn tree() -> Response<Body> {
let instant = now();
let mut context = tera::Context::default();
let mut graph = Graph::load();
pub async fn about(State(state): State<GlobalState>) -> Response<Body> {
handlers::template::with_graph("about", state).await
}
context.insert("graph", &graph);
if let Some(root_node) = graph.get_root() {
graph.nodes.remove(&root_node.id);
pub async fn tree(State(state): State<GlobalState>) -> Response<Body> {
let instant = now();
let mut context = tera::Context::default();
context.insert("graph", &state.graph);
if let Some(root_node) = state.graph.get_root() {
context.insert("root_node", &root_node);
context.insert(
"nodes",
&graph.nodes.values().cloned().collect::<Vec<Node>>(),
);
} else {
context.insert(
"nodes",
&graph.nodes.values().cloned().collect::<Vec<Node>>(),
);
}
tlog!(&instant, "Assembled response for tree endpoint");
handlers::template::by_filename("tree.html", &context, 500, None, false)
handlers::template::with_context("tree", &context, 500, None, false)
}
pub async fn data() -> Response<Body> {
pub async fn data(State(state): State<GlobalState>) -> Response<Body> {
let instant = now();
let mut context = tera::Context::default();
let graph = Graph::load();
let mut detached_pairs: Vec<(String, u32)> =
graph.stats.detached.clone().into_iter().collect();
state.graph.stats.detached.clone().into_iter().collect();
detached_pairs.sort_by(|a, b| b.1.cmp(&a.1));
context.insert("graph", &graph);
context.insert("detached_count", &graph.stats.detached.len());
let mut context = tera::Context::default();
context.insert("graph", &state.graph);
context.insert("detached_count", &state.graph.stats.detached.len());
context.insert("detached_pairs", &detached_pairs);
tlog!(&instant, "Assembled response for data endpoint");
handlers::template::by_filename("data.html", &context, 500, None, false)
handlers::template::with_context("data", &context, 500, None, false)
}
pub async fn search(Form(query): Form<Query>) -> Redirect {
@ -79,11 +57,18 @@ pub struct Query {
#[cfg(test)]
mod tests {
use axum::{
http::{StatusCode},
};
use axum::http::StatusCode;
use crate::graph::Graph;
use super::*;
async fn wrap_page(path: &str) -> Response<Body> {
let state = GlobalState {
graph: Graph::load(),
};
handlers::template::with_graph(path, state).await
}
#[tokio::test]
async fn search_redirect() {
let query = Form(Query {
@ -95,19 +80,19 @@ mod tests {
#[tokio::test]
async fn about_page_ok() {
let response = page("about.html").await;
let response = wrap_page("about").await;
assert_eq!(response.status(), StatusCode::OK);
}
#[tokio::test]
async fn tree_page_ok() {
let response = page("tree.html").await;
let response = wrap_page("tree").await;
assert_eq!(response.status(), StatusCode::OK);
}
#[tokio::test]
async fn inexistent_page_error() {
let response = page("HBvcwqT8wLk6hxk1GdvNcEzJ6IiZ2Fod").await;
let response = wrap_page("HBvcwqT8wLk6hxk1GdvNcEzJ6IiZ2Fod").await;
assert_eq!(response.status(), StatusCode::INTERNAL_SERVER_ERROR);
}

View file

@ -3,9 +3,28 @@ use axum::{
http::{header, Response, StatusCode},
};
use crate::{prelude::*, router::handlers::raw::make_response};
use crate::{
prelude::*,
router::{GlobalState, handlers::raw::make_response},
};
pub(in crate::router::handlers) fn by_filename(
/// Assembles a response containing the graph as its only context
///
/// The template name **must not** contain the extension.
#[expect(clippy::unused_async)]
pub async fn with_graph(template: &str, state: GlobalState) -> Response<Body> {
let instant = now();
let mut context = tera::Context::default();
context.insert("graph", &state.graph);
tlog!(&instant, "Assembled response for template {template}");
with_context(template, &context, 500, None, false)
}
/// Assembles a response with a custom context.
///
/// The template name **must not** contain the extension.
pub(in crate::router::handlers) fn with_context(
name: &str,
context: &tera::Context,
error_code: u16,
@ -19,8 +38,11 @@ pub(in crate::router::handlers) fn by_filename(
make_response(&body, status_code, &[(header::CONTENT_TYPE, "text/html")])
}
/// Renderes a template into a String and error code
///
/// The template name **must not** contain the extension (e.g. `.html`).
pub(in crate::router::handlers) fn render(
name: &str,
template: &str,
// TODO take Option, skip context if None,
// then template_handler can replace static_template_handler
context: &tera::Context,
@ -35,29 +57,35 @@ pub(in crate::router::handlers) fn render(
},
};
match tera.render(name, context) {
match tera.render(format!("{template}.html").as_str(), context) {
Ok(t) => {
tlog!(&instant, "Rendered template {name}");
tlog!(&instant, "Rendered template {template}");
(t, 200)
},
Err(e) => {
let mut error_context = tera::Context::default();
let out_error_message = match error_message {
Some(s) => &format!(
let mut out_error_message = match error_message {
Some(s) => format!(
"Template render failed.\n\
User message: {s},
Engine message:\n<pre>{e:#?}</pre>\n\
Context:\n<pre>{context:#?}</pre>"
Engine message:\n<pre>{e:#?}</pre>"
),
None => &format!(
None => format!(
"Template render failed.\n\
Engine message:\n<pre>{e:#?}</pre>\n\
Context:\n<pre>{context:#?}</pre>"
Engine message:\n<pre>{e:#?}</pre>"
),
};
error_context.insert("message", out_error_message);
if log::env_level() >= VERBOSE {
out_error_message = format!(
"{out_error_message}\n\
Context:\n<pre>{context:#?}</pre>"
);
}
log!(ERROR, "{out_error_message}");
error_context.insert("message", &out_error_message);
error_context.insert(
"title",
&StatusCode::INTERNAL_SERVER_ERROR.to_string(),
@ -113,31 +141,21 @@ mod tests {
#[test]
fn by_filename_forced_error() {
let response = by_filename(
"index.html",
&tera::Context::default(),
418,
None,
true,
);
let response =
with_context("index", &tera::Context::default(), 418, None, true);
assert_eq!(response.status(), 418);
}
#[test]
fn by_filename_index() {
let response = by_filename(
"index.html",
&tera::Context::default(),
418,
None,
false,
);
let response =
with_context("index", &tera::Context::default(), 418, None, false);
assert_eq!(response.status(), 200);
}
#[test]
fn by_filename_file_not_found() {
let response = by_filename(
let response = with_context(
"bwbl3BnWsluIgbO2NV9t3vtihwcjuF6t",
&tera::Context::default(),
418,
@ -150,7 +168,7 @@ mod tests {
#[test]
fn by_filename_empty() {
let response =
by_filename("", &tera::Context::default(), 418, None, false);
with_context("", &tera::Context::default(), 418, None, false);
assert_eq!(response.status(), 500);
}
@ -163,7 +181,7 @@ mod tests {
context.insert("node", &node);
context.insert("graph", &graph);
context.insert("incoming", &graph.incoming.get(&node.id));
let (body, status) = render("node.html", &context, None);
let (body, status) = render("node", &context, None);
assert_eq!(status, 200);
assert!(body.matches(payload).count() == 1);
}
@ -203,8 +221,7 @@ mod tests {
#[test]
fn render_bad_context() {
let (body, status) =
render("node.html", &tera::Context::default(), None);
let (body, status) = render("node", &tera::Context::default(), None);
assert!(body.matches("Template render failed.").count() > 0);
assert_eq!(status, 500);
}

View file

@ -21,15 +21,11 @@ const LEXMAP: LexMap = &[
];
fn lex(text: &str, map: LexMap, graph: &Graph, blocking: bool) -> TokenOutput {
let mut instant = now();
let mut tokens: Vec<Token> = Vec::default();
let mut state = State::default();
let segments = segment::segment(text);
let segments_count = segments.len();
instant = tlog!(&instant, "Segmented {segments_count} segments");
let lexemes = Lexeme::collect(&segments);
instant = tlog!(&instant, "{segments_count} segments: Collected lexemes");
log!(VERBOSE, "Segments: {segments:?}");
@ -77,10 +73,8 @@ fn lex(text: &str, map: LexMap, graph: &Graph, blocking: bool) -> TokenOutput {
}
}
}
instant = tlog!(&instant, "{segments_count} segments: Parsed");
context::close(&state, &mut tokens);
tlog!(&instant, "{segments_count} segments: Closed");
TokenOutput {
tokens,

View file

@ -13,7 +13,7 @@ pub mod delimiter {
}
impl Default for Delimiters {
fn default() -> Self {
fn default() -> Delimiters {
Delimiters {
atomic: vec!['`', '|', '\\'],
double: vec!['_', '~'],

View file

@ -28,9 +28,7 @@
{% if graph.meta.config.tree %}
<li><a href="/tree">Tree</a></li>
{% endif %}
{% if graph.meta.config.raw %}
<li><a href="/data">Data</a></li>
{% endif %}
</ul>
{% if graph.meta.config.node_selector or graph.meta.config.navbar_search %}
<div class="nav-inputs">

View file

@ -1,5 +1,4 @@
{% extends "base.html" %}
{% set config = graph.meta.config %}
{% block title %}Data{% endblock title %}
@ -31,15 +30,15 @@
</table>
</details>
{% if config.raw_toml or config.raw_json %}
{% if graph.meta.config.raw and (graph.meta.config.raw_toml or graph.meta.config.raw_json) %}
<h2>Raw formats</h2>
<p>The raw data used to render this graph is available in the following formats:</p>
<p>Structured data representing this graph is available in the following formats:</p>
<ul>
{% if config.raw_toml %}
{% if graph.meta.config.raw_toml %}
<li><a href="/graph/toml">TOML</a></li>
{% endif %}
{% if config.raw_json %}
{% if graph.meta.config.raw_json %}
<li><a href="/graph/json">JSON</a></li>
{% endif %}
</ul>

View file

@ -1,4 +1,10 @@
<p>There are no nodes. The graph is either empty or failed to parse.</p>
{% if graph.meta.messages %}
<p>Error messages:</p>
<pre>
{{ graph.meta.messages }}
</pre>
{% endif %}
{% if graph.meta.config.raw %}
<p>Check the
{% if graph.meta.config.raw_toml %}