use std::collections::{BTreeMap, BTreeSet};
use std::path::{Path, PathBuf};
use super::schema::UagArtifact;
pub const PAGE_EXTENSIONS: &[&str] = &["tsx", "vue", "svelte"];
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum UagDiagnostic {
DuplicateRoute {
method: String,
path: String,
handlers: Vec<String>,
},
UnregisteredPage {
route: String,
page: String,
},
MissingPageComponent {
page: String,
searched: Vec<String>,
},
UndeclaredPolicy {
route: String,
policy: String,
},
UndeclaredExport {
module: String,
export: String,
},
}
impl std::fmt::Display for UagDiagnostic {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::DuplicateRoute {
method,
path,
handlers,
} => write!(
f,
"duplicate route `{method} {path}` declared by {}",
handlers.join(", ")
),
Self::UnregisteredPage { route, page } => write!(
f,
"route `{route}` renders page `{page}`, which no contract registers"
),
Self::MissingPageComponent { page, searched } => write!(
f,
"page `{page}` has no component file (looked for {})",
searched.join(", ")
),
Self::UndeclaredPolicy { route, policy } => write!(
f,
"route `{route}` is guarded by policy `{policy}`, which no module declares"
),
Self::UndeclaredExport { module, export } => write!(
f,
"module `{module}` exports `{export}`, which it does not declare"
),
}
}
}
impl std::error::Error for UagDiagnostic {}
#[derive(Debug, Clone, Default)]
pub struct ValidateOptions {
pub pages_dir: Option<PathBuf>,
}
impl ValidateOptions {
#[must_use]
pub fn new() -> Self {
Self::default()
}
#[must_use]
pub fn with_pages_dir(mut self, dir: impl Into<PathBuf>) -> Self {
self.pages_dir = Some(dir.into());
self
}
}
pub fn validate(
artifact: &UagArtifact,
options: &ValidateOptions,
) -> Result<(), Vec<UagDiagnostic>> {
let mut diagnostics = Vec::new();
duplicate_routes(artifact, &mut diagnostics);
unregistered_pages(artifact, &mut diagnostics);
if let Some(dir) = &options.pages_dir {
missing_page_components(artifact, dir, &mut diagnostics);
}
undeclared_policies(artifact, &mut diagnostics);
undeclared_exports(artifact, &mut diagnostics);
if diagnostics.is_empty() {
Ok(())
} else {
Err(diagnostics)
}
}
fn duplicate_routes(artifact: &UagArtifact, out: &mut Vec<UagDiagnostic>) {
let mut by_key: BTreeMap<(&str, &str), Vec<String>> = BTreeMap::new();
for route in artifact.routes() {
by_key
.entry((route.method.as_str(), route.path.as_str()))
.or_default()
.push(route.handler.clone());
}
for ((method, path), handlers) in by_key {
if handlers.len() > 1 {
out.push(UagDiagnostic::DuplicateRoute {
method: method.to_owned(),
path: path.to_owned(),
handlers,
});
}
}
}
fn unregistered_pages(artifact: &UagArtifact, out: &mut Vec<UagDiagnostic>) {
for route in artifact.routes() {
for page in &route.pages {
if !artifact.pages().contains_key(page) {
out.push(UagDiagnostic::UnregisteredPage {
route: route_label(route),
page: page.clone(),
});
}
}
}
}
fn missing_page_components(artifact: &UagArtifact, dir: &Path, out: &mut Vec<UagDiagnostic>) {
for page in artifact.pages().keys() {
let searched: Vec<String> = PAGE_EXTENSIONS
.iter()
.map(|ext| format!("{page}.{ext}"))
.collect();
if !searched.iter().any(|name| dir.join(name).is_file()) {
out.push(UagDiagnostic::MissingPageComponent {
page: page.clone(),
searched,
});
}
}
}
fn undeclared_policies(artifact: &UagArtifact, out: &mut Vec<UagDiagnostic>) {
let declared: BTreeSet<&String> = artifact
.modules()
.values()
.flat_map(|module| module.policies.iter())
.collect();
for route in artifact.routes() {
for policy in &route.policies {
if !declared.contains(policy) {
out.push(UagDiagnostic::UndeclaredPolicy {
route: route_label(route),
policy: policy.clone(),
});
}
}
}
}
fn undeclared_exports(artifact: &UagArtifact, out: &mut Vec<UagDiagnostic>) {
for (name, module) in artifact.modules() {
for export in &module.exports {
let declared = module.controllers.contains_key(export)
|| module.services.contains(export)
|| module.policies.contains(export);
if !declared {
out.push(UagDiagnostic::UndeclaredExport {
module: name.clone(),
export: export.clone(),
});
}
}
}
}
fn route_label(route: &super::schema::UagRoute) -> String {
if route.name.is_empty() {
format!("{} {}", route.method, route.path)
} else {
route.name.clone()
}
}
#[cfg(test)]
mod tests {
use std::collections::{BTreeMap, BTreeSet};
use super::*;
use crate::inertia::contracts::{ContractType, PageSchema, PropsSchema};
use crate::uag::schema::{UagModule, UagRoute};
fn route(name: &str, method: &str, path: &str, handler: &str, pages: &[&str]) -> UagRoute {
UagRoute {
module: "Links".to_owned(),
method: method.to_owned(),
path: path.to_owned(),
name: name.to_owned(),
handler: handler.to_owned(),
params: Vec::new(),
pages: pages.iter().map(|p| (*p).to_owned()).collect(),
action: None,
query: None,
query_string: None,
policies: BTreeSet::new(),
}
}
fn page_map(names: &[&str]) -> BTreeMap<String, PageSchema> {
names
.iter()
.map(|n| {
(
(*n).to_owned(),
PageSchema::new(PropsSchema::new().required("id", ContractType::number())),
)
})
.collect()
}
fn artifact(routes: Vec<UagRoute>, pages: &[&str]) -> UagArtifact {
UagArtifact::new(BTreeMap::new(), routes, page_map(pages))
}
#[test]
fn a_clean_artifact_produces_no_diagnostics() {
let art = artifact(
vec![route(
"links.show",
"GET",
"/links/{link}",
"C::show",
&["Show"],
)],
&["Show"],
);
assert_eq!(validate(&art, &ValidateOptions::new()), Ok(()));
}
#[test]
fn the_same_path_and_method_twice_is_reported_once_with_both_handlers() {
let art = artifact(
vec![
route("a", "GET", "/links", "A::index", &[]),
route("b", "GET", "/links", "B::index", &[]),
],
&[],
);
let found = validate(&art, &ValidateOptions::new()).unwrap_err();
assert_eq!(
found,
vec![UagDiagnostic::DuplicateRoute {
method: "GET".to_owned(),
path: "/links".to_owned(),
handlers: vec!["A::index".to_owned(), "B::index".to_owned()],
}]
);
}
#[test]
fn the_same_path_under_two_methods_is_not_a_duplicate() {
let art = artifact(
vec![
route("a", "GET", "/links", "A::index", &[]),
route("b", "POST", "/links", "A::store", &[]),
],
&[],
);
assert_eq!(validate(&art, &ValidateOptions::new()), Ok(()));
}
#[test]
fn a_page_without_a_contract_is_reported_against_its_route() {
let art = artifact(
vec![route("links.show", "GET", "/l", "C::show", &["Ghost"])],
&["Show"],
);
let found = validate(&art, &ValidateOptions::new()).unwrap_err();
assert_eq!(
found,
vec![UagDiagnostic::UnregisteredPage {
route: "links.show".to_owned(),
page: "Ghost".to_owned(),
}]
);
}
#[test]
fn an_unnamed_route_is_identified_by_method_and_path() {
let art = artifact(vec![route("", "GET", "/l", "C::show", &["Ghost"])], &[]);
let found = validate(&art, &ValidateOptions::new()).unwrap_err();
assert!(matches!(
&found[0],
UagDiagnostic::UnregisteredPage { route, .. } if route == "GET /l"
));
}
#[test]
fn the_component_check_is_skipped_when_no_pages_directory_is_given() {
let art = artifact(Vec::new(), &["Nowhere"]);
assert_eq!(validate(&art, &ValidateOptions::new()), Ok(()));
}
#[test]
fn a_page_with_no_component_file_on_disk_is_reported() {
let dir = tempfile::tempdir().expect("a temp dir");
let art = artifact(Vec::new(), &["Missing"]);
let found = validate(&art, &ValidateOptions::new().with_pages_dir(dir.path())).unwrap_err();
assert_eq!(
found,
vec![UagDiagnostic::MissingPageComponent {
page: "Missing".to_owned(),
searched: vec![
"Missing.tsx".to_owned(),
"Missing.vue".to_owned(),
"Missing.svelte".to_owned(),
],
}]
);
}
#[test]
fn any_supported_extension_satisfies_the_component_check() {
for ext in PAGE_EXTENSIONS {
let dir = tempfile::tempdir().expect("a temp dir");
std::fs::write(dir.path().join(format!("Home.{ext}")), "").expect("write component");
let art = artifact(Vec::new(), &["Home"]);
assert_eq!(
validate(&art, &ValidateOptions::new().with_pages_dir(dir.path())),
Ok(()),
"{ext} should satisfy the check"
);
}
}
#[test]
fn a_nested_page_identity_resolves_to_a_nested_file() {
let dir = tempfile::tempdir().expect("a temp dir");
std::fs::create_dir_all(dir.path().join("links")).expect("create nested dir");
std::fs::write(dir.path().join("links/Show.tsx"), "").expect("write component");
let art = artifact(Vec::new(), &["links/Show"]);
assert_eq!(
validate(&art, &ValidateOptions::new().with_pages_dir(dir.path())),
Ok(())
);
}
#[test]
fn an_export_that_names_nothing_the_module_declares_is_reported() {
let module = UagModule {
exports: BTreeSet::from(["LinksService".to_owned(), "Ghost".to_owned()]),
services: BTreeSet::from(["LinksService".to_owned()]),
..UagModule::default()
};
let art = UagArtifact::new(
BTreeMap::from([("Links".to_owned(), module)]),
Vec::new(),
BTreeMap::new(),
);
let found = validate(&art, &ValidateOptions::new()).unwrap_err();
assert_eq!(
found,
vec![UagDiagnostic::UndeclaredExport {
module: "Links".to_owned(),
export: "Ghost".to_owned(),
}]
);
}
fn guarded(name: &str, method: &str, path: &str, policies: &[&str]) -> UagRoute {
UagRoute {
policies: policies.iter().map(|p| (*p).to_owned()).collect(),
..route(name, method, path, "C::act", &[])
}
}
fn with_policies(routes: Vec<UagRoute>, policies: &[&str]) -> UagArtifact {
let module = UagModule {
policies: policies.iter().map(|p| (*p).to_owned()).collect(),
..UagModule::default()
};
UagArtifact::new(
BTreeMap::from([("Links".to_owned(), module)]),
routes,
BTreeMap::new(),
)
}
#[test]
fn a_route_guarded_by_a_declared_policy_is_clean() {
let art = with_policies(
vec![guarded(
"links.update",
"PUT",
"/links/{link}",
&["LinkPolicy"],
)],
&["LinkPolicy"],
);
assert_eq!(validate(&art, &ValidateOptions::new()), Ok(()));
}
#[test]
fn a_policy_no_module_declares_is_reported_against_the_route() {
let art = with_policies(
vec![guarded(
"links.update",
"PUT",
"/links/{link}",
&["GhostPolicy"],
)],
&["LinkPolicy"],
);
let found = validate(&art, &ValidateOptions::new()).unwrap_err();
assert_eq!(
found,
vec![UagDiagnostic::UndeclaredPolicy {
route: "links.update".to_owned(),
policy: "GhostPolicy".to_owned(),
}]
);
}
#[test]
fn a_policy_declared_by_another_module_still_counts_as_declared() {
let owner = UagModule {
policies: BTreeSet::from(["LinkPolicy".to_owned()]),
..UagModule::default()
};
let art = UagArtifact::new(
BTreeMap::from([
("Admin".to_owned(), UagModule::default()),
("Links".to_owned(), owner),
]),
vec![guarded(
"admin.links.update",
"PUT",
"/admin/links/{l}",
&["LinkPolicy"],
)],
BTreeMap::new(),
);
assert_eq!(validate(&art, &ValidateOptions::new()), Ok(()));
}
#[test]
fn an_unnamed_route_with_an_undeclared_policy_is_reported_by_method_and_path() {
let art = with_policies(
vec![guarded("", "DELETE", "/links/{link}", &["GhostPolicy"])],
&[],
);
let found = validate(&art, &ValidateOptions::new()).unwrap_err();
assert_eq!(
found,
vec![UagDiagnostic::UndeclaredPolicy {
route: "DELETE /links/{link}".to_owned(),
policy: "GhostPolicy".to_owned(),
}]
);
}
#[test]
fn an_unguarded_mutation_is_not_a_diagnostic() {
let art = with_policies(vec![guarded("auth.login", "POST", "/login", &[])], &[]);
assert_eq!(validate(&art, &ValidateOptions::new()), Ok(()));
}
#[test]
fn a_diagnostic_message_names_the_application_not_the_machine() {
let message = UagDiagnostic::MissingPageComponent {
page: "links/Show".to_owned(),
searched: vec!["links/Show.tsx".to_owned()],
}
.to_string();
assert_eq!(
message,
"page `links/Show` has no component file (looked for links/Show.tsx)"
);
}
}