1use 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
27pub 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 fn scan_exercise(&self, path: &Path, exercise_name: String) -> Result<ExerciseDesc, TmcError>;
54
55 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 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 fn run_tests_with_timeout(
86 &self,
87 path: &Path,
88 timeout: Option<Duration>,
89 ) -> Result<RunResult, TmcError>;
90
91 fn check_code_style(
93 &self,
94 _path: &Path,
95 _locale: Language,
96 ) -> Result<Option<StyleValidationResult>, TmcError> {
97 Ok(None)
98 }
99
100 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 let project_dir = Self::find_project_dir_in_archive(archive)?;
117 log::debug!("Project dir in zip: {}", project_dir.display());
118
119 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 let mut files_from_archive = HashSet::new();
132 files_from_archive.insert(target_location.join(".tmcproject.yml")); 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 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 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 if file.is_file() {
167 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 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 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 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 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 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 file_util::remove_all(&path_in_target)?;
257 file_util::read_to_file(&mut file, &path_in_target)?;
258 } else {
259 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 fn find_project_dir_in_archive<R: Read + Seek>(
283 archive: &mut Archive<R>,
284 ) -> Result<PathBuf, TmcError>;
285
286 fn safe_find_project_dir_in_archive<R: Read + Seek>(archive: &mut Archive<R>) -> PathBuf {
294 if let Ok(dir) = Self::find_project_dir_in_archive(archive) {
296 return dir;
297 }
298
299 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 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 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 PathBuf::from("")
359 }
360
361 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 fn is_exercise_type_correct(path: &Path) -> bool;
370
371 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 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 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 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 let etc_parser = combinator::value(Parse::Other, character::complete::anychar);
428
429 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 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 let points_parser = combinator::map(Self::points_parser, |p| {
459 Parse::Points(p.into_iter().map(|s| s.to_string()).collect())
460 });
461
462 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 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 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 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 MockPlugin::extract_student_files(
1069 std::io::Cursor::new(zip),
1070 Compression::Zip,
1071 &temp.path().join("extracted"),
1072 )
1073 .unwrap();
1074
1075 assert!(temp.path().join("extracted/src/student_file").exists());
1077 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 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 MockPlugin::extract_student_files(
1094 std::io::Cursor::new(zip),
1095 Compression::Zip,
1096 &temp.path().join("extracted"),
1097 )
1098 .unwrap();
1099
1100 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 fn safe_find_project_dir_fallback_to_single_folder() {
1110 init();
1111
1112 let temp = tempfile::tempdir().unwrap();
1113 file_to(&temp, "project_folder/src/student_file", "content");
1115 let zip = dir_to_zip(&temp);
1116
1117 MockPlugin::extract_student_files(
1119 std::io::Cursor::new(zip),
1120 Compression::Zip,
1121 &temp.path().join("extracted"),
1122 )
1123 .unwrap();
1124
1125 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 file_to(&temp, "src/student_file", "content");
1140 let zip = dir_to_zip(&temp);
1141
1142 MockPlugin::extract_student_files(
1144 std::io::Cursor::new(zip),
1145 Compression::Zip,
1146 &temp.path().join("extracted"),
1147 )
1148 .unwrap();
1149
1150 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 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 SimpleMockPlugin::extract_student_files(
1172 std::io::Cursor::new(zip),
1173 Compression::Zip,
1174 &temp.path().join("extracted"),
1175 )
1176 .unwrap();
1177
1178 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 file_to(&temp, "src/student_file", "content");
1201 let zip = dir_to_zip(&temp);
1202
1203 SimpleMockPlugin::extract_student_files(
1205 std::io::Cursor::new(zip),
1206 Compression::Zip,
1207 &temp.path().join("extracted"),
1208 )
1209 .unwrap();
1210
1211 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}