1use std::fs;
2use std::io::ErrorKind;
3use std::path::{Path, PathBuf};
4use std::process::Command;
5
6use sofie_check::Rule;
7use sofie_model::App;
8
9use crate::cargo::{self, Diagnostics};
10use crate::check_command::check_model;
11use crate::dockerfile::{
12 DOCKERIGNORE, UNPINNED, dockerfile, island_toolchain, refuse_paths_outside,
13};
14use crate::gen_command::{GenError, io_error, read_existing};
15use crate::pgo;
16use crate::source_edits::read;
17
18#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
21pub struct BuildOptions {
22 pub image: bool,
23 pub worker: bool,
24 pub pgo: bool,
25}
26
27const WORKER_FEATURE: &str = "worker-binary";
29const DIST_PROFILE: &str = "dist";
30const DIST_SECTION: &str =
31 "[profile.dist]\ninherits = \"release\"\nlto = \"fat\"\ncodegen-units = 1\nstrip = \"symbols\"";
32
33#[derive(Debug)]
34pub enum Built {
35 Binary(Binary),
36 Image(Image),
37}
38
39#[derive(Debug)]
40pub struct Binary {
41 pub path: PathBuf,
42 pub bytes: u64,
43}
44
45#[derive(Debug, PartialEq, Eq)]
46pub enum Image {
47 Built {
48 tag: String,
49 },
50 Unpinned {
51 dockerfile: PathBuf,
52 },
53 NoDocker {
54 dockerfile: PathBuf,
55 command: String,
56 },
57}
58
59pub fn build(root: &Path, options: BuildOptions) -> Result<Built, GenError> {
61 let app = sofie_model::load(root).map_err(GenError::Model)?;
62 if !options.image {
63 return build_binary(root, &app, options).map(Built::Binary);
64 }
65 if options.worker || options.pgo {
66 return Err(GenError::Usage(
67 "`--image` builds the app's binary inside the image, without `--worker` or `--pgo`"
68 .to_owned(),
69 ));
70 }
71 refuse_findings(root, &app)?;
72 let context =
73 sofie_model::workspace_root(&cargo::app_manifest(root)).map_err(GenError::Model)?;
74 refuse_missing_dist_profile(&context)?;
75 refuse_paths_outside(root, &context)?;
76 build_image(root, &app, &context).map(Built::Image)
77}
78
79pub(crate) fn build_binary(
80 root: &Path,
81 app: &App,
82 options: BuildOptions,
83) -> Result<Binary, GenError> {
84 refuse_findings(root, app)?;
85 let workspace = sofie_model::workspace_root(&root.join("app").join("Cargo.toml"))
86 .map_err(GenError::Model)?;
87 refuse_missing_dist_profile(&workspace)?;
88 let package = &app.app.package;
89 let worker = format!("{package}-worker");
90 let mut arguments = vec!["--profile", DIST_PROFILE, "--bin"];
91 if options.worker {
92 refuse_missing_worker(root, &worker, app.has_feature_flags())?;
93 arguments.extend([worker.as_str(), "--features", WORKER_FEATURE]);
94 } else {
95 arguments.push(package);
96 }
97 let path = if options.pgo {
98 pgo::build(root, app, &arguments)?
99 } else {
100 cargo::build(root, &arguments, Diagnostics::Terminal)?
101 };
102 let bytes = fs::metadata(&path)
103 .map_err(|source| io_error(&path, source))?
104 .len();
105 Ok(Binary { path, bytes })
106}
107
108fn refuse_missing_dist_profile(workspace: &Path) -> Result<(), GenError> {
110 let manifest = workspace.join("Cargo.toml");
111 if read(&manifest)?
112 .lines()
113 .any(|line| line.trim() == "[profile.dist]")
114 {
115 return Ok(());
116 }
117 Err(GenError::Usage(format!(
118 "{} defines no `dist` profile, which `sofie build` builds with; add:\n\n{DIST_SECTION}",
119 manifest.display()
120 )))
121}
122
123fn refuse_findings(root: &Path, app: &App) -> Result<(), GenError> {
126 let findings = check_model(root, app)?;
127 let stale: Option<Vec<String>> = findings
128 .iter()
129 .map(|finding| {
130 (finding.rule == Rule::StaleWiring).then(|| finding.file.display().to_string())
131 })
132 .collect();
133 match stale {
134 _ if findings.is_empty() => Ok(()),
135 Some(stale) => Err(GenError::App(format!(
136 "stale generated files: {}; run `sofie gen {}`",
137 stale.join(", "),
138 root.display()
139 ))),
140 None => Err(GenError::Check(findings)),
141 }
142}
143
144fn refuse_missing_worker(root: &Path, worker: &str, flags: bool) -> Result<(), GenError> {
146 let manifest = cargo::app_manifest(root);
147 if read(&manifest)?.contains(&format!("name = \"{worker}\"")) {
148 return Ok(());
149 }
150 let run = if flags {
152 "run_worker_with_flags"
153 } else {
154 "run_worker"
155 };
156 Err(GenError::Usage(format!(
157 "{} declares no `{worker}` binary; add `src/bin/worker.rs` calling \
158 `sofie::{run}(MODULES, worker)` and declare it:\n\
159 [features]\n{WORKER_FEATURE} = []\n\n\
160 [[bin]]\nname = \"{worker}\"\npath = \"src/bin/worker.rs\"\n\
161 required-features = [\"{WORKER_FEATURE}\"]",
162 manifest.display()
163 )))
164}
165
166fn build_image(root: &Path, app: &App, context: &Path) -> Result<Image, GenError> {
169 let path = root.join("Dockerfile");
170 let text = match read_existing(&path)? {
171 Some(text) => text,
172 None => write_dockerfile(app, context, &path)?,
173 };
174 if text.contains(UNPINNED) {
175 return Ok(Image::Unpinned { dockerfile: path });
176 }
177 let tag = image_tag(&app.app.package);
178 let built = Command::new("docker")
179 .arg("build")
180 .arg("--file")
181 .arg(&path)
182 .args(["--tag", &tag])
183 .arg(context)
184 .status();
185 let status = match built {
186 Err(error) if error.kind() == ErrorKind::NotFound => {
187 let command = format!(
188 "docker build --file {} --tag {tag} {}",
189 path.display(),
190 context.display()
191 );
192 return Ok(Image::NoDocker {
193 dockerfile: path,
194 command,
195 });
196 }
197 built => built.map_err(|source| io_error(&path, source))?,
198 };
199 if !status.success() {
200 return Err(GenError::App(format!(
201 "`docker build` failed with {status}"
202 )));
203 }
204 Ok(Image::Built { tag })
205}
206
207fn image_tag(package: &str) -> String {
210 package
211 .trim_start_matches(|character: char| !character.is_ascii_alphanumeric())
212 .to_lowercase()
213}
214
215fn write_dockerfile(app: &App, context: &Path, path: &Path) -> Result<String, GenError> {
216 let toolchain = island_toolchain(app, context)?;
217 let text = dockerfile(&app.app.package, toolchain.as_deref());
218 fs::write(path, &text).map_err(|source| io_error(path, source))?;
219 let ignore = path.with_file_name("Dockerfile.dockerignore");
220 fs::write(&ignore, DOCKERIGNORE).map_err(|source| io_error(&ignore, source))?;
221 Ok(text)
222}
223
224#[cfg(test)]
225mod tests {
226 use std::fs;
227
228 use sofie_model::testing::TemporaryDirectory;
229
230 use super::{image_tag, refuse_missing_dist_profile, refuse_missing_worker};
231
232 #[test]
233 fn a_missing_worker_binary_is_told_to_hand_flags_when_the_app_has_them() {
234 let directory = TemporaryDirectory::new("sofie-worker-binary");
235 directory.write("app/Cargo.toml", "[package]\nname = \"shop\"\n");
236
237 let plain = refuse_missing_worker(directory.root(), "shop-worker", false)
238 .unwrap_err()
239 .to_string();
240 let flagged = refuse_missing_worker(directory.root(), "shop-worker", true)
241 .unwrap_err()
242 .to_string();
243
244 assert!(
245 plain.contains("`sofie::run_worker(MODULES, worker)`"),
246 "{plain}"
247 );
248 assert!(
249 flagged.contains("`sofie::run_worker_with_flags(MODULES, worker)`"),
250 "{flagged}"
251 );
252 }
253
254 #[test]
255 fn an_image_is_tagged_with_the_package_name_lowercased() {
256 assert_eq!(image_tag("My_Shop"), "my_shop");
257 assert_eq!(image_tag("_Shop-2"), "shop-2");
258 }
259
260 #[test]
261 fn a_workspace_without_a_dist_profile_is_told_what_to_add() {
262 let workspace = std::env::temp_dir().join(format!("sofie-dist-{}", std::process::id()));
263 fs::create_dir_all(&workspace).unwrap();
264 let manifest = workspace.join("Cargo.toml");
265
266 fs::write(&manifest, "[profile.release]\nlto = \"fat\"\n").unwrap();
267 let missing = refuse_missing_dist_profile(&workspace)
268 .unwrap_err()
269 .to_string();
270 fs::write(&manifest, "[profile.dist]\ninherits = \"release\"\n").unwrap();
271 let present = refuse_missing_dist_profile(&workspace);
272 fs::remove_dir_all(&workspace).unwrap();
273
274 assert!(
275 missing.contains("[profile.dist]\ninherits = \"release\""),
276 "{missing}"
277 );
278 assert!(present.is_ok());
279 }
280}