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}