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(screen_line(site, &screen));
121    }
122    lines.join("\n")
123}
124
125/// A resource's screen is built in until the area has a file of its key.
126fn 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
199/// `()` replies nothing, which a JSON caller gets as 204 No Content.
200fn 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
209/// Sign-up hides which addresses have accounts, so its answer cannot tell a caller it created nothing.
210fn 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
278/// Every way of signing in or out ends in a `Session` method, so a handler that reaches one
279/// through the functions and methods it calls does it.
280fn 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
355/// From the same fixtures as `sofie fuzz`'s valid bodies, so the example is one the route accepts.
356fn 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
383/// `#[derive(Value)]` reads one field named after its type; the model does not know which types derive it.
384fn 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
422/// Only where the Rust type does not already say it.
423fn 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
500/// Cargo unifies a package's features across the workspace, so the app and its modules share them.
501fn 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
729/// What a screen's template is given and what it reads, for `explain` alone: the `screens` list stays short.
730fn 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
778/// A `<Form>` draws a field for each of its route's input fields, which its markup does not show.
779fn 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
845/// Verbatim and unindented, so `edit_screen`'s `find` can be copied from it.
846fn 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    /// The place-order form's markup is a lone Submit button; the money field it draws comes from the posted route's input.
1373    #[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}