1use std::collections::BTreeMap;
2
3use crate::naming::{RouteSite, snake_case};
4use crate::{Aggregate, App, AreaDefinition, KnownArea, Method, ModelError, Module, Route};
5
6const PERMITTED_PRINCIPAL: &str = "Permitted";
7
8const WHOLE_RECORD: &str = "*";
9const RECORD_ACTIONS: [&str; 4] = ["read", "create", "update", "delete"];
10const FIELD_ACTIONS: [&str; 2] = ["read", "update"];
11
12impl AreaDefinition {
13 #[must_use]
14 pub fn is_permitted(&self) -> bool {
15 self.principal
16 .last()
17 .is_some_and(|name| name == PERMITTED_PRINCIPAL)
18 }
19}
20
21impl KnownArea<'_> {
22 #[must_use]
23 pub fn is_permitted(&self) -> bool {
24 self.definition.is_some_and(AreaDefinition::is_permitted)
25 }
26}
27
28impl RouteSite<'_> {
29 #[must_use]
30 pub fn permission(&self, app: &App) -> Option<String> {
31 app.area(&self.area.area)
32 .is_some_and(|area| area.is_permitted())
33 .then(|| route_permission(self.module, self.route))
34 }
35}
36
37#[must_use]
38pub fn permission_constant(permission: &str) -> String {
39 let axes: Vec<&str> = permission
40 .split('.')
41 .skip(1)
42 .filter(|axis| *axis != WHOLE_RECORD)
43 .collect();
44 axes.join("_").to_ascii_uppercase()
45}
46
47pub fn refuse_colliding_constants(module: &Module) -> Result<(), ModelError> {
49 let mut constants: BTreeMap<String, String> = BTreeMap::new();
50 for permission in module_permissions(module) {
51 let constant = permission_constant(&permission);
52 if let Some(earlier) = constants.insert(constant.clone(), permission.clone()) {
53 return Err(ModelError::in_file(
54 &module.krate.path.join("src").join("lib.rs"),
55 format!(
56 "permissions `{earlier}` and `{permission}` both become the constant \
57 `{constant}`; rename the record or field of one"
58 ),
59 ));
60 }
61 }
62 Ok(())
63}
64
65#[must_use]
66pub fn module_permissions(module: &Module) -> Vec<String> {
67 let name = &module.krate.crate_name;
68 let mut permissions: Vec<String> = module
69 .code
70 .aggregates
71 .iter()
72 .flat_map(|aggregate| aggregate_permissions(name, aggregate))
73 .collect();
74 let routed = module.areas.iter().flat_map(|area| &area.routes);
75 permissions.extend(routed.map(|route| route_permission(module, route)));
76 permissions.sort();
77 permissions.dedup();
78 permissions
79}
80
81fn aggregate_permissions(module: &str, aggregate: &Aggregate) -> Vec<String> {
82 let model = snake_case(&aggregate.name);
83 let record = RECORD_ACTIONS
84 .iter()
85 .map(|action| format!("{module}.{model}.{WHOLE_RECORD}.{action}"));
86 let fields = aggregate.fields.iter().flat_map(|field| {
87 FIELD_ACTIONS
88 .iter()
89 .map(|action| format!("{module}.{model}.{}.{action}", field.name))
90 });
91 record.chain(fields).collect()
92}
93
94#[must_use]
96pub fn route_permission(module: &Module, route: &Route) -> String {
97 let handler = route.handler.last().map_or("", String::as_str);
98 let words: Vec<&str> = handler.split('_').filter(|word| !word.is_empty()).collect();
99 let (action, subject) = route_action(route.method, &words);
100 let models: Vec<String> = module
101 .code
102 .aggregates
103 .iter()
104 .map(|aggregate| snake_case(&aggregate.name))
105 .collect();
106 let model = subject_model(&models, subject, handler);
107 format!(
108 "{}.{model}.{WHOLE_RECORD}.{action}",
109 module.krate.crate_name
110 )
111}
112
113fn route_action<'a>(method: Method, words: &'a [&'a str]) -> (&'a str, &'a [&'a str]) {
117 match (method, words.split_first()) {
118 (Method::Get, Some((&"new", rest))) => ("create", rest),
119 (Method::Get, Some((&"edit", rest))) => ("update", rest),
120 (Method::Get, _) => ("read", words),
121 (_, None) => ("update", words),
122 (_, Some((&"read", rest))) => ("update", rest),
123 (_, Some((verb, rest))) => (verb, rest),
124 }
125}
126
127fn subject_model(models: &[String], words: &[&str], handler: &str) -> String {
129 (1..=words.len())
130 .rev()
131 .find_map(|length| {
132 words.windows(length).find_map(|run| {
133 let joined = run.join("_");
134 models
135 .iter()
136 .find(|model| {
137 **model == joined
138 || **model == singular_run(run)
139 || sofie_core::plural(model) == joined
140 })
141 .cloned()
142 })
143 })
144 .unwrap_or_else(|| singular(words.last().copied().unwrap_or(handler)))
145}
146
147fn singular_run(run: &[&str]) -> String {
148 let mut words: Vec<String> = run.iter().map(|word| (*word).to_owned()).collect();
149 if let Some(last) = words.last_mut() {
150 *last = singular(last);
151 }
152 words.join("_")
153}
154
155fn singular(word: &str) -> String {
157 if let Some(stem) = word.strip_suffix("ies") {
158 return format!("{stem}y");
159 }
160 if ["ss", "us", "is"]
161 .iter()
162 .any(|ending| word.ends_with(ending))
163 {
164 return word.to_owned();
165 }
166 match word.strip_suffix('s') {
167 Some(stem) if !stem.is_empty() => stem.to_owned(),
168 _ => word.to_owned(),
169 }
170}
171
172#[cfg(test)]
173mod tests {
174 use super::{permission_constant, route_action, singular, subject_model};
175 use crate::Method;
176
177 #[test]
178 fn a_permission_s_constant_names_its_model_field_and_action() {
179 assert_eq!(permission_constant("order.order.*.cancel"), "ORDER_CANCEL");
180 assert_eq!(
181 permission_constant("order.order_line.total.read"),
182 "ORDER_LINE_TOTAL_READ"
183 );
184 }
185
186 #[test]
187 fn a_form_page_needs_the_action_its_form_submits() {
188 assert_eq!(
189 route_action(Method::Get, &["new", "order"]),
190 ("create", &["order"][..])
191 );
192 assert_eq!(
193 route_action(Method::Get, &["edit", "order"]),
194 ("update", &["order"][..])
195 );
196 assert_eq!(route_action(Method::Get, &["show", "order"]).0, "read");
197 assert_eq!(
198 route_action(Method::Post, &["read", "notifications"]).0,
199 "update"
200 );
201 }
202
203 #[test]
204 fn plural_record_names_become_singular() {
205 assert_eq!(singular("orders"), "order");
206 assert_eq!(singular("categories"), "category");
207 assert_eq!(singular("order"), "order");
208 assert_eq!(singular("address"), "address");
209 assert_eq!(singular("status"), "status");
210 assert_eq!(singular("campus"), "campus");
211 }
212
213 #[test]
214 fn a_list_handler_names_its_record_by_the_record_s_plural() {
215 let models = ["opening_hours", "category", "status"].map(str::to_owned);
216 let model = |handler: &str| {
217 let words: Vec<&str> = handler.split('_').collect();
218 subject_model(&models, &words[1..], handler)
219 };
220
221 assert_eq!(model("list_opening_hours"), "opening_hours");
222 assert_eq!(model("list_categories"), "category");
223 assert_eq!(model("list_statuses"), "status");
224 assert_eq!(model("list_hourses"), "hourse");
225 }
226}