sofie_cli/
scaffold_command.rs1use 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
18pub(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
115fn 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}