001    package org.hackystat.sensor.ant.svn;
002    
003    import java.io.BufferedReader;
004    import java.io.ByteArrayInputStream;
005    import java.io.ByteArrayOutputStream;
006    import java.io.InputStreamReader;
007    import java.util.ArrayList;
008    import java.util.List;
009    import java.util.Locale;
010    import java.util.Map;
011    import java.util.TreeMap;
012    
013    import org.hackystat.sensor.ant.vcs.GenericDiffCounter;
014    import org.hackystat.sensor.ant.vcs.GenericSizeCounter;
015    import org.tmatesoft.svn.core.SVNNodeKind;
016    import org.tmatesoft.svn.core.SVNProperties;
017    import org.tmatesoft.svn.core.io.SVNRepository;
018    
019    /**
020     * A record tracing the change of one file or one directory in an SVN commit.
021     * Note that the path may indication either a directory or a file. During a
022     * commit a path name can change, such as in the case of renaming.
023     * <p>
024     * If an item is newly created in the commit, then fromPath is null. If an item
025     * is deleted, then toPath is null.
026     * 
027     * @author (Cedric) Qin ZHANG
028     * @version $Id$
029     */
030    public class CommitRecordEntry {
031    
032      private SVNRepository svnRepository;
033      private String fromPath, toPath;
034      private long fromRevision, toRevision;
035    
036      private boolean statisticsComputed = false;
037      private boolean isFile = false; // valid only if statisticsComputed=true
038      private boolean isTextFile = false; // valid only if isFile=true
039      private int linesAdded, linesDeleted, totalLines; // valid only if
040    
041      // isTextFile=true
042    
043      /**
044       * Create this instance. One of fromPath and toPath can be null, but not both.
045       * 
046       * @param svnRepository SVN repository.
047       * @param fromPath The from path.
048       * @param fromRevision The from revision.
049       * @param toPath The to path.
050       * @param toRevision The to revision.
051       * 
052       * @throws Exception If both fromPath and toPath are null.
053       */
054      CommitRecordEntry(SVNRepository svnRepository, String fromPath, long fromRevision,
055          String toPath, long toRevision) throws Exception {
056        this.svnRepository = svnRepository;
057        this.fromPath = fromPath;
058        this.fromRevision = fromRevision;
059        this.toPath = toPath;
060        this.toRevision = toRevision;
061        if (this.fromPath == null && this.toPath == null) {
062          throw new Exception("FromPath and ToPath cannot both be null.");
063        }
064      }
065    
066      /**
067       * Gets the from path.
068       * 
069       * @return The from path.
070       */
071      public String getFromPath() {
072        return this.fromPath;
073      }
074    
075      /**
076       * Gets the to path.
077       * 
078       * @return The to path.
079       */
080      public String getToPath() {
081        return this.toPath;
082      }
083    
084      /**
085       * Gets the from revision.
086       * 
087       * @return The from revision.
088       */
089      public long getFromRevision() {
090        return this.fromRevision;
091      }
092    
093      /**
094       * Checks whether this entry represents a file or not. Note that a false
095       * return value does not necessary mean this entry represents a directory.
096       * 
097       * @return True if this commit entry represents a file.
098       * 
099       * @throws Exception If there is any error.
100       */
101      public boolean isFile() throws Exception {
102        this.computeStatistics();
103        return this.isFile;
104      }
105    
106      /**
107       * Checks whether this entry represents a text file.
108       * 
109       * @return True if this commit entry represents a text file. False if either
110       * this commit entry represents a binary file, or it's not a file at all.
111       * 
112       * @throws Exception If there is any error.
113       */
114      public boolean isTextFile() throws Exception {
115        this.computeStatistics();
116        return this.isTextFile;
117      }
118    
119      /**
120       * Gets the to revision.
121       * 
122       * @return The to revision.
123       */
124      public long getToRevision() {
125        return this.toRevision;
126      }
127    
128      /**
129       * Gets the number of lines added.
130       * 
131       * @return The number of lines added.
132       * 
133       * @throws Exception If this entry does not represent a text file, or if there
134       * is any other error.
135       */
136      public int getLinesAdded() throws Exception {
137        this.computeStatistics();
138        if (this.isTextFile) {
139          return this.linesAdded;
140        }
141        else {
142          throw new Exception("This is not a text file.");
143        }
144      }
145    
146      /**
147       * Gets the number of lines deleted.
148       * 
149       * @return The number of lines deleted.
150       * 
151       * @throws Exception If this entry does not represent text a file, or if there
152       * is any other error.
153       */
154      public int getLinesDeleted() throws Exception {
155        this.computeStatistics();
156        if (this.isTextFile) {
157          return this.linesDeleted;
158        }
159        else {
160          throw new Exception("This is not a text file.");
161        }
162      }
163    
164      /**
165       * Gets the total number of lines for the toRevision.
166       * 
167       * @return The total number of lines.
168       * 
169       * @throws Exception If this entry does not represent a text file, or if there
170       * is any other error.
171       */
172      public int getTotalLines() throws Exception {
173        this.computeStatistics();
174        if (this.isTextFile) {
175          return this.totalLines;
176        }
177        else {
178          throw new Exception("This is not a text file.");
179        }
180      }
181    
182      /**
183       * Computes file metrics. Note that if the file is a binary file, either an
184       * exception will be thrown or the computed value is invalid. The exact
185       * behavior depends on the underlying metrics computation engine.
186       * 
187       * @throws Exception If this entry does not represent a file, or if there is
188       * any other error.
189       */
190      private void computeStatistics() throws Exception {
191    
192        if (!this.statisticsComputed) {
193          boolean useToPath = (this.toPath != null);
194    
195          // check isFile
196          SVNNodeKind nodeKind = useToPath ? this.svnRepository.checkPath(this.toPath,
197              this.toRevision) : this.svnRepository.checkPath(this.fromPath, this.fromRevision);
198          this.isFile = (nodeKind == SVNNodeKind.FILE);
199          // TODO: this is a hack
200          // It seems that each call of checkPath method will open a new socket
201          // connection,
202          // If this method repeatedly called, OS will return a "address already in
203          // use" exception.
204          // This is underlying JavaSVN issue, the only thing I can do is to force
205          // this
206          // thread to sleep for some time.
207          Thread.sleep(50);
208    
209          if (this.isFile) {
210            // check isTextFile
211            TreeMap<String, String> properties = new TreeMap<String, String>();
212            if (useToPath) {
213              this.getVersionedProperties(this.toPath, this.toRevision, properties);
214            }
215            else {
216              this.getVersionedProperties(this.fromPath, this.fromRevision, properties);
217            }
218            String svnFileMineType = properties.get("svn:mime-type");
219            this.isTextFile = (svnFileMineType == null || svnFileMineType.toLowerCase(Locale.ENGLISH)
220                .startsWith("/text"));
221    
222            if (this.isTextFile) {
223              byte[] content = useToPath ? this.getVersionedContent(this.toPath, this.toRevision,
224                  null) : this.getVersionedContent(this.fromPath, this.fromRevision, null);
225              // compute diff
226              byte[] fromContent = useToPath ? null : content;
227              byte[] toContent = useToPath ? content : null;
228    
229              if (fromContent == null && this.fromPath != null) {
230                fromContent = this.getVersionedContent(this.fromPath, this.fromRevision, null);
231              }
232    
233              if (toContent == null && this.toPath != null) {
234                toContent = this.getVersionedContent(this.toPath, this.toRevision, null);
235              }
236    
237              String[] fromStrings = fromContent == null ? null : this
238                  .byteArrayToStringArray(fromContent);
239              String[] toStrings = toContent == null ? null : this
240                  .byteArrayToStringArray(toContent);
241    
242              if (fromStrings == null) {
243                this.totalLines = new GenericSizeCounter(toStrings).getNumOfTotalLines();
244                this.linesAdded = this.totalLines;
245                this.linesDeleted = 0;
246              }
247              else if (toStrings == null) {
248                this.totalLines = 0;
249                this.linesAdded = 0;
250                this.linesDeleted = new GenericSizeCounter(fromStrings).getNumOfTotalLines();
251              }
252              else {
253                this.totalLines = new GenericSizeCounter(toStrings).getNumOfTotalLines();
254                GenericDiffCounter diff = new GenericDiffCounter(fromStrings, toStrings);
255                this.linesAdded = diff.getLinesAdded();
256                this.linesDeleted = diff.getLinesDeleted();
257              }
258    
259            } // this.isTextFile
260          } // this.isFile
261    
262          this.statisticsComputed = true;
263        } // this.isStatisticsComputed
264      }
265    
266      /**
267       * Gets the content for a file at the specified revision.
268       * @param filePath The file path.
269       * @param revision The revision number.
270       * @param properties A map to receive SVN properties associated with the file.
271       * Note that this is an output parameter, it's advised that you use an empty
272       * map. Null is a valid value.
273       * 
274       * @return The content as a byte array.
275       * @throws Exception If there is any error.
276       */
277      private byte[] getVersionedContent(String filePath, long revision,
278          Map<String, String> properties) throws Exception {
279        ByteArrayOutputStream output = new ByteArrayOutputStream(4096);
280        SVNProperties svnProps = SVNProperties.wrap(properties);
281        this.svnRepository.getFile(filePath, revision, svnProps, output);
282        output.flush();
283        return output.toByteArray();
284      }
285    
286      /**
287       * Gets the properties for a file at the specified revision.
288       * @param filePath The file path.
289       * @param revision The revision number.
290       * @param properties A map to receive SVN properties associated with the file.
291       * Note that this is an output parameter, it's advised that you use an empty
292       * map. Null is a valid value.
293       * @throws Exception If there is any error.
294       */
295      private void getVersionedProperties(String filePath, long revision,
296          Map<String, String> properties) throws Exception {
297        SVNProperties svnProps = SVNProperties.wrap(properties);
298        this.svnRepository.getFile(filePath, revision, svnProps, null);
299      }
300    
301      /**
302       * Converts a byte array holding text into a string array with each string
303       * represeting one line. TODO: check shits like unicode, text locale, and line
304       * break with \r, \n or both!
305       * 
306       * @param byteArray A byte array holding text.
307       * 
308       * @return The converted string array. Note that if you pass in a byte array
309       * containing binary contents, then the return value is undefined.
310       * 
311       * @throws Exception If there is any error.
312       */
313      private String[] byteArrayToStringArray(byte[] byteArray) throws Exception {
314        List<String> strings = new ArrayList<String>(16);
315        BufferedReader reader = new BufferedReader(new InputStreamReader(new ByteArrayInputStream(
316            byteArray)));
317        String str = reader.readLine();
318        while (str != null) {
319          strings.add(str);
320          str = reader.readLine();
321        }
322        // convert to vanilla string array
323        int size = strings.size();
324        String[] strs = new String[size];
325        for (int i = 0; i < size; i++) {
326          strs[i] = strings.get(i);
327        }
328        return strs;
329      }
330    
331      /**
332       * Gets a string representation of this instance.
333       * 
334       * @return The string representation.
335       */
336      @Override
337      public String toString() {
338        StringBuffer buff = new StringBuffer(64);
339        buff.append("CommitRecordEntry (");
340        buff.append(this.fromPath).append('[').append(this.fromRevision).append("] ==> ");
341        buff.append(this.toPath).append('[').append(this.toRevision).append("]) ");
342        return buff.toString();
343      }
344    }