use anyhow::anyhow;
use matchit::Router;
use std::collections::btree_set::Iter;
use std::collections::{BTreeSet, HashMap};
use thiserror::Error;
use crate::path::PathArgs;
use crate::task::Job;
#[derive(Debug, Error)]
#[error(transparent)]
pub struct PathSearchError(#[from] anyhow::Error);
#[derive(Default, Debug, Clone)]
pub struct Domain {
router: Router<BTreeSet<Job>>,
index: HashMap<Box<str>, String>,
}
impl Domain {
pub fn new() -> Self {
Self {
router: Router::new(),
index: HashMap::new(),
}
}
pub fn job(self, route: &'static str, job: Job) -> Self {
let Self {
mut router,
mut index,
} = self;
let job_id = String::from(job.id());
let operation = job.operation();
let mut queue = router.remove(route).unwrap_or_default();
if queue.iter().any(|j| j.id() == job_id) {
panic!(
"cannot assign job '{job_id}' to operation '{operation:?}', a previous assignment exists"
)
}
let updated = queue.insert(job);
router.insert(route, queue).expect("route should be valid");
if updated {
if let Some(oldroute) =
index.insert(job_id.clone().into_boxed_str(), String::from(route))
{
panic!(
"cannot assign job '{job_id}' to route '{route}', a previous assignment exists to '{oldroute}'"
)
}
}
Self { router, index }
}
pub fn jobs<const N: usize>(self, route: &'static str, list: [Job; N]) -> Self {
list.into_iter()
.fold(self, |domain, job| domain.job(route, job))
}
pub(crate) fn find_path_for_job(
&self,
job_id: &str,
args: &mut PathArgs,
) -> Result<String, PathSearchError> {
if let Some(route) = self.index.get(job_id) {
let mut route = route.clone();
let mut replacements = Vec::new();
let mut used_keys = Vec::new();
for (k, v) in args.iter() {
let param = format!("{{{k}}}");
let wildcard_param = format!("{{*{k}}}");
let escaped_param = format!("{{{{{k}}}}}");
let placeholder = format!("__ESCAPED_{k}__");
route = route.replace(&escaped_param, &placeholder);
if route.contains(¶m) || route.contains(&wildcard_param) {
used_keys.push(k.clone());
replacements.push((param, v.clone()));
replacements.push((wildcard_param, v.clone()));
}
}
for (param, value) in replacements {
route = route.replace(¶m, &value);
}
let mut final_route = String::new();
let mut chars = route.chars().peekable();
let mut missing_args = Vec::new();
while let Some(c) = chars.next() {
if c == '{' && chars.peek() == Some(&'{') {
chars.next(); final_route.push('{');
} else if c == '}' && chars.peek() == Some(&'}') {
chars.next(); final_route.push('}');
} else if c == '{' {
let mut placeholder = String::from("{");
while let Some(&next) = chars.peek() {
placeholder.push(next);
chars.next();
if next == '}' {
break;
}
}
if placeholder.ends_with('}') {
missing_args.push(placeholder.clone());
}
final_route.push_str(&placeholder);
} else {
final_route.push(c);
}
}
if !missing_args.is_empty() {
return Err(anyhow!(
"missing arguments for task {job_id}: {:?}",
missing_args
))?;
}
for (k, _) in args.iter() {
let placeholder = format!("__ESCAPED_{k}__");
let param = format!("{{{k}}}");
final_route = final_route.replace(&placeholder, ¶m);
}
args.retain(|(k, _)| used_keys.contains(k));
Ok(final_route)
} else {
Err(anyhow!(
"could not find a job with id {job_id} in the search domain"
))?
}
}
pub(crate) fn find_job(&self, path: &str, job_id: &str) -> Option<&Job> {
self.router
.at(path)
.ok()
.and_then(|matched| matched.value.iter().find(|job| job.id() == job_id))
}
pub(crate) fn find_matching_jobs(&self, path: &str) -> Option<(PathArgs, Iter<'_, Job>)> {
self.router
.at(path)
.map(|matched| (PathArgs::from(matched.params), matched.value.iter()))
.ok()
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::extract::{Target, View};
use crate::path::PathArgs;
use crate::task::*;
fn plus_one(mut counter: View<i32>, tgt: Target<i32>) -> View<i32> {
if *counter < *tgt {
*counter += 1;
}
counter
}
fn plus_two(counter: View<i32>, tgt: Target<i32>) -> Vec<Task> {
if *tgt - *counter < 2 {
return vec![];
}
vec![plus_one.with_target(*tgt), plus_one.with_target(*tgt)]
}
#[test]
#[should_panic]
fn it_fails_if_assigning_the_same_job_to_multiple_ops() {
Domain::new()
.job("/counters/{counter}", update(plus_one))
.job("/counters/{counter}", update(plus_one));
}
#[test]
#[should_panic]
fn it_fails_if_assigning_the_same_job_to_multiple_routes() {
Domain::new()
.job("/counters/{counter}", update(plus_one))
.job("/numbers/{counter}", create(plus_one));
}
#[test]
fn it_constructs_a_path_given_arguments() {
let domain = Domain::new()
.job("/counters/{counter}", none(plus_one))
.job("/counters/{counter}", update(plus_two));
let mut args = PathArgs::from(vec![("counter", "one")]);
let path = domain.find_path_for_job(plus_one.id(), &mut args).unwrap();
assert_eq!(path, String::from("/counters/one"))
}
#[test]
fn test_wildcard_parameter_replacement() {
let func = |file: View<()>| file;
let domain = Domain::new().job("/files/{*path}", update(func));
let mut args = PathArgs::from(vec![("path", "documents/report.pdf")]);
let result = domain.find_path_for_job(func.id(), &mut args).unwrap();
assert_eq!(result, "/files/documents/report.pdf".to_string());
}
#[test]
fn test_escaped_parameters_remain() {
let func = |file: View<()>| file;
let domain = Domain::new().job("/data/{{counter}}/edit", update(func));
let mut args = PathArgs::from(vec![("counter", "456")]);
let result = domain.find_path_for_job(func.id(), &mut args).unwrap();
assert_eq!(result, "/data/{counter}/edit".to_string()); assert_eq!(args, PathArgs::default()); }
#[test]
fn test_mixed_placeholders() {
let func = |file: View<()>| file;
let domain = Domain::new().job("/users/{id}/files/{{file}}/{*path}", update(func));
let mut args = PathArgs::from(vec![
("id", "42"),
("path", "reports/january.csv"),
("unused", "some-value"),
]);
let result = domain.find_path_for_job(func.id(), &mut args).unwrap();
assert_eq!(
result,
"/users/42/files/{file}/reports/january.csv".to_string()
);
assert_eq!(
args,
PathArgs::from(vec![("id", "42"), ("path", "reports/january.csv"),])
);
}
#[test]
fn test_no_replacement_if_job_not_found() {
let func = |file: View<()>| file;
let domain = Domain::new();
let mut args = PathArgs::from(vec![("counter", "999")]);
let result = domain.find_path_for_job(func.id(), &mut args);
assert!(result.is_err());
}
#[test]
fn test_error_if_unmatched_placeholders_remain() {
let func = |file: View<()>| file;
let domain = Domain::new().job("/tasks/{task_id}/check", update(func));
let mut args = PathArgs::default(); let result = domain.find_path_for_job(func.id(), &mut args);
assert!(result.is_err());
}
#[test]
#[ignore]
fn test_finds_jobs_for_empty_paths() {
let func = |view: View<()>| view;
let domain = Domain::new()
.job("", update(func))
.job("/other", update(plus_two));
if let Some((_, mut jobs)) = domain.find_matching_jobs("") {
assert!(jobs.any(|j| j.id() == func.id()));
} else {
panic!("should find a job")
}
}
}