Skip to main content

basalt_bedrock/
lib.rs

1use std::{
2    hash::{DefaultHasher, Hasher},
3    io::Read,
4    path::PathBuf,
5    time::Duration,
6};
7
8use integrations::Integrations;
9use language::LanguageSet;
10use miette::{Diagnostic, LabeledSpan, NamedSource, SourceCode};
11use packet::Packet;
12use roi::RawOrImport;
13use serde::{Deserialize, Serialize};
14use typst::foundations::{Array, Dict, IntoValue, Str, Value};
15
16mod custom_serde;
17pub mod integrations;
18pub mod language;
19pub mod packet;
20pub mod render;
21pub mod roi;
22pub mod scoring;
23
24mod util;
25
26#[cfg(test)]
27mod tests;
28
29pub(crate) fn default_false() -> bool {
30    false
31}
32
33pub(crate) fn default_true() -> bool {
34    false
35}
36
37pub(crate) fn default_port() -> u16 {
38    8517
39}
40
41pub(crate) fn default_time_limit() -> Duration {
42    Duration::from_secs(60 * 75)
43}
44
45pub(crate) fn default_points() -> i32 {
46    10
47}
48
49/// Authentication details for a specific user (competitor or host)
50#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, Ord, PartialOrd, Hash, Default)]
51#[serde(deny_unknown_fields)]
52pub struct User {
53    pub name: String,
54    pub display_name: Option<String>,
55    pub password: String,
56}
57
58impl User {
59    pub fn display_name(&self) -> &str {
60        self.display_name.as_ref().unwrap_or(&self.name)
61    }
62}
63
64/// Set of users that are either hosts or competitors
65#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, Ord, PartialOrd, Hash, Default)]
66#[serde(deny_unknown_fields)]
67pub struct Accounts {
68    /// Hosts in charge of managing the competition
69    pub hosts: Vec<User>,
70    /// Competitors participating in the competition
71    pub competitors: Vec<User>,
72}
73
74impl Accounts {
75    pub fn to_value(&self) -> (Value, Value) {
76        let hosts = self
77            .hosts
78            .iter()
79            .map(|h| {
80                [
81                    (Str::from("username"), Str::from(&*h.name).into_value()),
82                    (Str::from("password"), Str::from(&*h.password).into_value()),
83                ]
84                .into_iter()
85                .collect::<Dict>()
86                .into_value()
87            })
88            .collect::<Array>();
89        let competitors = self
90            .competitors
91            .iter()
92            .map(|c| {
93                [
94                    (Str::from("username"), Str::from(&*c.name).into_value()),
95                    (Str::from("password"), Str::from(&*c.password).into_value()),
96                ]
97                .into_iter()
98                .collect::<Dict>()
99                .into_value()
100            })
101            .collect::<Array>();
102
103        (hosts.into_value(), competitors.into_value())
104    }
105}
106
107/// Configuration for setting up the docker container and starting the server
108#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, Ord, PartialOrd, Hash, Default)]
109#[serde(deny_unknown_fields)]
110pub struct Setup {
111    /// Specifies what commands are to be run when building the container to ensure dependencies
112    /// are installed.
113    pub install: Option<RawOrImport<String, roi::Raw>>,
114    /// Specifies commands to run before running basalt-server so that dependencies are enabled
115    /// properly.
116    pub init: Option<RawOrImport<String, roi::Raw>>,
117}
118
119#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, Ord, PartialOrd, Hash)]
120#[serde(deny_unknown_fields)]
121pub struct PointsSettings {
122    /// An expression used to evaluate how many points a competitor should get upon
123    /// completion of a problem. This expression has access to variables that can allow for
124    /// very flexible scoring mechanisms. By default however, all points will simply be
125    /// awarded upon successful submission and evaluation.
126    ///
127    /// Variables:
128    ///
129    /// `p` or `points`: number of points available for the problem
130    ///
131    /// `t` or `teams`: number of teams in the competition
132    ///
133    /// `c` or `completed`: number of teams that have already completed the problem
134    ///
135    /// `a` or `attempts`: number of attempts that a team has made to solve the problem
136    ///
137    /// Example:
138    /// ```toml
139    /// # Decrease the points by 2 each time someone completes it.
140    /// score = "p - 2*c"
141    /// ```
142    pub score: String,
143    #[serde(default = "default_points")]
144    pub question_point_value: i32,
145    #[serde(default = "default_time_limit", with = "custom_serde::duration")]
146    pub time_limit: Duration,
147}
148
149impl Default for PointsSettings {
150    fn default() -> Self {
151        Self {
152            score: "p".into(),
153            question_point_value: default_points(),
154            time_limit: default_time_limit(),
155        }
156    }
157}
158
159#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, Ord, PartialOrd, Hash, Default)]
160#[serde(untagged)]
161pub enum RaceMode {
162    #[default]
163    Sprint,
164    Endurance,
165    Relay,
166}
167
168#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, Ord, PartialOrd, Hash, Default)]
169pub struct RaceSettings {
170    pub race: RaceMode,
171    pub arcade: bool,
172    #[serde(with = "custom_serde::option_duration")]
173    pub time_limit: Option<Duration>,
174}
175
176#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, Ord, PartialOrd, Hash)]
177#[serde(untagged)]
178pub enum Game {
179    Points(PointsSettings),
180    Race(RaceSettings),
181}
182
183impl Default for Game {
184    fn default() -> Self {
185        Self::Points(PointsSettings::default())
186    }
187}
188
189#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, Ord, PartialOrd, Hash)]
190#[serde(deny_unknown_fields)]
191pub struct FileCopy {
192    /// Source file to copy
193    ///
194    /// Relative to the directory in which the server is running
195    pub from: PathBuf,
196    /// Destination of the file
197    ///
198    /// Relative to the directory in which the test is run
199    pub to: PathBuf,
200}
201
202/// Mirrors the `CommandConfig` type in [leucite](https://basalt-rs.github.io/erudite/erudite/struct.CommandConfig.html)
203#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, Ord, PartialOrd, Hash, Default)]
204#[serde(deny_unknown_fields, untagged)]
205pub enum CommandConfig<T> {
206    #[default]
207    Neither,
208    Both(T),
209    Compile {
210        compile: T,
211    },
212    Run {
213        run: T,
214    },
215    Each {
216        compile: T,
217        run: T,
218    },
219}
220
221impl<T> CommandConfig<T> {
222    pub fn compile(&self) -> Option<&T> {
223        match self {
224            CommandConfig::Neither => None,
225            CommandConfig::Both(t) => Some(t),
226            CommandConfig::Compile { compile } => Some(compile),
227            CommandConfig::Run { .. } => None,
228            CommandConfig::Each { compile, .. } => Some(compile),
229        }
230    }
231
232    pub fn run(&self) -> Option<&T> {
233        match self {
234            CommandConfig::Neither => None,
235            CommandConfig::Both(t) => Some(t),
236            CommandConfig::Compile { .. } => None,
237            CommandConfig::Run { run } => Some(run),
238            CommandConfig::Each { run, .. } => Some(run),
239        }
240    }
241}
242
243/// Configuration for the test runner
244#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, Ord, PartialOrd, Hash)]
245#[serde(deny_unknown_fields)]
246pub struct TestRunner {
247    /// The amount of time that a test may run before it is cancelled by the test runner and marked
248    /// as failure
249    ///
250    /// Measured in milliseconds
251    ///
252    /// [Default: 10 seconds]
253    #[serde(
254        with = "custom_serde::duration",
255        default = "TestRunner::default_timeout"
256    )]
257    pub timeout: Duration,
258    /// Whether the test runner should trim the output of a test before comparing with the
259    /// expected output
260    ///
261    /// If this is true, the output of `hello world    ` matches the expected output of ` hello
262    /// world`
263    ///
264    /// [Default: true]
265    #[serde(default = "TestRunner::default_trim_output")]
266    pub trim_output: bool,
267    /// Files to copy into the test directory
268    #[serde(default)]
269    pub copy_files: Vec<FileCopy>,
270    /// Amount of memory that may be used by the process, measured in MiB
271    #[serde(default)]
272    pub max_memory: CommandConfig<u64>,
273    /// Maximum size of files that may be created by the tests, measured in MiB
274    #[serde(default)]
275    pub max_file_size: CommandConfig<u64>,
276}
277
278impl TestRunner {
279    fn default_timeout() -> Duration {
280        Duration::from_secs(10)
281    }
282
283    fn default_trim_output() -> bool {
284        true
285    }
286}
287
288impl Default for TestRunner {
289    fn default() -> Self {
290        Self {
291            timeout: Self::default_timeout(),
292            trim_output: Self::default_trim_output(),
293            copy_files: Default::default(),
294            max_memory: CommandConfig::Neither,
295            max_file_size: CommandConfig::Neither,
296        }
297    }
298}
299
300#[derive(Debug, thiserror::Error, Diagnostic)]
301pub enum ConfigReadError {
302    /// The Config file was unable to be read due to an IO error
303    #[error("Failed to read file: {0}")]
304    ReadError(#[from] std::io::Error),
305    /// The data being deserialised was formatted incorrectly
306    #[error("{}", .0.to_string())] // needed to use the miette error instead of thiserror
307    #[diagnostic(transparent)]
308    MalformedData(miette::Error),
309}
310
311impl ConfigReadError {
312    fn malformed<S>(source: S, value: toml_edit::de::Error) -> Self
313    where
314        S: SourceCode + 'static,
315    {
316        let labels = if let Some(span) = value.span() {
317            vec![LabeledSpan::new_with_span(Some("here".into()), span)]
318        } else {
319            Vec::new()
320        };
321        Self::MalformedData(
322            miette::miette! {
323                labels = labels,
324                "{}", value.message()
325            }
326            .with_source_code(source),
327        )
328    }
329}
330
331#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq)]
332#[serde(deny_unknown_fields)]
333pub struct Config {
334    /// Configuration for setting up the docker container and starting the server
335    pub setup: Option<RawOrImport<Setup>>,
336    /// Port on which the server will be hosted
337    #[serde(default = "default_port")]
338    pub port: u16,
339    /// Whether this competition should host the web client
340    #[serde(default = "default_true")]
341    pub web_client: bool,
342    /// Indicates the mode by which the competition is held.
343    ///
344    /// In points, each team must attempt to score the most points possible
345    #[serde(default)]
346    pub game: Game,
347    #[serde(default)]
348    /// Contains information for all things related to programmability and
349    /// external integrations in Basalt.
350    pub integrations: Integrations,
351    /// Maximum number of attempts that a user is allowed to make for a given problem
352    pub max_submissions: Option<std::num::NonZero<u32>>,
353    /// List of languages available for the server
354    pub languages: RawOrImport<LanguageSet>,
355    /// Accounts that will be granted access to the server
356    pub accounts: RawOrImport<Accounts>,
357    /// The packet for this competition
358    pub packet: RawOrImport<Packet>,
359    /// Configuration for the test runner
360    #[serde(default)]
361    pub test_runner: RawOrImport<TestRunner>,
362}
363
364impl std::hash::Hash for Config {
365    fn hash<H: Hasher>(&self, state: &mut H) {
366        self.setup.hash(state);
367        // skip port
368        // self.setup.hash(port);
369        // self.events.hash(state);
370        self.web_client.hash(state);
371        self.languages.hash(state);
372        self.accounts.hash(state);
373        self.packet.hash(state);
374        self.test_runner.hash(state);
375    }
376}
377
378impl Config {
379    /// Read config from a string
380    ///
381    /// - `file_name` provided for better miette errors
382    pub fn from_str(
383        content: impl AsRef<str>,
384        file_name: Option<impl AsRef<str>>,
385    ) -> Result<Self, ConfigReadError> {
386        let content = content.as_ref();
387        let config: Self = toml_edit::de::from_str(content).map_err(|e| {
388            if let Some(file_name) = file_name {
389                ConfigReadError::malformed(
390                    NamedSource::new(file_name, content.to_string()).with_language("TOML"),
391                    e,
392                )
393            } else {
394                ConfigReadError::malformed(content.to_string(), e)
395            }
396        })?;
397        Ok(config)
398    }
399
400    /// Read config from a file
401    ///
402    /// - `file_name` provided for better miette errors
403    pub fn read<R>(
404        reader: &mut R,
405        file_name: Option<impl AsRef<str>>,
406    ) -> Result<Self, ConfigReadError>
407    where
408        R: Read,
409    {
410        let mut buf = String::new();
411        reader.read_to_string(&mut buf)?;
412        Self::from_str(&buf, file_name)
413    }
414
415    /// Read config from a file asynchronously
416    ///
417    /// - `file_name` provided for better miette errors
418    #[cfg(feature = "tokio")]
419    pub async fn read_async<R>(
420        reader: &mut R,
421        file_name: Option<impl AsRef<str>>,
422    ) -> Result<Self, ConfigReadError>
423    where
424        R: tokio::io::AsyncRead + Unpin,
425    {
426        use tokio::io::AsyncReadExt;
427        let mut buf = String::new();
428        reader.read_to_string(&mut buf).await?;
429        Self::from_str(&buf, file_name)
430    }
431
432    /// Generate a hash string for this config
433    ///
434    /// ```
435    /// # use basalt_bedrock::Config;
436    /// # let config = Config::default();
437    /// let hash = format!("Your hash is: {}", config.hash());
438    /// ```
439    pub fn hash(&self) -> String {
440        let mut hasher = DefaultHasher::new();
441        std::hash::Hash::hash(self, &mut hasher);
442        let mut hash = hasher.finish();
443        const N: u64 = 36;
444        const ALPHABET: [u8; N as usize] = *b"abcdefghijklmnopqrstuvwxyz0123456789";
445        let mut out = String::with_capacity(14);
446        loop {
447            let n = (hash % N) as usize;
448            hash /= N;
449            out.push(ALPHABET[n] as char);
450            if hash == 0 {
451                break;
452            }
453        }
454        out
455    }
456
457    /// Render the competition information to a PDF, either using a provided template (written in
458    /// [typst](https://typst.app/)) or the default template
459    ///
460    /// # Template
461    ///
462    /// The template currently provides several variables that contain information about the
463    /// competition.
464    ///
465    /// - `#title`: `str` - the title of the competition
466    /// - `#preamble`: `content` - rendered markdown of the competition
467    /// - `#problems`: `array<Dict>` - array of problems in the packet
468    pub fn render_pdf(&self, template: Option<String>) -> std::io::Result<Vec<u8>> {
469        let template = if let Some(template) = template {
470            template
471        } else {
472            #[cfg(feature = "dev")]
473            {
474                std::fs::read_to_string("./data/template.typ").unwrap()
475            }
476            #[cfg(not(feature = "dev"))]
477            {
478                include_str!("../data/template.typ").into()
479            }
480        };
481
482        let mut world = render::typst::TypstWrapperWorld::new(template);
483
484        let mut errs = Vec::new();
485        let mut problems = Array::with_capacity(self.packet.problems.len());
486        for p in &self.packet.problems {
487            match p.as_value(&world) {
488                Ok(v) => problems.push(v),
489                Err(err) => errs.push(err),
490            }
491        }
492
493        world
494            .library
495            .global
496            .scope_mut()
497            .define("problems", problems);
498
499        world
500            .library
501            .global
502            .scope_mut()
503            .define("title", self.packet.title.as_str());
504
505        let preamble = self
506            .packet
507            .preamble
508            .as_deref()
509            .map(|s| s.content(&world))
510            .transpose()?;
511        world
512            .library
513            .global
514            .scope_mut()
515            .define("preamble", preamble);
516
517        let document = typst::compile(&world)
518            .output
519            .expect("Error compiling typst");
520        typst_pdf::pdf(&document, &typst_pdf::PdfOptions::default())
521            .map_err(|e| std::io::Error::other(format!("{:?}", e)))
522    }
523
524    /// Note: In the current implementation of `typst-pdf`, this just renders to a vector and then
525    /// writes that to the `writer`.
526    pub fn write_pdf<W>(&self, writer: &mut W, template: Option<String>) -> std::io::Result<()>
527    where
528        W: std::io::Write,
529    {
530        // XXX: I would really love it if typst offered an API that did not have to create a vec
531        // just to render the PDF
532        let vec = self.render_pdf(template)?;
533        writer.write_all(&vec)
534    }
535
536    /// Render competition logins to PDF, either using a provided template (written in
537    /// [typst](https://typst.app/)) or the default template
538    ///
539    /// # Template
540    ///
541    /// The template currently provides several variables that contain information about the
542    /// competition.
543    ///
544    /// - `#title`: `str` - the title of the competition
545    /// - `#preamble`: `content` - rendered markdown of the competition
546    /// - `#problems`: `array<Dict>` - array of problems in the packet
547    pub fn render_login_pdf(&self, template: Option<String>) -> std::io::Result<Vec<u8>> {
548        let template = if let Some(template) = template {
549            template
550        } else {
551            #[cfg(feature = "dev")]
552            {
553                std::fs::read_to_string("./data/login-template.typ").unwrap()
554            }
555            #[cfg(not(feature = "dev"))]
556            {
557                include_str!("../data/login-template.typ").into()
558            }
559        };
560
561        let mut world = render::typst::TypstWrapperWorld::new(template);
562
563        let mut errs = Vec::new();
564        let mut problems = Array::with_capacity(self.packet.problems.len());
565        for p in &self.packet.problems {
566            match p.as_value(&world) {
567                Ok(v) => problems.push(v),
568                Err(err) => errs.push(err),
569            }
570        }
571
572        world
573            .library
574            .global
575            .scope_mut()
576            .define("problems", problems);
577
578        world
579            .library
580            .global
581            .scope_mut()
582            .define("title", self.packet.title.as_str());
583
584        let preamble = self
585            .packet
586            .preamble
587            .as_deref()
588            .map(|s| s.content(&world))
589            .transpose()?;
590        world
591            .library
592            .global
593            .scope_mut()
594            .define("preamble", preamble);
595
596        let (hosts, competitors) = self.accounts.to_value();
597        world.library.global.scope_mut().define("hosts", hosts);
598        world
599            .library
600            .global
601            .scope_mut()
602            .define("competitors", competitors);
603
604        let document = typst::compile(&world)
605            .output
606            .expect("Error compiling typst");
607        typst_pdf::pdf(&document, &typst_pdf::PdfOptions::default())
608            .map_err(|e| std::io::Error::other(format!("{:?}", e)))
609    }
610
611    /// Note: In the current implementation of `typst-pdf`, this just renders to a vector and then
612    /// writes that to the `writer`.
613    pub fn write_login_pdf<W>(
614        &self,
615        writer: &mut W,
616        template: Option<String>,
617    ) -> std::io::Result<()>
618    where
619        W: std::io::Write,
620    {
621        // XXX: I would really love it if typst offered an API that did not have to create a vec
622        // just to render the PDF
623        let vec = self.render_login_pdf(template)?;
624        writer.write_all(&vec)
625    }
626}
627
628impl Default for Config {
629    fn default() -> Self {
630        Self {
631            setup: None,
632            port: default_port(),
633            web_client: true,
634            integrations: Default::default(),
635            game: Default::default(),
636            max_submissions: None,
637            languages: Default::default(),
638            accounts: Default::default(),
639            packet: Default::default(),
640            test_runner: Default::default(),
641        }
642    }
643}