tmc_langs_java/
maven_plugin.rs

1//! Java Maven plugin.
2
3use 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"; // the name of the base directory in the maven archive
24const 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    // check if mvn is in PATH, if yes return mvn
37    // if not, check if the bundled maven has been extracted already,
38    // if not, extract
39    // finally, return the path to the extracted executable
40    // the executable used from within the extracted maven differs per platform
41    fn get_mvn_command() -> Result<OsString, JavaError> {
42        // check if mvn is in PATH
43        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            // TODO: remove this bit eventually, this is just to clean up the old maven cachce that had the version in the name
74            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
97/// Project directory:
98/// Contains pom.xml file
99impl 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            // try to find pom.xml
138            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                // accept any dir with pom.xml
160                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                // accept any dir with src/main/*.java
167                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    /// Checks if the directory has a pom.xml file.
211    fn is_exercise_type_correct(path: &Path) -> bool {
212        path.join("pom.xml").exists()
213    }
214
215    /// Runs the Maven clean plugin.
216    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        // canonicalize root path to avoid issues where the cwd and project root are different directories
249        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    /// Runs the tmc-maven-plugin.
304    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"))] // issues with maven dependencies
334#[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    /// Maven doesn't like being run in parallel, at least on Windows.
362    /// For now the tests access the MavenPlugin with a function that locks a mutex.
363    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        // verify that there's a timeout error in the source chain
514        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_eq!(exception.class_name, "org.junit.ComparisonFailure");
637        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}