Skip to main content

henad_app/
options.rs

1//! Options a host opens the app with, the opening the app starts on, and the reasons it does not start.
2//!
3//! The product is the app a host ships: its name, its build, its icon and links, and the command line it specifies. The
4//! official app is Henad's own product, the `henad-app` binary over the example models.
5
6#[cfg(not(target_arch = "wasm32"))]
7use std::path::PathBuf;
8
9use henad_compute::entry::ModelSet;
10use henad_compute::simulation::{RunSetup, SetupError};
11use henad_core::explore::fingerprint::schema_hash;
12use henad_core::explore::replay::Replay;
13use henad_core::provenance::BuildInfo;
14
15use crate::state::OpenAt;
16use crate::ui::sweep::draft::error_chain;
17
18/// Henad's icon, the default of [`AppOptions::icon_png`].
19const HENAD_ICON_PNG: &[u8] = include_bytes!("../assets/icon-256.png");
20
21/// Model set, product details and opening a host starts the app with.
22#[derive(Debug)]
23pub struct AppOptions {
24    pub(crate) models: ModelSet,
25    pub(crate) product: Product,
26    pub(crate) opening: Option<AppOpening>,
27    /// Note shown in the Performance tab when the browser's thread pool failed to start, set by `start_web`.
28    pub(crate) thread_pool_note: Option<String>,
29}
30
31impl AppOptions {
32    /// Returns options that open `models` under the name `product`.
33    ///
34    /// `product` is the window title and, on native targets, the folder eframe stores the app's state in, with `-` for
35    /// each character a folder name cannot contain and `henad-app` for a name that ends up empty. `host` is the
36    /// build recorded as the host in each sweep's manifest, in the run details and in the About window. The app opens
37    /// on the first model of `models` that runs on the device, with Henad's icon, no links, no licence and no command
38    /// line.
39    pub fn new(models: ModelSet, product: impl Into<String>, host: BuildInfo) -> Self {
40        Self {
41            models,
42            product: Product {
43                name: product.into(),
44                host,
45                icon_png: HENAD_ICON_PNG,
46                source_url: None,
47                documentation_url: None,
48                license: None,
49                cli_command: None,
50                official: false,
51            },
52            opening: None,
53            thread_pool_note: None,
54        }
55    }
56
57    /// Sets the window icon and the About window's image, as the bytes of a PNG file.
58    pub fn icon_png(mut self, png: &'static [u8]) -> Self {
59        self.product.icon_png = png;
60        self
61    }
62
63    /// Sets the link to the product's source code, shown in the About menu and the About window.
64    pub fn source_url(mut self, url: impl Into<String>) -> Self {
65        self.product.source_url = Some(url.into());
66        self
67    }
68
69    /// Sets the link to the product's documentation, shown in the About menu and the About window.
70    pub fn documentation_url(mut self, url: impl Into<String>) -> Self {
71        self.product.documentation_url = Some(url.into());
72        self
73    }
74
75    /// Sets the licence shown in the About window, such as `MIT OR Apache-2.0`.
76    pub fn license(mut self, license: impl Into<String>) -> Self {
77        self.product.license = Some(license.into());
78        self
79    }
80
81    /// Sets the command line the Copy command writes and the Sweep tab's advice refers to, as one program name such as
82    /// `henad-cli`. Without one the button is hidden.
83    ///
84    /// Note that the Copy command quotes the name as one shell word. A name with a space, such as `cargo run --`, is
85    /// written as a single quoted word.
86    pub fn cli_command(mut self, command: impl Into<String>) -> Self {
87        self.product.cli_command = Some(command.into());
88        self
89    }
90
91    /// Sets the opening the app starts on instead of the first model of the set.
92    pub fn opening(mut self, opening: AppOpening) -> Self {
93        self.opening = Some(opening);
94        self
95    }
96
97    /// Marks the options as Henad's own app, whose About window shows Henad's logo and tagline and no Built on row.
98    ///
99    /// Only the official binary calls it.
100    #[doc(hidden)]
101    pub fn __official(mut self) -> Self {
102        self.product.official = true;
103        self
104    }
105
106    /// Returns the reason the options' models cannot serve their opening.
107    ///
108    /// The check reads the set before any device exists. A model the device turns out unable to run opens the app with
109    /// nothing selected instead.
110    pub(crate) fn check_opening(&self) -> Result<(), OpeningError> {
111        let Some(opening) = &self.opening else {
112            return Ok(());
113        };
114        match opening {
115            #[cfg(not(target_arch = "wasm32"))]
116            AppOpening::Results(_) => Ok(()),
117            AppOpening::Run { replay, .. } => {
118                let entry = self
119                    .models
120                    .get(&replay.model)
121                    .ok_or_else(|| OpeningError::NotInSet(replay.model.clone()))?;
122                match RunSetup::from_replay(entry, replay) {
123                    Ok(_) => Ok(()),
124                    Err(SetupError::ParamCount { expected, found }) => Err(OpeningError::ParamCount {
125                        model: replay.model.clone(),
126                        given: found,
127                        declared: expected,
128                    }),
129                    Err(error) => Err(OpeningError::RunRefused {
130                        model: replay.model.clone(),
131                        reason: refusal_reason(&error),
132                    }),
133                }
134            }
135            AppOpening::Setup { setup, .. } => {
136                let id = setup.entry().id();
137                let entry = self
138                    .models
139                    .get(id)
140                    .ok_or_else(|| OpeningError::NotInSet(id.to_owned()))?;
141                if schema_hash(&entry.schema()) == schema_hash(&setup.entry().schema()) {
142                    Ok(())
143                } else {
144                    Err(OpeningError::OtherSchema(id.to_owned()))
145                }
146            }
147        }
148    }
149}
150
151/// Name, build, icon, links and command line of the app a host ships.
152#[derive(Clone)]
153pub(crate) struct Product {
154    pub name: String,
155    /// Build of the host, recorded in every sweep's manifest and in the run details.
156    pub host: BuildInfo,
157    pub icon_png: &'static [u8],
158    pub source_url: Option<String>,
159    pub documentation_url: Option<String>,
160    pub license: Option<String>,
161    /// Program that the Copy command and the Sweep tab's advice refer to.
162    pub cli_command: Option<String>,
163    /// Whether this is Henad's own app, set by the official binary through [`AppOptions::__official`].
164    pub official: bool,
165}
166
167impl std::fmt::Debug for Product {
168    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
169        formatter
170            .debug_struct("Product")
171            .field("name", &self.name)
172            .field("host", &self.host)
173            .field("icon_png_bytes", &self.icon_png.len())
174            .field("source_url", &self.source_url)
175            .field("documentation_url", &self.documentation_url)
176            .field("license", &self.license)
177            .field("cli_command", &self.cli_command)
178            .field("official", &self.official)
179            .finish()
180    }
181}
182
183/// Returns the phrase that advice uses for the command line, "with henad-cli" for `command` `henad-cli`, or "on the
184/// command line" without a command.
185pub(crate) fn cli_phrase(command: Option<&str>) -> String {
186    command.map_or_else(|| "on the command line".to_owned(), |command| format!("with {command}"))
187}
188
189/// Results folder, run or setup the app opens on, beside its model list.
190#[derive(Debug, Clone)]
191#[non_exhaustive]
192pub enum AppOpening {
193    /// A results folder, as specified by `--open DIR`. Native only.
194    #[cfg(not(target_arch = "wasm32"))]
195    Results(PathBuf),
196    /// One recorded run, `replay`, rebuilt and stepped to `open_at`.
197    Run {
198        /// Recorded run, with its model, values, seed and schedule.
199        replay: Replay,
200        /// Tick the app steps the run to.
201        open_at: OpenAt,
202    },
203    /// A setup the host built, `setup`, rebuilt and stepped to `open_at`.
204    ///
205    /// The app builds the entry from its own set under the setup's model id, which has to declare the same schema as
206    /// `setup.entry()`.
207    Setup {
208        /// Setup that provides the model id, values, seed and schedule.
209        setup: RunSetup,
210        /// Tick the app steps the model to.
211        open_at: OpenAt,
212    },
213}
214
215/// Reason the options' models cannot serve their opening.
216#[derive(Debug, Clone, PartialEq, Eq)]
217pub(crate) enum OpeningError {
218    /// The set holds no model under the id.
219    NotInSet(String),
220    /// A run sets a different number of parameters than its model declares.
221    ParamCount {
222        model: String,
223        given: usize,
224        declared: usize,
225    },
226    /// The set's model under the setup's id declares a different schema than the setup's entry.
227    OtherSchema(String),
228    /// The model rejects a value or a scheduled action of the run.
229    RunRefused { model: String, reason: String },
230}
231
232/// Returns the reason in `error`, in the form that ends an [`OpeningError::RunRefused`] message.
233fn refusal_reason(error: &SetupError) -> String {
234    match error {
235        SetupError::Param(reason) => error_chain(reason),
236        other => error_chain(other),
237    }
238}
239
240impl std::fmt::Display for OpeningError {
241    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
242        match self {
243            Self::NotInSet(model) => write!(formatter, "this build does not include model '{model}'"),
244            Self::ParamCount { model, given, declared } => write!(
245                formatter,
246                "the run to open sets {given} {}, but model '{model}' has {declared}",
247                crate::ui::plural(*given as u64, "parameter")
248            ),
249            Self::OtherSchema(model) => write!(
250                formatter,
251                "the setup to open declares other parameters, stats or actions than model '{model}' of this build"
252            ),
253            Self::RunRefused { model, reason } => {
254                write!(formatter, "model '{model}' refuses the run to open: {reason}")
255            }
256        }
257    }
258}
259
260/// Reason the native app did not start, or ended with an error.
261///
262/// Its `Debug` writes the reason and its source, so a `main` that returns it prints a readable message.
263#[cfg(not(target_arch = "wasm32"))]
264pub struct AppError {
265    kind: AppErrorKind,
266}
267
268#[cfg(not(target_arch = "wasm32"))]
269#[derive(Debug)]
270enum AppErrorKind {
271    Opening(OpeningError),
272    Eframe(eframe::Error),
273}
274
275#[cfg(not(target_arch = "wasm32"))]
276impl AppError {
277    pub(crate) fn opening(error: OpeningError) -> Self {
278        Self {
279            kind: AppErrorKind::Opening(error),
280        }
281    }
282
283    pub(crate) fn eframe(error: eframe::Error) -> Self {
284        Self {
285            kind: AppErrorKind::Eframe(error),
286        }
287    }
288}
289
290#[cfg(not(target_arch = "wasm32"))]
291impl std::fmt::Debug for AppError {
292    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
293        std::fmt::Display::fmt(self, formatter)?;
294        match &self.kind {
295            AppErrorKind::Opening(_) => Ok(()),
296            AppErrorKind::Eframe(error) => write!(formatter, ": {error}"),
297        }
298    }
299}
300
301#[cfg(not(target_arch = "wasm32"))]
302impl std::fmt::Display for AppError {
303    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
304        match &self.kind {
305            AppErrorKind::Opening(error) => write!(formatter, "the app cannot open: {error}"),
306            AppErrorKind::Eframe(_) => formatter.write_str("the app window failed"),
307        }
308    }
309}
310
311#[cfg(not(target_arch = "wasm32"))]
312impl std::error::Error for AppError {
313    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
314        match &self.kind {
315            AppErrorKind::Opening(_) => None,
316            AppErrorKind::Eframe(error) => Some(error),
317        }
318    }
319}
320
321/// Reason the web app did not start.
322#[cfg(target_arch = "wasm32")]
323#[derive(Debug)]
324pub struct WebStartError {
325    kind: WebStartErrorKind,
326}
327
328#[cfg(target_arch = "wasm32")]
329#[derive(Debug)]
330enum WebStartErrorKind {
331    /// The code runs outside a browser window, or the window has no document.
332    NoWindow,
333    /// The page has no canvas under the id.
334    MissingCanvas(&'static str),
335    Opening(OpeningError),
336    /// eframe's error, written out. eframe throws a JavaScript value, which is not a `std::error::Error`.
337    Eframe(String),
338}
339
340#[cfg(target_arch = "wasm32")]
341impl WebStartError {
342    pub(crate) fn no_window() -> Self {
343        Self {
344            kind: WebStartErrorKind::NoWindow,
345        }
346    }
347
348    pub(crate) fn missing_canvas(id: &'static str) -> Self {
349        Self {
350            kind: WebStartErrorKind::MissingCanvas(id),
351        }
352    }
353
354    pub(crate) fn opening(error: OpeningError) -> Self {
355        Self {
356            kind: WebStartErrorKind::Opening(error),
357        }
358    }
359
360    pub(crate) fn eframe(error: &eframe::wasm_bindgen::JsValue) -> Self {
361        Self {
362            kind: WebStartErrorKind::Eframe(format!("{error:?}")),
363        }
364    }
365}
366
367#[cfg(target_arch = "wasm32")]
368impl std::fmt::Display for WebStartError {
369    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
370        match &self.kind {
371            WebStartErrorKind::NoWindow => formatter.write_str("the app runs outside a browser window with a document"),
372            WebStartErrorKind::MissingCanvas(id) => write!(formatter, "the page has no canvas '{id}'"),
373            WebStartErrorKind::Opening(error) => write!(formatter, "the app cannot open: {error}"),
374            WebStartErrorKind::Eframe(error) => write!(formatter, "the app failed to start: {error}"),
375        }
376    }
377}
378
379#[cfg(target_arch = "wasm32")]
380impl std::error::Error for WebStartError {}
381
382#[cfg(test)]
383mod tests {
384    use henad_compute::entry::{ModelSet, register_grid_model};
385    use henad_core::action::{Schedule, Scheduled};
386    use henad_core::authoring::model::grid_model::GridModel;
387    use henad_core::explore::replay::Replay;
388    use henad_core::grid::Grid2D;
389    use henad_core::params::{ParamDescriptor, ParamValue};
390    use henad_core::topology::NeighborhoodKind;
391    use henad_core::view::{StatDescriptor, StatValue};
392
393    use super::{AppOpening, AppOptions, OpeningError};
394    use crate::state::OpenAt;
395
396    /// A host's own model under the id of an example model, declaring no parameters.
397    struct OtherSir;
398
399    impl GridModel for OtherSir {
400        const NAME: &'static str = "Other SIR";
401        const ID: &'static str = "sir";
402        const DESCRIPTION: &'static str = "A model under the example SIR's id, registered only by tests";
403        const PALETTE: &'static [[u8; 4]] = &[[0, 0, 0, 0xFF]];
404        const NEIGHBORHOOD: NeighborhoodKind = NeighborhoodKind::Moore;
405        const STATS: &'static [StatDescriptor] = &[StatDescriptor::new("Cells", [0xFF, 0xFF, 0xFF, 0xFF])];
406        type Params = ();
407
408        fn param_descriptors() -> Vec<ParamDescriptor> {
409            Vec::new()
410        }
411
412        fn from_params(_params: &[ParamValue]) {}
413
414        fn init(_grid: &mut Grid2D<u8>, _params: &[ParamValue], _rng: &mut u64) {}
415
416        fn step_cell(cell: u8, _neighbors: &[u8], _params: &(), _rng: &mut u64) -> u8 {
417            cell
418        }
419
420        fn stats(grid: &Grid2D<u8>) -> Vec<StatValue> {
421            vec![StatValue::Scalar(grid.current().len() as f64)]
422        }
423    }
424
425    fn options(models: ModelSet, opening: AppOpening) -> AppOptions {
426        AppOptions::new(models, "Test", henad_core::build_info!()).opening(opening)
427    }
428
429    fn run_of(model: &str, params: Vec<ParamValue>) -> AppOpening {
430        AppOpening::Run {
431            replay: Replay {
432                model: model.to_owned(),
433                params,
434                seed: 1,
435                schedule: Schedule::default(),
436                ticks: 10,
437                label: "Sweep run 0".to_owned(),
438            },
439            open_at: OpenAt::Start,
440        }
441    }
442
443    #[test]
444    fn a_mismatched_opening_is_refused() {
445        let examples = henad_models::example_models();
446        let sir = examples.get("sir").expect("the example models include sir");
447        let defaults = sir.setup().values().to_vec();
448        let declared = defaults.len();
449
450        assert_eq!(
451            options(examples.clone(), run_of("sir", defaults.clone())).check_opening(),
452            Ok(())
453        );
454        assert_eq!(
455            options(examples.clone(), run_of("absent", defaults.clone())).check_opening(),
456            Err(OpeningError::NotInSet("absent".to_owned()))
457        );
458        assert_eq!(
459            options(examples.clone(), run_of("sir", defaults[1..].to_vec())).check_opening(),
460            Err(OpeningError::ParamCount {
461                model: "sir".to_owned(),
462                given: declared - 1,
463                declared,
464            })
465        );
466
467        let rate = sir
468            .param_descriptors()
469            .iter()
470            .position(|descriptor| descriptor.id == "infection_rate")
471            .expect("sir declares infection_rate");
472        let mut out_of_bounds = defaults.clone();
473        out_of_bounds[rate] = ParamValue::F32(2.0);
474        assert!(
475            matches!(
476                options(examples.clone(), run_of("sir", out_of_bounds)).check_opening(),
477                Err(OpeningError::RunRefused { model, reason })
478                    if model == "sir" && reason.starts_with("parameter 'infection_rate'")
479            ),
480            "a value out of bounds opened the window"
481        );
482        let mut unknown_action = run_of("sir", defaults.clone());
483        if let AppOpening::Run { replay, .. } = &mut unknown_action {
484            replay.schedule = Schedule::from_entries(vec![Scheduled {
485                index: 0,
486                id: "absent".to_owned(),
487                tick: 5,
488            }]);
489        }
490        assert_eq!(
491            options(examples.clone(), unknown_action).check_opening(),
492            Err(OpeningError::RunRefused {
493                model: "sir".to_owned(),
494                reason: "model has no action 'absent'".to_owned(),
495            })
496        );
497
498        let example_setup = || AppOpening::Setup {
499            setup: sir.setup(),
500            open_at: OpenAt::Start,
501        };
502        assert_eq!(options(examples.clone(), example_setup()).check_opening(), Ok(()));
503
504        let mut others = ModelSet::new(henad_core::build_info!());
505        others
506            .insert(register_grid_model::<OtherSir>())
507            .expect("one entry under a valid id");
508        assert_eq!(
509            options(others.clone(), example_setup()).check_opening(),
510            Err(OpeningError::OtherSchema("sir".to_owned())),
511            "a setup of the example sir opened over another model under its id"
512        );
513        let other_setup = AppOpening::Setup {
514            setup: others.get("sir").expect("just inserted").setup(),
515            open_at: OpenAt::Start,
516        };
517        assert_eq!(options(others, other_setup).check_opening(), Ok(()));
518
519        let empty = ModelSet::new(henad_core::build_info!());
520        assert_eq!(
521            options(empty, example_setup()).check_opening(),
522            Err(OpeningError::NotInSet("sir".to_owned()))
523        );
524    }
525}