1use std::path::{Path, PathBuf};
2
3use sofie_check::{Screen, Screens};
4use sofie_engine::{Binding, Step};
5
6use sofie_model::naming::{RouteSite, file_stem, job_name, screen_key, snake_case, task_name};
7use sofie_model::{
8 Aggregate, App, AreaContribution, Code, Effects, EnumDeclaration, FeatureDeclaration,
9 FieldDeclaration, Function, InputDeclaration, Module, ReadModel, ServedRoute, TypeName,
10};
11use sofie_values::Seed;
12
13use crate::flows::{Flow, endpoint_flow};
14use crate::inputs::{Field, Kind, valid_object};
15
16use crate::routes_command::{feature, handler_path, modifiers, owner_scope, shown_path};
17use crate::tasks_command::task_arguments;
18
19mod packages;
20
21#[must_use]
22pub fn explain(app: &App, name: &str) -> Vec<String> {
23 let mut sections = Vec::new();
24 let sites = app.route_sites();
25 sections.extend(
26 sites
27 .iter()
28 .filter(|site| site.name() == name || handler_path(site) == name)
29 .map(|site| route(app, site)),
30 );
31 sections.extend(identity_route(app, name));
32 sections.extend(
33 app.package_routes()
34 .other_packages()
35 .iter()
36 .filter(|served| served.name == name)
37 .map(|served| package_route_lines(app, served).join("\n")),
38 );
39 sections.extend(packages::identity_screen(app, name));
40 sections.extend(crate::components::component(name, || {
41 sofie_model::framework_features(&app.root).ok()
42 }));
43 sections.extend(area(app, name, &sites));
44 if app.app.crate_name == name || app.app.package == name {
45 sections.push(app_section(app));
46 }
47 for module in &app.modules {
48 if module.krate.crate_name == name || module.krate.package == name {
49 sections.push(format!(
50 "{}\n{}",
51 module_section(app, module),
52 framework_features_line(app)
53 ));
54 }
55 sections.extend(event(module, name));
56 sections.extend(job(module, name));
57 sections.extend(task(module, name));
58 sections.extend(
59 module
60 .code
61 .read_models
62 .iter()
63 .filter(|model| model.name == name)
64 .map(|model| read_model(module, model, &sites)),
65 );
66 sections.extend(
67 module
68 .code
69 .aggregates
70 .iter()
71 .filter(|aggregate| aggregate.name == name)
72 .map(|aggregate| aggregate_section(module, aggregate)),
73 );
74 sections.extend(screen(module, name, &sites).map(|summary| {
75 let mut lines = vec![summary];
76 lines.extend(screen_data(app, module, name, &sites));
77 lines.join("\n")
78 }));
79 sections.extend(flag(module, name, &sites));
80 }
81 sections
82}
83
84fn route(app: &App, site: &RouteSite) -> String {
85 let route = site.route;
86 let mut lines = vec![
87 format!("route {}", site.name()),
88 format!(" area: {}", site.area.area),
89 format!(" {} {}", route.method.name(), shown_path(app, site)),
90 format!(" handler: {}", handler_path(site)),
91 format!(
92 " declared: {}:{}",
93 site.area.routes_file.display(),
94 route.line
95 ),
96 format!(
97 " permission: {}",
98 site.permission(app)
99 .unwrap_or_else(|| "none (its area admits without one)".to_owned())
100 ),
101 format!(" owner scope: {}", owner_scope(app, &site.area.area)),
102 ];
103 lines.extend(feature(site).map(|flag| format!(" feature: {flag}")));
104 let declared = modifiers(route);
105 if !declared.is_empty() {
106 lines.push(format!(" modifiers: {}", declared.join(" ")));
107 }
108 if let Some(function) = site.module.code.function(&route.handler) {
109 lines.push(format!(
110 " function: {}",
111 located(&function.file, function.line)
112 ));
113 lines.extend(answer(function));
114 lines.extend(arguments(function));
115 lines.extend(input(&site.module.code, function));
116 lines.extend(query(&site.module.code, function));
117 lines.extend(flow_lines(&endpoint_flow(app, site)));
118 }
119 for screen in shown_screens(site) {
120 lines.push(format!(
121 " screen: {}",
122 site.area.screen_file(&screen).display()
123 ));
124 }
125 lines.join("\n")
126}
127
128fn flow_lines(flow: &Flow) -> Vec<String> {
129 let edges = flow.edges.iter().map(|edge| {
130 format!(
131 " flow: {} {} {}",
132 edge.from.label(),
133 edge.kind.verb(),
134 edge.to.label()
135 )
136 });
137 let unresolved = flow.unresolved.iter().map(|unresolved| {
138 format!(
139 " flow unresolved: {}: {}",
140 located(&unresolved.file, unresolved.line),
141 unresolved.reason
142 )
143 });
144 edges.chain(unresolved).collect()
145}
146
147fn identity_route(app: &App, name: &str) -> Option<String> {
148 let all = match app.package_routes().identity() {
149 Ok(all) => all,
150 Err(error) => {
151 return Some(format!(
152 "sofie-identity's routes could not be read: {error}"
153 ));
154 }
155 };
156 let served = all.iter().find(|served| served.name == name)?;
157 let mut lines = package_route_lines(app, served);
158 let identity = app.identity.as_ref()?;
159 match identity.code() {
160 Ok(code) => lines.extend(identity_function_lines(&code, served, &all)),
161 Err(error) => lines.push(format!(
162 " sofie-identity's code could not be read: {error}"
163 )),
164 }
165 Some(lines.join("\n"))
166}
167
168fn identity_function_lines(code: &Code, served: &ServedRoute, all: &[ServedRoute]) -> Vec<String> {
169 let Some(function) = served.route.function(code) else {
170 return Vec::new();
171 };
172 let mut lines = vec![format!(
173 " function: {}",
174 located(&function.file, function.line)
175 )];
176 lines.extend(answer(function));
177 lines.extend(unit_answer(function));
178 lines.extend(taken_address(served).map(str::to_owned));
179 lines.extend(session_lines(code, served, all, function));
180 lines.extend(arguments(function));
181 if code.input_of(function).is_some() {
182 lines.extend(input(code, function));
183 }
184 lines
185}
186
187fn unit_answer(function: &Function) -> Option<String> {
189 let mut answered = function.output.as_ref()?;
190 while answered.name == "Result" {
191 answered = answered.arguments.first()?;
192 }
193 (answered.name == "()")
194 .then(|| " answers a JSON caller: 204 No Content, without a body".to_owned())
195}
196
197fn taken_address(served: &ServedRoute) -> Option<&'static str> {
199 (served.route.handler == "sign_up").then_some(
200 " a taken address: answered exactly like a new one, and nothing is created; sign up \
201 with a fresh address",
202 )
203}
204
205fn session_lines(
206 code: &Code,
207 served: &ServedRoute,
208 all: &[ServedRoute],
209 function: &Function,
210) -> Vec<String> {
211 let signs_in = reaches_session(code, &function.effects, "sign_in");
212 let signs_out = reaches_session(code, &function.effects, "sign_out");
213 let mut lines = vec![
214 if signs_in {
215 " signs in: yes, its handler reaches `Session::sign_in`"
216 } else {
217 " signs in: no, its handler never reaches `Session::sign_in`"
218 }
219 .to_owned(),
220 ];
221 if signs_out {
222 lines.push(" signs out: yes, its handler reaches `Session::sign_out`".to_owned());
223 }
224 if !signs_in && !signs_out && served.route.method == "POST" {
225 let sign_ins: Vec<String> = all
226 .iter()
227 .filter(|other| other.area == served.area && other.route.method == "POST")
228 .filter(|other| {
229 other
230 .route
231 .function(code)
232 .is_some_and(|signing| reaches_session(code, &signing.effects, "sign_in"))
233 })
234 .map(|other| format!("`{}` (POST {})", other.name, other.path))
235 .collect();
236 if !sign_ins.is_empty() {
237 lines.push(format!(" to sign in after it: {}", sign_ins.join(", ")));
238 }
239 }
240 lines
241}
242
243fn package_route_lines(app: &App, served: &ServedRoute) -> Vec<String> {
244 vec![
245 format!("route {}", served.name),
246 format!(" area: {}", served.area),
247 format!(" {} {}", served.route.method, served.path),
248 format!(
249 " handler: `{}`, added by {}::{}",
250 served.route.handler,
251 served.package(),
252 served.route.mount
253 ),
254 format!(
255 " permission: {}",
256 served
257 .route
258 .permission
259 .as_deref()
260 .unwrap_or("none (its area admits without one)")
261 ),
262 format!(" owner scope: {}", owner_scope(app, &served.area)),
263 ]
264}
265
266fn reaches_session(code: &Code, effects: &Effects, method: &str) -> bool {
269 let mut seen: Vec<&Effects> = Vec::new();
270 let mut pending = vec![effects];
271 while let Some(effects) = pending.pop() {
272 if seen.iter().any(|done| std::ptr::eq(*done, effects)) {
273 continue;
274 }
275 seen.push(effects);
276 let calls_session = effects
277 .method_calls
278 .iter()
279 .any(|call| call.method == method && call.receiver.as_deref() == Some("Session"));
280 if calls_session {
281 return true;
282 }
283 pending.extend(callees(code, effects));
284 }
285 false
286}
287
288fn callees<'code>(code: &'code Code, effects: &Effects) -> Vec<&'code Effects> {
289 let functions = effects
290 .calls
291 .iter()
292 .filter_map(|call| code.function(&call.path))
293 .map(|function| &function.effects);
294 let methods = code
295 .methods
296 .iter()
297 .filter(|method| {
298 let by_receiver = effects.method_calls.iter().any(|call| {
299 call.method == method.name && call.receiver.as_deref() == Some(&method.owner)
300 });
301 let by_path = effects.calls.iter().any(|call| {
302 call.path
303 .ends_with(&[method.owner.clone(), method.name.clone()])
304 });
305 by_receiver || by_path
306 })
307 .map(|method| &method.effects);
308 functions.chain(methods).collect()
309}
310
311fn answer(function: &Function) -> Option<String> {
312 let output = function.output.as_ref()?;
313 Some(format!(" answers: {output}"))
314}
315
316fn arguments(function: &Function) -> Option<String> {
317 if function.arguments.is_empty() {
318 return None;
319 }
320 let written: Vec<String> = function.arguments.iter().map(ToString::to_string).collect();
321 Some(format!(" arguments: {}", written.join(", ")))
322}
323
324fn input(code: &Code, function: &Function) -> Vec<String> {
325 let Some(input) = code.input_of(function) else {
326 return vec![lone_value_input(function)];
327 };
328 let mut lines = vec![format!(
329 " input: {} ({}), a JSON object of",
330 input.name,
331 located(&input.file, input.line)
332 )];
333 lines.extend(
334 input
335 .fields
336 .iter()
337 .map(|field| format!(" {}", input_field(field, &code.enums))),
338 );
339 lines.push(format!(" a body it takes: {}", example_body(code, input)));
340 lines
341}
342
343fn example_body(code: &Code, input: &InputDeclaration) -> serde_json::Value {
345 let fields: Vec<Field> = input
346 .fields
347 .iter()
348 .map(|field| Field::of(&field.name, &field.ty, &code.enums))
349 .collect();
350 serde_json::Value::Object(valid_object(&fields, &mut Seed::new(1)))
351}
352
353fn query(code: &Code, function: &Function) -> Vec<String> {
354 let Some(query) = code.query_of(function) else {
355 return Vec::new();
356 };
357 let mut lines = vec![format!(
358 " query: {} ({}), parameters of the query string",
359 query.name,
360 located(&query.file, query.line)
361 )];
362 lines.extend(
363 query
364 .fields
365 .iter()
366 .map(|field| format!(" {}", input_field(field, &code.enums))),
367 );
368 lines
369}
370
371fn lone_value_input(function: &Function) -> String {
373 let optional =
374 function
375 .arguments
376 .iter()
377 .find_map(|argument| match argument.arguments.as_slice() {
378 [inner] if argument.name == "Option" && inner.arguments.is_empty() => Some(inner),
379 _ => None,
380 });
381 match optional {
382 Some(value) => format!(
383 " input: no `Input` type; if `{value}` is a `#[derive(Value)]` type, it reads the \
384 optional field `{}` and refuses any other field as not accepted",
385 snake_case(&value.name)
386 ),
387 None => " input: no `Input` type; a `#[derive(Value)]` argument would read the field \
388 named after its type in snake_case"
389 .to_owned(),
390 }
391}
392
393fn input_field(field: &FieldDeclaration, enums: &[EnumDeclaration]) -> String {
394 let read = Field::of(&field.name, &field.ty, enums);
395 let ty = match field.ty.arguments.as_slice() {
396 [inner] if read.optional => inner,
397 _ => &field.ty,
398 };
399 let presence = if read.optional {
400 "optional"
401 } else {
402 "required"
403 };
404 let shape = json_shape(&read.kind)
405 .map(|shape| format!(", as {shape}"))
406 .unwrap_or_default();
407 format!("{}: {ty}, {presence}{shape}", field.name)
408}
409
410fn json_shape(kind: &Kind) -> Option<String> {
412 match kind {
413 Kind::Text | Kind::Password | Kind::Integer | Kind::Boolean => None,
414 Kind::Uuid => Some("a UUID string".to_owned()),
415 Kind::Enum(names) => Some(
416 names
417 .iter()
418 .map(|name| format!("\"{name}\""))
419 .collect::<Vec<_>>()
420 .join(" or "),
421 ),
422 Kind::Value(valued) => Some(valued.samples().0.join(" or ")),
423 Kind::List(item) => Some(format!(
424 "a list of {}",
425 json_shape(item).unwrap_or_else(|| "values".to_owned())
426 )),
427 }
428}
429
430fn shown_screens(site: &RouteSite) -> Vec<String> {
431 let mut named: Vec<&String> = site.route.screen.iter().collect();
432 if let Some(sofie_model::Then::Screen { screen, .. }) = &site.route.then {
433 named.push(screen);
434 }
435 if let Some(function) = site.module.code.function(&site.route.handler) {
436 named.extend(
437 function
438 .renders
439 .iter()
440 .filter_map(|render| render.screen.as_ref()),
441 );
442 }
443 crate::sorted::first_seen(named.into_iter().cloned())
444}
445
446fn area(app: &App, name: &str, sites: &[RouteSite]) -> Option<String> {
447 let known = app.area(name)?;
448 let mut lines = vec![format!("area {name}")];
449 match known.definition {
450 Some(definition) => {
451 lines.push(format!(" type: {}", known.type_path));
452 lines.push(format!(
453 " defined: {}",
454 located(&definition.file, definition.line)
455 ));
456 lines.push(format!(" principal: {}", definition.principal.join("::")));
457 }
458 None => lines.push(" defined: by the framework".to_owned()),
459 }
460 lines.push(format!(
461 " prefix: {}",
462 known.prefix().unwrap_or("(not a literal)")
463 ));
464 lines.push(format!(" owner scope: {}", owner_scope(app, name)));
465 lines.extend(
466 sites
467 .iter()
468 .filter(|site| site.area.area == name)
469 .map(|site| format!(" route: {}", site.name())),
470 );
471 Some(lines.join("\n"))
472}
473
474pub(crate) fn app_section(app: &App) -> String {
475 let mut lines = vec![
476 format!("app {}", app.app.package),
477 format!(" path: {}", app.app.path.display()),
478 ];
479 lines.extend(
480 crate::sorted::modules(app)
481 .into_iter()
482 .map(|module| format!(" module: {}", module.krate.package)),
483 );
484 lines.push(framework_features_line(app));
485 lines.join("\n")
486}
487
488fn framework_features_line(app: &App) -> String {
490 match sofie_model::framework_features(&app.root) {
491 Ok(features) if features.is_empty() => " sofie features: none".to_owned(),
492 Ok(features) => format!(" sofie features: {}", features.join(", ")),
493 Err(error) => format!(" sofie features could not be read: {error}"),
494 }
495}
496
497pub(crate) fn module_section(app: &App, module: &Module) -> String {
498 let mut lines = vec![
499 format!("module {}", module.krate.package),
500 format!(" path: {}", module.krate.path.display()),
501 ];
502 lines.extend(
503 module
504 .manifest
505 .iter()
506 .map(|manifest| format!(" tier: {:?}", manifest.tier)),
507 );
508 let code = &module.code;
509 let entries = module
510 .areas
511 .iter()
512 .map(|area| ("area", area.area.clone()))
513 .chain(module.events.iter().map(|event| ("event", event.clone())))
514 .chain(
515 code.aggregates
516 .iter()
517 .map(|aggregate| ("aggregate", aggregate.name.clone())),
518 )
519 .chain(
520 code.read_models
521 .iter()
522 .map(|model| ("read model", model.name.clone())),
523 )
524 .chain(job_functions(module).map(|function| ("job", job_name(module, function))))
525 .chain(
526 module
527 .tasks
528 .iter()
529 .map(|task| ("task", task_name(module, &task.function))),
530 );
531 lines.extend(entries.map(|(kind, name)| format!(" {kind}: {name}")));
532 let routes = app
533 .route_sites()
534 .into_iter()
535 .filter(|site| std::ptr::eq(site.module, module))
536 .count();
537 lines.push(format!(" routes: {routes}"));
538 lines.join("\n")
539}
540
541fn event(module: &Module, name: &str) -> Option<String> {
542 let event = module.events.iter().find(|event| *event == name)?;
543 let mut lines = vec![
544 format!("event {event}"),
545 format!(" module: {}", module.krate.package),
546 ];
547 lines.extend(
548 module
549 .listeners
550 .iter()
551 .filter(|listener| listener.event.last() == Some(event))
552 .map(|listener| {
553 format!(
554 " heard by: {} at {}",
555 job_name(module, &listener.function),
556 located(&listener.file, listener.line)
557 )
558 }),
559 );
560 Some(lines.join("\n"))
561}
562
563fn job(module: &Module, name: &str) -> Option<String> {
564 if let Some(listener) = module
565 .listeners
566 .iter()
567 .find(|listener| job_name(module, &listener.function) == name)
568 {
569 return Some(format!(
570 "job {name}\n listens to: {}\n declared: {}",
571 listener.event.join("::"),
572 located(&listener.file, listener.line)
573 ));
574 }
575 if let Some(schedule) = module
576 .schedules
577 .iter()
578 .find(|schedule| job_name(module, &schedule.function) == name)
579 {
580 return Some(format!(
581 "job {name}\n runs: {}\n declared: {}",
582 schedule.cadence,
583 located(&schedule.file, schedule.line)
584 ));
585 }
586 let queued = module
587 .jobs
588 .iter()
589 .find(|job| job_name(module, &job.function) == name)?;
590 Some(format!(
591 "job {name}\n queued by: `Jobs::queue` or `routes.queue`\n declared: {}",
592 located(&queued.file, queued.line)
593 ))
594}
595
596fn task(module: &Module, name: &str) -> Option<String> {
597 let task = module
598 .tasks
599 .iter()
600 .find(|task| task_name(module, &task.function) == name)?;
601 let mut lines = vec![
602 format!("task {name}"),
603 format!(
604 " function: {}::{}",
605 module.krate.crate_name,
606 task.function.join("::")
607 ),
608 format!(" declared: {}", located(&task.file, task.line)),
609 format!(" run: <app> task {name} {}", task_arguments(module, task))
610 .trim_end()
611 .to_owned(),
612 ];
613 lines.extend(
614 module
615 .code
616 .function(&task.function)
617 .and_then(|function| function.doc.as_ref())
618 .map(|doc| format!(" description: {doc}")),
619 );
620 Some(lines.join("\n"))
621}
622
623pub(crate) fn read_model(module: &Module, model: &ReadModel, sites: &[RouteSite]) -> String {
624 let mut lines = vec![
625 format!("read model {}", model.name),
626 format!(" module: {}", module.krate.package),
627 format!(" declared: {}", located(&model.file, model.line)),
628 ];
629 lines.extend(model.from.iter().map(|from| format!(" from: {from}")));
630 lines.extend(
631 model
632 .fields
633 .iter()
634 .map(|field| format!(" field {}: {}", field.name, field.ty)),
635 );
636 for function in module.code.functions.iter().filter(|function| {
637 function
638 .output
639 .as_ref()
640 .is_some_and(|output| mentions(output, &model.name))
641 }) {
642 lines.push(format!(" answered by: {}", function.path.join("::")));
643 lines.extend(
644 sites
645 .iter()
646 .filter(|site| {
647 std::ptr::eq(site.module, module) && site.route.handler == function.path
648 })
649 .map(|site| format!(" route: {}", site.name())),
650 );
651 }
652 lines.join("\n")
653}
654
655pub(crate) fn aggregate_section(module: &Module, aggregate: &Aggregate) -> String {
656 let mut lines = vec![
657 format!("aggregate {}", aggregate.name),
658 format!(" module: {}", module.krate.package),
659 format!(" declared: {}", located(&aggregate.file, aggregate.line)),
660 ];
661 lines.extend(
662 aggregate
663 .fields
664 .iter()
665 .map(|field| format!(" field {}: {}", field.name, field.ty)),
666 );
667 if !aggregate.behaviors.is_empty() {
668 lines.push(format!(" behaviors: {}", aggregate.behaviors.join(", ")));
669 }
670 lines.push(format!(" columns: {}", aggregate.columns().join(", ")));
671 lines.extend(
672 module
673 .code
674 .read_models
675 .iter()
676 .filter(|model| model.from.as_deref() == Some(aggregate.name.as_str()))
677 .map(|model| format!(" read model: {}", model.name)),
678 );
679 lines.join("\n")
680}
681
682pub(crate) fn screen(module: &Module, name: &str, sites: &[RouteSite]) -> Option<String> {
683 let (area, file) = screen_file(module, name)?;
684 let stem = file_stem(file);
685 let mut lines = vec![
686 format!("screen {name}"),
687 format!(" template: {}", file.display()),
688 ];
689 lines.extend(
690 showing_sites(area, &stem, sites).map(|site| format!(" shown by: {}", site.name())),
691 );
692 Some(lines.join("\n"))
693}
694
695pub(crate) fn screen_file<'module>(
696 module: &'module Module,
697 name: &str,
698) -> Option<(&'module AreaContribution, &'module PathBuf)> {
699 module.areas.iter().find_map(|area| {
700 area.screens.iter().find_map(|file| {
701 (screen_key(&module.krate.crate_name, &area.area, &file_stem(file)) == name)
702 .then_some((area, file))
703 })
704 })
705}
706
707fn showing_sites<'sites, 'app>(
708 area: &'app AreaContribution,
709 stem: &'sites str,
710 sites: &'sites [RouteSite<'app>],
711) -> impl Iterator<Item = &'sites RouteSite<'app>> {
712 sites.iter().filter(move |site| {
713 std::ptr::eq(site.area, area) && shown_screens(site).iter().any(|shown| shown == stem)
714 })
715}
716
717fn screen_data(app: &App, module: &Module, name: &str, sites: &[RouteSite]) -> Vec<String> {
719 let Some((area, file)) = screen_file(module, name) else {
720 return Vec::new();
721 };
722 let mut lines = Vec::new();
723 for site in showing_sites(area, &file_stem(file), sites) {
724 let Some(function) = module.code.function(&site.route.handler) else {
725 continue;
726 };
727 let Some(output) = function.output.as_ref().map(TypeName::without_result) else {
728 continue;
729 };
730 lines.push(format!(
731 " data from `{}` ({}): {output}",
732 function.path.join("::"),
733 site.name()
734 ));
735 let model = std::iter::once(output)
736 .chain(&output.arguments)
737 .find_map(|candidate| module.code.read_model(&candidate.name));
738 lines.extend(model.into_iter().flat_map(|model| {
739 model
740 .fields
741 .iter()
742 .map(|field| format!(" {}: {}", field.name, field.ty))
743 }));
744 }
745 let screens = Screens::read(&app.modules);
746 match screens.screen(file) {
747 Ok(screen) => {
748 lines.extend(binding_lines(screen));
749 lines.extend(post_lines(app, screen));
750 lines.extend(markup_lines(&screen.source, file));
751 }
752 Err(error) => lines.push(format!(" template unreadable: {error}")),
753 }
754 lines
755}
756
757fn binding_lines(screen: &Screen) -> Option<String> {
758 let bindings = match &screen.bindings {
759 Ok(bindings) => bindings,
760 Err(error) => return Some(format!(" template error: {error}")),
761 };
762 let paths = crate::sorted::first_seen(bindings.iter().map(binding_path));
763 (!paths.is_empty()).then(|| format!(" binds: {}", paths.join(", ")))
764}
765
766fn post_lines(app: &App, screen: &Screen) -> Vec<String> {
768 let mut lines = Vec::new();
769 if let Ok(template) = &screen.template {
770 for form in template.form_posts() {
771 lines.extend(posted_route_lines(
772 app,
773 &format!("form `{}`", form.id),
774 &form.post,
775 ));
776 }
777 }
778 if let Ok(actions) = &screen.actions {
779 for action in actions {
780 let mut label = format!("action on line {}", action.line);
781 if !action.fields.is_empty() {
782 label = format!("{label} with {}", action.fields.join(", "));
783 }
784 lines.extend(posted_route_lines(app, &label, &action.post));
785 }
786 }
787 lines
788}
789
790fn posted_route_lines(app: &App, label: &str, post: &str) -> Vec<String> {
791 let Some(site) = app.posted_route(post) else {
792 return vec![package_post_line(app, label, post)];
793 };
794 let mut lines = vec![format!(
795 " {label} posts route {} ({} {})",
796 site.name(),
797 site.route.method.name(),
798 shown_path(app, &site)
799 )];
800 let code = &site.module.code;
801 if let Some(function) = code.function(&site.route.handler) {
802 lines.extend(
803 input(code, function)
804 .into_iter()
805 .map(|line| format!(" {line}")),
806 );
807 }
808 lines
809}
810
811fn package_post_line(app: &App, label: &str, post: &str) -> String {
812 let routes = app.package_routes();
813 let mut served = match routes.identity() {
814 Ok(served) => served,
815 Err(error) => {
816 return format!(
817 " {label} posts {post}; sofie-identity's routes could not be read: {error}"
818 );
819 }
820 };
821 served.extend(routes.other_packages());
822 match served.iter().find(|route| route.takes_post(post)) {
823 Some(route) => format!(
824 " {label} posts package route {} (POST {}); `explain {}` names its input",
825 route.name, route.path, route.name
826 ),
827 None => format!(" {label} posts {post}, which no route serves"),
828 }
829}
830
831const SHOWN_MARKUP_LINES: usize = 200;
832
833fn markup_lines(source: &str, file: &Path) -> Vec<String> {
835 let total = source.lines().count();
836 let mut lines = vec![
837 format!(" markup ({total} lines; copy `edit_screen`'s `find` from it exactly):"),
838 "```html".to_owned(),
839 ];
840 lines.extend(source.lines().take(SHOWN_MARKUP_LINES).map(str::to_owned));
841 lines.push("```".to_owned());
842 if total > SHOWN_MARKUP_LINES {
843 lines.push(format!(
844 " … {} more lines in {}",
845 total - SHOWN_MARKUP_LINES,
846 file.display()
847 ));
848 }
849 lines
850}
851
852fn binding_path(binding: &Binding) -> String {
853 let mut path = "data".to_owned();
854 for step in &binding.steps {
855 match step {
856 Step::Field(field) => {
857 path.push('.');
858 path.push_str(field);
859 }
860 Step::Item => path.push_str("[]"),
861 }
862 }
863 path
864}
865
866fn flag(module: &Module, name: &str, sites: &[RouteSite]) -> Option<String> {
867 let declaration = module
868 .code
869 .features
870 .declarations
871 .iter()
872 .find(|declaration| declaration.name.as_deref() == Some(name))?;
873 let gated =
874 |found: Option<&FeatureDeclaration>| found.is_some_and(|found| found == declaration);
875 let mut lines = vec![
876 format!("flag {name}"),
877 format!(
878 " declared: {}",
879 located(&declaration.file, declaration.line)
880 ),
881 format!(
882 " retire after: {} days from {}",
883 declaration.retire_after_days,
884 declaration.added.as_deref().unwrap_or("an undeclared day")
885 ),
886 ];
887 lines.extend(
888 sites
889 .iter()
890 .filter(|site| {
891 std::ptr::eq(site.module, module) && gated(module.route_feature(site.route))
892 })
893 .map(|site| format!(" hides route: {}", site.name())),
894 );
895 lines.extend(
896 job_functions(module)
897 .filter(|function| gated(module.code.features.gate_on(function)))
898 .map(|function| format!(" skips job: {}", job_name(module, function))),
899 );
900 Some(lines.join("\n"))
901}
902
903fn job_functions(module: &Module) -> impl Iterator<Item = &Vec<String>> {
904 let listeners = module.listeners.iter().map(|listener| &listener.function);
905 listeners
906 .chain(module.schedules.iter().map(|schedule| &schedule.function))
907 .chain(module.jobs.iter().map(|job| &job.function))
908}
909
910fn mentions(ty: &TypeName, name: &str) -> bool {
911 ty.name == name || ty.arguments.iter().any(|argument| mentions(argument, name))
912}
913
914fn located(file: &Path, line: usize) -> String {
915 format!("{}:{line}", file.display())
916}
917
918#[cfg(test)]
919mod tests {
920 use std::path::PathBuf;
921
922 use sofie_model::testing::{
923 app, area, define_area, enumeration, field, function, method, module, route, type_name,
924 };
925 use sofie_model::{
926 Call, Code, FeatureDeclaration, FeatureGate, InputDeclaration, ListedRoute, Listener,
927 Method, MethodCall, ScreenRender, ServedRoute,
928 };
929
930 use super::{explain, identity_function_lines};
931
932 #[test]
933 fn a_task_names_its_function_place_and_how_to_run_it() {
934 let mut order = module("order", "shop/modules/order");
935 order.tasks = vec![sofie_model::Task {
936 file: PathBuf::from("shop/modules/order/src/tasks.rs"),
937 line: 4,
938 function: vec!["tasks".to_owned(), "reindex".to_owned()],
939 }];
940 let shop = app("shop", vec![order]);
941
942 assert_eq!(
943 explain(&shop, "order.reindex"),
944 [
945 "task order.reindex\n function: order::tasks::reindex\n declared: \
946 shop/modules/order/src/tasks.rs:4\n run: <app> task order.reindex"
947 ]
948 );
949 assert!(explain(&shop, "order")[0].contains("\n task: order.reindex\n"));
950 }
951
952 #[test]
953 fn a_queued_job_is_listed_explained_and_skipped_by_its_flag() {
954 let mut order = module("order", "shop/modules/order");
955 order.jobs = vec![sofie_model::Job {
956 file: PathBuf::from("shop/modules/order/src/jobs.rs"),
957 line: 6,
958 function: vec!["jobs".to_owned(), "send_receipt".to_owned()],
959 }];
960 order.code.features.declarations = vec![FeatureDeclaration {
961 constant: vec!["module".to_owned(), "RECEIPTS".to_owned()],
962 name: Some("order.receipts".to_owned()),
963 added: Some("2026-09-01".to_owned()),
964 retire_after_days: 30,
965 variants: false,
966 file: PathBuf::from("shop/modules/order/src/module.rs"),
967 line: 5,
968 }];
969 order.code.features.gates = vec![FeatureGate {
970 function: vec!["jobs".to_owned(), "send_receipt".to_owned()],
971 feature: vec!["RECEIPTS".to_owned()],
972 file: PathBuf::from("shop/modules/order/src/jobs.rs"),
973 line: 5,
974 }];
975 let shop = app("shop", vec![order]);
976
977 assert!(explain(&shop, "order")[0].contains("\n job: order.jobs.send_receipt\n"));
978 assert_eq!(
979 explain(&shop, "order.jobs.send_receipt"),
980 [
981 "job order.jobs.send_receipt\n queued by: `Jobs::queue` or `routes.queue`\n \
982 declared: shop/modules/order/src/jobs.rs:6"
983 ]
984 );
985 assert!(
986 explain(&shop, "order.receipts")[0].ends_with("\n skips job: order.jobs.send_receipt")
987 );
988 }
989
990 #[test]
991 fn an_identity_route_states_whether_it_signs_in_and_how_to_after_it() {
992 let file = "identity/src/actions.rs";
993 let mut sign_up = function("actions::sign_up", file, 3);
994 sign_up.output = Some(type_name("Result<Registration>"));
995 let mut sign_in = function("actions::sign_in", file, 9);
996 sign_in.effects.method_calls = vec![method_call(Some("Door"), "enter")];
997 let mut enter = method("Door", "enter", file, 20);
998 enter.effects.calls = vec![Call {
999 path: vec!["signing_in".to_owned(), "start".to_owned()],
1000 line: 21,
1001 }];
1002 let mut start = function("signing_in::start", file, 30);
1003 start.effects.method_calls = vec![method_call(Some("Session"), "sign_in")];
1004 let mut sign_out = function("actions::sign_out", file, 40);
1005 sign_out.output = Some(type_name("Result<()>"));
1006 sign_out.effects.method_calls = vec![method_call(Some("Session"), "sign_out")];
1007 let code = Code {
1008 functions: vec![sign_up, sign_in, start, sign_out],
1009 methods: vec![enter],
1010 ..Code::default()
1011 };
1012 let routes = [
1013 ("sign_up", "/sign-up"),
1014 ("sign_in", "/sign-in"),
1015 ("sign_out", "/sign-out"),
1016 ]
1017 .map(|(handler, path)| ListedRoute {
1018 mount: "routes".to_owned(),
1019 method: "POST".to_owned(),
1020 path: path.to_owned(),
1021 handler: handler.to_owned(),
1022 permission: None,
1023 });
1024 let served: Vec<ServedRoute> = routes
1025 .iter()
1026 .map(|route| ServedRoute {
1027 area: "web".to_owned(),
1028 path: route.path.clone(),
1029 name: format!("sofie_identity.web.{}", route.handler),
1030 route,
1031 })
1032 .collect();
1033 let explained =
1034 |index: usize| identity_function_lines(&code, &served[index], &served).join("\n");
1035
1036 assert!(
1037 explained(0).contains(
1038 " a taken address: answered exactly like a new one, and nothing is created; \
1039 sign up with a fresh address\n \
1040 signs in: no, its handler never reaches `Session::sign_in`\n \
1041 to sign in after it: `sofie_identity.web.sign_in` (POST /sign-in)"
1042 ),
1043 "{}",
1044 explained(0)
1045 );
1046 assert!(
1047 explained(1).contains(" signs in: yes, its handler reaches `Session::sign_in`"),
1048 "{}",
1049 explained(1)
1050 );
1051 assert!(
1052 explained(2).contains(
1053 " answers a JSON caller: 204 No Content, without a body\n \
1054 signs in: no, its handler never reaches `Session::sign_in`\n \
1055 signs out: yes, its handler reaches `Session::sign_out`"
1056 ),
1057 "{}",
1058 explained(2)
1059 );
1060 assert!(
1061 !explained(1).contains("a taken address"),
1062 "{}",
1063 explained(1)
1064 );
1065 assert!(
1066 !explained(2).contains("to sign in after it"),
1067 "{}",
1068 explained(2)
1069 );
1070 }
1071
1072 #[test]
1073 fn a_sofie_identity_screen_names_its_package_template_and_routes() {
1074 let directory = sofie_model::testing::TemporaryDirectory::new("sofie-explain-identity");
1075 directory.write(
1076 "identity/src/lib.rs",
1077 "pub fn routes<A: MemberArea>(routes: &mut Routes<Web>) {\n \
1078 routes.screen(A::SIGN_IN, \"sign_in\", page::<A>).name_for::<A>(\"sign_in_page\");\n}\n",
1079 );
1080 directory.write(
1081 "shop/modules/order/contract/src/area.rs",
1082 "impl Area for Member {\n const SIGN_IN: &'static str = \"/sign-in\";\n}\n",
1083 );
1084 let mut order = module("order", directory.path("shop/modules/order"));
1085 define_area(&mut order, "customer");
1086 let mut shop = app(directory.path("shop"), vec![order]);
1087 let sign_in_page = ListedRoute {
1088 mount: "routes".to_owned(),
1089 method: "GET".to_owned(),
1090 path: "{sign_in}".to_owned(),
1091 handler: "sign_in_page".to_owned(),
1092 permission: None,
1093 };
1094 shop.identity = Some(sofie_model::Identity {
1095 directory: directory.path("identity"),
1096 screens: vec!["sofie_identity/web/sign_in".to_owned()],
1097 routes: vec![sign_in_page],
1098 ..sofie_model::Identity::default()
1099 });
1100
1101 let explained = explain(&shop, "sofie_identity/web/sign_in").join("\n");
1102
1103 assert_eq!(
1104 explained,
1105 format!(
1106 "screen sofie_identity/web/sign_in\n package: sofie-identity, area web\n \
1107 template: {}\n \
1108 shown by: route sofie_identity.customer.sign_in_page (GET /sign-in)",
1109 directory
1110 .path("identity/src/screens/sign_in.html")
1111 .display()
1112 )
1113 );
1114 }
1115
1116 fn method_call(receiver: Option<&str>, method: &str) -> MethodCall {
1117 MethodCall {
1118 receiver: receiver.map(str::to_owned),
1119 method: method.to_owned(),
1120 line: 1,
1121 }
1122 }
1123
1124 #[test]
1125 fn the_app_and_a_module_state_sofie_features() {
1126 let shop = app("shop", vec![module("order", "shop/modules/order")]);
1127
1128 let explained = explain(&shop, "shop").join("\n");
1129 let order = explain(&shop, "order").join("\n");
1130
1131 assert!(explained.starts_with("app shop\n"), "{explained}");
1132 assert!(explained.contains("\n module: order\n"), "{explained}");
1133 assert!(explained.contains("\n sofie features"), "{explained}");
1134 assert!(order.contains("\n sofie features"), "{order}");
1135 }
1136
1137 #[test]
1138 fn a_route_names_its_permission_and_flag_and_a_flag_what_it_gates() {
1139 let mut order = module("order", "shop/modules/order");
1140 let cancel = route(
1141 Method::Post,
1142 "/orders/{order}/cancel",
1143 "actions::cancel_order",
1144 4,
1145 );
1146 order.areas.push(area(&order, "console", vec![cancel]));
1147 define_area(&mut order, "console");
1148 order.defined_areas[0].principal = vec!["Permitted".to_owned()];
1149 order.listeners = vec![Listener {
1150 file: PathBuf::from("shop/modules/order/src/listeners.rs"),
1151 line: 3,
1152 function: vec!["listeners".to_owned(), "audit".to_owned()],
1153 event: vec!["OrderCancelled".to_owned()],
1154 }];
1155 order.code.features.declarations = vec![FeatureDeclaration {
1156 constant: vec!["module".to_owned(), "SPLIT".to_owned()],
1157 name: Some("order.split".to_owned()),
1158 added: Some("2026-09-01".to_owned()),
1159 retire_after_days: 30,
1160 variants: false,
1161 file: PathBuf::from("shop/modules/order/src/module.rs"),
1162 line: 5,
1163 }];
1164 order.code.features.gates = ["actions::cancel_order", "listeners::audit"]
1165 .map(|function| FeatureGate {
1166 function: function.split("::").map(str::to_owned).collect(),
1167 feature: vec!["SPLIT".to_owned()],
1168 file: PathBuf::from("shop/modules/order/src/actions.rs"),
1169 line: 2,
1170 })
1171 .to_vec();
1172 let shop = app("shop", vec![order]);
1173
1174 let route = explain(&shop, "order.console.cancel_order").join("\n");
1175 let flag = explain(&shop, "order.split").join("\n");
1176
1177 assert!(
1178 route.ends_with(
1179 " permission: order.order.*.cancel\n owner scope: any\n feature: order.split"
1180 ),
1181 "{route}"
1182 );
1183 assert_eq!(
1184 flag,
1185 "flag order.split\n declared: shop/modules/order/src/module.rs:5\n \
1186 retire after: 30 days from 2026-09-01\n hides route: order.console.cancel_order\n \
1187 skips job: order.listeners.audit"
1188 );
1189 }
1190
1191 #[test]
1192 fn a_route_states_the_fields_its_input_takes_and_their_json_shape() {
1193 let mut order = module("order", "shop/modules/order");
1194 let actions = "shop/modules/order/src/actions.rs";
1195 let routes = vec![
1196 route(Method::Post, "/orders", "actions::place_order", 3),
1197 route(
1198 Method::Post,
1199 "/orders/{order}/cancel",
1200 "actions::cancel_order",
1201 4,
1202 ),
1203 ];
1204 order.areas.push(area(&order, "web", routes));
1205 let mut place = function("actions::place_order", actions, 12);
1206 place.arguments = vec![type_name("Principal<Customer>"), type_name("PlaceOrder")];
1207 let mut cancel = function("actions::cancel_order", actions, 20);
1208 cancel.arguments = vec![type_name("Order"), type_name("Option<CancelReason>")];
1209 order.code.functions = vec![place, cancel];
1210 order.code.enums = vec![enumeration("Speed", &[("Standard", &[]), ("Express", &[])])];
1211 order.code.inputs = vec![InputDeclaration {
1212 name: "PlaceOrder".to_owned(),
1213 path: vec!["actions".to_owned(), "PlaceOrder".to_owned()],
1214 from: None,
1215 fields: vec![
1216 field("total", "Money", 8),
1217 field("note", "Option<String>", 9),
1218 field("speed", "Speed", 10),
1219 field("items", "Vec<Id<Product>>", 11),
1220 field("at", "LocalDateTime", 12),
1221 ],
1222 unconstrained: Vec::new(),
1223 file: PathBuf::from(actions),
1224 line: 7,
1225 }];
1226 let shop = app("shop", vec![order]);
1227
1228 let place = explain(&shop, "order.web.place_order").join("\n");
1229 let cancel = explain(&shop, "order.web.cancel_order").join("\n");
1230
1231 assert!(
1232 place.ends_with(
1233 " function: shop/modules/order/src/actions.rs:12\n \
1234 arguments: Principal<Customer>, PlaceOrder\n \
1235 input: PlaceOrder (shop/modules/order/src/actions.rs:7), a JSON object of\n \
1236 total: Money, required, as {\"minor\":14990,\"currency\":\"EUR\"} or \
1237 {\"amount\":\"12.50\",\"currency\":\"USD\"}\n \
1238 note: String, optional\n \
1239 speed: Speed, required, as \"STANDARD\" or \"EXPRESS\"\n \
1240 items: Vec<Id<Product>>, required, as a list of a UUID string\n \
1241 at: LocalDateTime, required, as \
1242 {\"local\":\"2026-10-11T09:30:00\",\"zone\":\"Europe/Istanbul\"} or \
1243 {\"local\":\"2026-10-11T09:30\",\"zone\":\"Europe/Istanbul\"}\n \
1244 a body it takes: {\"at\":{\"local\":\"2026-09-07T13:30:00\",\"zone\":\
1245 \"Asia/Tokyo\"},\"items\":[\"e099ec6c-d736-4ca5-85e7-bb0f12278575\",\
1246 \"491718de-357e-4da8-8b43-5c8e74616796\",\
1247 \"6775dc77-0156-4f61-9afc-d44d14cf8bfe\"],\"note\":\"orchard linen\",\
1248 \"speed\":\"EXPRESS\",\"total\":{\"currency\":\"USD\",\"minor\":22319}}"
1249 ),
1250 "{place}"
1251 );
1252 assert!(
1253 cancel.ends_with(
1254 " arguments: Order, Option<CancelReason>\n \
1255 input: no `Input` type; if `CancelReason` is a `#[derive(Value)]` type, it \
1256 reads the optional field `cancel_reason` and refuses any other field as not \
1257 accepted"
1258 ),
1259 "{cancel}"
1260 );
1261 }
1262
1263 #[test]
1264 fn a_route_lists_its_flow() {
1265 let mut order = module("order", "shop/modules/order");
1266 let cancel = route(Method::Post, "/orders/{order}/cancel", "actions::cancel", 4);
1267 order.areas.push(area(&order, "web", vec![cancel]));
1268 let file = "shop/modules/order/src/actions.rs";
1269 let mut handler = function("actions::cancel", file, 3);
1270 handler.effects.calls = vec![Call {
1271 path: vec!["actions".to_owned(), "notify".to_owned()],
1272 line: 4,
1273 }];
1274 order.code.functions = vec![handler, function("actions::notify", file, 9)];
1275 let shop = app("shop", vec![order]);
1276
1277 let explained = explain(&shop, "order.web.cancel").join("\n");
1278
1279 assert!(
1280 explained.contains("\n flow: order::actions::cancel calls order::actions::notify"),
1281 "{explained}"
1282 );
1283 }
1284
1285 #[test]
1286 fn a_screen_shows_its_markup_verbatim_and_bounded() {
1287 let directory =
1288 std::env::temp_dir().join(format!("sofie-explain-markup-{}", std::process::id()));
1289 std::fs::create_dir_all(&directory).unwrap();
1290 let short = directory.join("orders.html");
1291 let long = directory.join("history.html");
1292 std::fs::write(
1293 &short,
1294 "<Section title=\"Orders\">\n <p>none</p>\n</Section>\n",
1295 )
1296 .unwrap();
1297 let many = (1..=230)
1298 .map(|line| format!("<p>{line}</p>"))
1299 .collect::<Vec<_>>()
1300 .join("\n");
1301 std::fs::write(&long, many).unwrap();
1302 let mut order = module("order", "shop/modules/order");
1303 let mut web = area(&order, "web", Vec::new());
1304 web.screens = vec![short, long.clone()];
1305 order.areas.push(web);
1306 let shop = app("shop", vec![order]);
1307
1308 let orders = explain(&shop, "order/web/orders").join("\n");
1309 let history = explain(&shop, "order/web/history").join("\n");
1310 std::fs::remove_dir_all(&directory).unwrap();
1311
1312 assert!(
1313 orders.ends_with(
1314 " markup (3 lines; copy `edit_screen`'s `find` from it exactly):\n\
1315 ```html\n\
1316 <Section title=\"Orders\">\n <p>none</p>\n</Section>\n\
1317 ```"
1318 ),
1319 "{orders}"
1320 );
1321 assert!(history.contains("\n<p>200</p>\n```\n"), "{history}");
1322 assert!(!history.contains("<p>201</p>"), "{history}");
1323 assert!(
1324 history.ends_with(&format!(" … 30 more lines in {}", long.display())),
1325 "{history}"
1326 );
1327 }
1328
1329 #[test]
1331 fn a_screen_names_the_route_each_form_and_action_posts_and_its_body() {
1332 let directory =
1333 std::env::temp_dir().join(format!("sofie-explain-posts-{}", std::process::id()));
1334 std::fs::create_dir_all(&directory).unwrap();
1335 let orders = directory.join("orders.html");
1336 std::fs::write(
1337 &orders,
1338 "<Section title=\"Orders\">\n \
1339 <Form id=\"place-order\" post=\"/orders\"><Submit label=\"Place\" /></Form>\n \
1340 <Table id=\"orders\" of={data} columns=\"id\" as=\"order\">\n \
1341 <Action post=\"/orders/{order.id}/cancel\" label=\"Cancel\" />\n </Table>\n\
1342 </Section>\n",
1343 )
1344 .unwrap();
1345 let mut order = module("order", "shop/modules/order");
1346 let actions = "shop/modules/order/src/actions.rs";
1347 let mut web = area(
1348 &order,
1349 "web",
1350 vec![
1351 route(Method::Post, "/orders", "actions::place_order", 3),
1352 route(
1353 Method::Post,
1354 "/orders/{order}/cancel",
1355 "actions::cancel_order",
1356 4,
1357 ),
1358 ],
1359 );
1360 web.screens = vec![orders];
1361 order.areas.push(web);
1362 let mut place = function("actions::place_order", actions, 12);
1363 place.arguments = vec![type_name("PlaceOrder")];
1364 let mut cancel = function("actions::cancel_order", actions, 20);
1365 cancel.arguments = vec![type_name("Order")];
1366 order.code.functions = vec![place, cancel];
1367 order.code.inputs = vec![InputDeclaration {
1368 name: "PlaceOrder".to_owned(),
1369 path: vec!["actions".to_owned(), "PlaceOrder".to_owned()],
1370 from: None,
1371 fields: vec![field("total", "Money", 8)],
1372 unconstrained: Vec::new(),
1373 file: PathBuf::from(actions),
1374 line: 7,
1375 }];
1376 let shop = app("shop", vec![order]);
1377
1378 let explained = explain(&shop, "order/web/orders").join("\n");
1379 std::fs::remove_dir_all(&directory).unwrap();
1380
1381 assert!(
1382 explained.contains(
1383 " binds: data, data[].id\n \
1384 form `place-order` posts route order.web.place_order (POST /orders)\n \
1385 input: PlaceOrder (shop/modules/order/src/actions.rs:7), a JSON object of\n \
1386 total: Money, required, as {\"minor\":14990,\"currency\":\"EUR\"} or \
1387 {\"amount\":\"12.50\",\"currency\":\"USD\"}\n \
1388 a body it takes: {\"total\":{\"currency\":\"USD\",\"minor\":22319}}\n \
1389 action on line 4 posts route order.web.cancel_order \
1390 (POST /orders/{order}/cancel)\n \
1391 input: no `Input` type"
1392 ),
1393 "{explained}"
1394 );
1395 }
1396
1397 #[test]
1398 fn a_route_names_the_parameters_its_query_struct_reads() {
1399 let mut order = module("order", "shop/modules/order");
1400 order.areas.push(area(
1401 &order,
1402 "web",
1403 vec![route(Method::Get, "/orders", "pages::orders", 3)],
1404 ));
1405 let mut page = function("pages::orders", "shop/modules/order/src/pages.rs", 5);
1406 page.arguments = vec![type_name("Listing<OrderRow, OrderSearch>")];
1407 order.code.functions = vec![page];
1408 order.code.inputs = vec![InputDeclaration {
1409 name: "OrderSearch".to_owned(),
1410 path: vec!["pages".to_owned(), "OrderSearch".to_owned()],
1411 from: None,
1412 fields: vec![field("text", "String", 2), field("days", "Option<u32>", 3)],
1413 unconstrained: Vec::new(),
1414 file: "shop/modules/order/src/pages.rs".into(),
1415 line: 1,
1416 }];
1417 let shop = app("shop", vec![order]);
1418
1419 let explained = explain(&shop, "order.web.orders").join("\n");
1420
1421 assert!(
1422 explained.contains(
1423 " query: OrderSearch (shop/modules/order/src/pages.rs:1), parameters of the \
1424 query string\n text: String, required\n days: u32, optional"
1425 ),
1426 "{explained}"
1427 );
1428 }
1429
1430 #[test]
1431 fn a_screen_rendered_twice_is_named_once_in_first_seen_order() {
1432 let mut order = module("order", "shop/modules/order");
1433 let mut orders = route(Method::Get, "/orders", "pages::orders", 3);
1434 orders.screen = Some("orders".to_owned());
1435 order.areas.push(area(&order, "web", vec![orders]));
1436 let mut page = function("pages::orders", "shop/modules/order/src/pages.rs", 5);
1437 page.renders = ["empty", "orders", "empty"]
1438 .map(|screen| ScreenRender {
1439 screen: Some(screen.to_owned()),
1440 line: 6,
1441 })
1442 .to_vec();
1443 order.code.functions = vec![page];
1444 let shop = app("shop", vec![order]);
1445
1446 let explained = explain(&shop, "order.web.orders").join("\n");
1447 let screens: Vec<&str> = explained
1448 .lines()
1449 .filter_map(|line| line.strip_prefix(" screen: "))
1450 .collect();
1451
1452 assert_eq!(screens.len(), 2, "{explained}");
1453 assert!(screens[0].ends_with("orders.html"), "{explained}");
1454 assert!(screens[1].ends_with("empty.html"), "{explained}");
1455 }
1456}