Skip to main content

sofie_cli/new_command/
menu.rs

1use std::path::PathBuf;
2
3use sofie_model::naming::path_parameters;
4use sofie_model::{App, AreaContribution, KnownArea, Method, Module, Route};
5
6use super::edits::area_type;
7use super::{Edits, checked_name, find_module};
8use crate::gen_command::GenError;
9use crate::source_edits::{add_call, read};
10
11pub struct NewMenuEntry {
12    pub module: String,
13    pub area: String,
14    pub handler: String,
15    pub label: String,
16}
17
18pub(super) fn new_menu_entry(
19    edits: &mut Edits,
20    app: &App,
21    entry: &NewMenuEntry,
22) -> Result<(), GenError> {
23    let module = find_module(app, &entry.module)?;
24    let contribution = contribution(module, &entry.area)?;
25    let area = app.area(&entry.area).ok_or_else(|| {
26        GenError::Usage(format!(
27            "the app has no area `{}`; declare it in a contract first",
28            entry.area
29        ))
30    })?;
31    let handler = checked_name(&entry.handler)?;
32    page(contribution, handler)?;
33    let label = entry.label.trim();
34    if label.is_empty() {
35        return Err(GenError::Usage(
36            "a menu entry needs a label, as `Orders`".to_owned(),
37        ));
38    }
39    let contract = module.contract.as_ref().ok_or_else(|| {
40        GenError::Usage(format!(
41            "module `{}` has no contract, whose generated `routes` a menu entry links to; add \
42             `contract/` as `sofie new module` writes it",
43            module.krate.crate_name
44        ))
45    })?;
46    let route = format!("routes::{}::{handler}()", contribution.area);
47    write_entry(
48        edits,
49        module,
50        contribution,
51        &area,
52        &route,
53        &format!("entry({label:?}, {}::{route})", contract.crate_name),
54    )?;
55    edits.depend(&module.krate, contract)?;
56    edits.enable_framework_feature(&module.krate, "menu")
57}
58
59fn contribution<'app>(
60    module: &'app Module,
61    area: &str,
62) -> Result<&'app AreaContribution, GenError> {
63    module
64        .areas
65        .iter()
66        .find(|contribution| contribution.area == area)
67        .ok_or_else(|| {
68            let known: Vec<&str> = module
69                .areas
70                .iter()
71                .map(|contribution| contribution.area.as_str())
72                .collect();
73            GenError::Usage(format!(
74                "module `{}` routes nothing in area `{area}`; its areas: {}",
75                module.krate.crate_name,
76                known.join(", ")
77            ))
78        })
79}
80
81/// Matches the check the app runs at start, which refuses an entry no page of the area without parameters answers.
82fn page(contribution: &AreaContribution, handler: &str) -> Result<(), GenError> {
83    let area = &contribution.area;
84    let routed: Vec<&Route> = contribution
85        .routes
86        .iter()
87        .filter(|route| route.handler.last().is_some_and(|last| last == handler))
88        .collect();
89    let Some(page) = routed.iter().find(|route| route.method == Method::Get) else {
90        return Err(match routed.first() {
91            Some(write) => GenError::Usage(format!(
92                "`{handler}` answers {} {} in area `{area}`, which is no page: a menu entry \
93                 links to a `get` route",
94                write.method.name(),
95                write.path
96            )),
97            None => GenError::Usage(format!(
98                "area `{area}` routes no handler `{handler}`; its pages: {}",
99                page_handlers(contribution).join(", ")
100            )),
101        });
102    };
103    let parameters = path_parameters(&page.path);
104    if !parameters.is_empty() {
105        return Err(GenError::Usage(format!(
106            "a menu entry links to a page of the area without parameters; GET {} takes `{}`",
107            page.path,
108            parameters.join("`, `")
109        )));
110    }
111    Ok(())
112}
113
114fn page_handlers(contribution: &AreaContribution) -> Vec<&str> {
115    contribution
116        .routes
117        .iter()
118        .filter(|route| route.method == Method::Get && path_parameters(&route.path).is_empty())
119        .filter_map(|route| route.handler.last().map(String::as_str))
120        .collect()
121}
122
123fn write_entry(
124    edits: &mut Edits,
125    module: &Module,
126    contribution: &AreaContribution,
127    area: &KnownArea,
128    route: &str,
129    call: &str,
130) -> Result<(), GenError> {
131    let file = menu_file(contribution);
132    if file.is_file() {
133        if read(&file)?.contains(route) {
134            return Err(GenError::Usage(format!(
135                "{} has an entry for `{route}` already",
136                file.display()
137            )));
138        }
139        return edits.edit_rust(&file, |text| {
140            add_call(text, "menu", call).ok_or_else(|| {
141                GenError::Usage(format!(
142                    "{} has no `pub fn menu(menu: &mut Menu<…>) {{ … }}` to add the entry to",
143                    file.display()
144                ))
145            })
146        });
147    }
148    let (area_import, area_type) = area_type(area);
149    edits.create_rust(
150        &file,
151        &format!(
152            "{area_import}use sofie::prelude::*;\n\n\
153             pub fn menu(menu: &mut Menu<{area_type}>) {{\n    menu.{call};\n}}\n"
154        ),
155    )?;
156    edits.declare(&module.krate, &["areas", &contribution.area, "menu"])
157}
158
159fn menu_file(contribution: &AreaContribution) -> PathBuf {
160    contribution.menu.as_ref().map_or_else(
161        || contribution.routes_file.with_file_name("menu.rs"),
162        |menu| menu.file.clone(),
163    )
164}