001package edu.pdx.cs.joy.grader;
002
003import com.google.common.annotations.VisibleForTesting;
004
005import java.io.IOException;
006import java.io.InputStream;
007import java.nio.file.FileVisitOption;
008import java.nio.file.Files;
009import java.nio.file.Path;
010import java.time.LocalDateTime;
011import java.time.ZonedDateTime;
012import java.time.format.DateTimeFormatter;
013import java.time.format.DateTimeParseException;
014import java.util.ArrayList;
015import java.util.List;
016import java.util.function.Consumer;
017import java.util.jar.Attributes;
018import java.util.jar.Manifest;
019import java.util.regex.Matcher;
020import java.util.regex.Pattern;
021import java.util.stream.Stream;
022
023public class FindUngradedSubmissions {
024  private final SubmissionDetailsProvider submissionDetailsProvider;
025  private final TestOutputPathProvider testOutputProvider;
026  private final TestOutputDetailsProvider testOutputDetailsProvider;
027
028  @VisibleForTesting
029  FindUngradedSubmissions(SubmissionDetailsProvider submissionDetailsProvider, TestOutputPathProvider testOutputProvider, TestOutputDetailsProvider testOutputDetailsProvider) {
030    this.submissionDetailsProvider = submissionDetailsProvider;
031    this.testOutputProvider = testOutputProvider;
032    this.testOutputDetailsProvider = testOutputDetailsProvider;
033  }
034
035  public FindUngradedSubmissions() {
036    this(new SubmissionDetailsProviderFromZipFile(), new TestOutputProviderInParentDirectory(), new TestOutputDetailsProviderFromTestOutputFile());
037  }
038
039  @VisibleForTesting
040  SubmissionAnalysis analyzeSubmission(Path submissionPath) {
041    SubmissionDetails submission = this.submissionDetailsProvider.getSubmissionDetails(submissionPath);
042    Path submissionDirectory = submissionPath.getParent();
043    Path testOutputPath = this.testOutputProvider.getTestOutput(submissionDirectory, submission.studentId());
044    boolean needsToBeTested;
045    boolean needsToBeGraded;
046    String reason;
047
048    if (!Files.exists(testOutputPath)) {
049      needsToBeTested = true;
050      needsToBeGraded = true;
051      reason = "Test output file does not exist: " + testOutputPath;
052
053    } else {
054
055      TestOutputDetails testOutput = this.testOutputDetailsProvider.getTestOutputDetails(testOutputPath);
056      if (submittedAfterTesting(submission, testOutput)) {
057        needsToBeTested = true;
058        needsToBeGraded = true;
059        reason = "Submission on " + submission.submissionTime() + " is after testing on " + testOutput.testedSubmissionTime();
060
061      } else if (!testOutput.hasGrade()) {
062        needsToBeTested = false;
063        needsToBeGraded = true;
064        reason = "Test output file does not have a grade: " + testOutputPath;
065
066      } else {
067        needsToBeTested = false;
068        needsToBeGraded = false;
069        reason = "Test output file was graded after submission: " + testOutputPath;
070      }
071    }
072
073    return new SubmissionAnalysis(submissionPath, needsToBeTested, needsToBeGraded, reason);
074  }
075
076  private static boolean submittedAfterTesting(SubmissionDetails submission, TestOutputDetails testOutput) {
077    LocalDateTime submissionTime = submission.submissionTime();
078    LocalDateTime testedSubmissionTime = testOutput.testedSubmissionTime();
079    return submissionTime.isAfter(testedSubmissionTime.plusMinutes(1L));
080  }
081
082  @VisibleForTesting
083  record SubmissionDetails(String studentId, LocalDateTime submissionTime) {
084
085  }
086
087  public static void main(String[] args) {
088    if (args.length == 0) {
089      System.err.println("Usage: java FindUngradedSubmissions -includeReason submissionZipOrDirectory+");
090      System.exit(1);
091    }
092
093    boolean includeReason = false;
094    List<String> fileNames = new ArrayList<>();
095
096    for (String arg : args) {
097      if (arg.equals("-includeReason")) {
098        includeReason = true;
099
100      } else if (arg.startsWith("-")) {
101        System.err.println("Unknown option: " + arg);
102        System.exit(1);
103
104      } else {
105        fileNames.add(arg);
106      }
107    }
108
109    Stream<Path> submissions = findSubmissionsIn(fileNames);
110    FindUngradedSubmissions finder = new FindUngradedSubmissions();
111    Stream<SubmissionAnalysis> analyses = submissions.map(finder::analyzeSubmission);
112    List<SubmissionAnalysis> needsToBeTested = new ArrayList<>();
113    List<SubmissionAnalysis> needsToBeGraded = new ArrayList<>();
114    analyses.forEach(analysis -> {
115      if (analysis.needsToBeTested()) {
116        needsToBeTested.add(analysis);
117
118      } else if (analysis.needsToBeGraded()) {
119        needsToBeGraded.add(analysis);
120      }
121    });
122
123    printOutAnalyses(needsToBeTested, "tested", includeReason);
124    printOutAnalyses(needsToBeGraded, "graded", includeReason);
125  }
126
127  private static void printOutAnalyses(List<SubmissionAnalysis> analyses, String action, boolean includeReason) {
128    int size = analyses.size();
129    String description = (size == 1 ? " submission needs" : " submissions need");
130    System.out.println(size + description + " to be " + action + ": ");
131    analyses.forEach(analysis -> {
132      System.out.print("  " + analysis.submission);
133      if (includeReason) {
134        System.out.print("   " + analysis.reason);
135      }
136      System.out.println();
137    });
138  }
139
140  private static Stream<Path> findSubmissionsIn(List<String> fileNames) {
141    return fileNames.stream()
142        .map(Path::of)
143        .filter(Files::exists)
144        .flatMap(FindUngradedSubmissions::findSubmissionsIn);
145  }
146
147  private static Stream<? extends Path> findSubmissionsIn(Path path) {
148    if (Files.isDirectory(path)) {
149      try {
150        // If we put the walk into a try-with-resources, the consumer of the stream will encounter and
151        // exception, because the stream will be closed immediately.
152        Stream<Path> walk = Files.walk(path, FileVisitOption.FOLLOW_LINKS);
153        return walk.filter(FindUngradedSubmissions::isZipFile);
154
155      } catch (IOException e) {
156        throw new RuntimeException("Error while walking through directory: " + path, e);
157      }
158    } else if (isZipFile(path)) {
159      return Stream.of(path);
160    } else {
161      return Stream.empty();
162    }
163  }
164
165  private static boolean isZipFile(Path p) {
166    return Files.isRegularFile(p) && p.getFileName().toString().endsWith(".zip");
167  }
168
169  interface SubmissionDetailsProvider {
170    SubmissionDetails getSubmissionDetails(Path submission);
171  }
172
173  interface TestOutputPathProvider {
174    Path getTestOutput(Path submissionDirectory, String studentId);
175  }
176
177  interface TestOutputDetailsProvider {
178    TestOutputDetails getTestOutputDetails(Path testOutput);
179  }
180
181  @VisibleForTesting
182    record TestOutputDetails(LocalDateTime testedSubmissionTime, boolean hasGrade) {
183  }
184
185  @VisibleForTesting
186  record SubmissionAnalysis (Path submission, boolean needsToBeTested, boolean needsToBeGraded, String reason) {
187
188  }
189
190  private static class SubmissionDetailsProviderFromZipFile implements SubmissionDetailsProvider {
191    @Override
192    public SubmissionDetails getSubmissionDetails(Path submission) {
193      try (InputStream zipFile = Files.newInputStream(submission)) {
194        Manifest manifest = ProjectSubmissionsProcessor.getManifestFromZipFile(zipFile);
195        return getSubmissionDetails(manifest);
196
197      } catch (IOException | StudentEmailAttachmentProcessor.SubmissionException e) {
198        throw new RuntimeException(e);
199      }
200    }
201
202    private SubmissionDetails getSubmissionDetails(Manifest manifest) throws StudentEmailAttachmentProcessor.SubmissionException {
203      Attributes attrs = manifest.getMainAttributes();
204      String studentId = ProjectSubmissionsProcessor.getStudentIdFromManifestAttributes(attrs);
205      LocalDateTime submissionTime = ProjectSubmissionsProcessor.getSubmissionTime(attrs);
206      return new SubmissionDetails(studentId, submissionTime);
207    }
208  }
209
210  private static class TestOutputProviderInParentDirectory implements TestOutputPathProvider {
211    @Override
212    public Path getTestOutput(Path submissionDirectory, String studentId) {
213      return submissionDirectory.resolve(studentId + ".out");
214    }
215  }
216
217  @VisibleForTesting
218  static class TestOutputDetailsProviderFromTestOutputFile implements TestOutputDetailsProvider {
219    private static final Pattern SUBMISSION_TIME_PATTERN = Pattern.compile(".*Submitted on (.+)");
220
221    public static LocalDateTime parseSubmissionTime(String line) {
222      if (line.contains("Submitted on")) {
223        Matcher matcher = TestOutputDetailsProviderFromTestOutputFile.SUBMISSION_TIME_PATTERN.matcher(line);
224        if (matcher.matches()) {
225          String timeString = matcher.group(1).trim();
226          return parseTime(timeString);
227        } else {
228          throw new IllegalArgumentException("Could not parse submission time from line: " + line);
229        }
230      }
231
232      return null;
233    }
234
235    private static LocalDateTime parseTime(String timeString) {
236      try {
237        ZonedDateTime zoned;
238        try {
239          DateTimeFormatter formatter = DateTimeFormatter.ofPattern("E MMM d hh:mm:ss a z yyyy");
240          zoned = ZonedDateTime.parse(timeString, formatter);
241
242        } catch (DateTimeParseException ex) {
243          // Single-digit day format
244          DateTimeFormatter formatter = DateTimeFormatter.ofPattern("E MMM  d hh:mm:ss a z yyyy");
245          zoned = ZonedDateTime.parse(timeString, formatter);
246        }
247        return zoned.toLocalDateTime();
248
249      } catch (DateTimeParseException ex) {
250        return LocalDateTime.parse(timeString);
251      }
252    }
253
254    public static Double parseGrade(String line) {
255      if (line.contains("out of")) {
256        String[] parts = line.split("out of");
257        if (parts.length == 2) {
258          try {
259            return Double.parseDouble(parts[0].trim());
260          } catch (NumberFormatException e) {
261            return Double.NaN;
262          }
263        }
264      }
265      return null;
266    }
267
268    @Override
269    public TestOutputDetails getTestOutputDetails(Path testOutput) {
270      try {
271        return parseTestOutputDetails(Files.lines(testOutput));
272      } catch (IOException e) {
273        throw new RuntimeException(e);
274      }
275    }
276
277    static TestOutputDetails parseTestOutputDetails(Stream<String> lines) {
278      TestOutputDetailsCreator creator = new TestOutputDetailsCreator();
279      lines.forEach(creator);
280      return creator.createTestOutputDetails();
281    }
282
283    private static class TestOutputDetailsCreator implements Consumer<String> {
284      private LocalDateTime testedSubmissionTime;
285      private Boolean hasGrade;
286      private int lineCount;
287
288      @Override
289      public void accept(String line) {
290        this.lineCount++;
291
292        LocalDateTime submissionTime = parseSubmissionTime(line);
293        if (submissionTime != null) {
294          this.testedSubmissionTime = submissionTime;
295        }
296
297        Double grade = parseGrade(line);
298        if (grade != null) {
299          boolean testOutputHasNotesForStudent = lineCount > 7;
300          this.hasGrade = testOutputHasNotesForStudent || !grade.isNaN();
301        }
302      }
303
304      public TestOutputDetails createTestOutputDetails() {
305        if (this.testedSubmissionTime == null) {
306          throw new IllegalStateException("Tested submission time was not set");
307
308        } else if( this.hasGrade == null) {
309          throw new IllegalStateException("Has grade was not set");
310        }
311
312        return new TestOutputDetails(this.testedSubmissionTime, hasGrade);
313      }
314    }
315  }
316}