Skip to main content

sofie_cli/
explain.rs

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
187/// `()` replies nothing, which a JSON caller gets as 204 No Content.
188fn 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
197/// Sign-up hides which addresses have accounts, so its answer cannot tell a caller it created nothing.
198fn 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
266/// Every way of signing in or out ends in a `Session` method, so a handler that reaches one
267/// through the functions and methods it calls does it.
268fn 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
343/// From the same fixtures as `sofie fuzz`'s valid bodies, so the example is one the route accepts.
344fn 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
371/// `#[derive(Value)]` reads one field named after its type; the model does not know which types derive it.
372fn 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
410/// Only where the Rust type does not already say it.
411fn 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
488/// Cargo unifies a package's features across the workspace, so the app and its modules share them.
489fn 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
717/// What a screen's template is given and what it reads, for `explain` alone: the `screens` list stays short.
718fn 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
766/// A `<Form>` draws a field for each of its route's input fields, which its markup does not show.
767fn 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
833/// Verbatim and unindented, so `edit_screen`'s `find` can be copied from it.
834fn 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    /// The place-order form's markup is a lone Submit button; the money field it draws comes from the posted route's input.
1330    #[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}