1use std::fs;
2use std::path::{Path, PathBuf};
3
4use syn::visit::Visit;
5use syn::{Expr, ExprMethodCall};
6
7use crate::load::rust_files;
8use crate::naming::screen_key;
9use crate::syntax::{parse_tokens, string_literal, tokenize};
10use crate::{Identity, KnownArea, ModelError};
11
12const SHIPPED_CALL: &str = "shipped";
13const SCREEN_CALL: &str = "screen";
14
15#[derive(Clone, Debug, PartialEq, Eq)]
18pub struct ShippedScreen {
19 pub handler: String,
20 pub name: String,
21 pub file: PathBuf,
22 pub source: String,
23}
24
25#[derive(Clone, Debug, PartialEq, Eq)]
27pub struct MountedScreen<'app> {
28 pub key: String,
29 pub screen: &'app ShippedScreen,
30}
31
32pub(crate) fn read_shipped(directory: &Path) -> Result<Vec<ShippedScreen>, ModelError> {
34 let mut screens = Vec::new();
35 for file in rust_files(&directory.join("src"))? {
36 let text = fs::read_to_string(&file).map_err(|error| {
37 ModelError::in_file(&file, format!("cannot read the file: {error}"))
38 })?;
39 if !text.contains(".shipped(") {
40 continue;
41 }
42 let syntax = parse_tokens(&file, tokenize(&file, &text)?)?;
43 let mut calls = ShippedCalls::default();
44 calls.visit_file(&syntax);
45 for (handler, name, template) in calls.found {
46 let template = file.parent().unwrap_or(directory).join(template);
47 let source = fs::read_to_string(&template).map_err(|error| {
48 ModelError::in_file(&template, format!("cannot read the template: {error}"))
49 })?;
50 screens.push(ShippedScreen {
51 handler,
52 name,
53 file: template,
54 source,
55 });
56 }
57 }
58 Ok(screens)
59}
60
61#[derive(Clone, Debug, PartialEq, Eq)]
64pub struct ScreenRoute {
65 pub screen: String,
66 pub handler: String,
67}
68
69pub(crate) fn read_screen_routes(directory: &Path) -> Result<Vec<ScreenRoute>, ModelError> {
72 let mut found = ScreenCalls::default();
73 for file in rust_files(&directory.join("src"))? {
74 let text = fs::read_to_string(&file).map_err(|error| {
75 ModelError::in_file(&file, format!("cannot read the file: {error}"))
76 })?;
77 if !text.contains(".screen(") {
78 continue;
79 }
80 found.visit_file(&parse_tokens(&file, tokenize(&file, &text)?)?);
81 }
82 Ok(found.routes)
83}
84
85#[derive(Default)]
86struct ScreenCalls {
87 routes: Vec<ScreenRoute>,
88}
89
90impl<'ast> Visit<'ast> for ScreenCalls {
91 fn visit_expr_method_call(&mut self, call: &'ast ExprMethodCall) {
92 if (call.method == "name" || call.method == "name_for")
93 && let Expr::MethodCall(screen) = call.receiver.as_ref()
94 && screen.method == SCREEN_CALL
95 && let Some(handler) = call.args.first().and_then(string_literal)
96 && let Some(name) = screen.args.iter().nth(1).and_then(string_literal)
97 {
98 self.routes.push(ScreenRoute {
99 screen: name,
100 handler,
101 });
102 }
103 syn::visit::visit_expr_method_call(self, call);
104 }
105}
106
107#[derive(Default)]
108struct ShippedCalls {
109 found: Vec<(String, String, String)>,
111}
112
113impl<'ast> Visit<'ast> for ShippedCalls {
114 fn visit_expr_method_call(&mut self, call: &'ast ExprMethodCall) {
115 if call.method == "name"
116 && let Expr::MethodCall(shipped) = call.receiver.as_ref()
117 && shipped.method == SHIPPED_CALL
118 && let Some(handler) = call.args.first().and_then(string_literal)
119 && let Some((name, template)) = shipped_arguments(shipped)
120 {
121 self.found.push((handler, name, template));
122 }
123 syn::visit::visit_expr_method_call(self, call);
124 }
125}
126
127fn shipped_arguments(call: &ExprMethodCall) -> Option<(String, String)> {
128 let [_, name, Expr::Tuple(template), _] = call.args.iter().collect::<Vec<_>>()[..] else {
129 return None;
130 };
131 let Some(Expr::Macro(included)) = template.elems.first() else {
132 return None;
133 };
134 if !included.mac.path.is_ident("include_str") {
135 return None;
136 }
137 let file = included.mac.parse_body::<syn::LitStr>().ok()?.value();
138 Some((string_literal(name)?, file))
139}
140
141impl Identity {
142 pub fn screen_routes(&self) -> Result<Vec<ScreenRoute>, ModelError> {
144 read_screen_routes(&self.directory)
145 }
146
147 #[must_use]
149 pub fn mounted_screens(&self, area: &KnownArea<'_>) -> Vec<MountedScreen<'_>> {
150 let area_type = area.type_path.rsplit("::").next().unwrap_or_default();
151 self.mounts
152 .iter()
153 .filter(|mount| mount.area_type == area_type)
154 .flat_map(|mount| {
155 self.routes
156 .iter()
157 .filter(|route| route.mount == mount.mount)
158 })
159 .filter_map(|route| self.shipped_by(&route.handler))
160 .map(|screen| MountedScreen {
161 key: screen_key(crate::identity::IDENTITY_CRATE, area.name, &screen.name),
162 screen,
163 })
164 .collect()
165 }
166
167 fn shipped_by(&self, handler: &str) -> Option<&ShippedScreen> {
168 self.shipped.iter().find(|screen| screen.handler == handler)
169 }
170}
171
172#[cfg(test)]
173mod tests {
174 use super::read_shipped;
175 use crate::testing::TemporaryDirectory;
176
177 #[test]
178 fn a_shipped_screen_is_read_with_its_handler_and_template() {
179 let directory = TemporaryDirectory::new("shipped");
180 directory.write("src/sessions/screen.html", "<p>Sessions</p>");
181 directory.write(
182 "src/sessions/screen.rs",
183 "pub fn routes(routes: &mut Routes<A>) {\n\
184 routes.shipped(\"/sessions\", \"sessions\", (include_str!(\"screen.html\"), None), \
185 own).name(\"list_sessions\");\n\
186 routes.post(\"/sessions/others\", end).name(\"end_other_sessions\");\n}\n",
187 );
188 directory.write("src/lib.rs", "pub mod sessions;\n");
189
190 let screens = read_shipped(directory.root()).unwrap();
191
192 assert_eq!(screens.len(), 1);
193 assert_eq!(screens[0].handler, "list_sessions");
194 assert_eq!(screens[0].name, "sessions");
195 assert_eq!(screens[0].source, "<p>Sessions</p>");
196 assert!(screens[0].file.ends_with("src/sessions/screen.html"));
197 }
198
199 #[test]
200 fn a_package_screen_is_read_with_the_route_rendering_it() {
201 let directory = TemporaryDirectory::new("screen-routes");
202 directory.write(
203 "src/lib.rs",
204 "pub fn routes<A: MemberArea>(routes: &mut Routes<Web>) {\n\
205 routes.screen(A::SIGN_IN, \"sign_in\", page::<A>).name_for::<A>(\"sign_in_page\");\n\
206 routes.screen(\"/pages/{slug}\", \"page\", show).name(\"page\").signed();\n\
207 routes.screen(&asking, ask_screen, ask).name_for::<A>(ask_name);\n}\n",
208 );
209
210 let routes = super::read_screen_routes(directory.root()).unwrap();
211
212 let found: Vec<(&str, &str)> = routes
213 .iter()
214 .map(|route| (route.screen.as_str(), route.handler.as_str()))
215 .collect();
216 assert_eq!(found, [("sign_in", "sign_in_page"), ("page", "page")]);
217 }
218
219 #[test]
220 fn sofie_identity_s_shipped_screens_are_found_by_their_handlers() {
221 let identity = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../sofie-identity");
222
223 let screens = read_shipped(&identity).unwrap();
224
225 let mut handlers: Vec<&str> = screens
226 .iter()
227 .map(|screen| screen.handler.as_str())
228 .collect();
229 handlers.sort_unstable();
230 assert_eq!(
231 handlers,
232 [
233 "account_data_page",
234 "list_blocks",
235 "list_connections",
236 "list_flags",
237 "list_impersonations",
238 "list_passkeys",
239 "list_sessions",
240 "list_settings",
241 "list_tokens",
242 "two_factor_page",
243 ]
244 );
245 }
246
247 #[test]
248 fn a_shipped_screen_is_mounted_in_the_area_the_app_adds_its_routes_to() {
249 use super::ShippedScreen;
250 use crate::testing::{app, define_area, module};
251 use crate::{Identity, ListedRoute, RouteMount};
252
253 let mut notes = module("notes", "/app/modules/notes");
254 define_area(&mut notes, "board");
255 let mut demo = app("/app", vec![notes]);
256 let route = |mount: &str, handler: &str| ListedRoute {
257 mount: mount.to_owned(),
258 method: "GET".to_owned(),
259 path: format!("/{handler}"),
260 handler: handler.to_owned(),
261 permission: None,
262 };
263 let shipped = |handler: &str, name: &str| ShippedScreen {
264 handler: handler.to_owned(),
265 name: name.to_owned(),
266 file: format!("{name}.html").into(),
267 source: String::new(),
268 };
269 demo.identity = Some(Identity {
270 routes: vec![
271 route("sessions::routes", "list_sessions"),
272 route("passkeys::routes", "list_passkeys"),
273 ],
274 mounts: vec![RouteMount {
275 mount: "sessions::routes".to_owned(),
276 area_type: "Member".to_owned(),
277 }],
278 shipped: vec![
279 shipped("list_sessions", "sessions"),
280 shipped("list_passkeys", "passkeys"),
281 ],
282 ..Identity::default()
283 });
284
285 let areas = demo.areas();
286 let identity = demo.identity.as_ref().unwrap();
287
288 let mounted = identity.mounted_screens(&areas[1]);
289
290 let keys: Vec<&str> = mounted.iter().map(|screen| screen.key.as_str()).collect();
291 assert_eq!(keys, ["sofie_identity/board/sessions"]);
292 assert_eq!(identity.mounted_screens(&areas[0]), []);
293 }
294}