/********************************************************************************
* CruiseControl, a Continuous Integration Toolkit
* Copyright (c) 2001, ThoughtWorks, Inc.
* 200 E. Randolph, 25th Floor
* Chicago, IL 60601 USA
* All rights reserved.
*
* Redistribution and use in source and binary forms, with or without
* modification, are permitted provided that the following conditions
* are met:
*
* + Redistributions of source code must retain the above copyright
* notice, this list of conditions and the following disclaimer.
*
* + Redistributions in binary form must reproduce the above
* copyright notice, this list of conditions and the following
* disclaimer in the documentation and/or other materials provided
* with the distribution.
*
* + Neither the name of ThoughtWorks, Inc., CruiseControl, nor the
* names of its contributors may be used to endorse or promote
* products derived from this software without specific prior
* written permission.
*
* THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
* "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
* LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
* A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE REGENTS OR
* CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL,
* EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO,
* PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR
* PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF
* LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING
* NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
* SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
********************************************************************************/
package net.sourceforge.cruisecontrol.builders;
import java.io.BufferedReader;
import java.io.BufferedWriter;
import java.io.FileInputStream;
import java.io.FileOutputStream;
import java.io.IOException;
import java.io.InputStreamReader;
import java.io.OutputStream;
import java.io.OutputStreamWriter;
import java.io.Reader;
import java.io.StringReader;
import java.io.StringWriter;
import java.nio.charset.CodingErrorAction;
import java.nio.charset.Charset;
import java.nio.charset.CharsetDecoder;
import java.nio.charset.CharsetEncoder;
import java.nio.charset.IllegalCharsetNameException;
import java.nio.charset.UnmappableCharacterException;
import java.util.LinkedList;
import java.util.Map;
import java.util.zip.GZIPOutputStream;
import org.apache.log4j.Logger;
import org.jdom.Element;
import org.jdom.Text;
import net.sourceforge.cruisecontrol.Builder;
import net.sourceforge.cruisecontrol.CruiseControlException;
import net.sourceforge.cruisecontrol.Progress;
import net.sourceforge.cruisecontrol.gendoc.annotations.Default;
import net.sourceforge.cruisecontrol.gendoc.annotations.Description;
import net.sourceforge.cruisecontrol.gendoc.annotations.DescriptionFile;
import net.sourceforge.cruisecontrol.gendoc.annotations.ExamplesFile;
import net.sourceforge.cruisecontrol.gendoc.annotations.Optional;
import net.sourceforge.cruisecontrol.gendoc.annotations.Required;
import net.sourceforge.cruisecontrol.util.DateUtil;
import net.sourceforge.cruisecontrol.util.IO;
import net.sourceforge.cruisecontrol.util.StreamConsumer;
import net.sourceforge.cruisecontrol.util.Util;
import net.sourceforge.cruisecontrol.util.ValidationHelper;
/**
* Builder allowing to write text messages into a file.
* @author Tomas Ausberger, Daniel Tihelka
*/
@DescriptionFile
@ExamplesFile
public class WriterBuilder extends Builder {
/** UTF8 encoding definition */
public static final String UTF8 = "UTF-8";
/** Value set through {@link #setFile(String)} */
private java.io.File file = null;
/** Value set through {@link #setWorkingDir(String)} */
private java.io.File workingDir = null;
/** Value set through {@link #setEncoding(String)} */
private String encoding = UTF8;
/** Value set through {@link #setGzip(boolean)} */
private boolean gzip = false;
/** Value set through {@link #setTrim(boolean)} */
private boolean trim = false;
/** Value set according to the action passed to {@link #setAction(String)} */
private boolean overwrite = true;
/** Value set according to the action passed to {@link #setAction(String)} */
private boolean append = false;
/** Value set through {@link #setReplaceChar(String)} */
private String replaceChar = null;
/** The list of sub-node-related actions */
private final LinkedList<Content> messages = new LinkedList<Content>();
/** Serialization UID */
private static final long serialVersionUID = -8630377927448150601L;
/** Logger output */
private static final Logger LOG = Logger.getLogger(WriterBuilder.class);
@Override
public Element build(Map<String, String> properties, Progress progress)
throws CruiseControlException {
final long startTime = System.currentTimeMillis();
final Element status = new Element("writer");
OutputStream out = null;
java.io.File f;
// Resolve properties in the settings. Fail. if they cannot be resolved
final String fname = Util.parsePropertiesInString(properties, this.file.getAbsolutePath(), true);
f = new java.io.File(fname);
try {
// The output file must not exist
if (!this.overwrite && f.exists()) {
throw new IOException("File " + f + " exists but overwrite=false");
}
// gzip compression is set on
if (this.gzip) {
if (!f.getName().endsWith(".gzip")) {
f = new java.io.File(f.getAbsolutePath() + ".gzip");
}
out = new GZIPOutputStream(new FileOutputStream(f, this.append));
// not-compressed file is required
} else {
out = new FileOutputStream(f, this.append);
}
final StreamConsumer consumer = getStreamConsumer(out, encoding);
// Pass content to the consumer
for (Content content : this.messages) {
BufferedReader input = new BufferedReader(content.getContent(properties));
String line;
while ((line = input.readLine()) != null) {
consumer.consumeLine(line);
}
IO.close(input);
}
// Close the output
consumer.consumeLine(null);
} catch (Exception exc) {
status.setAttribute("error", "build failed with exception: " + exc.getMessage());
} finally {
IO.close(out);
}
final long endTime = System.currentTimeMillis();
status.setAttribute("time", DateUtil.getDurationAsString((endTime - startTime)));
return status;
}
@Override
public Element buildWithTarget(Map<String, String> properties,
String target, Progress progress) throws CruiseControlException {
// TODO: what to put here?
return null;
}
@Override
public void validate() throws CruiseControlException {
// Must be set
ValidationHelper.assertEncoding(this.encoding, getClass());
ValidationHelper.assertIsSet(this.file, "file", getClass());
// Invalid combination
ValidationHelper.assertFalse(!this.overwrite && this.append, "action not set properly");
// Set the file
this.file = joinPath(this.file);
ValidationHelper.assertIsNotDirectory(this.file, "file", getClass());
// check, replacement char, if set. Empty string is valid replacement!
if (this.replaceChar != null) {
ValidationHelper.assertTrue(this.replaceChar.length() <= 1, "invalid replace char [" + replaceChar + "]");
}
if (!this.overwrite) {
// When overwrite is disabled, the file must not exist
try {
ValidationHelper.assertNotExists(this.file, "file", getClass());
} catch (CruiseControlException e) {
ValidationHelper.fail("Trying to overwrite file without permission.");
}
}
for (Content c : this.messages) {
c.validate();
}
}
/**
* Creates new object to be filled from {@code <msg>...</msg>} element.
* @return instance of {@link Msg}
*/
public Object createMsg() {
return addContent(new Msg());
}
/**
* Creates new object to be filled from {@code <file/>} element.
* @return instance of {@link File}
*/
public Object createFile() {
return addContent(new File());
}
@Description("The encoding of the output file. The string must be recognised by Java text "
+ "encoders")
@Optional
@Default(WriterBuilder.UTF8)
@SuppressWarnings("javadoc")
public void setEncoding(String encoding) {
this.encoding = encoding;
}
@Description("When set to <tt>true</tt>, the output file will be gzipped")
@Optional
@Default("false")
@SuppressWarnings("javadoc")
public void setGzip(boolean gzip) {
this.gzip = gzip;
}
@Description("The path to file to write the messages into. When the path is not absolute, "
+ "the path set by <tt>workingdir=''</tt> attribute is prepended, or actual working "
+ "directory is used when <tt>workingdir=''</tt> is not set.")
@Required
@SuppressWarnings("javadoc")
public void setFile(String file) {
this.file = new java.io.File(file);
}
@Description("When set, all white characters are stripped from the beginning and the end "
+ "of each line")
@Optional
@Default("false")
@SuppressWarnings("javadoc")
public void setTrim(boolean strip) {
this.trim = strip;
}
@Description("When set to <i>overwrite</i>, the messages are written to the file even when it "
+ "does not exist, when set to <i>create</i>, new file is created but the build fails "
+ "when the file already exists, when set to <i>append</i>, the content is appended to "
+ "the existing file or new file is created if it does not exist")
@Optional
@Default("overwrite")
@SuppressWarnings("javadoc")
public void setAction(String action) {
action = action.toLowerCase();
if ("overwrite".equals(action)) {
overwrite = true;
append = false;
} else if ("create".equals(action)) {
overwrite = false;
append = false;
} else if ("append".equals(action)) {
overwrite = true; // append = overwrite
append = true;
} else {
// Invalid combination, cannot append to non-overwriteable file
overwrite = false;
append = true;
}
}
/** @return <code>true</code> when {@link #setAction(String)} is set to <i>overwrite</i> */
public boolean getOverwrite() {
return overwrite;
}
/** @return <code>true</code> when {@link #setAction(String)} is set to <i>append</i> */
public boolean getAppend() {
return append;
}
@Description("When set, the path set is prepended to all files used in the builder")
@Optional
@SuppressWarnings("javadoc")
public void setWorkingDir(String path) {
workingDir = new java.io.File(path);
}
/** @return value set by {@link #setWorkingDir(String)} */
public String getWorkingDir() {
return workingDir != null ? workingDir.getAbsolutePath() : null;
}
/**
* Implementation of {@link PipedScript#setTimeout(long)}. Does nothing, however.
* @param val ignored
*/
public void setTimeout(long val) {
LOG.warn("timeout=" + val + " ignored");
}
@Description("Sets the character to be used when the text cannot be encoded to the required coding. "
+ "Use empty string to ignore the non-mappable characters.")
@Optional("<i>Unless set, build fails on non-mappable characters.</i>")
@SuppressWarnings("javadoc")
public void setReplaceChar(String chr) {
replaceChar = chr;
}
/**
* Adds the instance of {@link Content} to the end of list of contents.
* @param obj the instance to add
* @return the instance
*/
protected Content addContent(Content obj) {
this.messages.add(obj);
return obj;
}
/**
* Creates instance of {@link StreamConsumer} passing data into the given {@link OutputStream}
* instance. Note that <code>null</code> line must be passed to the consume to close the underlying
* output!
*
* @param out stream to write messages into
* @param encoding the encoding in which the output is written
* @return {@link StreamConsumer} instance passing data to the given stream
*/
protected StreamConsumer getStreamConsumer(final OutputStream out, final String encoding) {
try {
final BufferedWriter output = new BufferedWriter(new OutputStreamWriter(out,
createEncoder(encoding, replaceChar)));
// Get consumer passing data to the output stream
return new StreamConsumer() {
@Override
public void consumeLine(String line) {
try {
if (line == null) {
output.close();
} else {
output.write(line);
output.newLine();
}
} catch (UnmappableCharacterException exc) {
// THIS IS A BIT TRICKY: Must wrap it into RuntimeException, since the
// StreamConsumer#consumeLine() does not allow any exception to be thrown.
throw new RuntimeException("Unmappable characters found when encoding to " + encoding, exc);
} catch (IOException exc) {
// THIS IS A BIT TRICKY: dtto
throw new RuntimeException("Unable to write line \"" + line + "\"", exc);
}
}
};
// Encoding was checked in validate(), so this exception should not occure
} catch (IllegalCharsetNameException exc) {
LOG.error("Unknown encoding: " + encoding, exc);
return null;
}
}
/**
* Joins the given path with the path set through {@link #setWorkingDir(String)}, if it is not
* absolute already; if no path was set through the method, returns the original path.
*
* @param path the path to append to
* @return new absolute path (or the original path transformed to absolute path)
*/
protected java.io.File joinPath(java.io.File path) {
// If working dir was not set or the file is absolute already,
if (this.workingDir == null || path.isAbsolute()) {
return path;
}
// Join
return new java.io.File(this.workingDir, path.getPath()).getAbsoluteFile();
}
/**
* Creates action for the given replacement character
*/
protected static CodingErrorAction createAction(String replaceChar) {
if (replaceChar == null) {
return CodingErrorAction.REPORT;
}
if ("".equals(replaceChar)) {
return CodingErrorAction.IGNORE;
} else {
return CodingErrorAction.REPLACE;
}
}
/**
* Creates decoder for the given encoding
*/
protected static CharsetDecoder createDecoder(String encoding, String replaceChar) {
final CharsetDecoder decoder = Charset.forName(encoding).newDecoder();
final CodingErrorAction act = createAction(replaceChar);
// Configure it
if (act == CodingErrorAction.REPLACE) {
decoder.replaceWith(replaceChar); // replacement set and valid
}
decoder.onMalformedInput(act);
decoder.onUnmappableCharacter(act);
// Get it
return decoder;
}
/**
* Creates encoder for the given encoding
*/
protected static CharsetEncoder createEncoder(String encoding, String replaceChar) {
final CharsetEncoder encoder = Charset.forName(encoding).newEncoder();
final CodingErrorAction act = createAction(replaceChar);
// Configure it
if (act == CodingErrorAction.REPLACE) {
encoder.replaceWith(replaceChar.getBytes()); // replacement set and valid
}
encoder.onMalformedInput(act);
encoder.onUnmappableCharacter(act);
// Get it
return encoder;
}
/**
* Interface allowing to work with {@code <msg></msg>} and {@code <file/>} sub-nodes in the same way.
*/
public static interface Content {
/** Returns the content of a message to write embedded in the {@link Reader}
* @param properties the additional build-time properties passed to {@link Builder#build(Map, Progress)}
* @return the text content as Reader */
public Reader getContent(final Map<String, String> properties);
/** Validation
* @throws CruiseControlException if not valid*/
public void validate() throws CruiseControlException;
}
@Description("Element holding text to be written to the output file. The text is stored 'as-is' "
+ "stored withing the element, except when <tt>trim='true'</tt> is set in which case "
+ "the white spaces from the beginning and the end of each line are removed")
@SuppressWarnings("javadoc")
public final class Msg extends StringWriter implements Content {
@Override
public Reader getContent(final Map<String, String> properties) {
String str = this.toString();
// Resolve the properties
try {
str = Util.parsePropertiesInString(properties, str, false);
} catch (CruiseControlException e) {
LOG.warn("Unable to resolve property in " + str + "message", e);
}
if (str == null || str.length() == 0) {
str = "\n";
}
return new StringReader(str);
}
@Override
public void validate() throws CruiseControlException {
// Nothing to validate in fact ...
}
/** Set the text content from a XML node.
* @param t inner text element of XML {@code <msg></msg>} element. */
public void xmltext(final Text t) {
/* if trim is required, split the text and trim each line */
if (trim) {
final String[] lines = t.getText().split(System.lineSeparator());
for (String s : lines) {
this.append(s.trim() + System.lineSeparator());
}
} else {
this.append(t.getText());
}
}
}
@Description("Element used to configure file to be copied to the output file. The content of the "
+ "file is copied among messages into the position when the attribute is configured")
@SuppressWarnings("javadoc")
public final class File implements Content {
/** The value set by {@link #setFile(String)} */
private String file;
/** The value set by {@link #setEncoding(String)} */
private String encoding = UTF8;
@Description("The path to file to be copied among the messages. If the path is not absolute, "
+ "it behaves exactly as the <tt>file=''</tt> attribute of the parent builder's node")
@Required
public void setFile(String file) {
this.file = file;
}
@Description("The encoding of the file to be read. The string must be recognised by Java "
+ "text encoders")
@Optional
@Default(WriterBuilder.UTF8)
public void setEncoding(String encoding) {
this.encoding = encoding;
}
@Override
public Reader getContent(final Map<String, String> properties) {
java.io.File f = null;
try {
// Join with working dir and resolve properties in the path
f = joinPath(new java.io.File(this.file));
f = new java.io.File(Util.parsePropertiesInString(properties, f.getAbsolutePath(), true));
// Get the stream reader
return new InputStreamReader(new FileInputStream(f), createDecoder(encoding, replaceChar));
} catch (CruiseControlException e) {
LOG.warn("Unable to resolve property in " + f.getAbsolutePath() + "message", e);
} catch (Exception exc) {
LOG.error("Unable to read data from " + file + " (in " + encoding + ")", exc);
}
return new StringReader("");
}
@Override
public void validate() throws CruiseControlException {
// Validate
ValidationHelper.assertEncoding(this.encoding, getClass());
ValidationHelper.assertIsSet(this.file, "file", getClass());
//
// // Check if exists, ...
// ValidationHelper.assertExists(this.file, "file", getClass());
// ValidationHelper.assertIsReadable(this.file, "file", getClass());
// ValidationHelper.assertIsNotDirectory(this.file, "file", getClass());
}
}
}