1use std::ops::RangeInclusive;
4use std::time::Duration;
5
6const INTERVALS: RangeInclusive<Duration> =
8 Duration::from_secs(1)..=Duration::from_hours(1000 * 365 * 24);
9
10pub const INTERVALS_ALLOWED: &str =
11 "a schedule runs at most once a second and at least once in 1000 years";
12
13#[derive(Clone, Copy, Debug)]
14pub enum Key<'a> {
15 Route {
17 method: &'a str,
18 path: Option<&'a str>,
19 name: Option<&'a str>,
20 },
21 Job {
22 name: &'a str,
23 every: Option<Duration>,
24 },
25}
26
27#[derive(Clone, Copy, Debug, PartialEq, Eq)]
28pub enum Problem {
29 SamePath(usize, usize),
30 SameRouteName(usize, usize),
31 SameJobName(usize, usize),
32 Interval(usize),
33}
34
35impl Problem {
36 #[must_use]
37 pub fn pair(self) -> Option<(usize, usize)> {
38 match self {
39 Self::SamePath(first, second)
40 | Self::SameRouteName(first, second)
41 | Self::SameJobName(first, second) => Some((first, second)),
42 Self::Interval(_) => None,
43 }
44 }
45}
46
47#[must_use]
48pub fn validate(keys: &[Key<'_>]) -> Vec<Problem> {
49 let mut problems: Vec<Problem> = keys
50 .iter()
51 .enumerate()
52 .filter(|(_, key)| runs_out_of_range(key))
53 .map(|(index, _)| Problem::Interval(index))
54 .collect();
55 let served: Vec<Option<String>> = keys.iter().map(served_at).collect();
56 let pairs = (0..keys.len()).flat_map(|second| (0..second).map(move |first| (first, second)));
57 for (first, second) in pairs {
58 match (keys[first], keys[second]) {
59 (
60 Key::Route { method, name, .. },
61 Key::Route {
62 method: other_method,
63 name: other_name,
64 ..
65 },
66 ) => {
67 if method == other_method
68 && routers_clash(served[first].as_deref(), served[second].as_deref())
69 {
70 problems.push(Problem::SamePath(first, second));
71 }
72 if name.is_some() && name == other_name {
73 problems.push(Problem::SameRouteName(first, second));
74 }
75 }
76 (Key::Job { name, .. }, Key::Job { name: other, .. }) if name == other => {
77 problems.push(Problem::SameJobName(first, second));
78 }
79 _ => {}
80 }
81 }
82 problems
83}
84
85fn runs_out_of_range(key: &Key<'_>) -> bool {
86 matches!(key, Key::Job { every: Some(every), .. } if !INTERVALS.contains(every))
87}
88
89fn served_at(key: &Key<'_>) -> Option<String> {
90 match key {
91 Key::Route { path, .. } => path.map(without_parameter_names),
92 Key::Job { .. } => None,
93 }
94}
95
96fn routers_clash(left: Option<&str>, right: Option<&str>) -> bool {
98 let (Some(left), Some(right)) = (left, right) else {
99 return false;
100 };
101 let common = left
102 .bytes()
103 .zip(right.bytes())
104 .take_while(|(left, right)| left == right)
105 .count();
106 left == right || left.as_bytes()[..common].ends_with(b"{")
107}
108
109fn without_parameter_names(path: &str) -> String {
110 path.split('/')
111 .map(|segment| match segment.strip_prefix('{') {
112 Some(parameter) if parameter.starts_with('*') => "{*}",
113 Some(_) => "{}",
114 None => segment,
115 })
116 .collect::<Vec<_>>()
117 .join("/")
118}
119
120#[cfg(test)]
121mod tests {
122 use std::time::Duration;
123
124 use super::{Key, Problem, validate};
125
126 fn route<'a>(method: &'a str, path: &'a str, name: &'a str) -> Key<'a> {
127 Key::Route {
128 method,
129 path: Some(path),
130 name: Some(name),
131 }
132 }
133
134 fn job(name: &str, every: Option<Duration>) -> Key<'_> {
135 Key::Job { name, every }
136 }
137
138 #[test]
139 fn distinct_routes_and_jobs_pass() {
140 let keys = [
141 route("GET", "/orders/{order}", "order.web.show"),
142 route("POST", "/orders/{order}", "order.web.cancel"),
143 route("GET", "/orders/new", "order.web.new"),
144 job("order.listeners.audit", None),
145 job("order.schedules.tidy", Some(Duration::from_mins(15))),
146 ];
147 assert_eq!(validate(&keys), []);
148 }
149
150 #[test]
151 fn paths_alike_but_for_parameter_names_clash() {
152 let keys = [
153 route("GET", "/account/orders/{order}", "order.customer.show"),
154 route("GET", "/account/orders/{id}", "order.web.show"),
155 ];
156 assert_eq!(validate(&keys), [Problem::SamePath(0, 1)]);
157 }
158
159 #[test]
160 fn a_parameter_and_a_catch_all_at_one_place_clash() {
161 let keys = [
162 route("GET", "/files/{file}", "storage.web.show"),
163 route("GET", "/files/{*rest}", "storage.web.serve"),
164 ];
165 assert_eq!(validate(&keys), [Problem::SamePath(0, 1)]);
166 }
167
168 #[test]
169 fn a_route_name_is_one_route_s() {
170 let keys = [
171 route("GET", "/orders", "order.web.list"),
172 route("GET", "/all-orders", "order.web.list"),
173 ];
174 assert_eq!(validate(&keys), [Problem::SameRouteName(0, 1)]);
175 }
176
177 #[test]
178 fn unnamed_routes_never_share_a_name() {
179 let unnamed = |path| Key::Route {
180 method: "GET",
181 path: Some(path),
182 name: None,
183 };
184 assert_eq!(validate(&[unnamed("/a"), unnamed("/b")]), []);
185 }
186
187 #[test]
188 fn a_route_whose_path_is_unknown_clashes_with_none() {
189 let unplaced = Key::Route {
190 method: "GET",
191 path: None,
192 name: Some("order.member.orders"),
193 };
194 let keys = [unplaced, route("GET", "/orders", "order.web.orders")];
195 assert_eq!(validate(&keys), []);
196 }
197
198 #[test]
199 fn a_job_name_is_one_job_s() {
200 let keys = [
201 job("tidy", Some(Duration::from_mins(1))),
202 route("GET", "/tidy", "tidy"),
203 job("tidy", None),
204 ];
205 assert_eq!(validate(&keys), [Problem::SameJobName(0, 2)]);
206 }
207
208 #[test]
209 fn a_schedule_runs_between_once_a_second_and_once_in_1000_years() {
210 let keys = [
211 job("eager", Some(Duration::from_millis(500))),
212 job("second", Some(Duration::from_secs(1))),
213 job("never", Some(Duration::from_hours(1001 * 365 * 24))),
214 ];
215 assert_eq!(
216 validate(&keys),
217 [Problem::Interval(0), Problem::Interval(2)]
218 );
219 }
220}