Skip to main content

sofie_cli/
scaffold_command.rs

1//! A resource's screens come from its description, which only the compiled app has, so the app's binary prints the source.
2
3use std::path::{Path, PathBuf};
4use std::process::Command;
5
6use sofie_model::{App, AreaContribution, Module};
7
8use crate::cargo::{self, Diagnostics};
9use crate::child_process;
10use crate::dev_command::{DEVELOPMENT, app_command};
11use crate::gen_command::{GenError, io_error};
12use crate::source_edits::write;
13
14pub fn scaffold_screen(root: &Path, key: &str) -> Result<PathBuf, GenError> {
15    scaffold_screen_built_by(root, root, key, Diagnostics::Terminal)
16}
17
18/// `built` prints the source, so a dry run's copy writes what the original app prints
19/// and nothing is built outside the original's target directory.
20pub(crate) fn scaffold_screen_built_by(
21    root: &Path,
22    built: &Path,
23    key: &str,
24    diagnostics: Diagnostics,
25) -> Result<PathBuf, GenError> {
26    let app = sofie_model::load(root).map_err(GenError::Model)?;
27    let file = override_file(&app, key)?;
28    let source = built_in_source(built, key, diagnostics)?;
29    write(&file, &source)?;
30    Ok(file)
31}
32
33fn override_file(app: &App, key: &str) -> Result<PathBuf, GenError> {
34    let usage = || {
35        let keys = built_in_keys(app);
36        let known = if keys.is_empty() {
37            "the app has none: a module's `routes.resource(…)` routes the built-in screens"
38                .to_owned()
39        } else {
40            keys.join(", ")
41        };
42        GenError::Usage(format!(
43            "`{key}` names no built-in screen of a module; name one as `<module>/<area>/<screen>`: \
44             {known}"
45        ))
46    };
47    let [module, area, name] = key.split('/').collect::<Vec<_>>()[..] else {
48        return Err(usage());
49    };
50    let contribution = app
51        .modules
52        .iter()
53        .filter(|candidate| candidate.krate.crate_name == module)
54        .flat_map(|candidate| &candidate.areas)
55        .find(|contribution| contribution.area == area && routes_built_in(contribution, name))
56        .ok_or_else(usage)?;
57    let file = contribution.screen_file(name);
58    if file.exists() {
59        return Err(GenError::Usage(format!(
60            "{} overrides `{key}` already; edit it",
61            file.display()
62        )));
63    }
64    Ok(file)
65}
66
67fn routes_built_in(area: &AreaContribution, screen: &str) -> bool {
68    area.routes
69        .iter()
70        .any(|route| route.is_resource() && route.screen.as_deref() == Some(screen))
71}
72
73fn built_in_keys(app: &App) -> Vec<String> {
74    let mut keys: Vec<String> = app
75        .modules
76        .iter()
77        .flat_map(|module: &Module| {
78            module.areas.iter().flat_map(move |area| {
79                area.routes
80                    .iter()
81                    .filter(|route| route.is_resource())
82                    .filter_map(move |route| {
83                        let screen = route.screen.as_deref()?;
84                        Some(format!(
85                            "{}/{}/{screen}",
86                            module.krate.crate_name, area.area
87                        ))
88                    })
89            })
90        })
91        .collect();
92    keys.sort();
93    keys.dedup();
94    keys
95}
96
97fn built_in_source(root: &Path, key: &str, diagnostics: Diagnostics) -> Result<String, GenError> {
98    let executable = cargo::build(root, DEVELOPMENT, diagnostics)?;
99    let output = child_process::output(
100        diagnostics.process_group(),
101        &mut source_command(root, &executable, key)?,
102    )
103    .map_err(|source| io_error(&executable, source))?;
104    if !output.status.success() {
105        return Err(GenError::App(format!(
106            "`<app> screen source {key}` failed with {}: {}",
107            output.status,
108            String::from_utf8_lossy(&output.stderr).trim()
109        )));
110    }
111    String::from_utf8(output.stdout)
112        .map_err(|_| GenError::App(format!("`<app> screen source {key}` printed no UTF-8")))
113}
114
115/// The app logs to stdout, where the source is read from.
116fn source_command(root: &Path, executable: &Path, key: &str) -> Result<Command, GenError> {
117    let mut command = app_command(root, executable)?;
118    command
119        .env("SOFIE_LOG", "off")
120        .args(["screen", "source", key]);
121    Ok(command)
122}
123
124#[cfg(test)]
125mod tests {
126    use std::ffi::OsStr;
127    use std::path::Path;
128
129    use sofie_model::testing::{TemporaryDirectory, app, area, module, route};
130    use sofie_model::{Method, Route};
131
132    use super::{override_file, source_command};
133    use crate::gen_command::GenError;
134
135    fn resource_route(screen: &str) -> Route {
136        let mut index = route(Method::Get, "/orders", "sofie::resource::index", 3);
137        index.screen = Some(screen.to_owned());
138        index
139    }
140
141    #[test]
142    fn writes_over_a_built_in_screen_of_the_area_routing_it() {
143        let mut order = module("order", "shop/modules/order");
144        order
145            .areas
146            .push(area(&order, "console", vec![resource_route("order_index")]));
147        let shop = app("shop", vec![order]);
148
149        let file = override_file(&shop, "order/console/order_index").unwrap();
150        let refused = override_file(&shop, "order/console/orders");
151
152        assert_eq!(
153            file,
154            std::path::Path::new("shop/modules/order/src/areas/console/screens/order_index.html")
155        );
156        let Err(GenError::Usage(message)) = refused else {
157            panic!("{refused:?}");
158        };
159        assert!(
160            message.ends_with(": order/console/order_index"),
161            "{message}"
162        );
163    }
164
165    #[test]
166    fn an_app_without_built_in_screens_is_told_where_they_come_from() {
167        let shop = app("shop", vec![module("order", "shop/modules/order")]);
168
169        let refused = override_file(&shop, "order/web/orders");
170
171        let Err(GenError::Usage(message)) = refused else {
172            panic!("{refused:?}");
173        };
174        assert!(
175            message.ends_with(
176                "the app has none: a module's `routes.resource(…)` routes the built-in screens"
177            ),
178            "{message}"
179        );
180    }
181
182    #[test]
183    fn runs_the_development_binary_in_the_app_root_with_its_env_file_and_no_logs() {
184        let directory = TemporaryDirectory::new("sofie-scaffold-screen");
185        directory.write(".env", "SOFIE_SCAFFOLD_TEST_KEY=local\n");
186
187        let command = source_command(directory.root(), Path::new("app"), "a/b/c").unwrap();
188
189        assert_eq!(command.get_current_dir(), Some(directory.root()));
190        let variables: Vec<_> = command.get_envs().collect();
191        assert!(variables.contains(&(
192            OsStr::new("SOFIE_SCAFFOLD_TEST_KEY"),
193            Some(OsStr::new("local"))
194        )));
195        assert!(variables.contains(&(OsStr::new("SOFIE_LOG"), Some(OsStr::new("off")))));
196        let arguments: Vec<_> = command.get_args().collect();
197        assert_eq!(arguments, ["screen", "source", "a/b/c"]);
198    }
199}