Просмотр исходного кода

Allow specifying more than one submission directory (#278)

* Use canonical path name as submission identification.
* Allow multiple root directories at the command-line.
* and process the given root directories.
* Adapt and extend tests for multiple root directories.
- Use the specified root directory path as unique prefix for submissions.
- Add missing root cause argument when rethrowing an exception.
- Give warning instead of aborting with duplicate root directory arguments.
- Reduce clutter in constructing CommandLineArguments.
Alberth289346 4 лет назад
Родитель
Сommit
3325cbdb72

+ 11 - 11
README.md

@@ -39,23 +39,23 @@ JPlag can either be used via the CLI or directly via its Java API. For more info
 ```
 JPlag - Detecting Software Plagiarism
 
-Usage: JPlag [ options ] [<root-dir>]
- <root-dir>        The root-directory that contains all submissions
+Usage: JPlag [ options ] [<root-dir> ...]
+ <root-dir>        Root-directory that contains submissions
 
 named arguments:
   -h, --help             show this help message and exit
-  -l                     {java,python3,cpp,csharp,char,text,scheme} Select the language to parse the submissions (Standard: java)
-  -bc BC                 Name of the subdirectory of the root directory which contains the base code (common framework used in all submissions)
-  -v                     {quiet,long} Verbosity of the logging (Standard: quiet)
-  -d                     Debug parser. Non-parsable files will be stored (Standard: false)
+  -l                     {java,python3,cpp,csharp,char,text,scheme} Select the language to parse the submissions (default: java)
+  -bc BC                 Path of the directory containing the base code (common framework used in all submissions)
+  -v                     {quiet,long} Verbosity of the logging (default: quiet)
+  -d                     Debug parser. Non-parsable files will be stored (default false)
   -S S                   Look in directories <root-dir>/*/<dir> for programs
   -p P                   comma-separated list of all filename suffixes that are included
   -x X                   All files named in this file will be ignored in the comparison (line-separated list)
   -t T                   Tunes the comparison sensitivity by adjusting the minimum token required to be counted as a matching section. A smaller <n> increases the sensitivity but might lead to more false-positives
-  -m M                   Match similarity threshold [0-100]: All matches above this threshold will be saved (Standard: 0.0)
-  -n N                   The maximum number of comparisons that will be shown in the generated report, if set to -1 all comparisons will be shown (Standard: 30)
-  -r R                   Name of the directory in which the comparison results will be stored (Standard: result)
-  -c                     {normal,parallel} Comparison mode used to compare the programs (Standard: normal)
+  -m M                   Comparison similarity threshold [0-100]: All comparisons above this threshold will be saved (default: 0.0)
+  -n N                   The maximum number of comparisons that will be shown in the generated report, if set to -1 all comparisons will be shown (default: 30)
+  -r R                   Name of the directory in which the comparison results will be stored (default: result)
+  -c                     {normal,parallel} Comparison mode used to compare the programs (default: normal)
 ```
 
 ### Java API
@@ -63,7 +63,7 @@ named arguments:
 The new API makes it easy to integrate JPlag's plagiarism detection into external Java projects:
 
 ```java
-JPlagOptions options = new JPlagOptions("/path/to/rootDir", LanguageOption.JAVA);
+JPlagOptions options = new JPlagOptions(List.of("/path/to/rootDir"), LanguageOption.JAVA);
 options.setBaseCodeSubmissionName("template");
 
 JPlag jplag = new JPlag(options);

+ 1 - 1
jplag/src/main/java/de/jplag/CLI.java

@@ -103,7 +103,7 @@ public class CLI {
             fileSuffixes = fileSuffixString.replaceAll("\\s+", "").split(",");
         }
         LanguageOption language = LanguageOption.fromDisplayName(LANGUAGE.getFrom(namespace));
-        JPlagOptions options = new JPlagOptions(ROOT_DIRECTORY.getFrom(namespace), language);
+        JPlagOptions options = new JPlagOptions(ROOT_DIRECTORY.getListFrom(namespace), language);
         options.setBaseCodeSubmissionName(BASE_CODE.getFrom(namespace));
         options.setVerbosity(Verbosity.fromOption(VERBOSITY.getFrom(namespace)));
         options.setDebugParser(DEBUG.getFrom(namespace));

+ 31 - 7
jplag/src/main/java/de/jplag/CommandLineArgument.java

@@ -21,7 +21,7 @@ import de.jplag.strategy.ComparisonMode;
  * @author Timur Saglam
  */
 public enum CommandLineArgument {
-    ROOT_DIRECTORY("rootDir", String.class),
+    ROOT_DIRECTORY("rootDir", NumberOfArgumentValues.ONE_OR_MORE_VALUES, String.class),
     LANGUAGE("-l", String.class, LanguageOption.getDefault().getDisplayName(), LanguageOption.getAllDisplayNames()),
     BASE_CODE("-bc", String.class),
     VERBOSITY("-v", String.class, "quiet", List.of("quiet", "long")), // TODO SH: Replace verbosity when integrating a real logging library
@@ -36,25 +36,32 @@ public enum CommandLineArgument {
     COMPARISON_MODE("-c", String.class, DEFAULT_COMPARISON_MODE.getName(), ComparisonMode.allNames());
 
     private final String flag;
+    private final NumberOfArgumentValues numberOfValues;
     private final String description;
     private final Optional<Object> defaultValue;
     private final Optional<Collection<String>> choices;
     private final Class<?> type;
 
     private CommandLineArgument(String flag, Class<?> type) {
-        this(flag, type, Optional.empty(), Optional.empty());
+        this(flag, NumberOfArgumentValues.SINGLE_VALUE, type, Optional.empty(), Optional.empty());
+    }
+
+    private CommandLineArgument(String flag, NumberOfArgumentValues numberOfValues, Class<?> type) {
+        this(flag, numberOfValues, type, Optional.empty(), Optional.empty());
     }
 
     private CommandLineArgument(String flag, Class<?> type, Object defaultValue) {
-        this(flag, type, Optional.of(defaultValue), Optional.empty());
+        this(flag, NumberOfArgumentValues.SINGLE_VALUE, type, Optional.of(defaultValue), Optional.empty());
     }
 
     private CommandLineArgument(String flag, Class<?> type, Object defaultValue, Collection<String> choices) {
-        this(flag, type, Optional.of(defaultValue), Optional.of(choices));
+        this(flag, NumberOfArgumentValues.SINGLE_VALUE, type, Optional.of(defaultValue), Optional.of(choices));
     }
 
-    private CommandLineArgument(String flag, Class<?> type, Optional<Object> defaultValue, Optional<Collection<String>> choices) {
+    private CommandLineArgument(String flag, NumberOfArgumentValues numberOfValues, Class<?> type, Optional<Object> defaultValue,
+            Optional<Collection<String>> choices) {
         this.flag = flag;
+        this.numberOfValues = numberOfValues;
         this.type = type;
         this.defaultValue = defaultValue;
         this.choices = choices;
@@ -76,8 +83,8 @@ public enum CommandLineArgument {
     }
 
     /**
-     * Returns the value of this argument. Convenience method for {@link Namespace#get(String)} and
-     * {@link CommandLineArgument#flagWithoutDash()}.
+     * Returns the value of this argument for arguments with a single value. Convenience method for
+     * {@link Namespace#get(String)} and {@link CommandLineArgument#flagWithoutDash()}.
      * @param <T> is the argument type.
      * @param namespace stores a value for the argument.
      * @return the argument value.
@@ -86,6 +93,20 @@ public enum CommandLineArgument {
         return namespace.get(flagWithoutDash());
     }
 
+    /**
+     * Returns the value of this argument for arguments that allow more than a single value. Convenience method for
+     * {@link Namespace#getList(String)} and {@link CommandLineArgument#flagWithoutDash()}.
+     * <p>
+     * Depending on the action of the option, result types may change.
+     * </p>
+     * @param <T> is the argument type.
+     * @param namespace stores a value for the argument.
+     * @return the argument value.
+     */
+    public <T> List<T> getListFrom(Namespace namespace) {
+        return namespace.getList(flagWithoutDash());
+    }
+
     /**
      * Parses the command line argument with a specific parser.
      * @param parser is that parser.
@@ -98,6 +119,9 @@ public enum CommandLineArgument {
         if (type == Boolean.class) {
             argument.action(storeTrue());
         }
+        if (numberOfValues == NumberOfArgumentValues.ONE_OR_MORE_VALUES) {
+            argument.nargs("+");
+        }
     }
 
     /**

+ 8 - 4
jplag/src/main/java/de/jplag/JPlag.java

@@ -94,10 +94,14 @@ public class JPlag {
     }
 
     private ComparisonStrategy initializeComparisonStrategy(final ComparisonMode comparisonMode) {
-        return switch (comparisonMode) {
-            case NORMAL -> new NormalComparisonStrategy(options, coreAlgorithm);
-            case PARALLEL -> new ParallelComparisonStrategy(options, coreAlgorithm);
-        };
+        switch (comparisonMode) {
+            case NORMAL:
+                return new NormalComparisonStrategy(options, coreAlgorithm);
+            case PARALLEL:
+                return new ParallelComparisonStrategy(options, coreAlgorithm);
+            default:
+                throw new AssertionError("Unknown comparison strategy found.");
+        }
     }
 
     private Language initializeLanguage() {

+ 7 - 0
jplag/src/main/java/de/jplag/NumberOfArgumentValues.java

@@ -0,0 +1,7 @@
+package de.jplag;
+
+/** Allowed number of values of an argument. */
+public enum NumberOfArgumentValues {
+    SINGLE_VALUE,
+    ONE_OR_MORE_VALUES,
+}

+ 1 - 1
jplag/src/main/java/de/jplag/Submission.java

@@ -166,7 +166,7 @@ public class Submission implements Comparable<Submission> {
      */
     public boolean parse(boolean debugParser) {
         if (files == null || files.size() == 0) {
-            errorCollector.print("ERROR: nothing to parse for submission \"" + name, null);
+            errorCollector.print("ERROR: nothing to parse for submission \"" + name + "\"", null);
             tokenList = null;
             hasErrors = true; // invalidate submission
             return false;

+ 78 - 43
jplag/src/main/java/de/jplag/SubmissionSetBuilder.java

@@ -1,15 +1,15 @@
 package de.jplag;
 
 import java.io.File;
+import java.io.IOException;
 import java.util.ArrayList;
 import java.util.Arrays;
 import java.util.Collection;
 import java.util.Collections;
-import java.util.Iterator;
-import java.util.LinkedHashMap;
+import java.util.HashMap;
+import java.util.HashSet;
 import java.util.List;
 import java.util.Map;
-import java.util.Map.Entry;
 import java.util.Optional;
 import java.util.Set;
 
@@ -50,17 +50,30 @@ public class SubmissionSetBuilder {
      * @throws ExitException if the directory cannot be read.
      */
     public SubmissionSet buildSubmissionSet() throws ExitException {
-        // Read the root directory and collect valid looking submission entries from it.
-        File rootDirectory = new File(options.getRootDirectoryName());
-        verifyRootdirExistence(rootDirectory);
-        String[] fileNames = readSubmissionRootNames(rootDirectory);
-        Map<String, Submission> foundSubmissions = processRootDirEntries(rootDirectory, fileNames);
+        verifyRootDirectories();
+
+        List<String> rootDirectoryNames = options.getRootDirectoryNames();
+
+        // For backward compatibility, don't prefix submission names with their root directory
+        // if there is only one root directory.
+        boolean addRootDirectoryPrefix = (rootDirectoryNames.size() > 1);
+
+        // Collect valid looking entries from the root directories.
+        Map<File, Submission> foundSubmissions = new HashMap<>();
+        for (String rootDirectoryName : rootDirectoryNames) {
+            processRootDirectoryEntries(rootDirectoryName, addRootDirectoryPrefix, foundSubmissions);
+        }
 
         // Extract the basecode submission if necessary.
         Optional<Submission> baseCodeSubmission = Optional.empty();
         if (options.hasBaseCode()) {
             Submission baseCode = tryLoadBaseCodeAsPath();
             if (baseCode == null) {
+                if (rootDirectoryNames.size() > 1) {
+                    throw new BasecodeException("Basecode name cannot be specified in a submission set with more than one root directory.");
+                }
+
+                // Single root-directory, try the deprecated basecode specification.
                 baseCode = tryLoadBaseCodeAsRootSubDirectory(foundSubmissions);
 
                 String baseCodeName = options.getBaseCodeSubmissionName().get();
@@ -71,22 +84,16 @@ public class SubmissionSetBuilder {
                 } else {
                     // Found a base code as a submission, report about obsolete usage.
                     System.out.printf("Deprecated use of the -bc option found, please specify the basecode as \"%s%s%s\" instead.\n",
-                            rootDirectory.toString(), File.separator, baseCodeName);
+                            rootDirectoryNames.get(0), File.separator, baseCodeName);
                 }
             }
             baseCodeSubmission = Optional.of(baseCode);
             System.out.println(String.format("Basecode directory \"%s\" will be used.", baseCode.getRoot().toString()));
 
             // Basecode may also be registered as a user submission. If so, remove the latter.
-            File baseCodeRoot = baseCode.getCanonicalRoot(); // Use canonical form for a more sane equality notion.
-            Iterator<Entry<String, Submission>> submissionIterator = foundSubmissions.entrySet().iterator();
-            while (submissionIterator.hasNext()) {
-                Entry<String, Submission> entry = submissionIterator.next();
-                if (baseCodeRoot.equals(entry.getValue().getCanonicalRoot())) {
-                    submissionIterator.remove();
-                    System.out.println(String.format("Skipping \"%s\" as user submission.", entry.getValue().getRoot().toString()));
-                    break;
-                }
+            Submission removed = foundSubmissions.remove(baseCode.getCanonicalRoot());
+            if (removed != null) {
+                System.out.println(String.format("Skipping \"%s\" as user submission.", removed.getRoot().toString()));
             }
         }
 
@@ -96,15 +103,32 @@ public class SubmissionSetBuilder {
     }
 
     /**
-     * Verify that the given root directory exists.
+     * Verify that the given root directories exist and have no duplicate entries.
      */
-    private void verifyRootdirExistence(File rootDir) throws ExitException {
-        String rootDirectoryName = rootDir.getName();
-        if (!rootDir.exists()) {
-            throw new RootDirectoryException(String.format("Root directory \"%s\" does not exist!", rootDirectoryName));
-        }
-        if (!rootDir.isDirectory()) {
-            throw new RootDirectoryException(String.format("Root directory \"%s\" is not a directory!", rootDirectoryName));
+    private void verifyRootDirectories() throws ExitException {
+        List<String> rootDirectoryNames = options.getRootDirectoryNames();
+        Set<File> canonicalRootDirs = new HashSet<>(rootDirectoryNames.size());
+        for (String rootDirectoryName : rootDirectoryNames) {
+            File rootDir = new File(rootDirectoryName);
+
+            if (!rootDir.exists()) {
+                throw new RootDirectoryException(String.format("Root directory \"%s\" does not exist!", rootDirectoryName));
+            }
+            if (!rootDir.isDirectory()) {
+                throw new RootDirectoryException(String.format("Root directory \"%s\" is not a directory!", rootDirectoryName));
+            }
+
+            boolean added;
+            try {
+                added = canonicalRootDirs.add(rootDir.getCanonicalFile());
+            } catch (IOException exception) {
+                throw new RootDirectoryException(String.format("Cannot read root directory \"%s\".", rootDirectoryName), exception);
+            }
+            if (!added) {
+                // Root directory was already added, report a warning.
+                System.out.printf("Warning: Root directory \"%s\" was already added as submission root, avoiding to process it a second time.",
+                        rootDirectoryName);
+            }
         }
     }
 
@@ -126,13 +150,15 @@ public class SubmissionSetBuilder {
         }
 
         try {
-            return processDirEntry(basecodeSubmission);
+            // Use an unlikely short name for the base code. If all is well, this name should not appear
+            // in the output since basecode matches are removed from it.
+            return processDirectoryEntry(basecodeSubmission, "**basecode**");
 
         } catch (SubmissionException exception) {
             throw new BasecodeException(exception.getMessage(), exception); // Change thrown exception to basecode exception.
 
-        } catch (ExitException ex) {
-            throw ex;
+        } catch (ExitException exception) {
+            throw exception;
         }
     }
 
@@ -141,7 +167,7 @@ public class SubmissionSetBuilder {
      * @return Base code submission if the option value can be interpreted as a sub-directory, else {@code null}.
      * @throws ExitException when the option value is a sub-directory with errors.
      */
-    private Submission tryLoadBaseCodeAsRootSubDirectory(Map<String, Submission> foundSubmissions) throws ExitException {
+    private Submission tryLoadBaseCodeAsRootSubDirectory(Map<File, Submission> foundSubmissions) throws ExitException {
         String baseCodeName = options.getBaseCodeSubmissionName().get();
 
         // Is the option value a single name after trimming spurious separators?
@@ -162,7 +188,14 @@ public class SubmissionSetBuilder {
         }
 
         // Grab the basecode submission from the regular submissions.
-        return foundSubmissions.get(name);
+        File rootDirectory = new File(options.getRootDirectoryNames().get(0));
+        File basecodePath = new File(rootDirectory, baseCodeName);
+        try {
+            basecodePath = basecodePath.getCanonicalFile();
+        } catch (IOException exception) {
+            throw new BasecodeException(String.format("Cannot compute canonical file path of \"%s\".", basecodePath.toString()), exception);
+        }
+        return foundSubmissions.get(basecodePath);
     }
 
     /**
@@ -208,10 +241,11 @@ public class SubmissionSetBuilder {
     /**
      * Process the given directory entry as a submission root, the path MUST not be excluded.
      * @param submissionEntry Entry to process.
+     * @param rootDirectoryPrefix Prefix for connecting submissions to the root directory, may be empty.
      * @return The entry converted to a submission.
      * @throws ExitException when an error has been found with the entry.
      */
-    private Submission processDirEntry(File submissionEntry) throws ExitException {
+    private Submission processDirectoryEntry(File submissionEntry, String rootDirectoryPrefix) throws ExitException {
         if (isExcludedEntry(submissionEntry) != null) {
             throw new AssertionError("Pre-condition of non-exclusion is violated.");
         }
@@ -231,18 +265,20 @@ public class SubmissionSetBuilder {
             }
         }
 
-        return new Submission(fileName, submissionEntry, parseFilesRecursively(submissionEntry), language, errorCollector);
+        String submissionName = rootDirectoryPrefix + fileName;
+        return new Submission(submissionName, submissionEntry, parseFilesRecursively(submissionEntry), language, errorCollector);
     }
 
     /**
      * Process entries in the root directory to check whether they qualify as submissions.
-     * @param rootDirectory Root directory being examined.
-     * @param fileNames Entries found in the root directory.
-     * @return Candidate submissions ordered by their name.
+     * @param rootDirectoryName Root directory being examined.
+     * @param addRootDirectoryPrefix Whether to prefix the submission name with its root directory.
+     * @param foundSubmissions Submissions found so far, is updated in-place.
      */
-    private Map<String, Submission> processRootDirEntries(File rootDirectory, String[] fileNames) throws ExitException {
-        Map<String, Submission> foundSubmissions = new LinkedHashMap<>(fileNames.length); // Capacity is an over-estimate.
-
+    private void processRootDirectoryEntries(String rootDirectoryName, boolean addRootDirectoryPrefix, Map<File, Submission> foundSubmissions)
+            throws ExitException {
+        File rootDirectory = new File(rootDirectoryName);
+        String[] fileNames = readSubmissionRootNames(rootDirectory);
         for (String fileName : fileNames) {
             File submissionFile = new File(rootDirectory, fileName);
 
@@ -252,10 +288,10 @@ public class SubmissionSetBuilder {
                 continue;
             }
 
-            foundSubmissions.put(fileName, processDirEntry(submissionFile));
+            String rootDirectoryPrefix = addRootDirectoryPrefix ? (rootDirectoryName + "::") : "";
+            Submission submission = processDirectoryEntry(submissionFile, rootDirectoryPrefix);
+            foundSubmissions.put(submission.getCanonicalRoot(), submission);
         }
-
-        return foundSubmissions;
     }
 
     /**
@@ -310,5 +346,4 @@ public class SubmissionSetBuilder {
 
         return files;
     }
-
 }

+ 8 - 7
jplag/src/main/java/de/jplag/options/JPlagOptions.java

@@ -6,6 +6,7 @@ import java.io.File;
 import java.nio.charset.Charset;
 import java.nio.charset.StandardCharsets;
 import java.util.Collections;
+import java.util.List;
 import java.util.Optional;
 import java.util.Set;
 
@@ -80,7 +81,7 @@ public class JPlagOptions {
     /**
      * Directory that contains all submissions.
      */
-    private String rootDirectoryName;
+    private List<String> rootDirectoryNames;
 
     /**
      * Path name of the directory containing the base code.
@@ -117,8 +118,8 @@ public class JPlagOptions {
     /**
      * Constructor with required attributes.
      */
-    public JPlagOptions(String rootDirectoryName, LanguageOption languageOption) {
-        this.rootDirectoryName = rootDirectoryName;
+    public JPlagOptions(List<String> rootDirectoryNames, LanguageOption languageOption) {
+        this.rootDirectoryNames = rootDirectoryNames;
         this.languageOption = languageOption;
     }
 
@@ -158,8 +159,8 @@ public class JPlagOptions {
         return minimumTokenMatch;
     }
 
-    public String getRootDirectoryName() {
-        return rootDirectoryName;
+    public List<String> getRootDirectoryNames() {
+        return rootDirectoryNames;
     }
 
     public SimilarityMetric getSimilarityMetric() {
@@ -253,8 +254,8 @@ public class JPlagOptions {
         }
     }
 
-    public void setRootDirectoryName(String rootDirectoryName) {
-        this.rootDirectoryName = rootDirectoryName;
+    public void setRootDirectoryNames(List<String> rootDirectoryNames) {
+        this.rootDirectoryNames = rootDirectoryNames;
     }
 
     public void setSimilarityMetric(SimilarityMetric similarityMetric) {

+ 1 - 1
jplag/src/main/resources/de/jplag/messages.properties

@@ -6,7 +6,7 @@ CommandLineArgument.ExcludeFile=All files named in this file will be ignored in
 CommandLineArgument.Language=Select the language to parse the submissions
 CommandLineArgument.MinTokenMatch=Tunes the comparison sensitivity by adjusting the minimum token required to be counted as a matching section. A smaller <n> increases the sensitivity but might lead to more false-positives
 CommandLineArgument.ResultFolder=Name of the directory in which the comparison results will be stored
-CommandLineArgument.RootDirectory=The root-directory that contains all submissions
+CommandLineArgument.RootDirectory=Root-directory that contains submissions
 CommandLineArgument.ShownComparisons=The maximum number of comparisons that will be shown in the generated report, if set to -1 all comparisons will be shown
 CommandLineArgument.SimilarityThreshold=Comparison similarity threshold [0-100]: All comparisons above this threshold will be saved
 CommandLineArgument.Subdirectory=Look in directories <root-dir>/*/<dir> for programs

+ 2 - 1
jplag/src/test/java/de/jplag/BaseCodeTest.java

@@ -3,6 +3,7 @@ package de.jplag;
 import static org.junit.Assert.assertEquals;
 
 import java.io.File;
+import java.util.List;
 
 import org.junit.Test;
 
@@ -51,7 +52,7 @@ public class BaseCodeTest extends TestBase {
 
     @Test(expected = RootDirectoryException.class)
     public void testInvalidRoot() throws ExitException {
-        runJPlag("basecode", it -> it.setRootDirectoryName("WrongRoot"));
+        runJPlag("basecode", it -> it.setRootDirectoryNames(List.of("WrongRoot")));
     }
 
     @Test(expected = BasecodeException.class)

+ 35 - 0
jplag/src/test/java/de/jplag/NormalComparisonTest.java

@@ -1,11 +1,14 @@
 package de.jplag;
 
 import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.fail;
 
+import java.util.List;
 import java.util.Optional;
 
 import org.junit.Test;
 
+import de.jplag.exceptions.BasecodeException;
 import de.jplag.exceptions.ExitException;
 
 public class NormalComparisonTest extends TestBase {
@@ -83,4 +86,36 @@ public class NormalComparisonTest extends TestBase {
                         || comparison.getFirstSubmission().getName().equals(nameB) && comparison.getSecondSubmission().getName().equals(nameA))
                 .findFirst();
     }
+
+    @Test
+    public void testMultiRootDirNoBasecode() throws ExitException {
+        List<String> paths = List.of(getBasePath("basecode"), getBasePath("SimpleDuplicate")); // 3 + 2 submissions.
+        JPlagResult result = runJPlag(paths, options -> {
+        });
+        assertEquals(5, result.getNumberOfSubmissions());
+    }
+
+    @Test
+    public void testMultiRootDirSeparateBasecode() throws ExitException {
+        String basecodePath = getBasePath("basecode-base");
+        List<String> paths = List.of(getBasePath("basecode"), getBasePath("SimpleDuplicate")); // 3 + 2 submissions.
+        JPlagResult result = runJPlag(paths, it -> it.setBaseCodeSubmissionName(basecodePath));
+        assertEquals(5, result.getNumberOfSubmissions());
+    }
+
+    @Test
+    public void testMultiRootDirBasecodeInSubmissionDir() throws ExitException {
+        String basecodePath = getBasePath("basecode", "base");
+        List<String> paths = List.of(getBasePath("basecode"), getBasePath("SimpleDuplicate")); // 2 + 2 submissions.
+        JPlagResult result = runJPlag(paths, it -> it.setBaseCodeSubmissionName(basecodePath));
+        assertEquals(4, result.getNumberOfSubmissions());
+    }
+
+    @Test(expected = BasecodeException.class)
+    public void testMultiRootDirBasecodeName() throws ExitException {
+        List<String> paths = List.of(getBasePath("basecode"), getBasePath("SimpleDuplicate"));
+        String basecodePath = "base"; // Should *not* find basecode/base
+        runJPlag(paths, it -> it.setBaseCodeSubmissionName(basecodePath));
+        fail("No basecode exception was thrown!");
+    }
 }

+ 6 - 1
jplag/src/test/java/de/jplag/TestBase.java

@@ -2,6 +2,7 @@ package de.jplag;
 
 import java.io.File;
 import java.nio.file.Path;
+import java.util.List;
 import java.util.function.Consumer;
 
 import de.jplag.exceptions.ExitException;
@@ -37,7 +38,11 @@ public abstract class TestBase {
     }
 
     protected JPlagResult runJPlag(String testSampleName, Consumer<JPlagOptions> customization) throws ExitException {
-        JPlagOptions options = new JPlagOptions(Path.of(BASE_PATH, testSampleName).toString(), LanguageOption.JAVA);
+        return runJPlag(List.of(getBasePath(testSampleName)), customization);
+    }
+
+    protected JPlagResult runJPlag(List<String> testPaths, Consumer<JPlagOptions> customization) throws ExitException {
+        JPlagOptions options = new JPlagOptions(testPaths, LanguageOption.JAVA);
         options.setVerbosity(Verbosity.LONG);
         customization.accept(options);
         JPlag jplag = new JPlag(options);

+ 1 - 1
jplag/src/test/java/de/jplag/cli/CommandLineInterfaceTest.java

@@ -31,7 +31,7 @@ public class CommandLineInterfaceTest {
     private String parsedKeys(String... arguments) {
         var keys = namespace.getAttrs().keySet().stream()
                 .filter(key -> key.equals(ROOT_DIRECTORY.flag()) || Arrays.stream(arguments).anyMatch(arg -> arg.contains("-" + key)));
-        return keys.map(it -> it + "=" + namespace.get(it)).collect(toSet()).toString();
+        return keys.map(it -> it.toString() + "=" + namespace.get(it)).collect(toSet()).toString();
     }
 
     /**