001package edu.pdx.cs.joy.grader; 002 003import com.google.common.annotations.VisibleForTesting; 004import org.jspecify.annotations.NonNull; 005 006import java.io.BufferedReader; 007import java.io.IOException; 008import java.io.Reader; 009import java.time.LocalDateTime; 010import java.time.ZonedDateTime; 011import java.time.format.DateTimeFormatter; 012import java.time.format.DateTimeParseException; 013import java.util.Iterator; 014import java.util.regex.Matcher; 015import java.util.regex.Pattern; 016import java.util.stream.Stream; 017 018public class TestedProjectSubmissionOutputParser { 019 static final Pattern scorePattern = Pattern.compile("(\\d+\\.?\\d*)? out of (\\d+\\.?\\d*)", Pattern.CASE_INSENSITIVE); 020 static final Pattern projectNamePattern = Pattern.compile(".*The Joy of Coding Project (\\d+): .*", Pattern.CASE_INSENSITIVE); 021 static final Pattern koansProjectNamePattern = Pattern.compile(".*The Joy of Coding Koans.*", Pattern.CASE_INSENSITIVE); 022 private static final Pattern submissionTimePattern = Pattern.compile(".*Submitted on (.+)", Pattern.CASE_INSENSITIVE); 023 024 static @NonNull ProjectScore parseTestedSubmissionOutput(Reader reader) throws TestedProjectSubmissionOutputParsingException, IOException { 025 try (BufferedReader br = new BufferedReader(reader)) { 026 return parseTestedSubmissionOutput(br.lines()); 027 } 028 } 029 030 static @NonNull ProjectScore parseTestedSubmissionOutput(Stream<String> lines) throws TestedProjectSubmissionOutputParsingException { 031 String score = null; 032 int scoreLineNumber = 0; 033 String totalPoints = null; 034 String projectName = null; 035 LocalDateTime submissionTime = null; 036 037 int lineNumber = 0; 038 039 Iterator<String> iterator = lines.iterator(); 040 while (iterator.hasNext()) { 041 String line = iterator.next(); 042 lineNumber++; 043 if (score == null && totalPoints == null) { 044 Matcher matcher = scorePattern.matcher(line); 045 if (matcher.find()) { 046 score = matcher.group(1); 047 totalPoints = matcher.group(2); 048 scoreLineNumber = lineNumber; 049 } 050 } 051 052 if (projectName == null && line.contains("The Joy of Coding")) { 053 Matcher matcher = projectNamePattern.matcher(line); 054 if (matcher.find()) { 055 projectName = "Project" + matcher.group(1); 056 057 } else { 058 matcher = koansProjectNamePattern.matcher(line); 059 if (matcher.find()) { 060 projectName = "koans"; 061 } 062 } 063 } 064 065 if (submissionTime == null && line.contains("Submitted on")) { 066 submissionTime = parseSubmissionTime(line); 067 } 068 } 069 070 if (totalPoints == null) { 071 throw new TestedProjectSubmissionOutputParsingException("Could not find score line in project report"); 072 } 073 074 if (projectName == null) { 075 throw new TestedProjectSubmissionOutputParsingException("Could not find project name in project report"); 076 } 077 078 if (submissionTime == null) { 079 throw new TestedProjectSubmissionOutputParsingException("Could not find submission time in project report"); 080 } 081 082 return new ProjectScore(score, scoreLineNumber, totalPoints, projectName, submissionTime); 083 } 084 085 static LocalDateTime parseSubmissionTime(String line) { 086 Matcher matcher = submissionTimePattern.matcher(line); 087 String timeString; 088 if (matcher.matches()) { 089 timeString = matcher.group(1).trim(); 090 091 } else { 092 throw new IllegalArgumentException("Could not parse submission time from line: " + line); 093 } 094 095 try { 096 ZonedDateTime zoned; 097 try { 098 DateTimeFormatter formatter = DateTimeFormatter.ofPattern("E MMM d hh:mm:ss a z yyyy"); 099 zoned = ZonedDateTime.parse(timeString, formatter); 100 101 } catch (DateTimeParseException ex) { 102 // Single-digit day format 103 DateTimeFormatter formatter = DateTimeFormatter.ofPattern("E MMM d hh:mm:ss a z yyyy"); 104 zoned = ZonedDateTime.parse(timeString, formatter); 105 } 106 return zoned.toLocalDateTime(); 107 108 } catch (DateTimeParseException ex) { 109 return LocalDateTime.parse(timeString); 110 } 111 } 112 113 static class ProjectScore { 114 static final int UNREVIEWED_SCORE_LINE_NUMBER = 7; 115 private final double score; 116 private final double totalPoints; 117 private final String projectName; 118 private final boolean reviewed; 119 private final LocalDateTime submissionTime; 120 121 ProjectScore(String score, int scoreLineNumber, String totalPoints, String projectName, LocalDateTime submissionTime) { 122 this.score = score == null ? Double.NaN : Double.parseDouble(score); 123 this.totalPoints = Double.parseDouble(totalPoints); 124 this.projectName = projectName; 125 this.reviewed = !Double.isNaN(this.score) || scoreLineNumber > UNREVIEWED_SCORE_LINE_NUMBER; 126 this.submissionTime = submissionTime; 127 } 128 129 public double getScore() { 130 return this.score; 131 } 132 133 public double getTotalPoints() { 134 return this.totalPoints; 135 } 136 137 public String getProjectName() { 138 return projectName; 139 } 140 141 /** 142 * Determines if a submission has been reviewed by a grader. 143 * A submission is considered reviewed if: 144 * 1. It has a numeric score, OR 145 * 2. The score line appears after line 7 (indicating grader comments are present) 146 * <p> 147 * When a score line like " out of X.X" appears after line 7, it indicates 148 * the grader has left feedback for the student to fix issues and resubmit. 149 */ 150 public boolean isReviewed() { 151 return reviewed; 152 } 153 154 public LocalDateTime getSubmissionTime() { 155 return submissionTime; 156 } 157 } 158 159 @VisibleForTesting 160 static class TestedProjectSubmissionOutputParsingException extends Exception { 161 162 public TestedProjectSubmissionOutputParsingException(String message) { 163 super(message); 164 } 165 } 166}