tmc_langs_framework/
plugin.rs

1//! Contains LanguagePlugin.
2
3use crate::{
4    Archive, Compression,
5    archive::ArchiveIterator,
6    domain::{
7        ExerciseDesc, ExercisePackagingConfiguration, RunResult, RunStatus, StyleValidationResult,
8        TestResult,
9    },
10    error::TmcError,
11    policy::StudentFilePolicy,
12};
13pub use isolang::Language;
14use nom::{IResult, Parser, branch, bytes, character, combinator, multi, sequence};
15use nom_language::error::VerboseError;
16use std::{
17    collections::HashSet,
18    ffi::{OsStr, OsString},
19    io::{Read, Seek},
20    ops::ControlFlow::{Break, Continue},
21    path::{Path, PathBuf},
22    time::Duration,
23};
24use tmc_langs_util::file_util;
25use walkdir::WalkDir;
26
27/// The trait that each language plug-in must implement.
28///
29/// These implement the operations needed by the TMC server to support a
30/// programming language. These are provided as a library to IDE plug-ins as a
31/// convenience. IDE plug-ins often need additional integration work to support a
32/// language properly. This interface does NOT attempt to provide everything that
33/// an IDE plug-in might need to fully support a language.
34///
35/// Parts of this interface may be called in a TMC sandbox.
36///
37/// Implementations must be thread-safe and preferably fully stateless. Users of
38/// this interface are free to cache results if needed.
39pub trait LanguagePlugin {
40    const PLUGIN_NAME: &'static str;
41    const DEFAULT_SANDBOX_IMAGE: &'static str;
42    const LINE_COMMENT: &'static str;
43    const BLOCK_COMMENT: Option<(&'static str, &'static str)>;
44    type StudentFilePolicy: StudentFilePolicy;
45
46    /// Produces an exercise description of an exercise directory.
47    ///
48    /// This involves finding the test cases and the points offered by the
49    /// exercise.
50    ///
51    /// Must return `Err` if the given path is not a valid exercise directory for
52    /// this language.
53    fn scan_exercise(&self, path: &Path, exercise_name: String) -> Result<ExerciseDesc, TmcError>;
54
55    /// Runs the tests for the exercise.
56    fn run_tests(&self, path: &Path) -> Result<RunResult, TmcError> {
57        let timeout = Self::StudentFilePolicy::new(path)?
58            .get_project_config()
59            .tests_timeout_ms
60            .map(Into::into)
61            .map(Duration::from_millis);
62        let result = self.run_tests_with_timeout(path, timeout)?;
63
64        // override success on no test cases
65        if result.status == RunStatus::Passed && result.test_results.is_empty() {
66            Ok(RunResult {
67                status: RunStatus::TestsFailed,
68                test_results: vec![TestResult {
69                    name: "Tests found test".to_string(),
70                    successful: false,
71                    points: vec![],
72                    message: "No tests found. Did you terminate your program with an exit() command?\nYou can also try submitting the exercise to the server."
73                        .to_string(),
74                    exception: vec![],
75                }],
76                logs: result.logs,
77            })
78        } else {
79            Ok(result)
80        }
81    }
82
83    /// Runs the tests for the exercise with the given timeout.
84    /// Used by run_tests with the timeout from the project config.
85    fn run_tests_with_timeout(
86        &self,
87        path: &Path,
88        timeout: Option<Duration>,
89    ) -> Result<RunResult, TmcError>;
90
91    /// Run checkstyle or similar plugin to project if applicable, no-op by default
92    fn check_code_style(
93        &self,
94        _path: &Path,
95        _locale: Language,
96    ) -> Result<Option<StyleValidationResult>, TmcError> {
97        Ok(None)
98    }
99
100    /// Extract a given archive file containing a compressed project to a target location.
101    ///
102    /// This will overwrite any existing files as long as they are not specified as student files
103    /// by the language dependent student file policy.
104    fn extract_project<R: Read + Seek>(
105        archive: &mut Archive<R>,
106        target_location: &Path,
107        clean: bool,
108    ) -> Result<(), TmcError> {
109        log::debug!(
110            "Extracting to {} ({})",
111            target_location.display(),
112            archive.compression()
113        );
114
115        // find the exercise root directory inside the archive
116        let project_dir = Self::find_project_dir_in_archive(archive)?;
117        log::debug!("Project dir in zip: {}", project_dir.display());
118
119        // extract config file if any
120        let tmc_project_yml_path = project_dir.join(".tmcproject.yml");
121        let tmc_project_yml_path_s = tmc_project_yml_path
122            .to_str()
123            .ok_or_else(|| TmcError::ProjectDirInvalidUtf8(project_dir.clone()))?;
124        if let Ok(mut file) = archive.by_path(tmc_project_yml_path_s) {
125            let target_path = target_location.join(".tmcproject.yml");
126            file_util::read_to_file(&mut file, target_path)?;
127        }
128        let policy = Self::StudentFilePolicy::new(target_location)?;
129
130        // used to clean non-student files not in the zip later
131        let mut files_from_archive = HashSet::new();
132        files_from_archive.insert(target_location.join(".tmcproject.yml")); // prevent cleaning .tmcproject.yml
133
134        let mut iter = archive.iter()?;
135        loop {
136            let next = iter.with_next::<(), _>(|mut file| {
137                let file_path = file.path()?;
138                if file_path == Path::new(tmc_project_yml_path_s) {
139                    // already extracted
140                    return Ok(Continue(()));
141                }
142
143                let relative = match file_path.strip_prefix(&project_dir) {
144                    Ok(relative) => relative,
145                    _ => {
146                        log::trace!("skip {}, not in project dir", file_path.display());
147                        return Ok(Continue(()));
148                    }
149                };
150                let path_in_target = target_location.join(relative);
151                log::trace!("processing {file_path:?} -> {path_in_target:?}");
152
153                files_from_archive.insert(path_in_target.clone());
154
155                if !path_in_target.exists() {
156                    // just extract
157                    if file.is_dir() {
158                        file_util::create_dir_all(path_in_target)?;
159                    } else {
160                        file_util::read_to_file(&mut file, path_in_target)?;
161                    }
162                } else if !policy.is_student_file(relative)
163                    || policy.is_updating_forced(relative)?
164                {
165                    // not student file, or forced update
166                    if file.is_file() {
167                        // remove old if dir
168                        if path_in_target.is_dir() {
169                            file_util::remove_dir_all(&path_in_target)?;
170                        }
171                        file_util::read_to_file(&mut file, path_in_target)?;
172                    }
173                }
174                Ok(Continue(()))
175            });
176            match next? {
177                Continue(_) => continue,
178                Break(_) => break,
179            }
180        }
181
182        if clean {
183            // delete non-student files that were not in archive
184            log::debug!("deleting non-student files not in archive");
185            for entry in WalkDir::new(target_location)
186                .into_iter()
187                .filter_map(|e| e.ok())
188            {
189                let relative = entry
190                    .path()
191                    .strip_prefix(target_location)
192                    .expect("all entries are inside target");
193                if !files_from_archive.contains(entry.path())
194                    && (policy.is_updating_forced(entry.path())?
195                        || !policy.is_student_file(relative))
196                {
197                    log::debug!(
198                        "rm {} {}",
199                        entry.path().display(),
200                        target_location.display()
201                    );
202                    if entry.path().is_dir() {
203                        // delete if empty
204                        if WalkDir::new(entry.path()).max_depth(1).into_iter().count() == 1 {
205                            log::debug!("deleting empty directory {}", entry.path().display());
206                            file_util::remove_dir_empty(entry.path())?;
207                        }
208                    } else {
209                        log::debug!("removing file {}", entry.path().display());
210                        file_util::remove_file(entry.path())?;
211                    }
212                }
213            }
214        }
215
216        Ok(())
217    }
218
219    /// Extracts student files from the compressed project.
220    /// It finds the project dir from the zip and extracts the student files from there.
221    /// Overwrites all files.
222    /// Important: does not extract .tmcproject.yml from the students' submission as they control that file and they could use it to modify the test files.
223    fn extract_student_files(
224        compressed_project: impl Read + Seek,
225        compression: Compression,
226        target_location: &Path,
227    ) -> Result<(), TmcError> {
228        log::debug!("Extracting student files to {}", target_location.display());
229
230        let mut archive = Archive::new(compressed_project, compression)?;
231
232        // find the exercise root directory inside the archive
233        let project_dir = Self::safe_find_project_dir_in_archive(&mut archive);
234        log::debug!("Project directory in archive: {}", project_dir.display());
235
236        let policy = Self::StudentFilePolicy::new(target_location)?;
237
238        let mut iter: ArchiveIterator<_> = archive.iter()?;
239        loop {
240            let next = iter.with_next::<(), _>(|mut file| {
241                // get the path where the file should be extracted
242                let file_path = file.path()?;
243                let relative = match file_path.strip_prefix(&project_dir) {
244                    Ok(relative) => relative,
245                    _ => {
246                        log::trace!("skip {}, not in project dir", file_path.display());
247                        return Ok(Continue(()));
248                    }
249                };
250                let path_in_target = target_location.join(relative);
251                log::trace!("processing {file_path:?} -> {path_in_target:?}");
252
253                if policy.is_student_file(relative) {
254                    if file.is_file() {
255                        // for files, everything should be removed out of the way
256                        file_util::remove_all(&path_in_target)?;
257                        file_util::read_to_file(&mut file, &path_in_target)?;
258                    } else {
259                        // for directories, we should keep existing directories but delete files at the same path
260                        if path_in_target.is_file() {
261                            file_util::remove_file(&path_in_target)?;
262                        }
263                        file_util::create_dir_all(&path_in_target)?;
264                    }
265                }
266                Ok(Continue(()))
267            });
268            match next? {
269                Continue(_) => continue,
270                Break(_) => break,
271            }
272        }
273
274        Ok(())
275    }
276
277    /// Searches the zip for a valid project directory.
278    /// This function is used to detect the language plugin for the archive, so
279    /// simply finding "src" is not sufficient, e.g. the Python plugin should check
280    /// that there is an actual "src/*.py" file inside src.
281    /// Note that the returned path may not actually have an entry in the zip.
282    fn find_project_dir_in_archive<R: Read + Seek>(
283        archive: &mut Archive<R>,
284    ) -> Result<PathBuf, TmcError>;
285
286    /// A safer variant of `find_project_dir_in_archive` used by default extraction helpers.
287    ///
288    /// Fallback order:
289    /// 1) Delegate to `find_project_dir_in_archive` implemented by the language plugin
290    /// 2) First directory containing a `.tmcproject.yml`
291    /// 3) If archive root has only one folder, use that folder
292    /// 4) Archive root (empty path)
293    fn safe_find_project_dir_in_archive<R: Read + Seek>(archive: &mut Archive<R>) -> PathBuf {
294        // 1) Try plugin-specific project dir detection first
295        if let Ok(dir) = Self::find_project_dir_in_archive(archive) {
296            return dir;
297        }
298
299        // 2) Try to find the first directory that contains a .tmcproject.yml
300        if let Ok(mut iter) = archive.iter() {
301            loop {
302                let next = iter.with_next(|file| {
303                    let file_path = file.path()?;
304                    if file.is_file()
305                        && file_path
306                            .file_name()
307                            .map(|name| name == OsStr::new(".tmcproject.yml"))
308                            .unwrap_or(false)
309                    {
310                        let parent = file_path
311                            .parent()
312                            .map(PathBuf::from)
313                            .unwrap_or_else(|| PathBuf::from(""));
314                        return Ok(Break(Some(parent)));
315                    }
316                    Ok(Continue(()))
317                });
318                match next {
319                    Ok(Continue(_)) => continue,
320                    Ok(Break(Some(dir))) => return dir,
321                    Ok(Break(None)) => break,
322                    Err(_) => break,
323                }
324            }
325        }
326
327        // 3) Check if archive root has only one folder. This is the format tmc-langs-cli sends submissions, so all official clients should use this format.
328        if let Ok(mut iter) = archive.iter() {
329            let mut root_entries = HashSet::<OsString>::new();
330            loop {
331                let next = iter.with_next::<(), _>(|file| {
332                    let file_path = file.path()?;
333                    if let Some(first_component) = file_path.iter().next() {
334                        root_entries.insert(first_component.to_os_string());
335                    }
336                    Ok(Continue(()))
337                });
338                match next {
339                    Ok(Continue(_)) => continue,
340                    Ok(Break(_)) => break,
341                    Err(_) => break,
342                }
343            }
344
345            // If there's exactly one folder at the root and no files, skip over it
346            // Special case: don't skip over certain folders
347            let excluded_folders = [OsStr::new("src")];
348
349            if root_entries.len() == 1 {
350                let only = root_entries.iter().next().expect("len is 1");
351                if !excluded_folders.contains(&only.as_os_str()) {
352                    return PathBuf::from(only);
353                }
354            }
355        }
356
357        // 4) Default to archive root
358        PathBuf::from("")
359    }
360
361    /// Tells if there's a valid exercise in this archive.
362    /// Unlike `is_exercise_type_correct`, searches the entire archive.
363    fn is_archive_type_correct<R: Read + Seek>(archive: &mut Archive<R>) -> bool {
364        Self::find_project_dir_in_archive(archive).is_ok()
365    }
366
367    /// Tells if there's a valid exercise in this path. Delegates to `find_project_dir_in_archive` by default.
368    /// Unlike `is_archive_type_correct`, only checks the root directory.
369    fn is_exercise_type_correct(path: &Path) -> bool;
370
371    /// Returns configuration which is used to package submission on tmc-server.
372    fn get_exercise_packaging_configuration(
373        path: &Path,
374    ) -> Result<ExercisePackagingConfiguration, TmcError> {
375        let policy = Self::StudentFilePolicy::new(path)?;
376        let mut config = ExercisePackagingConfiguration {
377            student_file_paths: HashSet::new(),
378            exercise_file_paths: HashSet::new(),
379        };
380        for entry in WalkDir::new(path).min_depth(1) {
381            let entry = entry?;
382            if entry.metadata()?.is_dir() {
383                continue;
384            }
385
386            let path = entry
387                .path()
388                .strip_prefix(path)
389                .expect("All entries are within path")
390                .to_path_buf();
391            if policy.is_student_file(&path) {
392                config.student_file_paths.insert(path);
393            } else {
394                config.exercise_file_paths.insert(path);
395            }
396        }
397
398        Ok(config)
399    }
400
401    /// Runs clean command e.g `make clean` for make or `mvn clean` for maven.
402    fn clean(&self, path: &Path) -> Result<(), TmcError>;
403
404    fn get_default_student_file_paths() -> Vec<PathBuf>;
405
406    fn get_default_exercise_file_paths() -> Vec<PathBuf>;
407
408    /// Parses exercise files using Self::LINE_COMMENT and Self::BLOCK_COMMENT to filter out comments and Self::points_parser to parse points from the actual code.
409    fn get_available_points(exercise_path: &Path) -> Result<Vec<String>, TmcError> {
410        let config = Self::get_exercise_packaging_configuration(exercise_path)?;
411
412        let mut points = Vec::new();
413        for exercise_file_path in config.exercise_file_paths {
414            let exercise_file_path = exercise_path.join(exercise_file_path);
415            if !exercise_file_path.exists() {
416                continue;
417            }
418
419            // file path may point to a directory of file, walkdir takes care of both
420            for entry in WalkDir::new(exercise_file_path) {
421                let entry = entry?;
422                if entry.path().is_file() {
423                    log::trace!("parsing points from {}", entry.path().display());
424                    let file_contents = file_util::read_file_to_string_lossy(entry.path())?;
425
426                    // reads any character
427                    let etc_parser = combinator::value(Parse::Other, character::complete::anychar);
428
429                    // reads a single line comment
430                    let line_comment_parser = combinator::value(
431                        Parse::LineComment,
432                        sequence::delimited(
433                            bytes::complete::tag(Self::LINE_COMMENT),
434                            bytes::complete::take_until("\n"),
435                            character::complete::newline,
436                        ),
437                    );
438
439                    // reads a single block comment
440                    let block_comment_parser = |i| {
441                        if let Some((block_start, block_end)) = Self::BLOCK_COMMENT {
442                            combinator::value(
443                                Parse::BlockComment,
444                                sequence::delimited(
445                                    bytes::complete::tag(block_start),
446                                    bytes::complete::take_until(block_end),
447                                    bytes::complete::tag(block_end),
448                                ),
449                            )
450                            .parse(i)
451                        } else {
452                            combinator::value(Parse::BlockComment, character::complete::one_of(""))
453                                .parse(i)
454                        }
455                    };
456
457                    // reads a points annotation
458                    let points_parser = combinator::map(Self::points_parser, |p| {
459                        Parse::Points(p.into_iter().map(|s| s.to_string()).collect())
460                    });
461
462                    // try to apply the interesting parsers, else read a character with the etc parser. repeat until the input ends
463                    let mut parser = multi::many0(branch::alt((
464                        line_comment_parser,
465                        block_comment_parser,
466                        points_parser,
467                        etc_parser,
468                    )));
469
470                    let res: IResult<_, _, _> = parser.parse(&file_contents);
471                    match res {
472                        Ok((_, parsed)) => {
473                            for parse in parsed {
474                                if let Parse::Points(parsed) = parse {
475                                    for point in parsed {
476                                        // a single points annotation can contain multiple whitespace separated points
477                                        let split_points =
478                                            point.split_whitespace().map(str::to_string);
479                                        points.extend(split_points);
480                                    }
481                                }
482                            }
483                        }
484                        Err(nom::Err::Incomplete(_)) => unreachable!("this should never happen"),
485                        Err(nom::Err::Error(e)) | Err(nom::Err::Failure(e)) => {
486                            return Err(TmcError::PointParse(
487                                entry.path().to_path_buf(),
488                                VerboseError {
489                                    errors: e
490                                        .errors
491                                        .into_iter()
492                                        .map(|(s, k)| (s.to_string(), k))
493                                        .collect(),
494                                },
495                            ));
496                        }
497                    }
498                }
499            }
500        }
501        Ok(points)
502    }
503
504    /// A nom parser that recognizes a points annotation and returns the inner points value(s).
505    ///
506    /// For example implementations, see the existing language plugins.
507    fn points_parser(i: &str) -> IResult<&str, Vec<&str>, VerboseError<&str>>;
508}
509
510#[derive(Debug, Clone)]
511enum Parse {
512    LineComment,
513    BlockComment,
514    Points(Vec<String>),
515    Other,
516}
517
518#[cfg(test)]
519#[allow(clippy::unwrap_used)]
520mod test {
521    use super::*;
522    use crate::test_helpers::{MockPlugin, SimpleMockPlugin};
523    use std::io::Write;
524    use zip::{ZipWriter, write::SimpleFileOptions};
525
526    fn init() {
527        use log::*;
528        use simple_logger::*;
529        let _ = SimpleLogger::new().with_level(LevelFilter::Trace).init();
530    }
531
532    fn file_to(
533        target_dir: impl AsRef<std::path::Path>,
534        target_relative: impl AsRef<std::path::Path>,
535        contents: impl AsRef<[u8]>,
536    ) -> PathBuf {
537        let target = target_dir.as_ref().join(target_relative);
538        if let Some(parent) = target.parent() {
539            std::fs::create_dir_all(parent).unwrap();
540        }
541        std::fs::write(&target, contents.as_ref()).unwrap();
542        target
543    }
544
545    fn dir_to(
546        target_dir: impl AsRef<std::path::Path>,
547        target_relative: impl AsRef<std::path::Path>,
548    ) -> PathBuf {
549        let target = target_dir.as_ref().join(target_relative);
550        std::fs::create_dir_all(&target).unwrap();
551        target
552    }
553
554    fn dir_to_zip(source_dir: impl AsRef<std::path::Path>) -> Vec<u8> {
555        let mut target = vec![];
556        let mut zip = zip::ZipWriter::new(std::io::Cursor::new(&mut target));
557
558        for entry in walkdir::WalkDir::new(&source_dir)
559            .min_depth(1)
560            .sort_by(|a, b| a.path().cmp(b.path()))
561        {
562            let entry = entry.unwrap();
563            let rela = entry
564                .path()
565                .strip_prefix(&source_dir)
566                .unwrap()
567                .to_str()
568                .unwrap();
569            if entry.path().is_dir() {
570                zip.add_directory(rela, SimpleFileOptions::default())
571                    .unwrap();
572            } else if entry.path().is_file() {
573                zip.start_file(rela, SimpleFileOptions::default()).unwrap();
574                let bytes = std::fs::read(entry.path()).unwrap();
575                zip.write_all(&bytes).unwrap();
576            }
577        }
578
579        zip.finish().unwrap();
580        target
581    }
582
583    #[test]
584    fn gets_exercise_packaging_configuration() {
585        init();
586
587        let temp = tempfile::tempdir().unwrap();
588        file_to(
589            &temp,
590            ".tmcproject.yml",
591            r#"
592extra_student_files:
593  - "test/StudentTest.java"
594  - "test/OtherTest.java"
595  - "InBothLists.java"
596extra_exercise_files:
597  - "src/SomeFile.java"
598  - "src/OtherTest.java"
599  - "InBothLists.java"
600"#,
601        );
602        file_to(&temp, "test/StudentTest.java", "");
603        file_to(&temp, "test/OtherTest.java", "");
604        file_to(&temp, "src/SomeFile.java", "");
605        file_to(&temp, "src/OtherTest.java", "");
606        file_to(&temp, "InBothLists.java", "");
607        let conf = MockPlugin::get_exercise_packaging_configuration(temp.path()).unwrap();
608        assert!(
609            conf.student_file_paths
610                .contains(Path::new("test/StudentTest.java"))
611        );
612        assert!(
613            conf.student_file_paths
614                .contains(Path::new("test/OtherTest.java"))
615        );
616        assert!(
617            conf.exercise_file_paths
618                .contains(Path::new("src/SomeFile.java"))
619        );
620        assert!(
621            !conf
622                .exercise_file_paths
623                .contains(Path::new("test/OtherTest.java"))
624        );
625
626        assert!(
627            conf.student_file_paths
628                .contains(Path::new("InBothLists.java"))
629        );
630        assert!(
631            !conf
632                .exercise_file_paths
633                .contains(Path::new("InBothLists.java"))
634        );
635    }
636
637    #[test]
638    fn empty_run_result_is_err() {
639        init();
640        let plugin = MockPlugin {};
641        let res = plugin.run_tests(Path::new("")).unwrap();
642        assert_eq!(res.status, RunStatus::TestsFailed);
643        assert_eq!(res.test_results[0].name, "Tests found test")
644    }
645
646    #[test]
647    fn gets_available_points() {
648        init();
649
650        let temp = tempfile::tempdir().unwrap();
651        file_to(
652            &temp,
653            "src/student_file.py",
654            r#"
655@Points("1.1")
656"#,
657        );
658        let points = MockPlugin::get_available_points(temp.path()).unwrap();
659        assert!(points.is_empty());
660
661        let temp = tempfile::tempdir().unwrap();
662        file_to(
663            &temp,
664            "test/exercise_file.py",
665            r#"
666@Points("1")
667def a():
668    pass
669
670@ points ( '2' )
671def b():
672    pass
673    @    Points    (    "3"    )
674def c():
675    pass
676
677@pOiNtS("4")
678def d():
679    pass
680"#,
681        );
682        let points = MockPlugin::get_available_points(temp.path()).unwrap();
683        assert_eq!(points, &["1", "2", "3", "4"]);
684
685        let temp = tempfile::tempdir().unwrap();
686        file_to(
687            &temp,
688            "test/exercise_file.py",
689            r#"
690@Points("1")
691def a():
692    pass
693
694// @Points("2")
695def b():
696    pass
697
698@Points("3") // comment
699def c():
700    pass
701
702/* @Points("4") */
703def d():
704    pass
705
706/*
707@Points("5")
708def e():
709    pass
710*/
711
712@Test // @Points("6")
713def f():
714    pass
715"#,
716        );
717        let points = MockPlugin::get_available_points(temp.path()).unwrap();
718        assert_eq!(points, &["1", "3"]);
719    }
720
721    #[test]
722    fn finds_project_dir_in_zip() {
723        init();
724
725        let temp = tempfile::tempdir().unwrap();
726        file_to(&temp, "dir1/dir2/dir3/src/file", "");
727        let zip = dir_to_zip(&temp);
728
729        let mut zip = Archive::zip(std::io::Cursor::new(zip)).unwrap();
730        let dir = MockPlugin::find_project_dir_in_archive(&mut zip).unwrap();
731        assert_eq!(dir, Path::new("dir1").join("dir2").join("dir3"));
732    }
733
734    #[test]
735    fn doesnt_find_project_dir_in_macos() {
736        init();
737
738        let temp = tempfile::tempdir().unwrap();
739        file_to(&temp, "dir1/dir2/dir3/__MACOSX/src/file", "");
740        file_to(&temp, "dir1/__MACOSX/dir2/dir3/src/file", "");
741        let zip = dir_to_zip(&temp);
742
743        let mut zip = Archive::zip(std::io::Cursor::new(zip)).unwrap();
744        let dir = MockPlugin::find_project_dir_in_archive(&mut zip);
745        assert!(dir.is_err());
746    }
747
748    #[test]
749    fn extracts_student_files() {
750        init();
751
752        let temp = tempfile::tempdir().unwrap();
753        file_to(&temp, "dir/src/more/dirs/student file", "");
754        file_to(&temp, "dir/test/exercise file", "");
755        file_to(&temp, "not in project dir", "");
756        let zip = dir_to_zip(&temp);
757
758        MockPlugin::extract_student_files(
759            std::io::Cursor::new(zip),
760            Compression::Zip,
761            &temp.path().join("extracted"),
762        )
763        .unwrap();
764
765        assert!(
766            temp.path()
767                .join("extracted/src/more/dirs/student file")
768                .exists()
769        );
770        assert!(!temp.path().join("extracted/test/exercise file").exists());
771    }
772
773    #[test]
774    fn extracts_student_dirs() {
775        init();
776
777        let temp = tempfile::tempdir().unwrap();
778        dir_to(&temp, "dir/src");
779        dir_to(&temp, "dir/test");
780        dir_to(&temp, "not in project dir");
781        let zip = dir_to_zip(&temp);
782
783        MockPlugin::extract_student_files(
784            std::io::Cursor::new(zip),
785            Compression::Zip,
786            &temp.path().join("extracted"),
787        )
788        .unwrap();
789
790        for entry in WalkDir::new(temp.path().join("extracted"))
791            .into_iter()
792            .flatten()
793        {
794            log::debug!("{}", entry.path().display());
795        }
796
797        assert!(temp.path().join("extracted/src").exists());
798        assert!(!temp.path().join("extracted/test").exists());
799        assert!(!temp.path().join("extracted/not in project dir").exists());
800    }
801
802    #[test]
803    fn extract_student_files_overwrites() {
804        init();
805
806        let temp = tempfile::tempdir().unwrap();
807        file_to(&temp, "dir/src/file overwrites file", "new");
808        file_to(&temp, "dir/src/file overwrites dir", "data");
809        dir_to(&temp, "dir/src/dir overwrites file");
810        let zip = dir_to_zip(&temp);
811        file_to(&temp, "extracted/src/file overwrites file", "old");
812        file_to(
813            &temp,
814            "extracted/src/file overwrites dir/some dir/some file",
815            "",
816        );
817        file_to(&temp, "extracted/src/dir overwrites file", "old");
818
819        MockPlugin::extract_student_files(
820            std::io::Cursor::new(zip),
821            Compression::Zip,
822            &temp.path().join("extracted"),
823        )
824        .unwrap();
825
826        for entry in WalkDir::new(temp.path().join("extracted"))
827            .into_iter()
828            .flatten()
829        {
830            log::debug!("{}", entry.path().display());
831        }
832
833        let path = temp.path().join("extracted/src/file overwrites file");
834        assert!(path.is_file());
835        let s = std::fs::read_to_string(path).unwrap();
836        assert_eq!(s, "new");
837
838        let path = temp.path().join("extracted/src/file overwrites dir");
839        assert!(path.is_file());
840        let s = std::fs::read_to_string(path).unwrap();
841        assert_eq!(s, "data");
842
843        let path = temp.path().join("extracted/src/dir overwrites file");
844        assert!(path.is_dir());
845    }
846
847    #[test]
848    fn extracts_project() {
849        init();
850
851        let temp = tempfile::tempdir().unwrap();
852        file_to(&temp, "dir/src/more/dirs/student file", "");
853        file_to(&temp, "dir/test/exercise file", "");
854        file_to(&temp, "not in project dir", "");
855        let zip = dir_to_zip(&temp);
856
857        let mut arch = Archive::zip(std::io::Cursor::new(zip)).unwrap();
858        MockPlugin::extract_project(&mut arch, &temp.path().join("extracted"), false).unwrap();
859
860        for entry in WalkDir::new(temp.path().join("extracted"))
861            .into_iter()
862            .flatten()
863        {
864            log::debug!("{}", entry.path().display());
865        }
866
867        assert!(
868            temp.path()
869                .join("extracted/src/more/dirs/student file")
870                .exists()
871        );
872        assert!(temp.path().join("extracted/test/exercise file").exists());
873        assert!(!temp.path().join("extracted/not in project dir").exists());
874    }
875
876    #[test]
877    fn extract_project_overwrites_default() {
878        init();
879
880        let temp = tempfile::tempdir().unwrap();
881        file_to(&temp, "dir/src/student file", "new");
882        file_to(&temp, "dir/test/exercise file", "new");
883        let zip = dir_to_zip(&temp);
884        file_to(&temp, "extracted/src/student file", "old");
885        file_to(&temp, "extracted/test/exercise file", "old");
886
887        let mut arch = Archive::zip(std::io::Cursor::new(zip)).unwrap();
888        MockPlugin::extract_project(&mut arch, &temp.path().join("extracted"), false).unwrap();
889
890        for entry in WalkDir::new(temp.path().join("extracted"))
891            .into_iter()
892            .flatten()
893        {
894            log::debug!("{}", entry.path().display());
895        }
896
897        let s = std::fs::read_to_string(temp.path().join("extracted/src/student file")).unwrap();
898        assert_eq!(s, "old");
899        let s = std::fs::read_to_string(temp.path().join("extracted/test/exercise file")).unwrap();
900        assert_eq!(s, "new");
901    }
902
903    #[test]
904    fn extract_project_overwrites_with_config_file() {
905        init();
906
907        let temp = tempfile::tempdir().unwrap();
908        file_to(&temp, "dir/src/forced update", "new");
909        file_to(&temp, "dir/extra student file", "new");
910        file_to(
911            &temp,
912            "dir/.tmcproject.yml",
913            r#"
914extra_student_files:
915  - "extra student file"
916force_update:
917  - "src/forced update"
918"#,
919        );
920        let zip = dir_to_zip(&temp);
921        file_to(&temp, "extracted/src/forced update", "old");
922        file_to(&temp, "extracted/extra student file", "old");
923
924        let mut arch = Archive::zip(std::io::Cursor::new(zip)).unwrap();
925        MockPlugin::extract_project(&mut arch, &temp.path().join("extracted"), false).unwrap();
926
927        for entry in WalkDir::new(temp.path().join("extracted"))
928            .into_iter()
929            .flatten()
930        {
931            log::debug!("{}", entry.path().display());
932        }
933
934        let s = std::fs::read_to_string(temp.path().join("extracted/src/forced update")).unwrap();
935        assert_eq!(s, "new");
936        let s = std::fs::read_to_string(temp.path().join("extracted/extra student file")).unwrap();
937        assert_eq!(s, "old");
938    }
939
940    #[test]
941    fn extract_project_doesnt_clean() {
942        init();
943
944        let temp = tempfile::tempdir().unwrap();
945        file_to(&temp, "dir/src/some file", "");
946        let zip = dir_to_zip(&temp);
947        file_to(&temp, "extracted/test/some existing non-student file", "");
948
949        for entry in WalkDir::new(temp.path().join("extracted"))
950            .into_iter()
951            .flatten()
952        {
953            log::debug!("{}", entry.path().display());
954        }
955
956        let mut arch = Archive::zip(std::io::Cursor::new(zip)).unwrap();
957        MockPlugin::extract_project(&mut arch, &temp.path().join("extracted"), false).unwrap();
958
959        for entry in WalkDir::new(temp.path().join("extracted"))
960            .into_iter()
961            .flatten()
962        {
963            log::debug!("{}", entry.path().display());
964        }
965
966        assert!(
967            temp.path()
968                .join("extracted/test/some existing non-student file")
969                .exists()
970        )
971    }
972
973    #[test]
974    fn extract_project_cleans() {
975        init();
976
977        let temp = tempfile::tempdir().unwrap();
978        file_to(&temp, "dir/src/some file", "");
979        let zip = dir_to_zip(&temp);
980        file_to(&temp, "extracted/test/some existing non-student file", "");
981
982        let mut arch = Archive::zip(std::io::Cursor::new(zip)).unwrap();
983        MockPlugin::extract_project(&mut arch, &temp.path().join("extracted"), true).unwrap();
984
985        for entry in WalkDir::new(temp.path().join("extracted"))
986            .into_iter()
987            .flatten()
988        {
989            log::debug!("{}", entry.path().display());
990        }
991
992        assert!(
993            !temp
994                .path()
995                .join("extracted/test/some existing non-student file")
996                .exists()
997        )
998    }
999
1000    #[test]
1001    fn splits_points_by_whitespace() {
1002        init();
1003
1004        let temp = tempfile::tempdir().unwrap();
1005        file_to(
1006            &temp,
1007            "test/file",
1008            r#"
1009@points("1 2 3 4")
1010@points("  5  6  7  8  ")
1011"#,
1012        );
1013
1014        let points = MockPlugin::get_available_points(temp.path()).unwrap();
1015        assert_eq!(points, &["1", "2", "3", "4", "5", "6", "7", "8"]);
1016    }
1017
1018    #[test]
1019    fn parses_empty() {
1020        init();
1021
1022        let temp = tempfile::tempdir().unwrap();
1023        file_to(&temp, "test/file", r#""#);
1024
1025        let points = MockPlugin::get_available_points(temp.path()).unwrap();
1026        assert!(points.is_empty());
1027
1028        let temp = tempfile::tempdir().unwrap();
1029        file_to(
1030            &temp,
1031            "test/file",
1032            r#"
1033"#,
1034        );
1035
1036        let points = MockPlugin::get_available_points(temp.path()).unwrap();
1037        assert!(points.is_empty());
1038    }
1039
1040    #[test]
1041    fn extract_student_files_does_not_clean_directories_incorrectly() {
1042        init();
1043
1044        let temp = tempfile::tempdir().unwrap();
1045        file_to(&temp, "src/file", "");
1046
1047        let buf = vec![];
1048        let mut zw = ZipWriter::new(std::io::Cursor::new(buf));
1049        zw.add_directory("src", SimpleFileOptions::default())
1050            .unwrap();
1051        let buf = zw.finish().unwrap();
1052
1053        MockPlugin::extract_student_files(buf, Compression::Zip, temp.path()).unwrap();
1054        assert!(temp.path().join("src/file").exists());
1055    }
1056
1057    #[test]
1058    fn extract_student_files_does_not_extract_tmcproject_yml() {
1059        init();
1060
1061        let temp = tempfile::tempdir().unwrap();
1062        // create a project with a .tmcproject.yml in the project root and a student file under src
1063        file_to(&temp, "dir/src/student_file", "");
1064        file_to(&temp, "dir/.tmcproject.yml", "some: yaml");
1065        let zip = dir_to_zip(&temp);
1066
1067        // extract student files
1068        MockPlugin::extract_student_files(
1069            std::io::Cursor::new(zip),
1070            Compression::Zip,
1071            &temp.path().join("extracted"),
1072        )
1073        .unwrap();
1074
1075        // ensure student files are extracted
1076        assert!(temp.path().join("extracted/src/student_file").exists());
1077        // ensure .tmcproject.yml is NOT extracted
1078        assert!(!temp.path().join("extracted/.tmcproject.yml").exists());
1079    }
1080
1081    #[test]
1082    fn safe_find_project_dir_fallback_to_tmcproject_yml() {
1083        init();
1084
1085        let temp = tempfile::tempdir().unwrap();
1086        // create an archive without src directory (which would normally fail)
1087        // but with a .tmcproject.yml in a subdirectory
1088        file_to(&temp, "some/deep/path/.tmcproject.yml", "some: yaml");
1089        file_to(&temp, "some/deep/path/src/student_file", "content");
1090        let zip = dir_to_zip(&temp);
1091
1092        // extract student files - should use fallback to .tmcproject.yml parent
1093        MockPlugin::extract_student_files(
1094            std::io::Cursor::new(zip),
1095            Compression::Zip,
1096            &temp.path().join("extracted"),
1097        )
1098        .unwrap();
1099
1100        // ensure student files are extracted from the fallback directory
1101        assert!(temp.path().join("extracted/src/student_file").exists());
1102        let content =
1103            std::fs::read_to_string(temp.path().join("extracted/src/student_file")).unwrap();
1104        assert_eq!(content, "content");
1105    }
1106
1107    #[test]
1108    /** Matches the format tmc-langs-cli sends submissions. This makes sure submissions created by official clients are supported. */
1109    fn safe_find_project_dir_fallback_to_single_folder() {
1110        init();
1111
1112        let temp = tempfile::tempdir().unwrap();
1113        // create an archive with only one folder at root level
1114        file_to(&temp, "project_folder/src/student_file", "content");
1115        let zip = dir_to_zip(&temp);
1116
1117        // extract student files - should use fallback to the single folder
1118        MockPlugin::extract_student_files(
1119            std::io::Cursor::new(zip),
1120            Compression::Zip,
1121            &temp.path().join("extracted"),
1122        )
1123        .unwrap();
1124
1125        // ensure student files are extracted from the single folder
1126        assert!(temp.path().join("extracted/src/student_file").exists());
1127        let content =
1128            std::fs::read_to_string(temp.path().join("extracted/src/student_file")).unwrap();
1129        assert_eq!(content, "content");
1130    }
1131
1132    #[test]
1133    fn safe_find_project_dir_fallback_to_root() {
1134        init();
1135
1136        let temp = tempfile::tempdir().unwrap();
1137        // create an archive without src directory and without .tmcproject.yml
1138        // should fallback to root
1139        file_to(&temp, "src/student_file", "content");
1140        let zip = dir_to_zip(&temp);
1141
1142        // extract student files - should use fallback to root
1143        MockPlugin::extract_student_files(
1144            std::io::Cursor::new(zip),
1145            Compression::Zip,
1146            &temp.path().join("extracted"),
1147        )
1148        .unwrap();
1149
1150        // ensure student files are extracted from the root
1151        assert!(temp.path().join("extracted/src/student_file").exists());
1152        let content =
1153            std::fs::read_to_string(temp.path().join("extracted/src/student_file")).unwrap();
1154        assert_eq!(content, "content");
1155    }
1156
1157    #[test]
1158    #[cfg(not(target_os = "windows"))]
1159    fn safe_find_project_dir_single_folder_not_used_when_root_has_files() {
1160        init();
1161
1162        let temp = tempfile::tempdir().unwrap();
1163        // root has a single folder and also a file at root
1164        // SimpleMockPlugin will never find project directory, so fallback logic will be used
1165        file_to(&temp, "project_folder/src/student_file", "content");
1166        file_to(&temp, "root_file.txt", "x");
1167        let zip = dir_to_zip(&temp);
1168
1169        // extract student files - should NOT use the single-folder fallback because there's a root file
1170        // so it should fallback to root, which extracts project_folder/src/student_file
1171        SimpleMockPlugin::extract_student_files(
1172            std::io::Cursor::new(zip),
1173            Compression::Zip,
1174            &temp.path().join("extracted"),
1175        )
1176        .unwrap();
1177
1178        // The file should be extracted as project_folder/src/student_file (preserving full path from root)
1179        // This test verifies that the single folder fallback is not used when there's a root file
1180        assert!(
1181            temp.path()
1182                .join("extracted/project_folder/src/student_file")
1183                .exists()
1184        );
1185        let content = std::fs::read_to_string(
1186            temp.path()
1187                .join("extracted/project_folder/src/student_file"),
1188        )
1189        .unwrap();
1190        assert_eq!(content, "content");
1191    }
1192
1193    #[test]
1194    fn safe_find_project_dir_does_not_skip_over_src_folder() {
1195        init();
1196
1197        let temp = tempfile::tempdir().unwrap();
1198        // root has only a single "src" folder, no root files
1199        // SimpleMockPlugin will never find project directory, so fallback logic will be used
1200        file_to(&temp, "src/student_file", "content");
1201        let zip = dir_to_zip(&temp);
1202
1203        // extract student files - should use the "src" folder as project root
1204        SimpleMockPlugin::extract_student_files(
1205            std::io::Cursor::new(zip),
1206            Compression::Zip,
1207            &temp.path().join("extracted"),
1208        )
1209        .unwrap();
1210
1211        // The file should be extracted as student_file (using the src folder as project root)
1212        // This test verifies that the "src" folder is used as the project directory
1213        assert!(temp.path().join("extracted/src/student_file").exists());
1214        let content =
1215            std::fs::read_to_string(temp.path().join("extracted/src/student_file")).unwrap();
1216        assert_eq!(content, "content");
1217    }
1218}