tmc_langs_framework/
tmc_project_yml.rs

1//! Contains a struct that models the .tmcproject.yml file.
2
3use crate::TmcError;
4use serde::{
5    Deserialize, Deserializer, Serialize,
6    de::{Error, Visitor},
7};
8use std::{
9    fmt::{self, Display},
10    path::{Path, PathBuf},
11};
12use tmc_langs_util::{
13    deserialize,
14    file_util::{self, Lock, LockOptions},
15};
16
17const DEFAULT_SUBMISSION_SIZE_LIMIT_MB: u32 = 1;
18
19/// Extra data from a `.tmcproject.yml` file.
20#[derive(Debug, Serialize, Deserialize, Default, Clone)]
21#[cfg_attr(feature = "ts-rs", derive(ts_rs::TS))]
22pub struct TmcProjectYml {
23    /// A list of files or directories that will always be considered student files.
24    #[serde(default)]
25    #[serde(skip_serializing_if = "Vec::is_empty")]
26    pub extra_student_files: Vec<PathBuf>,
27
28    /// A list of files or directories that will always be considered exercise files.
29    /// `extra_student_files` takes precedence if a file is both an extra student file and an extra exercise file.
30    #[serde(default)]
31    #[serde(skip_serializing_if = "Vec::is_empty")]
32    pub extra_exercise_files: Vec<PathBuf>,
33
34    /// A list of files that should always be overwritten by updates even if they are student files.
35    #[serde(default)]
36    #[serde(skip_serializing_if = "Vec::is_empty")]
37    pub force_update: Vec<PathBuf>,
38
39    /// If set, tests are forcibly stopped after this duration.
40    #[serde(default)]
41    #[serde(skip_serializing_if = "Option::is_none")]
42    pub tests_timeout_ms: Option<u32>,
43
44    /// Marks the exercise as not containing any tests.
45    #[serde(rename = "no-tests")]
46    #[cfg_attr(feature = "ts-rs", ts(skip))]
47    #[serde(default)]
48    #[serde(skip_serializing_if = "Option::is_none")]
49    pub no_tests: Option<NoTests>,
50
51    /// If set, Valgrind errors will be considered test errors.
52    #[serde(default)]
53    #[serde(skip_serializing_if = "Option::is_none")]
54    pub fail_on_valgrind_error: Option<bool>,
55
56    /// If set, will cause an error telling the student to update their Python if their version is older than the minimum.
57    #[serde(default)]
58    #[serde(skip_serializing_if = "Option::is_none")]
59    pub minimum_python_version: Option<PythonVer>,
60
61    /// Overrides the default sandbox image. e.g. `eu.gcr.io/moocfi-public/tmc-sandbox-python:latest`
62    #[serde(default)]
63    #[serde(skip_serializing_if = "Option::is_none")]
64    pub sandbox_image: Option<String>,
65
66    /// Overrides the default archive size limit (500 Mb).
67    #[serde(default)]
68    #[serde(skip_serializing_if = "Option::is_none")]
69    pub submission_size_limit_mb: Option<u32>,
70}
71
72impl TmcProjectYml {
73    /// Returns the path of the TmcProjectYml file in the given directory.
74    fn path_in_dir(dir: &Path) -> PathBuf {
75        dir.join(".tmcproject.yml")
76    }
77
78    /// Loads a TmcProjectYml either from the directory or the default if there is none.
79    pub fn load_or_default(project_dir: &Path) -> Result<Self, TmcError> {
80        if let Some(config) = Self::load(project_dir)? {
81            Ok(config)
82        } else {
83            Ok(Self::default())
84        }
85    }
86
87    /// Loads a TmcProjectYml from the given directory. Returns None if no such file exists.
88    pub fn load(project_dir: &Path) -> Result<Option<Self>, TmcError> {
89        let mut config_path = project_dir.to_owned();
90        config_path.push(".tmcproject.yml");
91
92        if !config_path.exists() {
93            log::trace!("no config found at {}", config_path.display());
94            return Ok(None);
95        }
96        log::debug!("reading .tmcproject.yml from {}", config_path.display());
97        let file = file_util::open_file(&config_path)?;
98        let config = deserialize::yaml_from_reader(file)
99            .map_err(|e| TmcError::YamlDeserialize(config_path, e))?;
100        log::trace!("read {config:#?}");
101        Ok(Some(config))
102    }
103
104    /// Merges the contents of `with` with `self`.
105    /// Empty or missing values in self are replaced with those from with. Other values are left unchanged.
106    /// Notably it does not merge lists together.
107    pub fn merge(&mut self, with: Self) {
108        let old = std::mem::take(self);
109        let new = Self {
110            extra_student_files: if self.extra_student_files.is_empty() {
111                with.extra_student_files
112            } else {
113                old.extra_student_files
114            },
115            extra_exercise_files: if self.extra_exercise_files.is_empty() {
116                with.extra_exercise_files
117            } else {
118                old.extra_exercise_files
119            },
120            force_update: if self.force_update.is_empty() {
121                with.force_update
122            } else {
123                old.force_update
124            },
125            tests_timeout_ms: old.tests_timeout_ms.or(with.tests_timeout_ms),
126            fail_on_valgrind_error: old.fail_on_valgrind_error.or(with.fail_on_valgrind_error),
127            minimum_python_version: old.minimum_python_version.or(with.minimum_python_version),
128            sandbox_image: old.sandbox_image.or(with.sandbox_image),
129            no_tests: old.no_tests.or(with.no_tests),
130            submission_size_limit_mb: old
131                .submission_size_limit_mb
132                .or(with.submission_size_limit_mb),
133        };
134        *self = new;
135    }
136
137    /// Saves the TmcProjectYml to the given directory.
138    pub fn save_to_dir(&self, dir: &Path) -> Result<(), TmcError> {
139        let config_path = Self::path_in_dir(dir);
140        // It is important to truncate the file here, when we save the merged tmcproject.yml files, the exercise folder can already contain a .tmcproject.yml file. If we don't truncate the file before writing, all the merged values will be appended to the file, and duplicate keys will make the file invalid YAML.
141        let mut lock = Lock::file(&config_path, LockOptions::WriteTruncate)?;
142        let mut guard = lock.lock()?;
143        serde_yaml::to_writer(guard.get_file_mut(), &self)?;
144        Ok(())
145    }
146
147    pub fn get_submission_size_limit_mb(&self) -> u32 {
148        self.submission_size_limit_mb
149            .unwrap_or(DEFAULT_SUBMISSION_SIZE_LIMIT_MB)
150    }
151}
152
153/// Python version from TmcProjectYml.
154#[derive(Debug, Default, Clone, Copy, Serialize, PartialEq, Eq)]
155#[cfg_attr(feature = "ts-rs", derive(ts_rs::TS))]
156pub struct PythonVer {
157    major: u32,
158    minor: Option<u32>,
159    patch: Option<u32>,
160}
161
162impl PythonVer {
163    // Try to keep up to date with https://devguide.python.org/versions/#versions
164    // As of writing, 3.7 is the oldest maintained release and its EOL 2023-06-27
165    pub fn recommended() -> (u32, u32, u32) {
166        (3, 7, 0)
167    }
168
169    /// Returns the Python version as a (major, minor, patch) tuple.
170    /// Defaults None values to to 3.0.0.
171    pub fn min(self) -> (u32, u32, u32) {
172        let major = self.major;
173        let minor = self.minor.unwrap_or(0);
174        let patch = self.patch.unwrap_or(0);
175        (major, minor, patch)
176    }
177}
178
179impl Display for PythonVer {
180    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
181        match (self.major, self.minor, self.patch) {
182            (major, Some(minor), Some(patch)) => write!(f, "{major}.{minor}.{patch}"),
183            (major, Some(minor), None) => write!(f, "{major}.{minor}"),
184            (major, None, _) => write!(f, "{major}"),
185        }
186    }
187}
188
189/// Deserializes a major.minor?.patch? version into a PythonVer.
190impl<'de> Deserialize<'de> for PythonVer {
191    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
192    where
193        D: Deserializer<'de>,
194    {
195        struct PythonVerVisitor;
196
197        impl Visitor<'_> for PythonVerVisitor {
198            type Value = PythonVer;
199
200            fn expecting(&self, formatter: &mut fmt::Formatter) -> fmt::Result {
201                formatter.write_str("A string in one of the following formats: {major_ver}, {major_ver}.{minor_ver}, or {major_ver}.{minor_ver}.{patch_ver} where each version is a non-negative integer")
202            }
203
204            fn visit_str<E>(self, v: &str) -> Result<Self::Value, E>
205            where
206                E: Error,
207            {
208                let mut parts = v.split('.');
209                let major = parts
210                    .next()
211                    .expect("split always yields at least one value");
212                let major: u32 = major.parse().map_err(Error::custom)?;
213                let minor = if let Some(minor) = parts.next() {
214                    let parsed: u32 = minor.parse().map_err(Error::custom)?;
215                    Some(parsed)
216                } else {
217                    None
218                };
219                let patch = if let Some(patch) = parts.next() {
220                    let parsed: u32 = patch.parse().map_err(Error::custom)?;
221                    Some(parsed)
222                } else {
223                    None
224                };
225                Ok(PythonVer {
226                    major,
227                    minor,
228                    patch,
229                })
230            }
231        }
232
233        deserializer.deserialize_str(PythonVerVisitor)
234    }
235}
236
237/// Contents of the no-tests field.
238#[derive(Debug, Serialize, Deserialize, Clone)]
239#[cfg_attr(feature = "ts-rs", derive(ts_rs::TS))]
240// we never take these structs as inputs from TS so it's ok to ignore from
241#[cfg_attr(feature = "ts-rs", ts(ignore_serde_attr = "from"))]
242#[serde(from = "NoTestsWrapper")]
243pub struct NoTests {
244    pub flag: bool,
245    pub points: Vec<String>,
246}
247
248/// Converts the wrapper type into the more convenient one.
249impl From<NoTestsWrapper> for NoTests {
250    fn from(wrapper: NoTestsWrapper) -> Self {
251        match wrapper {
252            NoTestsWrapper::Flag(flag) => Self {
253                flag,
254                points: vec![],
255            },
256            NoTestsWrapper::Points(no_tests_points) => Self {
257                flag: true,
258                points: no_tests_points
259                    .points
260                    .into_iter()
261                    .map(|v| match v {
262                        IntOrString::Int(i) => i.to_string(),
263                        IntOrString::String(s) => s,
264                    })
265                    .collect(),
266            },
267        }
268    }
269}
270
271/// no-tests can either be a bool or a list of points.
272#[derive(Debug, Deserialize)]
273#[serde(untagged)]
274pub enum NoTestsWrapper {
275    Flag(bool),
276    Points(NoTestsPoints),
277}
278
279/// The list of points can contain numbers or strings.
280#[derive(Debug, Deserialize)]
281pub struct NoTestsPoints {
282    pub points: Vec<IntOrString>,
283}
284
285#[derive(Debug, Deserialize)]
286#[serde(untagged)]
287pub enum IntOrString {
288    Int(isize),
289    String(String),
290}
291
292#[cfg(test)]
293#[allow(clippy::unwrap_used)]
294mod test {
295    use super::*;
296
297    fn init() {
298        use log::*;
299        use simple_logger::*;
300        let _ = SimpleLogger::new().with_level(LevelFilter::Debug).init();
301    }
302
303    #[test]
304    fn deserialize_no_tests() {
305        init();
306
307        let no_tests_yml = r#"no-tests:
308  points:
309    - 1
310    - notests
311"#;
312
313        let cfg: TmcProjectYml = deserialize::yaml_from_str(no_tests_yml).unwrap();
314        let no_tests = cfg.no_tests.unwrap();
315        assert!(no_tests.flag);
316        assert_eq!(no_tests.points, &["1", "notests"]);
317    }
318
319    #[test]
320    fn deserialize_python_ver() {
321        init();
322
323        let python_ver: PythonVer = deserialize::yaml_from_str("1.2.3").unwrap();
324        assert_eq!(python_ver.major, 1);
325        assert_eq!(python_ver.minor, Some(2));
326        assert_eq!(python_ver.patch, Some(3));
327
328        let python_ver: PythonVer = deserialize::yaml_from_str("1.2").unwrap();
329        assert_eq!(python_ver.major, 1);
330        assert_eq!(python_ver.minor, Some(2));
331        assert_eq!(python_ver.patch, None);
332
333        let python_ver: PythonVer = deserialize::yaml_from_str("1").unwrap();
334        assert_eq!(python_ver.major, 1);
335        assert_eq!(python_ver.minor, None);
336        assert_eq!(python_ver.patch, None);
337
338        assert!(deserialize::yaml_from_str::<PythonVer>("asd").is_err())
339    }
340
341    #[test]
342    fn merges() {
343        init();
344
345        let tpy_root = TmcProjectYml {
346            tests_timeout_ms: Some(123),
347            fail_on_valgrind_error: Some(true),
348            ..Default::default()
349        };
350        let mut tpy_exercise = TmcProjectYml {
351            tests_timeout_ms: Some(234),
352            ..Default::default()
353        };
354        tpy_exercise.merge(tpy_root);
355        assert_eq!(tpy_exercise.tests_timeout_ms, Some(234));
356        assert_eq!(tpy_exercise.fail_on_valgrind_error, Some(true));
357    }
358
359    #[test]
360    fn saves_to_dir() {
361        init();
362
363        let temp = tempfile::tempdir().unwrap();
364        let path = TmcProjectYml::path_in_dir(temp.path());
365
366        assert!(!path.exists());
367
368        let tpy = TmcProjectYml {
369            tests_timeout_ms: Some(1234),
370            ..Default::default()
371        };
372        tpy.save_to_dir(temp.path()).unwrap();
373
374        assert!(path.exists());
375        let tpy = TmcProjectYml::load(temp.path()).unwrap().unwrap();
376        assert_eq!(tpy.tests_timeout_ms, Some(1234));
377    }
378
379    #[test]
380    fn saves_truncates_file_not_appends() {
381        init();
382
383        let temp = tempfile::tempdir().unwrap();
384
385        // First save
386        let first = TmcProjectYml {
387            tests_timeout_ms: Some(1000),
388            ..Default::default()
389        };
390        first.save_to_dir(temp.path()).unwrap();
391
392        // Second save with a different value to ensure old contents are not kept
393        let second = TmcProjectYml {
394            tests_timeout_ms: Some(2000),
395            ..Default::default()
396        };
397        second.save_to_dir(temp.path()).unwrap();
398
399        // Read raw YAML and ensure tests_timeout_ms occurs only once
400        let yaml_path = TmcProjectYml::path_in_dir(temp.path());
401        let yaml = std::fs::read_to_string(&yaml_path).unwrap();
402        let occurrences = yaml.matches("tests_timeout_ms").count();
403        assert_eq!(
404            occurrences, 1,
405            "YAML should contain the key only once: {}",
406            yaml
407        );
408
409        // And the file is still valid YAML after the second save
410        let parsed = TmcProjectYml::load(temp.path()).unwrap().unwrap();
411        assert_eq!(parsed.tests_timeout_ms, Some(2000));
412    }
413}