1use 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#[derive(Debug, Serialize, Deserialize, Default, Clone)]
21#[cfg_attr(feature = "ts-rs", derive(ts_rs::TS))]
22pub struct TmcProjectYml {
23 #[serde(default)]
25 #[serde(skip_serializing_if = "Vec::is_empty")]
26 pub extra_student_files: Vec<PathBuf>,
27
28 #[serde(default)]
31 #[serde(skip_serializing_if = "Vec::is_empty")]
32 pub extra_exercise_files: Vec<PathBuf>,
33
34 #[serde(default)]
36 #[serde(skip_serializing_if = "Vec::is_empty")]
37 pub force_update: Vec<PathBuf>,
38
39 #[serde(default)]
41 #[serde(skip_serializing_if = "Option::is_none")]
42 pub tests_timeout_ms: Option<u32>,
43
44 #[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 #[serde(default)]
53 #[serde(skip_serializing_if = "Option::is_none")]
54 pub fail_on_valgrind_error: Option<bool>,
55
56 #[serde(default)]
58 #[serde(skip_serializing_if = "Option::is_none")]
59 pub minimum_python_version: Option<PythonVer>,
60
61 #[serde(default)]
63 #[serde(skip_serializing_if = "Option::is_none")]
64 pub sandbox_image: Option<String>,
65
66 #[serde(default)]
68 #[serde(skip_serializing_if = "Option::is_none")]
69 pub submission_size_limit_mb: Option<u32>,
70}
71
72impl TmcProjectYml {
73 fn path_in_dir(dir: &Path) -> PathBuf {
75 dir.join(".tmcproject.yml")
76 }
77
78 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 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 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 pub fn save_to_dir(&self, dir: &Path) -> Result<(), TmcError> {
139 let config_path = Self::path_in_dir(dir);
140 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#[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 pub fn recommended() -> (u32, u32, u32) {
166 (3, 7, 0)
167 }
168
169 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
189impl<'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#[derive(Debug, Serialize, Deserialize, Clone)]
239#[cfg_attr(feature = "ts-rs", derive(ts_rs::TS))]
240#[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
248impl 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#[derive(Debug, Deserialize)]
273#[serde(untagged)]
274pub enum NoTestsWrapper {
275 Flag(bool),
276 Points(NoTestsPoints),
277}
278
279#[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 let first = TmcProjectYml {
387 tests_timeout_ms: Some(1000),
388 ..Default::default()
389 };
390 first.save_to_dir(temp.path()).unwrap();
391
392 let second = TmcProjectYml {
394 tests_timeout_ms: Some(2000),
395 ..Default::default()
396 };
397 second.save_to_dir(temp.path()).unwrap();
398
399 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 let parsed = TmcProjectYml::load(temp.path()).unwrap().unwrap();
411 assert_eq!(parsed.tests_timeout_ms, Some(2000));
412 }
413}