trunk_build_time/config/
models.rs

1use std::collections::HashMap;
2use std::net::IpAddr;
3use std::path::PathBuf;
4use std::str::FromStr;
5use std::sync::Arc;
6
7use anyhow::{Context, Result};
8use axum::http::Uri;
9use clap::Args;
10use serde::{Deserialize, Deserializer};
11
12use crate::common::parse_public_url;
13use crate::config::{RtcBuild, RtcClean, RtcServe, RtcWatch};
14use crate::pipelines::PipelineStage;
15
16/// Config options for the build system.
17#[derive(Clone, Debug, Default, Deserialize, Args)]
18pub struct ConfigOptsBuild {
19    /// The index HTML file to drive the bundling process [default: index.html]
20    pub target: Option<PathBuf>,
21    /// Build in release mode [default: false]
22    #[arg(long)]
23    #[serde(default)]
24    pub release: bool,
25    /// The output dir for all final assets [default: dist]
26    #[arg(short, long)]
27    pub dist: Option<PathBuf>,
28    /// The public URL from which assets are to be served [default: /]
29    #[arg(long, value_parser = parse_public_url)]
30    pub public_url: Option<String>,
31    /// Build without default features [default: false]
32    #[arg(long)]
33    #[serde(default)]
34    pub no_default_features: bool,
35    /// Build with all features [default: false]
36    #[arg(long)]
37    #[serde(default)]
38    pub all_features: bool,
39    /// A comma-separated list of features to activate, must not be used with all-features
40    /// [default: ""]
41    #[arg(long)]
42    pub features: Option<String>,
43    /// Whether to include hash values in the output file names [default: true]
44    #[arg(long)]
45    pub filehash: Option<bool>,
46    /// Optional pattern for the app loader script [default: None]
47    ///
48    /// Patterns should include the sequences `{base}`, `{wasm}`, and `{js}` in order to
49    /// properly load the application. Other sequences may be included corresponding
50    /// to key/value pairs provided in `pattern_params`.
51    ///
52    /// These values can only be provided via config file.
53    #[arg(skip)]
54    #[serde(default)]
55    pub pattern_script: Option<String>,
56
57    /// Whether to inject scripts into your index file. [default: true]
58    ///
59    /// These values can only be provided via config file.
60    #[arg(skip)]
61    #[serde(default)]
62    pub inject_scripts: Option<bool>,
63    /// Optional pattern for the app preload element [default: None]
64    ///
65    /// Patterns should include the sequences `{base}`, `{wasm}`, and `{js}` in order to
66    /// properly preload the application. Other sequences may be included corresponding
67    /// to key/value pairs provided in `pattern_params`.
68    ///
69    /// These values can only be provided via config file.
70    #[arg(skip)]
71    #[serde(default)]
72    pub pattern_preload: Option<String>,
73    #[arg(skip)]
74    #[serde(default)]
75    /// Optional replacement parameters corresponding to the patterns provided in
76    /// `pattern_script` and `pattern_preload`.
77    ///
78    /// When a pattern is being replaced with its corresponding value from this map, if the value
79    /// is prefixed with the symbol `@`, then the value is expected to be a file path, and the
80    /// pattern will be replaced with the contents of the target file. This allows insertion of
81    /// some big JSON state or even HTML files as a part of the `index.html` build.
82    ///
83    /// Trunk will automatically insert the `base`, `wasm` and `js` key/values into this map. In
84    /// order for the app to be loaded properly, the patterns `{base}`, `{wasm}` and `{js}` should
85    /// be used in `pattern_script` and `pattern_preload`.
86    ///
87    /// These values can only be provided via config file.
88    pub pattern_params: Option<HashMap<String, String>>,
89}
90
91/// Config options for the watch system.
92#[derive(Clone, Debug, Default, Deserialize, Args)]
93pub struct ConfigOptsWatch {
94    /// Watch specific file(s) or folder(s) [default: build target parent folder]
95    #[arg(short, long, value_name = "path")]
96    pub watch: Option<Vec<PathBuf>>,
97    /// Paths to ignore [default: []]
98    #[arg(short, long, value_name = "path")]
99    pub ignore: Option<Vec<PathBuf>>,
100}
101
102/// Config options for the serve system.
103#[derive(Clone, Debug, Default, Deserialize, Args)]
104pub struct ConfigOptsServe {
105    /// The address to serve on [default: 127.0.0.1]
106    #[arg(long)]
107    pub address: Option<IpAddr>,
108    /// The port to serve on [default: 8080]
109    #[arg(long)]
110    pub port: Option<u16>,
111    /// Open a browser tab once the initial build is complete [default: false]
112    #[arg(long)]
113    #[serde(default)]
114    pub open: bool,
115    /// A URL to which requests will be proxied [default: None]
116    #[arg(long = "proxy-backend")]
117    #[serde(default, deserialize_with = "deserialize_uri")]
118    pub proxy_backend: Option<Uri>,
119    /// The URI on which to accept requests which are to be rewritten and proxied to backend
120    /// [default: None]
121    #[arg(long = "proxy-rewrite")]
122    #[serde(default)]
123    pub proxy_rewrite: Option<String>,
124    /// Configure the proxy for handling WebSockets [default: false]
125    #[arg(long = "proxy-ws")]
126    #[serde(default)]
127    pub proxy_ws: bool,
128    /// Configure the proxy to accept insecure requests [default: false]
129    #[arg(long = "proxy-insecure")]
130    #[serde(default)]
131    pub proxy_insecure: bool,
132    /// Disable auto-reload of the web app [default: false]
133    #[arg(long = "no-autoreload")]
134    #[serde(default)]
135    pub no_autoreload: bool,
136}
137
138/// Config options for the serve system.
139#[derive(Clone, Debug, Default, Deserialize, Args)]
140pub struct ConfigOptsClean {
141    /// The output dir for all final assets [default: dist]
142    #[arg(short, long)]
143    pub dist: Option<PathBuf>,
144    /// Optionally perform a cargo clean [default: false]
145    #[arg(long)]
146    #[serde(default)]
147    pub cargo: bool,
148}
149
150/// Config options for automatic application downloads.
151#[derive(Clone, Debug, Default, Deserialize)]
152pub struct ConfigOptsTools {
153    /// Version of `dart-sass` to use.
154    pub sass: Option<String>,
155    /// Version of `wasm-bindgen` to use.
156    pub wasm_bindgen: Option<String>,
157    /// Version of `wasm-opt` to use.
158    pub wasm_opt: Option<String>,
159    /// Version of `tailwindcss-cli` to use.
160    pub tailwindcss: Option<String>,
161}
162
163/// Config options for building proxies.
164///
165/// NOTE WELL: this configuration type is different from the others inasmuch as it is only used
166/// when parsing the `Trunk.toml` config file. It is not intended to be configured via CLI or env
167/// vars.
168#[derive(Clone, Debug, Deserialize)]
169pub struct ConfigOptsProxy {
170    /// The URL of the backend to which requests are to be proxied.
171    #[serde(deserialize_with = "deserialize_uri")]
172    pub backend: Uri,
173    /// An optional URI prefix which is to be used as the base URI for proxying requests, which
174    /// defaults to the URI of the backend.
175    ///
176    /// When a value is specified, requests received on this URI will have this URI segment
177    /// replaced with the URI of the `backend`.
178    pub rewrite: Option<String>,
179    /// Configure the proxy for handling WebSockets.
180    #[serde(default)]
181    pub ws: bool,
182    /// Configure the proxy to accept insecure certificates.
183    #[serde(default)]
184    pub insecure: bool,
185}
186
187/// Config options for build system hooks.
188#[derive(Clone, Debug, Deserialize)]
189#[serde(rename_all = "snake_case")]
190pub struct ConfigOptsHook {
191    /// The stage in the build process to execute this hook.
192    pub stage: PipelineStage,
193    /// The command to run for this hook.
194    pub command: String,
195    /// Any arguments to pass to the command.
196    #[serde(default)]
197    pub command_arguments: Vec<String>,
198}
199
200/// Deserialize a Uri from a string.
201fn deserialize_uri<'de, D, T>(data: D) -> std::result::Result<T, D::Error>
202where
203    D: Deserializer<'de>,
204    T: std::convert::From<Uri>,
205{
206    let val = String::deserialize(data)?;
207    Uri::from_str(val.as_str())
208        .map(Into::into)
209        .map_err(|err| serde::de::Error::custom(err.to_string()))
210}
211
212/// A model of all potential configuration options for the Trunk CLI system.
213#[derive(Clone, Debug, Default, Deserialize)]
214pub struct ConfigOpts {
215    pub build: Option<ConfigOptsBuild>,
216    pub watch: Option<ConfigOptsWatch>,
217    pub serve: Option<ConfigOptsServe>,
218    pub clean: Option<ConfigOptsClean>,
219    pub tools: Option<ConfigOptsTools>,
220    pub proxy: Option<Vec<ConfigOptsProxy>>,
221    pub hooks: Option<Vec<ConfigOptsHook>>,
222}
223
224impl ConfigOpts {
225    /// Extract the runtime config for the build system based on all config layers.
226    pub fn rtc_build(cli_build: ConfigOptsBuild, config: Option<PathBuf>) -> Result<Arc<RtcBuild>> {
227        let base_layer = Self::file_and_env_layers(config)?;
228        let build_layer = Self::cli_opts_layer_build(cli_build, base_layer);
229        let build_opts = build_layer.build.unwrap_or_default();
230        let tools_opts = build_layer.tools.unwrap_or_default();
231        let hooks_opts = build_layer.hooks.unwrap_or_default();
232        Ok(Arc::new(RtcBuild::new(
233            build_opts, tools_opts, hooks_opts, false,
234        )?))
235    }
236
237    /// Extract the runtime config for the watch system based on all config layers.
238    pub fn rtc_watch(
239        cli_build: ConfigOptsBuild,
240        cli_watch: ConfigOptsWatch,
241        config: Option<PathBuf>,
242    ) -> Result<Arc<RtcWatch>> {
243        let base_layer = Self::file_and_env_layers(config)?;
244        let build_layer = Self::cli_opts_layer_build(cli_build, base_layer);
245        let watch_layer = Self::cli_opts_layer_watch(cli_watch, build_layer);
246        let build_opts = watch_layer.build.unwrap_or_default();
247        let watch_opts = watch_layer.watch.unwrap_or_default();
248        let tools_opts = watch_layer.tools.unwrap_or_default();
249        let hooks_opts = watch_layer.hooks.unwrap_or_default();
250        Ok(Arc::new(RtcWatch::new(
251            build_opts, watch_opts, tools_opts, hooks_opts, false,
252        )?))
253    }
254
255    /// Extract the runtime config for the serve system based on all config layers.
256    pub fn rtc_serve(
257        cli_build: ConfigOptsBuild,
258        cli_watch: ConfigOptsWatch,
259        cli_serve: ConfigOptsServe,
260        config: Option<PathBuf>,
261    ) -> Result<Arc<RtcServe>> {
262        let base_layer = Self::file_and_env_layers(config)?;
263        let build_layer = Self::cli_opts_layer_build(cli_build, base_layer);
264        let watch_layer = Self::cli_opts_layer_watch(cli_watch, build_layer);
265        let serve_layer = Self::cli_opts_layer_serve(cli_serve, watch_layer);
266        let build_opts = serve_layer.build.unwrap_or_default();
267        let watch_opts = serve_layer.watch.unwrap_or_default();
268        let serve_opts = serve_layer.serve.unwrap_or_default();
269        let tools_opts = serve_layer.tools.unwrap_or_default();
270        let hooks_opts = serve_layer.hooks.unwrap_or_default();
271        Ok(Arc::new(RtcServe::new(
272            build_opts,
273            watch_opts,
274            serve_opts,
275            tools_opts,
276            hooks_opts,
277            serve_layer.proxy,
278        )?))
279    }
280
281    /// Extract the runtime config for the clean system based on all config layers.
282    pub fn rtc_clean(cli_clean: ConfigOptsClean, config: Option<PathBuf>) -> Result<Arc<RtcClean>> {
283        let base_layer = Self::file_and_env_layers(config)?;
284        let clean_layer = Self::cli_opts_layer_clean(cli_clean, base_layer);
285        let clean_opts = clean_layer.clean.unwrap_or_default();
286        Ok(Arc::new(RtcClean::new(clean_opts)))
287    }
288
289    /// Return the full configuration based on config file & environment variables.
290    pub fn full(config: Option<PathBuf>) -> Result<Self> {
291        Self::file_and_env_layers(config)
292    }
293
294    fn cli_opts_layer_build(cli: ConfigOptsBuild, cfg_base: Self) -> Self {
295        let opts = ConfigOptsBuild {
296            target: cli.target,
297            release: cli.release,
298            dist: cli.dist,
299            public_url: cli.public_url,
300            no_default_features: cli.no_default_features,
301            all_features: cli.all_features,
302            features: cli.features,
303            filehash: cli.filehash,
304            inject_scripts: cli.inject_scripts,
305            pattern_script: cli.pattern_script,
306            pattern_preload: cli.pattern_preload,
307            pattern_params: cli.pattern_params,
308        };
309        let cfg_build = ConfigOpts {
310            build: Some(opts),
311            watch: None,
312            serve: None,
313            clean: None,
314            tools: None,
315            proxy: None,
316            hooks: None,
317        };
318        Self::merge(cfg_base, cfg_build)
319    }
320
321    fn cli_opts_layer_watch(cli: ConfigOptsWatch, cfg_base: Self) -> Self {
322        let opts = ConfigOptsWatch {
323            watch: cli.watch,
324            ignore: cli.ignore,
325        };
326        let cfg = ConfigOpts {
327            build: None,
328            watch: Some(opts),
329            serve: None,
330            clean: None,
331            tools: None,
332            proxy: None,
333            hooks: None,
334        };
335        Self::merge(cfg_base, cfg)
336    }
337
338    fn cli_opts_layer_serve(cli: ConfigOptsServe, cfg_base: Self) -> Self {
339        let opts = ConfigOptsServe {
340            address: cli.address,
341            port: cli.port,
342            open: cli.open,
343            proxy_backend: cli.proxy_backend,
344            proxy_rewrite: cli.proxy_rewrite,
345            proxy_insecure: cli.proxy_insecure,
346            proxy_ws: cli.proxy_ws,
347            no_autoreload: cli.no_autoreload,
348        };
349        let cfg = ConfigOpts {
350            build: None,
351            watch: None,
352            serve: Some(opts),
353            clean: None,
354            tools: None,
355            proxy: None,
356            hooks: None,
357        };
358        Self::merge(cfg_base, cfg)
359    }
360
361    fn cli_opts_layer_clean(cli: ConfigOptsClean, cfg_base: Self) -> Self {
362        let opts = ConfigOptsClean {
363            dist: cli.dist,
364            cargo: cli.cargo,
365        };
366        let cfg = ConfigOpts {
367            build: None,
368            watch: None,
369            serve: None,
370            clean: Some(opts),
371            tools: None,
372            proxy: None,
373            hooks: None,
374        };
375        Self::merge(cfg_base, cfg)
376    }
377
378    fn file_and_env_layers(path: Option<PathBuf>) -> Result<Self> {
379        let toml_cfg = Self::from_file(path)?;
380        let env_cfg = Self::from_env().context("error reading trunk env var config")?;
381        let cfg = Self::merge(toml_cfg, env_cfg);
382        Ok(cfg)
383    }
384
385    /// Read runtime config from a `Trunk.toml` file at the target path.
386    ///
387    /// NOTE WELL: any paths specified in a Trunk.toml file must be interpreted as being relative
388    /// to the file itself.
389    fn from_file(path: Option<PathBuf>) -> Result<Self> {
390        let mut trunk_toml_path = path.unwrap_or_else(|| "Trunk.toml".into());
391        if !trunk_toml_path.exists() {
392            return Ok(Default::default());
393        }
394        if !trunk_toml_path.is_absolute() {
395            trunk_toml_path = trunk_toml_path.canonicalize().with_context(|| {
396                format!(
397                    "error getting canonical path to Trunk config file {:?}",
398                    &trunk_toml_path
399                )
400            })?;
401        }
402        let cfg_bytes =
403            std::fs::read_to_string(&trunk_toml_path).context("error reading config file")?;
404        let mut cfg: Self = toml::from_str(&cfg_bytes)
405            .context("error reading config file contents as TOML data")?;
406        if let Some(parent) = trunk_toml_path.parent() {
407            if let Some(build) = cfg.build.as_mut() {
408                if let Some(target) = build.target.as_mut() {
409                    if !target.is_absolute() {
410                        *target =
411                            std::fs::canonicalize(parent.join(&target)).with_context(|| {
412                                format!(
413                                    "error taking canonical path to [build].target {:?} in {:?}",
414                                    target, trunk_toml_path
415                                )
416                            })?;
417                    }
418                }
419                if let Some(dist) = build.dist.as_mut() {
420                    if !dist.is_absolute() {
421                        *dist = parent.join(&dist);
422                    }
423                }
424            }
425            if let Some(watch) = cfg.watch.as_mut() {
426                if let Some(watch_paths) = watch.watch.as_mut() {
427                    for path in watch_paths.iter_mut() {
428                        if !path.is_absolute() {
429                            *path =
430                                std::fs::canonicalize(parent.join(&path)).with_context(|| {
431                                    format!(
432                                        "error taking canonical path to [watch].watch {:?} in {:?}",
433                                        path, trunk_toml_path
434                                    )
435                                })?;
436                        }
437                    }
438                }
439                if let Some(ignore_paths) = watch.ignore.as_mut() {
440                    for path in ignore_paths.iter_mut() {
441                        if !path.is_absolute() {
442                            *path =
443                                std::fs::canonicalize(parent.join(&path)).with_context(|| {
444                                    format!(
445                                        "error taking canonical path to [watch].ignore {:?} in \
446                                         {:?}",
447                                        path, trunk_toml_path
448                                    )
449                                })?;
450                        }
451                    }
452                }
453            }
454            if let Some(clean) = cfg.clean.as_mut() {
455                if let Some(dist) = clean.dist.as_mut() {
456                    if !dist.is_absolute() {
457                        *dist = parent.join(&dist);
458                    }
459                }
460            }
461        }
462        Ok(cfg)
463    }
464
465    fn from_env() -> Result<Self> {
466        Ok(ConfigOpts {
467            build: Some(envy::prefixed("TRUNK_BUILD_").from_env()?),
468            watch: Some(envy::prefixed("TRUNK_WATCH_").from_env()?),
469            serve: Some(envy::prefixed("TRUNK_SERVE_").from_env()?),
470            clean: Some(envy::prefixed("TRUNK_CLEAN_").from_env()?),
471            tools: Some(envy::prefixed("TRUNK_TOOLS_").from_env()?),
472            proxy: None,
473            hooks: None,
474        })
475    }
476
477    /// Merge the given layers, where the `greater` layer takes precedence.
478    fn merge(mut lesser: Self, mut greater: Self) -> Self {
479        greater.build = match (lesser.build.take(), greater.build.take()) {
480            (None, None) => None,
481            (Some(val), None) | (None, Some(val)) => Some(val),
482            (Some(l), Some(mut g)) => {
483                g.target = g.target.or(l.target);
484                g.dist = g.dist.or(l.dist);
485                g.public_url = g.public_url.or(l.public_url);
486                g.filehash = g.filehash.or(l.filehash);
487                // NOTE: this can not be disabled in the cascade.
488                if l.release {
489                    g.release = true;
490                }
491                g.inject_scripts = g.inject_scripts.or(l.inject_scripts);
492                g.pattern_preload = g.pattern_preload.or(l.pattern_preload);
493                g.pattern_script = g.pattern_script.or(l.pattern_script);
494                g.pattern_params = g.pattern_params.or(l.pattern_params);
495                Some(g)
496            }
497        };
498        greater.watch = match (lesser.watch.take(), greater.watch.take()) {
499            (None, None) => None,
500            (Some(val), None) | (None, Some(val)) => Some(val),
501            (Some(l), Some(mut g)) => {
502                g.watch = g.watch.or(l.watch);
503                g.ignore = g.ignore.or(l.ignore);
504                Some(g)
505            }
506        };
507        greater.serve = match (lesser.serve.take(), greater.serve.take()) {
508            (None, None) => None,
509            (Some(val), None) | (None, Some(val)) => Some(val),
510            (Some(l), Some(mut g)) => {
511                g.proxy_backend = g.proxy_backend.or(l.proxy_backend);
512                g.proxy_rewrite = g.proxy_rewrite.or(l.proxy_rewrite);
513                g.address = g.address.or(l.address);
514                g.port = g.port.or(l.port);
515                g.proxy_ws = g.proxy_ws || l.proxy_ws;
516                // NOTE: this can not be disabled in the cascade.
517                if l.no_autoreload {
518                    g.no_autoreload = true;
519                }
520                // NOTE: this can not be disabled in the cascade.
521                if l.open {
522                    g.open = true;
523                }
524                Some(g)
525            }
526        };
527        greater.tools = match (lesser.tools.take(), greater.tools.take()) {
528            (None, None) => None,
529            (Some(val), None) | (None, Some(val)) => Some(val),
530            (Some(l), Some(mut g)) => {
531                g.sass = g.sass.or(l.sass);
532                g.wasm_bindgen = g.wasm_bindgen.or(l.wasm_bindgen);
533                g.wasm_opt = g.wasm_opt.or(l.wasm_opt);
534                Some(g)
535            }
536        };
537        greater.clean = match (lesser.clean.take(), greater.clean.take()) {
538            (None, None) => None,
539            (Some(val), None) | (None, Some(val)) => Some(val),
540            (Some(l), Some(mut g)) => {
541                g.dist = g.dist.or(l.dist);
542                // NOTE: this can not be disabled in the cascade.
543                if l.cargo {
544                    g.cargo = true;
545                }
546                Some(g)
547            }
548        };
549        greater.proxy = match (lesser.proxy.take(), greater.proxy.take()) {
550            (None, None) => None,
551            (Some(val), None) | (None, Some(val)) => Some(val),
552            (Some(_), Some(g)) => Some(g), // No meshing/merging. Only take the greater value.
553        };
554        greater.hooks = match (lesser.hooks.take(), greater.hooks.take()) {
555            (None, None) => None,
556            (Some(val), None) | (None, Some(val)) => Some(val),
557            (Some(_), Some(g)) => Some(g), // No meshing/merging. Only take the greater value.
558        };
559        greater
560    }
561}