Skip to main content

sofie_cli/
build_script.rs

1use std::env;
2use std::path::{Path, PathBuf};
3use std::process::ExitCode;
4
5use sofie_check::{Screens, report};
6use sofie_engine::{Components, TranslationError, Translations, compile_template_with_texts};
7use sofie_model::App;
8use sofie_model::naming::{SOURCE_LANGUAGE, file_stem};
9
10use crate::cargo::Diagnostics;
11use crate::check_command::check_loaded_app;
12use crate::clients::{build_clients, client_sources, has_clients};
13use crate::gen_command::{GenError, readable_screen};
14use crate::islands::{IslandMode, build_islands, has_islands, island_sources};
15use crate::source_edits::{read, write};
16use crate::wiring::{embedded_mails, embedded_screens};
17
18#[must_use]
19pub fn check_in_build_script(root: &Path) -> ExitCode {
20    for path in watched_paths(root) {
21        println!("cargo::rerun-if-changed={}", path.display());
22    }
23    match build(root) {
24        Ok(()) => return ExitCode::SUCCESS,
25        Err(GenError::Check(findings)) => eprint!("{}", report(root, &findings)),
26        Err(error) => eprintln!("{error}"),
27    }
28    ExitCode::FAILURE
29}
30
31fn build(root: &Path) -> Result<(), GenError> {
32    let app = sofie_model::load(root).map_err(GenError::Model)?;
33    println!("{}", app_crate_variable(&app));
34    let screens = Screens::read(&app.modules);
35    let findings = check_loaded_app(root, &app, &screens)?;
36    if !findings.is_empty() {
37        return Err(GenError::Check(findings));
38    }
39    let Some(out) = env::var_os("OUT_DIR").map(PathBuf::from) else {
40        return Err(GenError::Usage(
41            "the screens compile in a build script, which Cargo gives OUT_DIR".to_owned(),
42        ));
43    };
44    let texts = source_texts(&app)?;
45    compile_screens(&app, &screens, &texts, &out.join("screens"))?;
46    compile_mails(&app, &texts, &out.join("mails"))?;
47    let mode = match env::var("PROFILE").as_deref() {
48        Ok("release") => IslandMode::Release,
49        _ => IslandMode::Development,
50    };
51    build_islands_into(&app, &out.join("islands"), mode)?;
52    build_clients_into(&app, &screens, &out.join("clients"), mode)
53}
54
55/// `#[test_app]` names the app's crate by it, which `[lib] name` may set apart from the package.
56fn app_crate_variable(app: &App) -> String {
57    format!("cargo::rustc-env=SOFIE_APP_CRATE={}", app.app.crate_name)
58}
59
60/// Each `t(…)` call is compiled with its text, which an app built without `i18n` writes.
61fn source_texts(app: &App) -> Result<Translations, GenError> {
62    let refused = |error: TranslationError| {
63        GenError::App(format!(
64            "reading the app's `lang/{SOURCE_LANGUAGE}.toml` files: {error}"
65        ))
66    };
67    let mut texts = Translations::new(&[SOURCE_LANGUAGE]).map_err(refused)?;
68    for module in &app.modules {
69        let sources = module
70            .languages
71            .iter()
72            .filter(|path| file_stem(path) == SOURCE_LANGUAGE);
73        for path in sources {
74            texts
75                .add(&module.krate.crate_name, SOURCE_LANGUAGE, &read(path)?)
76                .map_err(refused)?;
77        }
78    }
79    Ok(texts)
80}
81
82fn compile_screens(
83    app: &App,
84    screens: &Screens,
85    texts: &Translations,
86    out: &Path,
87) -> Result<(), GenError> {
88    for (key, file) in app.modules.iter().flat_map(embedded_screens) {
89        let source = &readable_screen(screens, file)?.source;
90        let compiled = compile_template_with_texts(&key, source, screens.components(), texts)
91            .map_err(|error| GenError::Screen {
92                path: file.to_path_buf(),
93                message: error.to_string(),
94            })?;
95        write(&out.join(format!("{key}.sft")), &compiled)?;
96    }
97    Ok(())
98}
99
100fn compile_mails(app: &App, texts: &Translations, out: &Path) -> Result<(), GenError> {
101    for mail in embedded_mails(&app.modules) {
102        let source = read(&mail.file)?;
103        let compiled =
104            compile_template_with_texts(&mail.key, &source, &Components::default(), texts)
105                .map_err(|error| GenError::Screen {
106                    path: mail.file.clone(),
107                    message: error.to_string(),
108                })?;
109        write(&out.join(format!("{}.sft", mail.key)), &compiled)?;
110    }
111    Ok(())
112}
113
114fn build_islands_into(app: &App, out: &Path, mode: IslandMode) -> Result<(), GenError> {
115    if !has_islands(app) {
116        return Ok(());
117    }
118    for source in island_sources(app) {
119        println!("cargo::rerun-if-changed={}", source.display());
120    }
121    build_islands(app, out, mode, Diagnostics::Terminal).map(drop)
122}
123
124fn build_clients_into(
125    app: &App,
126    screens: &Screens,
127    out: &Path,
128    mode: IslandMode,
129) -> Result<(), GenError> {
130    if !has_clients(app) {
131        return Ok(());
132    }
133    for source in client_sources() {
134        println!("cargo::rerun-if-changed={}", source.display());
135    }
136    build_clients(app, screens, out, mode, Diagnostics::Terminal).map(drop)
137}
138
139/// Cargo watches a directory by every file beneath it: module sources,
140/// templates, migrations and manifests are all under `modules/`.
141fn watched_paths(root: &Path) -> Vec<PathBuf> {
142    let app = root.join("app");
143    let candidates = [
144        root.join("modules"),
145        app.join("Cargo.toml"),
146        app.join("src"),
147        app.join("tests"),
148        root.join(sofie_check::ALLOW_FILE),
149    ];
150    // A watched path that does not exist would make Cargo rerun every build.
151    candidates
152        .into_iter()
153        .filter(|path| path.exists())
154        .collect()
155}
156
157#[cfg(test)]
158mod tests {
159    use std::path::Path;
160
161    use sofie_engine::{Components, compile_template_with_texts, load_template};
162    use sofie_model::testing::{TemporaryDirectory, app, module};
163
164    use super::{app_crate_variable, source_texts};
165
166    #[test]
167    fn the_build_script_names_the_app_s_crate_for_test_app() {
168        let shop = Path::new(env!("CARGO_MANIFEST_DIR")).join("../../examples/shop");
169        let app = sofie_model::load(&shop).unwrap();
170
171        assert_eq!(
172            app_crate_variable(&app),
173            "cargo::rustc-env=SOFIE_APP_CRATE=shop"
174        );
175    }
176
177    #[test]
178    fn screens_are_compiled_with_the_source_language_s_texts_only() {
179        let directory = TemporaryDirectory::new("sofie-source-texts");
180        let mut order = module("order", directory.path("modules/order"));
181        order.languages = vec![
182            directory.write(
183                "modules/order/lang/en.toml",
184                "[orders]\ntitle = \"Your orders\"\n",
185            ),
186            directory.write(
187                "modules/order/lang/tr.toml",
188                "[orders]\ntitle = \"Siparişleriniz\"\n",
189            ),
190        ];
191        let shop = app(directory.root(), vec![order]);
192
193        let texts = source_texts(&shop).unwrap();
194        let compiled = compile_template_with_texts(
195            "order/web/orders",
196            "<h1>{t(\"orders.title\")}</h1>",
197            &Components::default(),
198            &texts,
199        )
200        .unwrap();
201
202        let loaded = load_template("order/web/orders", &compiled).unwrap();
203        assert_eq!(
204            loaded.render(&serde_json::json!({})).unwrap(),
205            "<h1>Your orders</h1>"
206        );
207    }
208}