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