tmc_langs/
course_refresher.rs

1//! Course refresher.
2
3use crate::{error::LangsError, progress_reporter};
4use md5::Context;
5use serde::{Deserialize, Serialize};
6use serde_yaml::Mapping;
7use std::{
8    io::Write,
9    path::{Path, PathBuf},
10    time::Duration,
11};
12use tmc_langs_framework::{TmcCommand, TmcProjectYml};
13use tmc_langs_util::{deserialize, file_util};
14use walkdir::WalkDir;
15use zip::write::SimpleFileOptions;
16
17#[cfg(unix)]
18pub type ModeBits = nix::sys::stat::mode_t;
19
20/// Data from a finished course refresh.
21#[derive(Debug, Serialize, Deserialize)]
22#[serde(rename_all = "kebab-case")]
23#[cfg_attr(feature = "ts-rs", derive(ts_rs::TS))]
24pub struct RefreshData {
25    pub new_cache_path: PathBuf,
26    #[cfg_attr(feature = "ts-rs", ts(type = "object"))]
27    pub course_options: Mapping,
28    pub exercises: Vec<RefreshExercise>,
29}
30
31/// An exercise from a finished course refresh.
32#[derive(Debug, Serialize, Deserialize)]
33#[serde(rename_all = "kebab-case")]
34#[cfg_attr(feature = "ts-rs", derive(ts_rs::TS))]
35pub struct RefreshExercise {
36    name: String,
37    checksum: String,
38    points: Vec<String>,
39    #[serde(skip)]
40    path: PathBuf,
41    sandbox_image: String,
42    #[cfg_attr(feature = "ts-rs", ts(type = "TmcProjectYml | null"))]
43    tmcproject_yml: Option<TmcProjectYml>,
44}
45
46/// Used by tmc-server. Refreshes the course.
47pub fn refresh_course(
48    course_name: String,
49    course_cache_path: PathBuf,
50    source_url: String,
51    git_branch: String,
52    cache_root: PathBuf,
53) -> Result<RefreshData, LangsError> {
54    log::info!("refreshing course {course_name}");
55    start_stage(10, "Refreshing course");
56
57    // create new cache path
58    let old_version = course_cache_path
59        .to_str()
60        .and_then(|s| s.split('-').next_back())
61        .and_then(|s| s.parse::<u32>().ok())
62        .ok_or_else(|| LangsError::InvalidCachePath(course_cache_path.clone()))?;
63    let new_cache_path = cache_root.join(format!("{}-{}", course_name, old_version + 1));
64    log::info!("next cache path: {}", new_cache_path.display());
65
66    if new_cache_path.exists() {
67        log::info!("clearing new cache path at {}", new_cache_path.display());
68        file_util::remove_dir_all(&new_cache_path)?;
69    }
70    file_util::create_dir_all(&new_cache_path)?;
71    progress_stage("Created new cache dir");
72
73    // initialize new clone path and verify directory names
74    let new_clone_path = new_cache_path.join("clone");
75    let old_clone_path = course_cache_path.join("clone");
76    initialize_new_cache_clone(
77        &new_cache_path,
78        &new_clone_path,
79        &old_clone_path,
80        &source_url,
81        &git_branch,
82    )?;
83    check_directory_names(&new_clone_path)?;
84    progress_stage("Updated repository");
85
86    let course_options = get_course_options(&new_clone_path, &course_name)?;
87    progress_stage("Fetched course options");
88
89    let new_solution_path = new_cache_path.join("solution");
90    let new_stub_path = new_cache_path.join("stub");
91
92    let exercise_dirs = super::find_exercise_directories(&new_clone_path)?
93        .into_iter()
94        .map(|ed| {
95            ed.strip_prefix(&new_clone_path)
96                .expect("exercise directories are inside new_clone_path")
97                .to_path_buf()
98        })
99        .collect::<Vec<_>>();
100
101    // collect .tmcproject.ymls and merge the root config with each exercise's, if any
102    let root_tmcproject_yml = TmcProjectYml::load(&new_clone_path)?;
103    let exercise_dirs_and_tmcprojects =
104        get_and_merge_tmcproject_configs(root_tmcproject_yml, &new_clone_path, exercise_dirs)?;
105    progress_stage("Merged .tmcproject.yml files in exercise directories to the root file, if any");
106
107    // make_solutions
108    log::info!("preparing solutions to {}", new_solution_path.display());
109    for (exercise, merged_tmcproject) in &exercise_dirs_and_tmcprojects {
110        // save merged config to solution
111        let dest_root = new_solution_path.join(exercise);
112        super::prepare_solution(&new_clone_path.join(exercise), &dest_root)?;
113        if let Some(merged_tmcproject) = merged_tmcproject {
114            merged_tmcproject.save_to_dir(&dest_root)?;
115        }
116    }
117    progress_stage("Prepared solutions");
118
119    // make_stubs
120    log::info!("preparing stubs to {}", new_stub_path.display());
121    for (exercise, merged_tmcproject) in &exercise_dirs_and_tmcprojects {
122        // save merged config to stub
123        let dest_root = new_stub_path.join(exercise);
124        super::prepare_stub(&new_clone_path.join(exercise), &dest_root)?;
125        if let Some(merged_tmcproject) = merged_tmcproject {
126            merged_tmcproject.save_to_dir(&dest_root)?;
127        }
128    }
129    progress_stage("Prepared stubs");
130
131    let exercises = get_exercises(
132        exercise_dirs_and_tmcprojects,
133        &new_clone_path,
134        &new_stub_path,
135    )?;
136    progress_stage("Located exercises");
137
138    // make_zips_of_solutions
139    let new_solution_zip_path = new_cache_path.join("solution_zip");
140    execute_zip(&exercises, &new_solution_path, &new_solution_zip_path)?;
141    progress_stage("Compressed solutions");
142
143    // make_zips_of_stubs
144    let new_stub_zip_path = new_cache_path.join("stub_zip");
145    log::info!(
146        "compressing stubs from {} to {}",
147        new_stub_path.display(),
148        new_stub_zip_path.display()
149    );
150    execute_zip(&exercises, &new_stub_path, &new_stub_zip_path)?;
151    progress_stage("Compressed stubs");
152
153    // make sure the new cache path is readable by anyone
154    set_permissions(&new_cache_path)?;
155
156    finish_stage("Refreshed course");
157    Ok(RefreshData {
158        new_cache_path,
159        course_options,
160        exercises,
161    })
162}
163
164/// Checks old_cache_path/clone for a git repo.
165/// If found, copies it to course_clone_path fetches origin from course_source_url, checks out origin/course_git_branch, cleans and checks out the repo.
166/// If not found or found but one of the git commands causes an error, deletes course_clone_path and clones course_git_branch from course_source_url there.
167/// NOP during testing.
168fn initialize_new_cache_clone(
169    new_course_root: &Path,
170    new_clone_path: &Path,
171    old_clone_path: &Path,
172    course_source_url: &str,
173    course_git_branch: &str,
174) -> Result<(), LangsError> {
175    log::info!("initializing repository at {}", new_clone_path.display());
176
177    if old_clone_path.join(".git").exists() {
178        log::info!(
179            "trying to copy clone from previous cache at {}",
180            old_clone_path.display()
181        );
182
183        // closure to collect any error that occurs during the process
184        let copy_and_update_repository = || -> Result<(), LangsError> {
185            file_util::copy(old_clone_path, new_course_root)?;
186
187            let run_git = |args: &[&str]| {
188                TmcCommand::piped("git")
189                    .with(|e| e.cwd(new_clone_path).args(args))
190                    .output_with_timeout_checked(Duration::from_secs(60 * 2))
191            };
192
193            run_git(&["remote", "set-url", "origin", course_source_url])?;
194            run_git(&["fetch", "origin"])?;
195            run_git(&["checkout", &format!("origin/{course_git_branch}")])?;
196            run_git(&["clean", "-df"])?;
197            run_git(&["checkout", "."])?;
198            Ok(())
199        };
200        match copy_and_update_repository() {
201            Ok(_) => {
202                log::info!("updated repository");
203                return Ok(());
204            }
205            Err(error) => {
206                log::warn!("failed to update repository: {error}");
207
208                file_util::remove_dir_all(new_clone_path)?;
209            }
210        }
211    };
212
213    log::info!("could not copy from previous cache, cloning");
214
215    // clone_repository
216    TmcCommand::piped("git")
217        .with(|e| {
218            e.args(&["clone", "-q", "-b"])
219                .arg(course_git_branch)
220                .arg(course_source_url)
221                .arg(new_clone_path)
222        })
223        .output_with_timeout_checked(Duration::from_secs(60 * 2))?;
224    Ok(())
225}
226
227/// Makes sure no directory directly under path is an exercise directory containing a dash in the relative path from path to the dir.
228/// A dash is used as a special delimiter.
229fn check_directory_names(path: &Path) -> Result<(), LangsError> {
230    log::info!("checking directory names for dashes");
231
232    // exercise directories in canonicalized form
233    for exercise_dir in super::find_exercise_directories(path)? {
234        let relative = exercise_dir
235            .strip_prefix(path)
236            .expect("the exercise dirs are all inside the path");
237        if relative.to_string_lossy().contains('-') {
238            return Err(LangsError::InvalidDirectory(exercise_dir));
239        }
240    }
241    Ok(())
242}
243
244fn get_and_merge_tmcproject_configs(
245    root_tmcproject: Option<TmcProjectYml>,
246    clone_path: &Path,
247    exercise_dirs: Vec<PathBuf>,
248) -> Result<Vec<(PathBuf, Option<TmcProjectYml>)>, LangsError> {
249    let mut res = vec![];
250    for exercise_dir in exercise_dirs {
251        let target_dir = clone_path.join(&exercise_dir);
252        let exercise_tmcproject = TmcProjectYml::load(&target_dir)?;
253        match (&root_tmcproject, exercise_tmcproject) {
254            (Some(root), Some(mut exercise)) => {
255                exercise.merge(root.clone());
256                res.push((exercise_dir, Some(exercise)));
257            }
258            (Some(root), None) => {
259                res.push((exercise_dir, Some(root.clone())));
260            }
261            (None, Some(exercise)) => res.push((exercise_dir, Some(exercise))),
262            (None, None) => res.push((exercise_dir, None)),
263        }
264    }
265    Ok(res)
266}
267
268/// Checks for a course_clone_path/course_options.yml
269/// If found, course-specific options are merged into it and it is returned.
270/// Else, an empty mapping is returned.
271fn get_course_options(course_clone_path: &Path, course_name: &str) -> Result<Mapping, LangsError> {
272    log::info!(
273        "collecting course options for {} in {}",
274        course_name,
275        course_clone_path.display()
276    );
277
278    let options_file = course_clone_path.join("course_options.yml");
279    if options_file.exists() {
280        let file = file_util::open_file(&options_file)?;
281        let course_options: Mapping = deserialize::yaml_from_reader(file)
282            .map_err(|e| LangsError::DeserializeYaml(options_file, e))?;
283        Ok(course_options)
284    } else {
285        Ok(Mapping::new())
286    }
287}
288
289/// Finds exercise directories, and converts the directories to "exercise names" by swapping the separators for dashes.
290/// Also calculates checksums and fetches points for all
291fn get_exercises(
292    exercise_dirs_and_tmcprojects: Vec<(PathBuf, Option<TmcProjectYml>)>,
293    course_clone_path: &Path,
294    course_stub_path: &Path,
295) -> Result<Vec<RefreshExercise>, LangsError> {
296    log::info!("finding exercise checksums and points");
297
298    let exercises = exercise_dirs_and_tmcprojects
299        .into_iter()
300        .map(|(exercise_dir, tmcproject_yml)| {
301            log::debug!(
302                "processing points and checksum for {}",
303                exercise_dir.display()
304            );
305            let name = exercise_dir.to_string_lossy().replace('/', "-");
306            let checksum = calculate_checksum(&course_stub_path.join(&exercise_dir))?;
307            let exercise_path = course_clone_path.join(&exercise_dir);
308            let points = super::get_available_points(&exercise_path)?;
309
310            let sandbox_image = if let Some(image_override) = tmcproject_yml
311                .as_ref()
312                .and_then(|y| y.sandbox_image.as_ref())
313            {
314                image_override.clone()
315            } else {
316                crate::get_default_sandbox_image(&exercise_path)?.to_string()
317            };
318
319            Ok(RefreshExercise {
320                name,
321                points,
322                checksum,
323                path: exercise_dir,
324                sandbox_image,
325                tmcproject_yml,
326            })
327        })
328        .collect::<Result<_, LangsError>>()?;
329    Ok(exercises)
330}
331
332fn calculate_checksum(exercise_dir: &Path) -> Result<String, LangsError> {
333    let mut digest = Context::new();
334
335    // order filenames for a consistent hash
336    for entry in WalkDir::new(exercise_dir)
337        .min_depth(1) // do not hash the directory itself ('.')
338        .sort_by(|a, b| a.file_name().cmp(b.file_name()))
339    {
340        let entry = entry?;
341        let relative = entry
342            .path()
343            .strip_prefix(exercise_dir)
344            .expect("the entry is inside the exercise dir");
345        let string = relative.as_os_str().to_string_lossy();
346        digest.consume(string.as_ref());
347        if entry.path().is_file() {
348            let file = file_util::read_file(entry.path())?;
349            digest.consume(file);
350        }
351    }
352
353    // convert the digest into a hex string
354    let digest = digest.finalize();
355    Ok(format!("{digest:x}"))
356}
357
358fn execute_zip(
359    course_exercises: &[RefreshExercise],
360    root_path: &Path,
361    zip_dir: &Path,
362) -> Result<(), LangsError> {
363    log::info!(
364        "compressing exercises from from {} to {}",
365        root_path.display(),
366        zip_dir.display()
367    );
368
369    file_util::create_dir_all(zip_dir)?;
370    for exercise in course_exercises {
371        let exercise_root = root_path.join(&exercise.path);
372        let zip_file_path = zip_dir.join(format!("{}.zip", exercise.name));
373
374        let mut writer = zip::ZipWriter::new(file_util::create_file(zip_file_path)?);
375        for entry in WalkDir::new(exercise_root) {
376            let entry = entry?;
377            let relative_path = entry
378                .path()
379                .strip_prefix(root_path)
380                .expect("entries are inside root_path");
381
382            if entry.path().is_file() {
383                writer.start_file(
384                    relative_path.to_string_lossy(),
385                    SimpleFileOptions::default().unix_permissions(0o755),
386                )?;
387                let bytes = file_util::read_file(entry.path())?;
388                writer.write_all(&bytes).map_err(LangsError::ZipWrite)?;
389            } else {
390                // java-langs expects directories to have their own entries
391                writer.start_file(
392                    relative_path.join("").to_string_lossy(), // java-langs expects directory entries to have a trailing slash
393                    SimpleFileOptions::default().unix_permissions(0o755),
394                )?;
395            }
396        }
397        writer.finish()?;
398    }
399    Ok(())
400}
401
402#[cfg(not(unix))]
403fn set_permissions(_path: &Path) -> Result<(), LangsError> {
404    // NOP on non-Unix platforms
405    Ok(())
406}
407
408#[cfg(unix)]
409fn set_permissions(path: &Path) -> Result<(), LangsError> {
410    use nix::sys::stat;
411    use std::os::fd::AsFd;
412
413    log::info!("setting permissions in {}", path.display());
414
415    let chmod: ModeBits = 0o775; // octal, read and execute permissions for all users
416    for entry in WalkDir::new(path) {
417        let entry = entry?;
418        let file = file_util::open_file(entry.path())?;
419        stat::fchmod(
420            file.as_fd(),
421            stat::Mode::from_bits(chmod).ok_or(LangsError::NixFlag(chmod))?,
422        )
423        .map_err(|e| LangsError::NixPermissionChange(path.to_path_buf(), e))?;
424    }
425
426    Ok(())
427}
428
429fn start_stage(steps: u32, message: impl Into<String>) {
430    progress_reporter::start_stage::<()>(steps, message.into(), None)
431}
432
433fn progress_stage(message: impl Into<String>) {
434    progress_reporter::progress_stage::<()>(message.into(), None)
435}
436
437fn finish_stage(message: impl Into<String>) {
438    progress_reporter::finish_stage::<()>(message.into(), None)
439}
440
441#[cfg(test)]
442#[cfg(unix)] // not used on windows
443#[allow(clippy::unwrap_used)]
444mod test {
445    use super::*;
446    use crate::find_exercise_directories;
447    use serde_yaml::Value;
448    use std::io::Read;
449    use tempfile::tempdir;
450
451    fn init() {
452        use log::*;
453        use simple_logger::*;
454        let _ = SimpleLogger::new().with_level(LevelFilter::Debug).init();
455    }
456
457    fn file_to(
458        target_dir: impl AsRef<std::path::Path>,
459        target_relative: impl AsRef<std::path::Path>,
460        contents: impl AsRef<[u8]>,
461    ) -> PathBuf {
462        let target = target_dir.as_ref().join(target_relative);
463        if let Some(parent) = target.parent() {
464            std::fs::create_dir_all(parent).unwrap();
465        }
466        std::fs::write(&target, contents.as_ref()).unwrap();
467        target
468    }
469
470    #[test]
471    fn checks_directory_names() {
472        init();
473
474        let temp = tempfile::tempdir().unwrap();
475        file_to(&temp, "course/valid_part/valid_ex/setup.py", "");
476        assert!(check_directory_names(&temp.path().join("course")).is_ok());
477
478        let course = tempfile::tempdir().unwrap();
479        file_to(course.path(), "course/part1/invalid-ex1/setup.py", "");
480        assert!(check_directory_names(&course.path().join("course")).is_err());
481
482        let course = tempfile::tempdir().unwrap();
483        file_to(course.path(), "course/invalid-part/valid_ex/setup.py", "");
484        assert!(check_directory_names(&course.path().join("course")).is_err());
485    }
486
487    #[test]
488    fn gets_course_options() {
489        init();
490
491        let temp = tempfile::tempdir().unwrap();
492        file_to(&temp, "course_options.yml", "option: true");
493        let options = get_course_options(temp.path(), "some course").unwrap();
494        assert_eq!(options.len(), 1);
495        assert!(
496            options
497                .get(Value::String("option".to_string()))
498                .unwrap()
499                .as_bool()
500                .unwrap()
501        )
502    }
503
504    #[test]
505    fn gets_exercises() {
506        init();
507
508        let temp = tempfile::tempdir().unwrap();
509        file_to(&temp, "course/part1/ex1/setup.py", "");
510        file_to(
511            &temp,
512            "course/part1/ex1/test/test.py",
513            "@points('1') @points('2')",
514        );
515        let exercise_dirs = find_exercise_directories(&temp.path().join("course"))
516            .unwrap()
517            .into_iter()
518            .map(|ed| {
519                (
520                    ed.strip_prefix(temp.path().join("course"))
521                        .unwrap()
522                        .to_path_buf(),
523                    None,
524                )
525            })
526            .collect();
527        let exercises = get_exercises(
528            exercise_dirs,
529            &temp.path().join("course"),
530            &temp.path().join("course"),
531        )
532        .unwrap();
533        assert_eq!(exercises.len(), 1);
534        assert_eq!(exercises[0].path, Path::new("part1/ex1"));
535        assert_eq!(exercises[0].points.len(), 2);
536        assert_eq!(exercises[0].points[0], "1");
537        assert_eq!(exercises[0].points[1], "2");
538        assert_eq!(exercises[0].checksum, "129e7e898698465c4f24494219f06df9");
539    }
540
541    #[test]
542    fn executes_zip() {
543        init();
544
545        let temp = tempfile::tempdir().unwrap();
546        file_to(&temp, "clone/part1/ex1/setup.py", "");
547        file_to(&temp, "clone/part1/ex2/setup.py", "");
548        file_to(&temp, "clone/part2/ex1/setup.py", "");
549        file_to(&temp, "clone/part2/ex2/setup.py", "");
550        file_to(&temp, "clone/part2/ex2/dir/subdir/file", "");
551        file_to(&temp, "clone/part2/ex2/.tmcproject.yml", "some: 'yaml'");
552        file_to(&temp, "stub/part1/ex1/setup.py", "");
553        file_to(&temp, "stub/part1/ex2/setup.py", "");
554        file_to(&temp, "stub/part2/ex1/setup.py", "");
555        file_to(&temp, "stub/part2/ex2/setup.py", "");
556        file_to(&temp, "stub/part2/ex2/dir/subdir/file", "some file");
557        file_to(&temp, "stub/part2/ex2/.tmcproject.yml", "some: 'yaml'");
558
559        let exercise_dirs = find_exercise_directories(&temp.path().join("clone"))
560            .unwrap()
561            .into_iter()
562            .map(|ed| {
563                (
564                    ed.strip_prefix(temp.path().join("clone"))
565                        .unwrap()
566                        .to_path_buf(),
567                    None,
568                )
569            })
570            .collect();
571        let exercises = get_exercises(
572            exercise_dirs,
573            &temp.path().join("clone"),
574            &temp.path().join("stub"),
575        )
576        .unwrap();
577
578        execute_zip(&exercises, &temp.path().join("stub"), temp.path()).unwrap();
579
580        let zip = temp.path().join("part1-ex1.zip");
581        assert!(zip.exists());
582        let zip = temp.path().join("part1-ex2.zip");
583        assert!(zip.exists());
584        let zip = temp.path().join("part2-ex1.zip");
585        assert!(zip.exists());
586        let zip = temp.path().join("part2-ex2.zip");
587        assert!(zip.exists());
588
589        let mut fz = zip::ZipArchive::new(file_util::open_file(&zip).unwrap()).unwrap();
590        for i in fz.file_names() {
591            log::debug!("{i}");
592        }
593        assert!(
594            fz.by_name(
595                &Path::new("part2")
596                    .join("ex2")
597                    .join("dir")
598                    .join("subdir")
599                    .join("")
600                    .to_string_lossy(),
601            )
602            .is_ok()
603        ); // directories have their own entries with trailing slashes
604        let mut file = fz
605            .by_name(
606                &Path::new("part2")
607                    .join("ex2")
608                    .join("dir")
609                    .join("subdir")
610                    .join("file")
611                    .to_string_lossy(),
612            )
613            .unwrap(); // other files have their stub contents
614        let mut buf = String::new();
615        file.read_to_string(&mut buf).unwrap();
616        drop(file);
617
618        assert_eq!(buf, "some file");
619        let mut file = fz
620            .by_name(
621                &Path::new("part2")
622                    .join("ex2")
623                    .join(".tmcproject.yml")
624                    .to_string_lossy(),
625            )
626            .unwrap();
627        let mut buf = String::new();
628        file.read_to_string(&mut buf).unwrap();
629        assert_eq!(buf, "some: 'yaml'");
630    }
631
632    #[test]
633    #[ignore = "issues in CI, maybe due to the user ID"]
634    fn sets_permissions() {
635        init();
636
637        let temp = tempdir().unwrap();
638        file_to(&temp, "file", "contents");
639
640        set_permissions(temp.path()).unwrap();
641    }
642
643    #[test]
644    fn checksum_matches_old_implementation() {
645        init();
646
647        let temp = tempfile::tempdir().unwrap();
648        file_to(
649            &temp,
650            "test/test.py",
651            r#"@points("test_point")
652@points("ex_and_test_point")
653"#,
654        );
655        file_to(
656            &temp,
657            ".hidden file that should be included in the hash",
658            "",
659        );
660        file_to(&temp, "invalid-but-not-dir", "");
661        file_to(&temp, "setup.py", "");
662
663        let checksum = calculate_checksum(temp.path()).unwrap();
664        assert_eq!(checksum, "6cacf02f21f9242674a876954132fb11");
665    }
666
667    #[test]
668    fn merges_tmcproject_configs() {
669        init();
670
671        let temp = tempfile::tempdir().unwrap();
672        let exap = PathBuf::from("exa");
673        let exap_path = temp.path().join(&exap);
674        file_util::create_dir(&exap_path).unwrap();
675        let exbp = PathBuf::from("exb");
676        let exbp_path = temp.path().join(&exbp);
677        file_util::create_dir(&exbp_path).unwrap();
678
679        let root = TmcProjectYml {
680            tests_timeout_ms: Some(1234),
681            fail_on_valgrind_error: Some(true),
682            ..Default::default()
683        };
684        let tpya = TmcProjectYml {
685            tests_timeout_ms: Some(2345),
686            ..Default::default()
687        };
688        tpya.save_to_dir(&exap_path).unwrap();
689        let tpyb = TmcProjectYml {
690            fail_on_valgrind_error: Some(false),
691            ..Default::default()
692        };
693        tpyb.save_to_dir(&exbp_path).unwrap();
694        let exercise_dirs = vec![exap, exbp];
695
696        let dirs_configs =
697            get_and_merge_tmcproject_configs(Some(root), temp.path(), exercise_dirs).unwrap();
698
699        let (_, tpya) = &dirs_configs
700            .iter()
701            .find(|(p, _)| p.ends_with("exa"))
702            .unwrap();
703        let tpya = tpya.as_ref().unwrap();
704        assert_eq!(tpya.tests_timeout_ms, Some(2345));
705        assert_eq!(tpya.fail_on_valgrind_error, Some(true));
706
707        let (_, tpyb) = &dirs_configs
708            .iter()
709            .find(|(p, _)| p.ends_with("exb"))
710            .unwrap();
711        let tpyb = tpyb.as_ref().unwrap();
712        assert_eq!(tpyb.tests_timeout_ms, Some(1234));
713        assert_eq!(tpyb.fail_on_valgrind_error, Some(false));
714    }
715
716    #[test]
717    fn merges_tmcproject_configs_exercise_overrides_root() {
718        init();
719
720        let temp = tempfile::tempdir().unwrap();
721        let exercise_path = PathBuf::from("exercise");
722        let exercise_dir = temp.path().join(&exercise_path);
723        file_util::create_dir(&exercise_dir).unwrap();
724
725        // Root config has tests_timeout_ms: 1000
726        let root = TmcProjectYml {
727            tests_timeout_ms: Some(1000),
728            fail_on_valgrind_error: Some(true),
729            ..Default::default()
730        };
731
732        // Exercise config has tests_timeout_ms: 2000 (should override root)
733        let exercise_config = TmcProjectYml {
734            tests_timeout_ms: Some(2000),
735            ..Default::default()
736        };
737        exercise_config.save_to_dir(&exercise_dir).unwrap();
738
739        let exercise_dirs = vec![exercise_path];
740
741        let dirs_configs =
742            get_and_merge_tmcproject_configs(Some(root), temp.path(), exercise_dirs).unwrap();
743
744        let (_, merged_config) = &dirs_configs[0];
745        let merged_config = merged_config.as_ref().unwrap();
746
747        // Exercise values should override root values when both are present
748        assert_eq!(merged_config.tests_timeout_ms, Some(2000));
749        // Root values should be inherited when exercise doesn't have that field
750        assert_eq!(merged_config.fail_on_valgrind_error, Some(true));
751    }
752}