1use crate::{
4 CompileResult, JvmWrapper, MavenStudentFilePolicy, SEPARATOR, TestRun, error::JavaError,
5 java_plugin::JavaPlugin,
6};
7use flate2::read::GzDecoder;
8use std::{
9 ffi::{OsStr, OsString},
10 io::{Cursor, Read, Seek},
11 ops::ControlFlow::{Break, Continue},
12 path::{Path, PathBuf},
13 time::Duration,
14};
15use tar::Archive as Tar;
16use tmc_langs_framework::{
17 Archive, ExerciseDesc, Language, LanguagePlugin, RunResult, StyleValidationResult, TmcCommand,
18 TmcError, nom::IResult, nom_language::error::VerboseError,
19};
20use tmc_langs_util::file_util;
21
22const MVN_ARCHIVE: &[u8] = include_bytes!("../deps/apache-maven-3.8.1-bin.tar.gz");
23const MVN_PATH_IN_ARCHIVE: &str = "apache-maven-3.8.1"; const MVN_VERSION: &str = "3.8.1";
25
26pub struct MavenPlugin {
27 jvm: JvmWrapper,
28}
29
30impl MavenPlugin {
31 pub fn new() -> Result<Self, JavaError> {
32 let jvm = crate::instantiate_jvm()?;
33 Ok(Self { jvm })
34 }
35
36 fn get_mvn_command() -> Result<OsString, JavaError> {
42 if let Ok(status) = TmcCommand::piped("mvn")
44 .with(|e| e.arg("--batch-mode").arg("--version"))
45 .status()
46 {
47 if status.success() {
48 return Ok(OsString::from("mvn"));
49 }
50 }
51 log::debug!("could not execute mvn, using bundled maven");
52 let tmc_path = dirs::cache_dir().ok_or(JavaError::CacheDir)?.join("tmc");
53
54 #[cfg(windows)]
55 let mvn_exec = "mvn.cmd";
56 #[cfg(not(windows))]
57 let mvn_exec = "mvn";
58
59 let mvn_path = tmc_path.join("apache-maven");
60 let mvn_version_path = mvn_path.join("VERSION");
61
62 let needs_update = if mvn_version_path.exists() {
63 let version_contents = file_util::read_file_to_string(&mvn_version_path)?;
64 MVN_VERSION != version_contents
65 } else {
66 true
67 };
68
69 if needs_update {
70 if mvn_path.exists() {
71 file_util::remove_dir_all(&mvn_path)?;
72 }
73 let old_path = tmc_path.join("apache-maven-3.6.3");
75 if old_path.exists() {
76 file_util::remove_dir_all(old_path)?;
77 }
78
79 log::debug!("extracting bundled tar");
80 let tar = GzDecoder::new(Cursor::new(MVN_ARCHIVE));
81 let mut tar = Tar::new(tar);
82 tar.unpack(&tmc_path)
83 .map_err(|e| JavaError::JarWrite(tmc_path.clone(), e))?;
84
85 log::debug!("renaming extracted archive to apache-maven");
86 file_util::rename(tmc_path.join(MVN_PATH_IN_ARCHIVE), &mvn_path)?;
87
88 log::debug!("writing bundle version data");
89 file_util::write_to_file(MVN_VERSION.as_bytes(), &mvn_version_path)?;
90 }
91
92 let mvn_exec_path = mvn_path.join("bin").join(mvn_exec);
93 Ok(mvn_exec_path.as_os_str().to_os_string())
94 }
95}
96
97impl LanguagePlugin for MavenPlugin {
100 const PLUGIN_NAME: &'static str = "apache-maven";
101 const DEFAULT_SANDBOX_IMAGE: &'static str = "eu.gcr.io/moocfi-public/tmc-sandbox-java:latest";
102 const LINE_COMMENT: &'static str = "//";
103 const BLOCK_COMMENT: Option<(&'static str, &'static str)> = Some(("/*", "*/"));
104 type StudentFilePolicy = MavenStudentFilePolicy;
105
106 fn check_code_style(
107 &self,
108 path: &Path,
109 locale: Language,
110 ) -> Result<Option<StyleValidationResult>, TmcError> {
111 Ok(Some(self.run_checkstyle(&locale, path)?))
112 }
113
114 fn scan_exercise(&self, path: &Path, exercise_name: String) -> Result<ExerciseDesc, TmcError> {
115 if !Self::is_exercise_type_correct(path) {
116 return JavaError::InvalidExercise(path.to_path_buf()).into();
117 }
118
119 let compile_result = self.build(path)?;
120 Ok(self.scan_exercise_with_compile_result(path, exercise_name, compile_result)?)
121 }
122
123 fn run_tests_with_timeout(
124 &self,
125 project_root_path: &Path,
126 timeout: Option<Duration>,
127 ) -> Result<RunResult, TmcError> {
128 Ok(self.run_java_tests(project_root_path, timeout)?)
129 }
130
131 fn find_project_dir_in_archive<R: Read + Seek>(
132 archive: &mut Archive<R>,
133 ) -> Result<PathBuf, TmcError> {
134 let mut iter = archive.iter()?;
135
136 let project_dir = loop {
137 let next = iter.with_next(|file| {
139 let file_path = file.path()?;
140
141 if file.is_file() && file_path.file_name() == Some(OsStr::new("pom.xml")) {
142 if let Some(pom_parent) = file_path.parent() {
143 return Ok(Break(Some(pom_parent.to_path_buf())));
144 }
145 }
146 Ok(Continue(()))
147 })?;
148 if let Some(Some(root)) = next.break_value() {
149 return Ok(root);
150 }
151
152 let root = iter.with_next(|file| {
153 let file_path = file.path()?;
154
155 let components = file_path.iter();
156 let mut in_src = false;
157 let mut in_src_main = false;
158
159 if file.is_file() && file_path.file_name() == Some(OsStr::new("pom.xml")) {
161 if let Some(pom_parent) = file_path.parent() {
162 return Ok(Break(Some(pom_parent.to_path_buf())));
163 }
164 }
165
166 for next in components {
168 if in_src_main {
169 if Path::new(next).extension() == Some(OsStr::new("java")) {
170 let root = file_path
171 .iter()
172 .take_while(|c| c != &OsStr::new("src"))
173 .collect();
174 return Ok(Break(Some(root)));
175 }
176 } else {
177 break;
178 }
179
180 if in_src {
181 if next == "main" {
182 in_src_main = true;
183 } else {
184 break;
185 }
186 } else {
187 break;
188 }
189
190 if next == "src" {
191 in_src = true;
192 } else {
193 break;
194 }
195 }
196 Ok(Continue(()))
197 });
198 match root? {
199 Continue(_) => continue,
200 Break(project_dir) => break project_dir,
201 }
202 };
203
204 match project_dir {
205 Some(project_dir) => Ok(project_dir),
206 None => Err(TmcError::NoProjectDirInArchive),
207 }
208 }
209
210 fn is_exercise_type_correct(path: &Path) -> bool {
212 path.join("pom.xml").exists()
213 }
214
215 fn clean(&self, path: &Path) -> Result<(), TmcError> {
217 log::info!("Cleaning maven project at {}", path.display());
218
219 let mvn_command = Self::get_mvn_command()?;
220 let _output = TmcCommand::piped(mvn_command)
221 .with(|e| e.cwd(path).arg("--batch-mode").arg("clean"))
222 .output_checked()?;
223
224 Ok(())
225 }
226
227 fn get_default_student_file_paths() -> Vec<PathBuf> {
228 vec![PathBuf::from("src/main")]
229 }
230
231 fn get_default_exercise_file_paths() -> Vec<PathBuf> {
232 vec![PathBuf::from("src/test")]
233 }
234
235 fn points_parser(i: &str) -> IResult<&str, Vec<&str>, VerboseError<&str>> {
236 Self::java_points_parser(i)
237 }
238}
239
240impl JavaPlugin for MavenPlugin {
241 const TEST_DIR: &'static str = "src";
242
243 fn jvm(&self) -> &JvmWrapper {
244 &self.jvm
245 }
246
247 fn get_project_class_path(&self, path: &Path) -> Result<String, JavaError> {
248 let path = file_util::canonicalize(path)?;
250 log::info!("Building classpath for maven project at {}", path.display());
251
252 let temp = tempfile::tempdir().map_err(JavaError::TempDir)?;
253 let class_path_file = temp.path().join("cp.txt");
254
255 let output_arg = format!("-Dmdep.outputFile={}", class_path_file.display());
256 let mvn_path = Self::get_mvn_command()?;
257 let _output = TmcCommand::piped(mvn_path)
258 .with(|e| {
259 e.cwd(&path)
260 .arg("--batch-mode")
261 .arg("dependency:build-classpath")
262 .arg(output_arg)
263 })
264 .output_checked()?;
265
266 let class_path = file_util::read_file_to_string(&class_path_file)?;
267 if class_path.is_empty() {
268 return Err(JavaError::NoMvnClassPath);
269 }
270
271 let mut class_path: Vec<String> = vec![class_path];
272 class_path.push(path.join("target/classes").to_string_lossy().into_owned());
273 class_path.push(
274 path.join("target/test-classes")
275 .to_string_lossy()
276 .into_owned(),
277 );
278
279 Ok(class_path.join(SEPARATOR))
280 }
281
282 fn build(&self, project_root_path: &Path) -> Result<CompileResult, JavaError> {
283 log::info!("Building maven project at {}", project_root_path.display());
284
285 let mvn_path = Self::get_mvn_command()?;
286 let output = TmcCommand::piped(mvn_path)
287 .with(|e| {
288 e.cwd(project_root_path)
289 .arg("--batch-mode")
290 .arg("clean")
291 .arg("compile")
292 .arg("test-compile")
293 })
294 .output()?;
295
296 Ok(CompileResult {
297 status_code: output.status,
298 stdout: output.stdout,
299 stderr: output.stderr,
300 })
301 }
302
303 fn create_run_result_file(
305 &self,
306 path: &Path,
307 timeout: Option<Duration>,
308 _compile_result: CompileResult,
309 ) -> Result<TestRun, JavaError> {
310 log::info!("Running tests for maven project at {}", path.display());
311
312 let mvn_path = Self::get_mvn_command()?;
313 let command = TmcCommand::piped(mvn_path).with(|e| {
314 e.cwd(path)
315 .arg("--batch-mode")
316 .arg("fi.helsinki.cs.tmc:tmc-maven-plugin:1.12:test")
317 });
318 let output = if let Some(timeout) = timeout {
319 command.output_with_timeout_checked(timeout)?
320 } else {
321 command.output_checked()?
322 };
323
324 Ok(TestRun {
325 test_results: path.join("target/test_output.txt"),
326 stdout: output.stdout,
327 stderr: output.stderr,
328 })
329 }
330}
331
332#[cfg(test)]
333#[cfg(not(target_os = "macos"))] #[allow(clippy::unwrap_used)]
335mod test {
336
337 use super::{
338 super::{TestCase, TestCaseStatus},
339 *,
340 };
341 use once_cell::sync::Lazy;
342 use std::{
343 fs,
344 sync::{Mutex, MutexGuard},
345 };
346 use tmc_langs_framework::{Archive, StyleValidationStrategy};
347 use tmc_langs_util::deserialize;
348 use zip::write::SimpleFileOptions;
349
350 static MAVEN_LOCK: Lazy<Mutex<()>> = Lazy::new(|| Mutex::new(()));
351
352 fn init() {
353 use log::*;
354 use simple_logger::*;
355 let _ = SimpleLogger::new()
356 .with_level(LevelFilter::Debug)
357 .with_module_level("j4rs", LevelFilter::Warn)
358 .init();
359 }
360
361 fn get_maven() -> (MavenPlugin, MutexGuard<'static, ()>) {
364 (MavenPlugin::new().unwrap(), MAVEN_LOCK.lock().unwrap())
365 }
366
367 fn file_to(
368 target_dir: impl AsRef<std::path::Path>,
369 target_relative: impl AsRef<std::path::Path>,
370 contents: impl AsRef<[u8]>,
371 ) -> PathBuf {
372 let target = target_dir.as_ref().join(target_relative);
373 if let Some(parent) = target.parent() {
374 std::fs::create_dir_all(parent).unwrap();
375 }
376 std::fs::write(&target, contents.as_ref()).unwrap();
377 target
378 }
379
380 fn dir_to(
381 target_dir: impl AsRef<std::path::Path>,
382 target_relative: impl AsRef<std::path::Path>,
383 ) -> PathBuf {
384 let target = target_dir.as_ref().join(target_relative);
385 std::fs::create_dir_all(&target).unwrap();
386 target
387 }
388
389 fn dir_to_temp(source_dir: impl AsRef<std::path::Path>) -> tempfile::TempDir {
390 let temp = tempfile::TempDir::new().unwrap();
391 for entry in walkdir::WalkDir::new(&source_dir).min_depth(1) {
392 let entry = entry.unwrap();
393 let rela = entry.path().strip_prefix(&source_dir).unwrap();
394 let target = temp.path().join(rela);
395 if entry.path().is_dir() {
396 std::fs::create_dir(target).unwrap();
397 } else if entry.path().is_file() {
398 std::fs::copy(entry.path(), target).unwrap();
399 }
400 }
401 temp
402 }
403
404 fn dir_to_zip(source_dir: impl AsRef<std::path::Path>) -> Vec<u8> {
405 use std::io::Write;
406
407 let mut target = vec![];
408 let mut zip = zip::ZipWriter::new(std::io::Cursor::new(&mut target));
409
410 for entry in walkdir::WalkDir::new(&source_dir)
411 .min_depth(1)
412 .sort_by(|a, b| a.path().cmp(b.path()))
413 {
414 let entry = entry.unwrap();
415 let rela = entry
416 .path()
417 .strip_prefix(&source_dir)
418 .unwrap()
419 .to_str()
420 .unwrap();
421 if entry.path().is_dir() {
422 zip.add_directory(rela, SimpleFileOptions::default())
423 .unwrap();
424 } else if entry.path().is_file() {
425 zip.start_file(rela, SimpleFileOptions::default()).unwrap();
426 let bytes = std::fs::read(entry.path()).unwrap();
427 zip.write_all(&bytes).unwrap();
428 }
429 }
430
431 zip.finish().unwrap();
432 target
433 }
434
435 #[test]
436 #[ignore = "changing PATH breaks other tests, figure out a better way to test this. or don't"]
437 fn unpacks_bundled_mvn() {
438 let cmd = MavenPlugin::get_mvn_command().unwrap();
439 let expected = format!(
440 "tmc{0}apache-maven-3.8.1{0}bin{0}mvn",
441 std::path::MAIN_SEPARATOR
442 );
443 assert!(cmd.to_string_lossy().ends_with(&expected))
444 }
445
446 #[test]
447 fn runs_checkstyle() {
448 init();
449
450 let temp_dir = dir_to_temp("tests/data/maven-exercise");
451 let (plugin, _lock) = get_maven();
452 let checkstyle_result = plugin
453 .check_code_style(temp_dir.path(), Language::from_639_3("fin").unwrap())
454 .unwrap()
455 .unwrap();
456
457 assert_eq!(checkstyle_result.strategy, StyleValidationStrategy::Fail);
458 let validation_errors = checkstyle_result.validation_errors.unwrap();
459 let errors = validation_errors
460 .get(Path::new("fi/helsinki/cs/maventest/App.java"))
461 .unwrap();
462 assert_eq!(errors.len(), 1);
463 let error = &errors[0];
464 assert_eq!(error.column, 0);
465 assert_eq!(error.line, 4);
466 assert!(error.message.starts_with("Sisennys väärin"));
467 assert_eq!(
468 error.source_name,
469 "com.puppycrawl.tools.checkstyle.checks.indentation.IndentationCheck"
470 );
471 }
472
473 #[test]
474 fn scans_exercise() {
475 init();
476
477 let temp_dir = dir_to_temp("tests/data/maven-exercise");
478 let (plugin, _lock) = get_maven();
479 let exercises = plugin
480 .scan_exercise(temp_dir.path(), "test".to_string())
481 .unwrap();
482 assert_eq!(exercises.name, "test");
483 assert_eq!(exercises.tests.len(), 1);
484 assert_eq!(
485 exercises.tests[0].name,
486 "fi.helsinki.cs.maventest.AppTest trol"
487 );
488 assert_eq!(exercises.tests[0].points, ["maven-exercise"]);
489 }
490
491 #[test]
492 fn runs_tests() {
493 init();
494
495 let temp_dir = dir_to_temp("tests/data/maven-exercise");
496 let (plugin, _lock) = get_maven();
497 let res = plugin.run_tests(temp_dir.path()).unwrap();
498 log::debug!("{res:#?}");
499 assert_eq!(res.status, tmc_langs_framework::RunStatus::TestsFailed);
500 }
501
502 #[test]
503 fn runs_tests_timeout() {
504 init();
505
506 let temp_dir = dir_to_temp("tests/data/maven-exercise");
507 let (plugin, _lock) = get_maven();
508 let test_result_err = plugin
509 .run_tests_with_timeout(temp_dir.path(), Some(std::time::Duration::from_nanos(1)))
510 .unwrap_err();
511 log::debug!("{test_result_err:#?}");
512
513 use std::error::Error;
515 let mut source = test_result_err.source();
516 while let Some(inner) = source {
517 source = inner.source();
518 if let Some(cmd_error) = inner.downcast_ref::<tmc_langs_framework::CommandError>() {
519 if matches!(cmd_error, tmc_langs_framework::CommandError::TimeOut { .. }) {
520 return;
521 }
522 }
523 }
524 panic!()
525 }
526
527 #[test]
528 fn exercise_type_is_correct() {
529 init();
530
531 let temp_dir = tempfile::tempdir().unwrap();
532 file_to(temp_dir.path(), "pom.xml", "");
533 assert!(MavenPlugin::is_exercise_type_correct(temp_dir.path()));
534 }
535
536 #[test]
537 fn exercise_type_is_incorrect() {
538 init();
539
540 let temp_dir = tempfile::tempdir().unwrap();
541 file_to(temp_dir.path(), "pom", "");
542 file_to(temp_dir.path(), "po.xml", "");
543 file_to(temp_dir.path(), "dir/pom.xml", "");
544 assert!(!MavenPlugin::is_exercise_type_correct(temp_dir.path()));
545 }
546
547 #[test]
548 fn cleans() {
549 init();
550
551 let temp_dir = dir_to_temp("tests/data/maven-exercise");
552 file_to(&temp_dir, "target/output file", "");
553
554 assert!(temp_dir.path().join("target/output file").exists());
555 assert!(temp_dir.path().join("src").exists());
556 let (plugin, _lock) = get_maven();
557 plugin.clean(temp_dir.path()).unwrap();
558 assert!(!temp_dir.path().join("target/output file").exists());
559 assert!(temp_dir.path().join("src").exists());
560 }
561
562 #[test]
563 fn finds_project_dir_in_zip() {
564 init();
565
566 let temp_dir = tempfile::tempdir().unwrap();
567 file_to(&temp_dir, "Outer/Inner/maven-exercise/pom.xml", "pom!");
568
569 let zip_contents = dir_to_zip(&temp_dir);
570 let mut zip = Archive::zip(std::io::Cursor::new(zip_contents)).unwrap();
571 let dir = MavenPlugin::find_project_dir_in_archive(&mut zip).unwrap();
572 assert_eq!(dir, Path::new("Outer/Inner/maven-exercise"));
573 }
574
575 #[test]
576 fn doesnt_find_project_dir_in_zip() {
577 init();
578
579 let temp_dir = tempfile::tempdir().unwrap();
580 dir_to(&temp_dir, "Outer/Inner/maven-exercise/srcb");
581
582 let zip_contents = dir_to_zip(&temp_dir);
583 let mut zip = Archive::zip(std::io::Cursor::new(zip_contents)).unwrap();
584 let dir = MavenPlugin::find_project_dir_in_archive(&mut zip);
585 assert!(dir.is_err());
586 }
587
588 #[test]
589 fn gets_project_class_path() {
590 init();
591
592 let temp_dir = dir_to_temp("tests/data/maven-exercise");
593 let (plugin, _lock) = get_maven();
594 let class_path = plugin.get_project_class_path(temp_dir.path()).unwrap();
595 log::debug!("{class_path}");
596 let expected = format!("{0}junit{0}", std::path::MAIN_SEPARATOR);
597 assert!(class_path.contains(&expected));
598 }
599
600 #[test]
601 fn builds() {
602 init();
603
604 use std::path::PathBuf;
605 log::debug!("{}", PathBuf::from(".").canonicalize().unwrap().display());
606
607 let temp_dir = dir_to_temp("tests/data/maven-exercise");
608 let (plugin, _lock) = get_maven();
609 let compile_result = plugin.build(temp_dir.path()).unwrap();
610 assert!(compile_result.status_code.success());
611 }
612
613 #[test]
614 fn creates_run_result_file() {
615 init();
616
617 let temp_dir = dir_to_temp("tests/data/maven-exercise");
618 let test_path = temp_dir.path();
619 let (plugin, _lock) = get_maven();
620 let compile_result = plugin.build(test_path).unwrap();
621 let test_run = plugin
622 .create_run_result_file(test_path, None, compile_result)
623 .unwrap();
624 let test_result: Vec<TestCase> =
625 deserialize::json_from_str(&fs::read_to_string(test_run.test_results).unwrap())
626 .unwrap();
627 let test_case = &test_result[0];
628
629 assert_eq!(test_case.class_name, "fi.helsinki.cs.maventest.AppTest");
630 assert_eq!(test_case.point_names, ["maven-exercise"]);
631 assert_eq!(test_case.status, TestCaseStatus::Failed);
632 let message = test_case.message.as_ref().unwrap();
633 assert!(message.starts_with("ComparisonFailure"));
634
635 let exception = test_case.exception.as_ref().unwrap();
636 assert!(exception.message.as_ref().unwrap().starts_with("expected"));
638 let stack_trace = &exception.stack_trace[0];
639 assert_eq!(stack_trace.declaring_class, "org.junit.Assert");
640 assert_eq!(stack_trace.file_name.as_ref().unwrap(), "Assert.java");
641 assert_eq!(stack_trace.line_number, 115);
642 assert_eq!(stack_trace.method_name, "assertEquals");
643 }
644}