001/*
002 * Licensed to the Apache Software Foundation (ASF) under one
003 * or more contributor license agreements.  See the NOTICE file
004 * distributed with this work for additional information
005 * regarding copyright ownership.  The ASF licenses this file
006 * to you under the Apache License, Version 2.0 (the
007 * "License"); you may not use this file except in compliance
008 * with the License.  You may obtain a copy of the License at
009 *
010 *   http://www.apache.org/licenses/LICENSE-2.0
011 *
012 * Unless required by applicable law or agreed to in writing,
013 * software distributed under the License is distributed on an
014 * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
015 * KIND, either express or implied.  See the License for the
016 * specific language governing permissions and limitations
017 * under the License.
018 */
019package org.apache.maven.scm.provider.svn.svnexe.command.changelog;
020
021import java.util.ArrayList;
022import java.util.Date;
023import java.util.List;
024import java.util.regex.Matcher;
025import java.util.regex.Pattern;
026
027import org.apache.maven.scm.ChangeFile;
028import org.apache.maven.scm.ChangeSet;
029import org.apache.maven.scm.ScmFileStatus;
030import org.apache.maven.scm.provider.svn.SvnChangeSet;
031import org.apache.maven.scm.util.AbstractConsumer;
032
033/**
034 * @author <a href="mailto:evenisse@apache.org">Emmanuel Venisse</a>
035 *
036 */
037public class SvnChangeLogConsumer extends AbstractConsumer {
038    /**
039     * Date formatter for svn timestamp (after a little massaging)
040     */
041    private static final String SVN_TIMESTAMP_PATTERN = "yyyy-MM-dd HH:mm:ss zzzzzzzzz";
042
043    /**
044     * State machine constant: expecting header
045     */
046    private static final int GET_HEADER = 1;
047
048    /**
049     * State machine constant: expecting file information
050     */
051    private static final int GET_FILE = 2;
052
053    /**
054     * State machine constant: expecting comments
055     */
056    private static final int GET_COMMENT = 3;
057
058    /**
059     * There is always action and affected path; when copying/moving, recognize also original path and revision
060     */
061    private static final Pattern FILE_PATTERN = Pattern.compile("^\\s\\s\\s([A-Z])\\s(.+)$");
062
063    /**
064     * This matches the 'original file info' part of the complete file line.
065     * Note the use of [:alpha:] instead of literal 'from' - this is meant to allow non-English localizations.
066     */
067    private static final Pattern ORIG_FILE_PATTERN = Pattern.compile("\\([A-Za-z]+ (.+):(\\d+)\\)");
068
069    /**
070     * The file section ends with a blank line
071     */
072    private static final String FILE_END_TOKEN = "";
073
074    /**
075     * The comment section ends with a dashed line
076     */
077    private static final String COMMENT_END_TOKEN =
078            "------------------------------------" + "------------------------------------";
079
080    /**
081     * Current status of the parser
082     */
083    private int status = GET_HEADER;
084
085    /**
086     * List of change log entries
087     */
088    private final List<ChangeSet> entries = new ArrayList<>();
089
090    /**
091     * The current log entry being processed by the parser
092     */
093    private SvnChangeSet currentChange;
094
095    /**
096     * The current revision of the entry being processed by the parser
097     */
098    private String currentRevision;
099
100    /**
101     * The current comment of the entry being processed by the parser
102     */
103    private StringBuilder currentComment;
104
105    /**
106     * The regular expression used to match header lines
107     */
108    private static final Pattern HEADER_REG_EXP = Pattern.compile("^(.+) \\| (.+) \\| (.+) \\|.*$");
109
110    private static final int REVISION_GROUP = 1;
111
112    private static final int AUTHOR_GROUP = 2;
113
114    private static final int DATE_GROUP = 3;
115
116    private static final Pattern REVISION_REG_EXP1 = Pattern.compile("rev (\\d+):");
117
118    private static final Pattern REVISION_REG_EXP2 = Pattern.compile("r(\\d+)");
119
120    private static final Pattern DATE_REG_EXP = Pattern.compile("(\\d+-\\d+-\\d+ " + // date 2002-08-24
121            "\\d+:\\d+:\\d+) "
122            + // time 16:01:00
123            "([\\-+])(\\d\\d)(\\d\\d)"); // gmt offset -0400);)
124
125    private final String userDateFormat;
126
127    /**
128     * Default constructor.
129     */
130    public SvnChangeLogConsumer(String userDateFormat) {
131        this.userDateFormat = userDateFormat;
132    }
133
134    public List<ChangeSet> getModifications() {
135        return entries;
136    }
137
138    // ----------------------------------------------------------------------
139    // StreamConsumer Implementation
140    // ----------------------------------------------------------------------
141
142    /**
143     * {@inheritDoc}
144     */
145    public void consumeLine(String line) {
146        if (logger.isDebugEnabled()) {
147            logger.debug(line);
148        }
149        switch (status) {
150            case GET_HEADER:
151                processGetHeader(line);
152                break;
153            case GET_FILE:
154                processGetFile(line);
155                break;
156            case GET_COMMENT:
157                processGetComment(line);
158                break;
159            default:
160                throw new IllegalStateException("Unknown state: " + status);
161        }
162    }
163
164    // ----------------------------------------------------------------------
165    //
166    // ----------------------------------------------------------------------
167
168    /**
169     * Process the current input line in the GET_HEADER state.  The
170     * author, date, and the revision of the entry are gathered.  Note,
171     * Subversion does not have per-file revisions, instead, the entire
172     * repository is given a single revision number, which is used for
173     * the revision number of each file.
174     *
175     * @param line A line of text from the svn log output
176     */
177    private void processGetHeader(String line) {
178        Matcher matcher = HEADER_REG_EXP.matcher(line);
179        if (!matcher.matches()) {
180            // The header line is not found. Intentionally do nothing.
181            return;
182        }
183
184        currentRevision = getRevision(matcher.group(REVISION_GROUP));
185
186        currentChange = new SvnChangeSet();
187
188        currentChange.setAuthor(matcher.group(AUTHOR_GROUP));
189
190        currentChange.setDate(getDate(matcher.group(DATE_GROUP)));
191
192        currentChange.setRevision(currentRevision);
193
194        status = GET_FILE;
195    }
196
197    /**
198     * Gets the svn revision, from the svn log revision output.
199     *
200     * @param revisionOutput
201     * @return the svn revision
202     */
203    private String getRevision(final String revisionOutput) {
204        Matcher matcher;
205        if ((matcher = REVISION_REG_EXP1.matcher(revisionOutput)).matches()) {
206            return matcher.group(1);
207        } else if ((matcher = REVISION_REG_EXP2.matcher(revisionOutput)).matches()) {
208            return matcher.group(1);
209        } else {
210            throw new IllegalOutputException(revisionOutput);
211        }
212    }
213
214    /**
215     * Process the current input line in the GET_FILE state.  This state
216     * adds each file entry line to the current change log entry.  Note,
217     * the revision number for the entire entry is used for the revision
218     * number of each file.
219     *
220     * @param line A line of text from the svn log output
221     */
222    private void processGetFile(String line) {
223        Matcher matcher = FILE_PATTERN.matcher(line);
224        if (matcher.matches()) {
225            final String fileinfo = matcher.group(2);
226            String name = fileinfo;
227            String originalName = null;
228            String originalRev = null;
229            final int n = fileinfo.indexOf(" (");
230            if (n > 1 && fileinfo.endsWith(")")) {
231                final String origFileInfo = fileinfo.substring(n);
232                Matcher matcher2 = ORIG_FILE_PATTERN.matcher(origFileInfo);
233                if (matcher2.find()) {
234                    // if original file is present, we must extract the affected one from the beginning
235                    name = fileinfo.substring(0, n);
236                    originalName = matcher2.group(1);
237                    originalRev = matcher2.group(2);
238                }
239            }
240            final String actionStr = matcher.group(1);
241            final ScmFileStatus action;
242            if ("A".equals(actionStr)) {
243                // TODO: this may even change to MOVED if we later explore whole changeset and find matching DELETED
244                action = originalRev == null ? ScmFileStatus.ADDED : ScmFileStatus.COPIED;
245            } else if ("D".equals(actionStr)) {
246                action = ScmFileStatus.DELETED;
247            } else if ("M".equals(actionStr)) {
248                action = ScmFileStatus.MODIFIED;
249            } else if ("R".equals(actionStr)) {
250                action = ScmFileStatus.UPDATED; // == REPLACED in svn terms
251            } else {
252                action = ScmFileStatus.UNKNOWN;
253            }
254            if (logger.isDebugEnabled()) {
255                logger.debug(actionStr + " : " + name);
256            }
257            final ChangeFile changeFile = new ChangeFile(name, currentRevision);
258            changeFile.setAction(action);
259            changeFile.setOriginalName(originalName);
260            changeFile.setOriginalRevision(originalRev);
261            currentChange.addFile(changeFile);
262
263            status = GET_FILE;
264        } else if (line.equals(FILE_END_TOKEN)) {
265            // Create a buffer for the collection of the comment now
266            // that we are leaving the GET_FILE state.
267            currentComment = new StringBuilder();
268
269            status = GET_COMMENT;
270        }
271    }
272
273    /**
274     * Process the current input line in the GET_COMMENT state.  This
275     * state gathers all of the comments that are part of a log entry.
276     *
277     * @param line a line of text from the svn log output
278     */
279    private void processGetComment(String line) {
280        if (line.equals(COMMENT_END_TOKEN)) {
281            currentChange.setComment(currentComment.toString());
282
283            entries.add(currentChange);
284
285            status = GET_HEADER;
286        } else {
287            currentComment.append(line).append('\n');
288        }
289    }
290
291    /**
292     * Converts the date time stamp from the svn output into a date
293     * object.
294     *
295     * @param dateOutput The date output from an svn log command.
296     * @return A date representing the time stamp of the log entry.
297     */
298    private Date getDate(final String dateOutput) {
299        Matcher matcher = DATE_REG_EXP.matcher(dateOutput);
300        if (!matcher.find()) {
301            throw new IllegalOutputException(dateOutput);
302        }
303
304        final StringBuilder date = new StringBuilder();
305        date.append(matcher.group(1));
306        date.append(" GMT");
307        date.append(matcher.group(2));
308        date.append(matcher.group(3));
309        date.append(':');
310        date.append(matcher.group(4));
311
312        return parseDate(date.toString(), userDateFormat, SVN_TIMESTAMP_PATTERN);
313    }
314}