tmc_langs/
submission_packaging.rs

1//! Submission packaging.
2
3use crate::{Compression, data::TmcParams, error::LangsError, extract_project_overwrite};
4use once_cell::sync::Lazy;
5use std::{
6    io::{Cursor, Write},
7    ops::ControlFlow::{Break, Continue},
8    path::{Path, PathBuf},
9    sync::Mutex,
10};
11use tmc_langs_framework::{Archive, TmcProjectYml};
12use tmc_langs_plugins::PluginType;
13use tmc_langs_util::{FileError, file_util};
14use walkdir::WalkDir;
15use zip::{ZipWriter, write::SimpleFileOptions};
16
17static MUTEX: Lazy<Mutex<()>> = Lazy::new(|| Mutex::new(()));
18
19pub struct PrepareSubmission<'a> {
20    pub archive: &'a Path,
21    pub compression: Compression,
22    pub extract_naively: bool,
23}
24
25/// Prepares a submission for further processing.
26/// Returns the sandbox image to be used for the submission.
27pub fn prepare_submission(
28    submission: PrepareSubmission,
29    target_path: &Path,
30    no_archive_prefix: bool,
31    tmc_params: TmcParams,
32    clone_path: &Path,
33    stub_archive: Option<(&Path, Compression)>,
34    output_format: Compression,
35) -> Result<String, LangsError> {
36    // FIXME: workaround for unknown issues when prepare_submission is ran multiple times in parallel
37    let _m = MUTEX.lock().map_err(|_| LangsError::MutexError)?;
38    log::debug!("preparing submission for {}", submission.archive.display());
39
40    let plugin = PluginType::from_exercise(clone_path)?;
41    let policy = tmc_langs_plugins::get_student_file_policy(clone_path)?;
42
43    let extract_dest = tempfile::tempdir().map_err(LangsError::TempDir)?;
44    let extract_dest_path = extract_dest.path().to_path_buf();
45
46    // extract base
47    log::debug!("extracting stub");
48    let ignore_list = [
49        ".DS_Store",
50        "desktop.ini",
51        "Thumbs.db",
52        ".directory",
53        "__MACOSX",
54        file_util::LOCK_FILE_NAME,
55    ];
56    if let Some((stub_zip, compression)) = stub_archive {
57        // This branch is used when a student downloads their old submission, and we take the files from the stub (the exercise template) instead of the clone path. This makes sure they cannot see the hidden tests in the downloaded file.
58        extract_with_filter(
59            plugin,
60            stub_zip,
61            compression,
62            |path| {
63                let relative_path = path.strip_prefix(clone_path).unwrap_or(path);
64
65                // do not take student files from stub
66                policy.is_student_file(relative_path)
67                    || relative_path.components().any(|c| {
68                        c.as_os_str()
69                            .to_str()
70                            .map(|s| ignore_list.contains(&s))
71                            .unwrap_or_default()
72                    })
73            },
74            &extract_dest_path,
75            false,
76        )?;
77    } else {
78        // This code branch is used when we package a submission for the sandbox. We use the clone path because it contains the hidden tests, and we want the sandbox to run them.
79        for entry in WalkDir::new(clone_path).min_depth(1) {
80            let entry = entry?;
81
82            let relative_path = entry
83                .path()
84                .strip_prefix(clone_path)
85                .expect("entry is in clone path");
86
87            // do not take student files from clone
88            if policy.is_student_file(relative_path)
89                || relative_path.components().any(|c| {
90                    c.as_os_str()
91                        .to_str()
92                        .map(|s| ignore_list.contains(&s))
93                        .unwrap_or_default()
94                })
95            {
96                // path is student file or component on ignore list
97                continue;
98            }
99
100            let target_path = extract_dest_path.join(relative_path);
101            if entry.path().is_file() {
102                file_util::copy(entry.path(), target_path)?;
103            } else if entry.path().is_dir() {
104                file_util::create_dir(target_path)?;
105            }
106        }
107    }
108
109    // extract student files from submission over base
110    log::debug!("extracting student files");
111    let file = file_util::open_file(submission.archive)?;
112    if submission.extract_naively {
113        // If the clone contains a .tmcproject.yml, preserve it so the student's cannot override it
114        let tmcproject_path = extract_dest_path.join(".tmcproject.yml");
115        let preserved_tmcproject = if tmcproject_path.exists() {
116            Some(
117                std::fs::read(&tmcproject_path)
118                    .map_err(|e| FileError::FileRead(tmcproject_path.clone(), e))?,
119            )
120        } else {
121            None
122        };
123
124        extract_project_overwrite(file, &extract_dest_path, submission.compression)?;
125
126        if let Some(bytes) = preserved_tmcproject {
127            // restore the clone's .tmcproject.yml
128            file_util::write_to_file(&bytes, &tmcproject_path)?;
129        }
130    } else {
131        // This code branch is used when we package a submission for the sandbox. This extraction method makes sure we don't allow the student to update the files they are not allowed to edit.
132        plugin.extract_student_files(file, submission.compression, &extract_dest_path)?;
133    }
134
135    // extract ide files
136    log::debug!("extracting ide files");
137    let ide_files = [
138        // netbeans
139        "nbproject",
140        // eclipse
141        ".classpath",
142        ".project",
143        ".settings",
144        // idea
145        ".idea",
146    ];
147    extract_with_filter(
148        plugin,
149        submission.archive,
150        submission.compression,
151        |path| {
152            path.components().all(|c| {
153                c.as_os_str()
154                    .to_str()
155                    .map(|s| !ide_files.contains(&s))
156                    .unwrap_or_default()
157            })
158        },
159        &extract_dest_path,
160        submission.extract_naively,
161    )?;
162
163    // write tmc params
164    if tmc_params.0.is_empty() {
165        log::debug!("no tmc params to write");
166    } else {
167        log::debug!("writing .tmcparams");
168        let tmc_params_path = extract_dest_path.join(".tmcparams");
169        let mut tmc_params_file = file_util::create_file(&tmc_params_path)?;
170        for (key, value) in tmc_params.0 {
171            // todo handle arrays, shell escapes
172            let export = format!("export {key}={value}\n");
173            log::debug!("{export}");
174            tmc_params_file
175                .write_all(export.as_bytes())
176                .map_err(|e| FileError::FileWrite(tmc_params_path.clone(), e))?;
177        }
178    }
179
180    // make archive
181    log::debug!("creating submission archive");
182    let exercise_name = clone_path.file_name();
183    let course_name = clone_path.parent().and_then(Path::file_name);
184    let prefix = if no_archive_prefix {
185        PathBuf::new()
186    } else {
187        match (course_name, exercise_name) {
188            (Some(course_name), Some(exercise_name)) => Path::new(course_name).join(exercise_name),
189            _ => {
190                log::warn!(
191                    "was not able to find exercise and/or course name from clone path {}",
192                    clone_path.display()
193                );
194                PathBuf::new()
195            }
196        }
197    };
198    let archive_file = file_util::create_file(target_path)?;
199    match output_format {
200        Compression::Tar => {
201            let mut archive = tar::Builder::new(archive_file);
202            for entry in WalkDir::new(&extract_dest_path)
203                .into_iter()
204                .filter_entry(|e| e.file_name() != file_util::LOCK_FILE_NAME)
205                .skip(1)
206            {
207                let entry = entry?;
208                let entry_path = entry.path();
209                let stripped = prefix.join(
210                    entry_path
211                        .strip_prefix(&extract_dest_path)
212                        .expect("the entry is inside dest"),
213                );
214                log::debug!(
215                    "adding {} to tar at {}",
216                    entry_path.display(),
217                    stripped.display()
218                );
219                if entry_path.is_dir() {
220                    archive
221                        .append_dir(&stripped, entry_path)
222                        .map_err(|e| LangsError::TarAppend(entry_path.to_path_buf(), e))?;
223                } else {
224                    archive
225                        .append_path_with_name(entry_path, stripped.to_string_lossy().as_ref())
226                        .map_err(|e| LangsError::TarAppend(entry_path.to_path_buf(), e))?;
227                }
228            }
229            archive
230                .finish()
231                .map_err(|e| LangsError::TarAppend(extract_dest_path.clone(), e))?;
232        }
233        Compression::Zip => {
234            let mut archive = ZipWriter::new(archive_file);
235            for entry in WalkDir::new(&extract_dest_path)
236                .into_iter()
237                .filter_entry(|e| e.file_name() != file_util::LOCK_FILE_NAME)
238                .skip(1)
239            {
240                let entry = entry?;
241                let entry_path = entry.path();
242                let stripped = prefix.join(
243                    entry_path
244                        .strip_prefix(&extract_dest_path)
245                        .expect("the entry is inside dest"),
246                );
247                log::debug!(
248                    "adding {} to zip at {}",
249                    entry_path.display(),
250                    stripped.display()
251                );
252                if entry_path.is_dir() {
253                    archive.add_directory(
254                        stripped.to_string_lossy(),
255                        SimpleFileOptions::default().unix_permissions(0o755),
256                    )?;
257                } else {
258                    archive.start_file(
259                        stripped.to_string_lossy(),
260                        SimpleFileOptions::default().unix_permissions(0o755),
261                    )?;
262                    let mut file = file_util::open_file(entry_path)?;
263                    std::io::copy(&mut file, &mut archive)
264                        .map_err(|e| LangsError::TarAppend(entry_path.to_path_buf(), e))?;
265                }
266            }
267            archive.finish()?;
268        }
269        Compression::TarZstd => {
270            let buf = Cursor::new(vec![]);
271            let mut archive = tar::Builder::new(buf);
272            for entry in WalkDir::new(&extract_dest_path)
273                .into_iter()
274                .filter_entry(|e| e.file_name() != file_util::LOCK_FILE_NAME)
275                .skip(1)
276            {
277                let entry = entry?;
278                let entry_path = entry.path();
279                let stripped = prefix.join(
280                    entry_path
281                        .strip_prefix(&extract_dest_path)
282                        .expect("the entry is inside dest"),
283                );
284                log::debug!(
285                    "adding {} to zip at {}",
286                    entry_path.display(),
287                    stripped.display()
288                );
289                if entry_path.is_dir() {
290                    archive
291                        .append_dir(stripped.to_string_lossy().as_ref(), entry_path)
292                        .map_err(|e| LangsError::TarAppend(entry_path.to_path_buf(), e))?;
293                } else {
294                    archive
295                        .append_path_with_name(entry_path, stripped.to_string_lossy().as_ref())
296                        .map_err(|e| LangsError::TarAppend(entry_path.to_path_buf(), e))?;
297                }
298            }
299
300            archive.finish().map_err(LangsError::TarFinish)?;
301            let mut tar = archive.into_inner().map_err(LangsError::TarIntoInner)?;
302            tar.set_position(0); // reset the cursor
303            zstd::stream::copy_encode(tar, archive_file, 0).map_err(LangsError::Zstd)?;
304        }
305    }
306
307    // get sandbox image
308    let sandbox_image = match TmcProjectYml::load(clone_path)?.and_then(|c| c.sandbox_image) {
309        Some(sandbox_image) => sandbox_image,
310        None => crate::get_default_sandbox_image(clone_path)?.to_string(),
311    };
312    Ok(sandbox_image)
313}
314
315fn extract_with_filter<F: Fn(&Path) -> bool>(
316    plugin: PluginType,
317    archive: &Path,
318    compression: Compression,
319    exclude_filter: F,
320    dest: &Path,
321    naive: bool,
322) -> Result<(), LangsError> {
323    let file = file_util::open_file(archive)?;
324    let mut zip = Archive::new(file, compression)?;
325    let project_dir_in_archive = if naive {
326        Ok(PathBuf::new())
327    } else {
328        plugin.safe_find_project_dir_in_archive(&mut zip)
329    }?;
330
331    let mut iter = zip.iter()?;
332    loop {
333        let next = iter.with_next::<(), _>(|mut file| {
334            if file.is_file() {
335                if let Ok(path) = file.path()?.strip_prefix(&project_dir_in_archive) {
336                    if exclude_filter(path) {
337                        // path component on ignore list
338                        return Ok(Continue(()));
339                    };
340                    let target = dest.join(path);
341                    file_util::read_to_file(&mut file, &target)?;
342                }
343            }
344            Ok(Continue(()))
345        });
346        match next? {
347            Continue(_) => continue,
348            Break(_) => break,
349        }
350    }
351    Ok(())
352}
353
354#[cfg(test)]
355#[cfg(target_os = "linux")] // no maven plugin on other OS
356#[allow(clippy::unwrap_used)]
357mod test {
358    use super::*;
359    use std::{fs, path::PathBuf};
360    use tempfile::TempDir;
361    use walkdir::WalkDir;
362
363    const MAVEN_CLONE: &str = "tests/data/some_course/MavenExercise";
364    const MAVEN_ZIP: &str = "tests/data/MavenExercise.zip";
365
366    const MAKE_CLONE: &str = "tests/data/some_course/MakeExercise";
367    const MAKE_ZIP: &str = "tests/data/MakeExercise.zip";
368
369    const PYTHON_CLONE: &str = "tests/data/some_course/PythonExercise";
370    const PYTHON_ZIP: &str = "tests/data/PythonExercise.zip";
371
372    fn init() {
373        use log::*;
374        use simple_logger::*;
375        let _ = SimpleLogger::new()
376            .with_level(LevelFilter::Debug)
377            .with_module_level("j4rs", LevelFilter::Warn)
378            .env()
379            .init();
380    }
381
382    fn generic_submission(clone: &str, zip: &str) -> (TempDir, PathBuf) {
383        let temp = tempfile::tempdir().unwrap();
384        let output_archive = temp.path().join("output.tar");
385
386        let mut tmc_params = TmcParams::new();
387        tmc_params.insert_string("param_one", "value_one").unwrap();
388        tmc_params
389            .insert_array("param_two", vec!["value_two", "value_three"])
390            .unwrap();
391        prepare_submission(
392            PrepareSubmission {
393                archive: Path::new(zip),
394                compression: Compression::Zip,
395                extract_naively: false,
396            },
397            &output_archive,
398            false,
399            tmc_params,
400            Path::new(clone),
401            None,
402            Compression::Tar,
403        )
404        .unwrap();
405        assert!(output_archive.exists());
406
407        let output_file = file_util::open_file(&output_archive).unwrap();
408        let mut archive = tar::Archive::new(output_file);
409        let output_extracted = temp.path().join("output");
410        archive.unpack(&output_extracted).unwrap();
411        for entry in WalkDir::new(temp.path()) {
412            log::debug!("file {}", entry.unwrap().path().display());
413        }
414        (temp, output_extracted)
415    }
416
417    #[test]
418    fn package_has_expected_files() {
419        init();
420        let (_temp, output) = generic_submission(MAVEN_CLONE, MAVEN_ZIP);
421        // expected files
422        assert!(
423            output
424                .join("some_course/MavenExercise/src/main/java/SimpleStuff.java")
425                .exists()
426        );
427        assert!(
428            output
429                .join("some_course/MavenExercise/src/test/java/SimpleTest.java")
430                .exists()
431        );
432        assert!(
433            output
434                .join("some_course/MavenExercise/src/test/java/SimpleHiddenTest.java")
435                .exists()
436        );
437        assert!(output.join("some_course/MavenExercise/pom.xml").exists());
438    }
439
440    #[test]
441    fn package_doesnt_have_unwanted_files() {
442        init();
443        let (_temp, output) = generic_submission(MAVEN_CLONE, MAVEN_ZIP);
444
445        // files that should not be included
446        assert!(!output.join("some_course/MavenExercise/__MACOSX").exists());
447        assert!(
448            !output
449                .join("some_course/MavenExercise/src/test/java/MadeUpTest.java")
450                .exists()
451        );
452    }
453
454    #[test]
455    fn modified_test_file_not_included_in_package() {
456        init();
457        let (_temp, output) = generic_submission(MAVEN_CLONE, MAVEN_ZIP);
458
459        // submission zip has a test file with the text MODIFIED...
460        let zipfile = file_util::open_file(MAVEN_ZIP).unwrap();
461        let mut zip = zip::ZipArchive::new(zipfile).unwrap();
462        let mut modified = zip
463            .by_name("MavenExercise/src/test/java/SimpleTest.java")
464            .unwrap();
465        let mut writer: Vec<u8> = vec![];
466        std::io::copy(&mut modified, &mut writer).unwrap();
467        let contents = String::from_utf8(writer).unwrap();
468        assert!(contents.contains("MODIFIED"));
469        // the text should not be in the package
470        let test_file = fs::read_to_string(
471            output.join("some_course/MavenExercise/src/test/java/SimpleTest.java"),
472        )
473        .unwrap();
474        assert!(!test_file.contains("MODIFIED"));
475    }
476
477    #[test]
478    fn writes_tmcparams() {
479        init();
480        let (_temp, output) = generic_submission(MAVEN_CLONE, MAVEN_ZIP);
481
482        let param_file = output.join("some_course/MavenExercise/.tmcparams");
483        assert!(param_file.exists());
484        let conts = fs::read_to_string(param_file).unwrap();
485        log::debug!("tmcparams {conts}");
486        let lines: Vec<_> = conts.lines().collect();
487        assert_eq!(lines.len(), 2);
488        assert!(lines.contains(&"export param_one=value_one"));
489        assert!(lines.contains(&"export param_two=( value_two value_three )"));
490    }
491
492    #[test]
493    fn packages_without_prefix() {
494        init();
495
496        let temp = tempfile::tempdir().unwrap();
497        let output = temp.path().join("output.tar");
498
499        assert!(!output.exists());
500        prepare_submission(
501            PrepareSubmission {
502                archive: Path::new(MAVEN_ZIP),
503                compression: Compression::Zip,
504                extract_naively: false,
505            },
506            &output,
507            true,
508            TmcParams::new(),
509            Path::new(MAVEN_CLONE),
510            None,
511            Compression::Tar,
512        )
513        .unwrap();
514        assert!(output.exists());
515
516        let output = file_util::open_file(output).unwrap();
517        let mut archive = tar::Archive::new(output);
518        let output = temp.path().join("output");
519        archive.unpack(&output).unwrap();
520        for entry in WalkDir::new(temp.path()) {
521            log::debug!("{}", entry.unwrap().path().display());
522        }
523        assert!(output.join("src/test/java/SimpleHiddenTest.java").exists());
524        assert!(output.join("pom.xml").exists());
525    }
526
527    #[test]
528    fn packages_with_output_zstd() {
529        init();
530
531        let temp = tempfile::tempdir().unwrap();
532        let output = temp.path().join("output.tar.zst");
533
534        assert!(!output.exists());
535        prepare_submission(
536            PrepareSubmission {
537                archive: Path::new(MAVEN_ZIP),
538                compression: Compression::Zip,
539                extract_naively: false,
540            },
541            &output,
542            false,
543            TmcParams::new(),
544            Path::new(MAVEN_CLONE),
545            None,
546            Compression::TarZstd,
547        )
548        .unwrap();
549        assert!(output.exists());
550
551        let output = file_util::open_file(output).unwrap();
552        let output = std::io::Cursor::new(zstd::decode_all(output).unwrap());
553        let mut archive = tar::Archive::new(output);
554        let output = temp.path().join("output");
555        archive.unpack(&output).unwrap();
556        for entry in WalkDir::new(temp.path()) {
557            log::debug!("{}", entry.unwrap().path().display());
558        }
559        assert!(
560            output
561                .join("some_course/MavenExercise/src/test/java/SimpleHiddenTest.java")
562                .exists()
563        );
564        assert!(output.join("some_course/MavenExercise/pom.xml").exists());
565    }
566
567    #[test]
568    fn packages_with_output_zip() {
569        init();
570
571        let temp = tempfile::tempdir().unwrap();
572        let output = temp.path().join("output.zip");
573
574        assert!(!output.exists());
575        prepare_submission(
576            PrepareSubmission {
577                archive: Path::new(MAVEN_ZIP),
578                compression: Compression::Zip,
579                extract_naively: false,
580            },
581            &output,
582            false,
583            TmcParams::new(),
584            Path::new(MAVEN_CLONE),
585            None,
586            Compression::Zip,
587        )
588        .unwrap();
589        assert!(output.exists());
590
591        let output = file_util::open_file(output).unwrap();
592        let mut archive = zip::ZipArchive::new(output).unwrap();
593        archive
594            .by_name("some_course/MavenExercise/src/test/java/SimpleHiddenTest.java")
595            .unwrap();
596    }
597
598    #[test]
599    fn packages_without_prefix_and_output_zip() {
600        init();
601
602        let temp = tempfile::tempdir().unwrap();
603        let output = temp.path().join("output.zip");
604
605        assert!(!output.exists());
606        prepare_submission(
607            PrepareSubmission {
608                archive: Path::new(MAVEN_ZIP),
609                compression: Compression::Zip,
610                extract_naively: false,
611            },
612            &output,
613            true,
614            TmcParams::new(),
615            Path::new(MAVEN_CLONE),
616            None,
617            Compression::Zip,
618        )
619        .unwrap();
620        assert!(output.exists());
621
622        let output = file_util::open_file(output).unwrap();
623        let mut archive = zip::ZipArchive::new(output).unwrap();
624        archive
625            .by_name("src/test/java/SimpleHiddenTest.java")
626            .unwrap();
627        archive.by_name("pom.xml").unwrap();
628    }
629
630    #[test]
631    fn package_with_stub_tests() {
632        init();
633
634        let temp = tempfile::tempdir().unwrap();
635        let output_arch = temp.path().join("output.tar");
636
637        assert!(!output_arch.exists());
638        prepare_submission(
639            PrepareSubmission {
640                archive: Path::new(MAVEN_ZIP),
641                compression: Compression::Zip,
642                extract_naively: false,
643            },
644            &output_arch,
645            false,
646            TmcParams::new(),
647            Path::new(MAVEN_CLONE),
648            Some((Path::new("tests/data/MavenStub.zip"), Compression::Zip)),
649            Compression::Tar,
650        )
651        .unwrap();
652        assert!(output_arch.exists());
653
654        let output_file = file_util::open_file(&output_arch).unwrap();
655        let mut archive = tar::Archive::new(output_file);
656        let output_extracted = temp.path().join("output");
657        archive.unpack(&output_extracted).unwrap();
658        for entry in WalkDir::new(temp.path()) {
659            log::debug!("{}", entry.unwrap().path().display());
660        }
661
662        // visible tests included, hidden test isn't
663        assert!(
664            output_extracted
665                .join("some_course/MavenExercise/src/test/java/SimpleTest.java")
666                .exists()
667        );
668        assert!(
669            !output_extracted
670                .join("some_course/MavenExercise/src/test/java/SimpleHiddenTest.java")
671                .exists()
672        );
673    }
674
675    #[test]
676    fn stub_tmcproject_yml_overrides_student_in_naive_mode() {
677        init();
678
679        // Copy an existing Python exercise fixture to a temp clone path
680        let temp = tempfile::tempdir().unwrap();
681        let clone_root = temp.path().join("some_course");
682        file_util::create_dir_all(&clone_root).unwrap();
683        let src_clone = Path::new(PYTHON_CLONE);
684        file_util::copy(src_clone, &clone_root).unwrap();
685
686        let stub_ex_dir = clone_root.join("PythonExercise");
687        // Ensure stub has its own .tmcproject.yml
688        file_util::write_to_file(b"key: stub", stub_ex_dir.join(".tmcproject.yml")).unwrap();
689
690        // Create a submission zip that attempts to include its own .tmcproject.yml
691        let sub_zip_path = temp.path().join("submission.zip");
692        let sub_zip_file = file_util::create_file(&sub_zip_path).unwrap();
693        let mut zw = zip::ZipWriter::new(sub_zip_file);
694        let opts = SimpleFileOptions::default();
695        zw.add_directory("PythonExercise", opts).unwrap();
696        zw.add_directory("PythonExercise/src", opts).unwrap();
697        zw.start_file("PythonExercise/__init__.py", opts).unwrap();
698        std::io::Write::write_all(&mut zw, b"print('student')\n").unwrap();
699        zw.start_file("PythonExercise/src/__main__.py", opts)
700            .unwrap();
701        std::io::Write::write_all(&mut zw, b"print('student')\n").unwrap();
702        zw.start_file("PythonExercise/.tmcproject.yml", opts)
703            .unwrap();
704        std::io::Write::write_all(&mut zw, b"key: student\n").unwrap();
705        zw.finish().unwrap();
706
707        let output_arch = temp.path().join("out.tar");
708        prepare_submission(
709            PrepareSubmission {
710                archive: &sub_zip_path,
711                compression: Compression::Zip,
712                extract_naively: false,
713            },
714            &output_arch,
715            false,
716            TmcParams::new(),
717            &stub_ex_dir,
718            None,
719            Compression::Tar,
720        )
721        .unwrap();
722        assert!(output_arch.exists());
723
724        // Unpack and verify that .tmcproject.yml content is from stub, not student
725        let output_file = file_util::open_file(&output_arch).unwrap();
726        let mut archive = tar::Archive::new(output_file);
727        let output_extracted = temp.path().join("output");
728        archive.unpack(&output_extracted).unwrap();
729
730        for file in WalkDir::new(&output_extracted) {
731            let file = file.unwrap();
732            println!("{}", file.path().display());
733        }
734
735        // Verify .tmcproject.yml content is from stub, not student
736        let yml =
737            fs::read_to_string(output_extracted.join("some_course/PythonExercise/.tmcproject.yml"))
738                .unwrap();
739        assert!(yml.contains("key: stub"));
740        assert!(!yml.contains("key: student"));
741
742        // Verify other expected files are present
743        assert!(
744            output_extracted
745                .join("some_course/PythonExercise/src/__main__.py")
746                .exists()
747        );
748        assert!(
749            output_extracted
750                .join("some_course/PythonExercise/test/test_greeter.py")
751                .exists()
752        );
753        assert!(
754            output_extracted
755                .join("some_course/PythonExercise/__init__.py")
756                .exists()
757        );
758    }
759
760    #[test]
761    fn prepare_make_submission() {
762        init();
763        let (_temp, output) = generic_submission(MAKE_CLONE, MAKE_ZIP);
764
765        // expected files
766        assert!(output.join("some_course/MakeExercise/src/main.c").exists());
767        assert!(
768            output
769                .join("some_course/MakeExercise/test/test_source.c")
770                .exists()
771        );
772        assert!(output.join("some_course/MakeExercise/Makefile").exists());
773    }
774
775    #[test]
776    fn prepare_langs_submission() {
777        init();
778        let (_temp, output) = generic_submission(PYTHON_CLONE, PYTHON_ZIP);
779
780        // expected files
781        assert!(
782            output
783                .join("some_course/PythonExercise/src/__main__.py")
784                .exists()
785        );
786        assert!(
787            output
788                .join("some_course/PythonExercise/test/test_greeter.py")
789                .exists()
790        );
791        // assert!(output.join("tmc/points.py").exists()); // not included?
792        assert!(
793            output
794                .join("some_course/PythonExercise/__init__.py")
795                .exists()
796        );
797    }
798
799    #[test]
800    fn includes_files_in_root_dir_from_exercise() {
801        init();
802
803        let temp = tempfile::tempdir().unwrap();
804        let clone_root = temp.path().join("some_course");
805        file_util::create_dir_all(&clone_root).unwrap();
806
807        // Copy the Maven exercise to our temp directory
808        let src_clone = Path::new(MAVEN_CLONE);
809        file_util::copy(src_clone, &clone_root).unwrap();
810
811        let exercise_dir = clone_root.join("MavenExercise");
812
813        // Create a file in the root directory of the exercise (simulating repo file)
814        let repo_file_path = exercise_dir.join("foo.txt");
815        file_util::write_to_file(b"repohello", &repo_file_path).unwrap();
816
817        // Create a submission zip that also has foo.txt but with different content
818        // We need to create a proper Maven project structure that the plugin can understand
819        let sub_zip_path = temp.path().join("submission.zip");
820        let sub_zip_file = file_util::create_file(&sub_zip_path).unwrap();
821        let mut zw = zip::ZipWriter::new(sub_zip_file);
822        let opts = SimpleFileOptions::default();
823
824        // Create the Maven project structure
825        zw.add_directory("MavenExercise", opts).unwrap();
826        zw.add_directory("MavenExercise/src", opts).unwrap();
827        zw.add_directory("MavenExercise/src/main", opts).unwrap();
828        zw.add_directory("MavenExercise/src/main/java", opts)
829            .unwrap();
830
831        // Add the student's modified file
832        zw.start_file("MavenExercise/foo.txt", opts).unwrap();
833        std::io::Write::write_all(&mut zw, b"submissionhello").unwrap();
834
835        // Add a source file
836        zw.start_file("MavenExercise/src/main/java/SimpleStuff.java", opts)
837            .unwrap();
838        std::io::Write::write_all(&mut zw, b"public class SimpleStuff { }").unwrap();
839
840        zw.finish().unwrap();
841
842        let output_arch = temp.path().join("out.tar");
843        prepare_submission(
844            PrepareSubmission {
845                archive: &sub_zip_path,
846                compression: Compression::Zip,
847                extract_naively: false,
848            },
849            &output_arch,
850            false,
851            TmcParams::new(),
852            &exercise_dir,
853            None,
854            Compression::Tar,
855        )
856        .unwrap();
857        assert!(output_arch.exists());
858
859        // Unpack and verify that foo.txt content is from the repo, not submission
860        let output_file = file_util::open_file(&output_arch).unwrap();
861        let mut archive = tar::Archive::new(output_file);
862        let output_extracted = temp.path().join("output");
863        archive.unpack(&output_extracted).unwrap();
864
865        // Verify foo.txt exists and has content from repo, not submission
866        let foo_file = output_extracted.join("some_course/MavenExercise/foo.txt");
867        assert!(
868            foo_file.exists(),
869            "foo.txt should be included in the archive"
870        );
871        let content = fs::read_to_string(foo_file).unwrap();
872        assert_eq!(
873            content, "repohello",
874            "Should use repo content, not submission content"
875        );
876    }
877}