Merge branch 'master' of https://code.google.com/p/plexi

Conflicts:
	src/adaptorlib/CommandStreamParser.java
diff --git a/src/adaptorlib/AbstractDocumentTransform.java b/src/adaptorlib/AbstractDocumentTransform.java
new file mode 100644
index 0000000..75b05ea
--- /dev/null
+++ b/src/adaptorlib/AbstractDocumentTransform.java
@@ -0,0 +1,78 @@
+// Copyright 2011 Google Inc. All Rights Reserved.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//      http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package adaptorlib;
+
+import java.util.Map;
+
+/**
+ * Convenience class for implementing {@code DocumentTransform}s.
+ * Implementations only need to implement {@link #transform}, although they
+ * should also likely have a static factory method as defined in {@link
+ * DocumentTransform}.
+ */
+public abstract class AbstractDocumentTransform implements DocumentTransform {
+  private String name = getClass().getName();
+  private boolean required = true;
+
+  public AbstractDocumentTransform() {}
+
+  /**
+   * If {@code name} is {@code null}, the default is used.
+   */
+  public AbstractDocumentTransform(String name, boolean required) {
+    if (name != null) {
+      this.name = name;
+    }
+    this.required = required;
+  }
+
+  /**
+   * Configure this instance with provided {@code config}. Accepts keys {@code
+   * "name"} and {@code "required"}. Unknown keys are ignored. This method is
+   * intended as a convenience for use in a static factory method.
+   */
+  protected void configure(Map<String, String> config) {
+    String name = config.get("name");
+    if (name != null) {
+      this.name = name;
+    }
+
+    String required = config.get("required");
+    if (required != null) {
+      this.required = Boolean.parseBoolean(required);
+    }
+  }
+
+  protected void setName(String name) {
+    if (name == null) {
+      throw new NullPointerException();
+    }
+    this.name = name;
+  }
+
+  @Override
+  public String getName() {
+    return name;
+  }
+
+  protected void setRequired(boolean required) {
+    this.required = required;
+  }
+
+  @Override
+  public boolean isRequired() {
+    return required;
+  }
+}
diff --git a/src/adaptorlib/Adaptor.java b/src/adaptorlib/Adaptor.java
index 92baeb9..a267121 100644
--- a/src/adaptorlib/Adaptor.java
+++ b/src/adaptorlib/Adaptor.java
@@ -44,8 +44,6 @@
    * to three concurrent calls may be average during initial GSA crawling, but
    * twenty or more concurrent calls is typical when the GSA is recrawling
    * unmodified content.
-   *
-   * @throws java.io.FileNotFoundException when requested document doesn't exist
    */
   public void getDocContent(Request request, Response response)
       throws IOException;
@@ -81,8 +79,8 @@
    * <p>If the document doesn't exist, then there are several possibilities. If
    * the repository is fully-public then it will return {@code PERMIT}. This
    * will allow the caller to provide a cached version of the file to the user
-   * or call {@link #getDocContent} which should throw a {@link java.io.
-   * FileNotFoundException}. If the adaptor is not sensitive to users knowing
+   * or call {@link #getDocContent} which should call {@link
+   * Response#respondNotFound}. If the adaptor is not sensitive to users knowing
    * that certain documents do not exist, then it will return {@code
    * INDETERMINATE}. This will be interpreted as the document does not exist; no
    * cached copy will be provided to the user but the user may be informed the
diff --git a/src/adaptorlib/AutoUnzipAdaptor.java b/src/adaptorlib/AutoUnzipAdaptor.java
index 11336a5..d8622dd 100644
--- a/src/adaptorlib/AutoUnzipAdaptor.java
+++ b/src/adaptorlib/AutoUnzipAdaptor.java
@@ -93,8 +93,13 @@
         try {
           OutputStream os = new FileOutputStream(tmpFile);
           try {
-            Response resp = new GetContentsResponse(os);
+            GetContentsResponse resp = new GetContentsResponse(os);
             super.getDocContent(new GetContentsRequest(docId), resp);
+            if (resp.isNotFound()) {
+              log.log(Level.FINE, "Unexpectedly, a doc just listed doesn't "
+                  + "exist: {0}", docId);
+              continue;
+            }
           } finally {
             os.close();
           }
@@ -242,6 +247,7 @@
           return;
 
         case NOTMODIFIED:
+        case NOTFOUND:
           // No content needed, we are done here
           return;
 
@@ -251,9 +257,10 @@
       try {
         extractDocFromZip(parts[1], tmpFile, new LazyOutputStream(resp));
       } catch (FileNotFoundException e) {
-        throw new FileNotFoundException(
-            "Could not find file within zip for docId '" + docId.getUniqueId()
-            + "': " + e.getMessage());
+        log.log(Level.FINE, "Could not find file within zip for docId ''{0}'': "
+                + "{1}", new Object[] {docId.getUniqueId(), e.getMessage()});
+        resp.respondNotFound();
+        return;
       }
     } finally {
       tmpFile.delete();
@@ -353,8 +360,15 @@
     }
 
     @Override
-    public void respondNotModified() {
+    public void respondNotModified() throws IOException {
       state = State.NOTMODIFIED;
+      super.respondNotModified();
+    }
+
+    @Override
+    public void respondNotFound() throws IOException {
+      state = State.NOTFOUND;
+      super.respondNotFound();
     }
 
     @Override
@@ -368,7 +382,7 @@
       return state;
     }
 
-    static enum State {NORESPONSE, NOTMODIFIED, CONTENT};
+    static enum State {NORESPONSE, NOTMODIFIED, NOTFOUND, CONTENT};
   }
 
   /**
@@ -388,7 +402,7 @@
    * {@link Response#getOutputStream}, but calls {@code getOutputStream} only
    * once needed. This allows for code to be provided an OutputStream that
    * writes directly to the {@code Response}, but also allows the code to
-   * throwing a {@link FileNotFoundException} before writing to the stream.
+   * call {@link Response#respondNotFound} before writing to the stream.
    */
   private static class LazyOutputStream extends AbstractLazyOutputStream {
     private Response resp;
@@ -397,6 +411,7 @@
       this.resp = resp;
     }
 
+    @Override
     protected OutputStream retrieveOs() throws IOException {
       return resp.getOutputStream();
     }
diff --git a/src/adaptorlib/CommandStreamParser.java b/src/adaptorlib/CommandStreamParser.java
index 891e66c..7cd4c54 100644
--- a/src/adaptorlib/CommandStreamParser.java
+++ b/src/adaptorlib/CommandStreamParser.java
@@ -25,9 +25,7 @@
 import java.util.Collection;
 import java.util.Collections;
 import java.util.HashMap;
-import java.util.HashSet;
 import java.util.Map;
-import java.util.Set;
 import java.util.regex.Matcher;
 import java.util.regex.Pattern;
 
@@ -110,7 +108,7 @@
  * "up-to-date" -- specifies that the document is up-to-date with respect to its last crawled
  * time.<p>
  *
- * "document-not-found" -- the document does not exists in the repository<p>
+ * "not-found" -- the document does not exists in the repository<p>
  *
  * "mime-type=" -- specifies the document's mime-type. If unspecified then the GSA will
  * automatically assign a type to the document. <p>
@@ -168,7 +166,7 @@
 public class CommandStreamParser {
 
 
-  public static enum Operation {
+  private static enum Operation {
     ID,
     LAST_MODIFIED,
     CRAWL_IMMEDIATELY,
@@ -327,7 +325,7 @@
       switch (command.getOperation()) {
         case ID:
           if (docId != null) {
-            // TODO (johnfelton) add lister options when API is available
+            // TODO(johnfelton) add lister options when API is available
             result.add(new DocIdPusher.Record.Builder(new DocId(docId)).build());
           }
           docId = command.getArgument();
@@ -357,7 +355,7 @@
       }
       command = readCommand();
     }
-    // TODO (johnfelton) add lister options when API is available
+    // TODO(johnfelton) add lister options when API is available
     result.add(new DocIdPusher.Record.Builder(new DocId(docId)).build());
 
     return result;
@@ -365,7 +363,7 @@
 
   public RetrieverInfo readFromRetriever() throws IOException {
 
-    Set<MetaItem> metadata = new HashSet<MetaItem>();
+    Metadata.Builder metadata = new Metadata.Builder();
     byte[] content = null;
     boolean upToDate = false;
     boolean notFound = false;
@@ -412,7 +410,7 @@
       command = readCommand();
     }
 
-    return new RetrieverInfo(new DocId(docId), new Metadata(metadata),
+    return new RetrieverInfo(new DocId(docId), metadata.build(),
         content, upToDate, mimeType, notFound);
   }
 
@@ -438,7 +436,7 @@
       Operation operation = STRING_TO_OPERATION.get(commandTokens[0]);
       // Skip over unrecognized commands
       if (operation == null) {
-        // TODO (johnfelton) add a warning about an unrecognized command
+        // TODO(johnfelton) add a warning about an unrecognized command
         continue;
       }
 
diff --git a/src/adaptorlib/DocumentHandler.java b/src/adaptorlib/DocumentHandler.java
index 2fc8c56..0b9dbdf 100644
--- a/src/adaptorlib/DocumentHandler.java
+++ b/src/adaptorlib/DocumentHandler.java
@@ -19,7 +19,6 @@
 import com.sun.net.httpserver.HttpsExchange;
 
 import java.io.ByteArrayOutputStream;
-import java.io.FileNotFoundException;
 import java.io.IOException;
 import java.io.OutputStream;
 import java.net.HttpURLConnection;
@@ -139,27 +138,15 @@
       DocumentResponse response = new DocumentResponse(ex, docId);
       journal.recordRequestProcessingStart();
       try {
-        try {
-          adaptor.getDocContent(request, response);
-        } catch (RuntimeException e) {
-          journal.recordRequestProcessingFailure();
-          throw e;
-        } catch (FileNotFoundException e) {
-          journal.recordRequestProcessingEnd(0);
-          throw e;
-        } catch (IOException e) {
-          journal.recordRequestProcessingFailure();
-          throw e;
-        }
-        journal.recordRequestProcessingEnd(response.getWrittenContentSize());
-      } catch (FileNotFoundException e) {
-        log.log(Level.FINE, "FileNotFound during getDocContent. Message: {0}",
-                e.getMessage());
-        log.log(Level.FINER, "Full FileNotFound information", e);
-        cannedRespond(ex, HttpURLConnection.HTTP_NOT_FOUND, "text/plain",
-                      "Unknown document");
-        return;
+        adaptor.getDocContent(request, response);
+      } catch (RuntimeException e) {
+        journal.recordRequestProcessingFailure();
+        throw e;
+      } catch (IOException e) {
+        journal.recordRequestProcessingFailure();
+        throw e;
       }
+      journal.recordRequestProcessingEnd(response.getWrittenContentSize());
 
       response.complete();
     } else {
@@ -316,6 +303,8 @@
     SETUP,
     /** No content to send, but we do need a different response code. */
     NOT_MODIFIED,
+    /** No content to send, but we do need a different response code. */
+    NOT_FOUND,
     /** Must not respond with content, but otherwise act like normal. */
     HEAD,
     /** No need to buffer contents before sending. */
@@ -358,7 +347,14 @@
         throw new IllegalStateException("Already responded");
       }
       state = State.NOT_MODIFIED;
-      os = new SinkOutputStream();
+    }
+
+    @Override
+    public void respondNotFound() throws IOException {
+      if (state != State.SETUP) {
+        throw new IllegalStateException("Already responded");
+      }
+      state = State.NOT_FOUND;
     }
 
     @Override
@@ -374,6 +370,8 @@
           return os;
         case NOT_MODIFIED:
           throw new IllegalStateException("respondNotModified already called");
+        case NOT_FOUND:
+          throw new IllegalStateException("respondNotFound already called");
         default:
           throw new IllegalStateException("Already responded");
       }
@@ -425,6 +423,11 @@
           respond(ex, HttpURLConnection.HTTP_NOT_MODIFIED, null, null);
           break;
 
+        case NOT_FOUND:
+          cannedRespond(ex, HttpURLConnection.HTTP_NOT_FOUND, "text/plain",
+                        "Unknown document");
+          break;
+
         case TRANSFORM:
           MaxBufferOutputStream mbos = (MaxBufferOutputStream) os;
           byte[] buffer = mbos.getBufferedContent();
@@ -480,12 +483,11 @@
       } catch (TransformException e) {
         throw new IOException(e);
       }
-      Set<MetaItem> metadataSet
-          = new HashSet<MetaItem>(metadataMap.size() * 2);
+      Metadata.Builder builder = new Metadata.Builder();
       for (Map.Entry<String, String> me : metadataMap.entrySet()) {
-        metadataSet.add(MetaItem.raw(me.getKey(), me.getValue()));
+        builder.add(MetaItem.raw(me.getKey(), me.getValue()));
       }
-      metadata = new Metadata(metadataSet);
+      metadata = builder.build();
       contentType = params.get("Content-Type");
       return contentOut;
     }
diff --git a/src/adaptorlib/DocumentTransform.java b/src/adaptorlib/DocumentTransform.java
index de35aad..1289ebf 100644
--- a/src/adaptorlib/DocumentTransform.java
+++ b/src/adaptorlib/DocumentTransform.java
@@ -21,32 +21,33 @@
 
 /**
  * Represents an individual transform in the transform pipeline.
- * Subclass this to add your own custom behavior.
+ *
+ * <p>Implementations should also typically have a static factory method with a
+ * single {@code Map<String, String>} argument for creating instances based on
+ * configuration. Implementations are encouraged to accept "name" and
+ * "required" as configuration keys.
  */
-public class DocumentTransform  {
-
-  public DocumentTransform(String name) {
-    this.name = name;
-  }
-
+public interface DocumentTransform {
   /**
-   * Override this function to do the actual data transformation.
-   * Read data from the ByteArrayOutputStream instances holding the incoming data,
-   * and write them to the OutputStreams. Any changes to the params map will be
-   * passed on the subsequent transforms.
+   * Read data from {@code contentIn}, transform it, and write it to {@code
+   * contentOut}. Any changes to {@code metadata} and {@code params} will be
+   * passed on to subsequent transforms. This method must be thread-safe.
    *
    * @throws TransformException
    * @throws IOException
    */
-  public void transform(ByteArrayOutputStream contentIn, OutputStream contentOut,
-                        Map<String, String> metadata, Map<String, String> params)
-      throws TransformException, IOException {
-    // Defaults to identity transform
-    contentIn.writeTo(contentOut);
-  }
+  public void transform(ByteArrayOutputStream contentIn,
+                        OutputStream contentOut,
+                        Map<String, String> metadata,
+                        Map<String, String> params)
+      throws TransformException, IOException;
 
-  public void name(String name) { this.name = name; }
-  public String name() { return name; }
+  /**
+   * The name of this transform instance, typically provided by the user. It
+   * should not be {@code null}. Using the class name as a default is reasonable
+   * if no name has been provided.
+   */
+  public String getName();
 
   /**
    * If this property is true, a failure of this transform will cause the entire
@@ -57,14 +58,5 @@
    * If this is false and a error occurs, this transform is treated as a
    * identity transform.
    */
-  public void errorHaltsPipeline(boolean errorHaltsPipeline) {
-    this.errorHaltsPipeline = errorHaltsPipeline;
-  }
-
-  public boolean errorHaltsPipeline() {
-    return errorHaltsPipeline;
-  }
-
-  private boolean errorHaltsPipeline = true;
-  private String name = "";
+  public boolean isRequired();
 }
diff --git a/src/adaptorlib/GsaCommunicationHandler.java b/src/adaptorlib/GsaCommunicationHandler.java
index 51e8247..cef5b09 100644
--- a/src/adaptorlib/GsaCommunicationHandler.java
+++ b/src/adaptorlib/GsaCommunicationHandler.java
@@ -26,7 +26,7 @@
 import org.opensaml.DefaultBootstrap;
 import org.opensaml.xml.ConfigurationException;
 
-import java.lang.reflect.Constructor;
+import java.lang.reflect.Method;
 import java.net.InetSocketAddress;
 import java.util.*;
 import java.util.concurrent.*;
@@ -218,11 +218,20 @@
     for (Map<String, String> element : pipelineConfig) {
       final String name = element.get("name");
       final String confPrefix = "transform.pipeline." + name + ".";
-      String className = element.get("class");
-      if (className == null) {
+      String factoryMethodName = element.get("factoryMethod");
+      if (factoryMethodName == null) {
         throw new RuntimeException(
-            "Missing " + confPrefix + "class configuration setting");
+            "Missing " + confPrefix + "factoryMethod configuration setting");
       }
+      int sepIndex = factoryMethodName.lastIndexOf(".");
+      if (sepIndex == -1) {
+        throw new RuntimeException("Could not separate method name from class "
+            + "name");
+      }
+      String className = factoryMethodName.substring(0, sepIndex);
+      String methodName = factoryMethodName.substring(sepIndex + 1);
+      log.log(Level.FINE, "Split {0} into class {1} and method {2}",
+          new Object[] {factoryMethodName, className, methodName});
       Class<?> klass;
       try {
         klass = Class.forName(className);
@@ -230,26 +239,26 @@
         throw new RuntimeException(
             "Could not load class for transform " + name, ex);
       }
-      Constructor<?> constructor;
+      Method method;
       try {
-        constructor = klass.getConstructor(Map.class);
+        method = klass.getDeclaredMethod(methodName, Map.class);
       } catch (NoSuchMethodException ex) {
-        throw new RuntimeException(
-            "Could not find constructor for " + className + ". It must have a "
-            + "constructor that accepts a Map as the lone parameter.", ex);
+        throw new RuntimeException("Could not find method " + methodName
+            + " on class " + className, ex);
       }
+      log.log(Level.FINE, "Found method {0}", new Object[] {method});
       Object o;
       try {
-        o = constructor.newInstance(Collections.unmodifiableMap(element));
+        o = method.invoke(null, Collections.unmodifiableMap(element));
       } catch (Exception ex) {
-        throw new RuntimeException("Could not instantiate " + className, ex);
+        throw new RuntimeException("Failure while running factory method "
+            + factoryMethodName, ex);
       }
       if (!(o instanceof DocumentTransform)) {
-        throw new RuntimeException(className
+        throw new ClassCastException(o.getClass().getName()
             + " is not an instance of DocumentTransform");
       }
       DocumentTransform transform = (DocumentTransform) o;
-      transform.name(name);
       pipeline.add(transform);
     }
     // If we created an empty pipeline, then we don't need the pipeline at all.
diff --git a/src/adaptorlib/Metadata.java b/src/adaptorlib/Metadata.java
index a2295c0..26fdaab 100644
--- a/src/adaptorlib/Metadata.java
+++ b/src/adaptorlib/Metadata.java
@@ -14,29 +14,22 @@
 
 package adaptorlib;
 
-import java.util.Collections;
-import java.util.HashMap;
-import java.util.HashSet;
-import java.util.Iterator;
-import java.util.Map;
-import java.util.Set;
-import java.util.TreeSet;
+import java.util.*;
 
 /** Represents a fixed set of validated {@link MetaItem}s. */
 public final class Metadata implements Iterable<MetaItem> {
   /** Empty convenience instance. */
-  public static final Metadata EMPTY
-      = new Metadata(Collections.<MetaItem>emptySet());
+  public static final Metadata EMPTY = new Metadata.Builder().build();
 
-  private final Set<MetaItem> items;
+  private final Map<String, MetaItem> items;
  
   /**
    * Validates that each meta name is unique, there is either
    * public-indicator or ACLs and that ACLs values are acceptable.
    */ 
-  public Metadata(Set<MetaItem> allMeta) {
-    items = Collections.unmodifiableSet(new TreeSet<MetaItem>(allMeta));
-    checkConsistency(items, toMap());
+  private Metadata(Map<String, MetaItem> allMeta) {
+    items = Collections.unmodifiableMap(new TreeMap<String, MetaItem>(allMeta));
+    checkConsistency(items);
   }
 
   @Override
@@ -56,12 +49,12 @@
 
   @Override
   public Iterator<MetaItem> iterator() {
-    return items.iterator();
+    return items.values().iterator();
   }
 
   @Override
   public String toString() {
-    return items.toString();
+    return items.values().toString();
   }
 
   public Map<String, String> toMap() {
@@ -80,33 +73,14 @@
     return items.size();
   }
 
-  private static void checkConsistency(Set<MetaItem> items,
-                                       Map<String, String> allMeta) {
-    checkEachNameIsUnique(items); 
+  private static void checkConsistency(Map<String, MetaItem> allMeta) {
     checkNandPublicAndAcls(allMeta);
-    checkBothOrNoneAcls(allMeta); 
-    checkPublicIsBoolean(allMeta); 
-  }
-
-  /** Each MetaItem name needs be unique. */
-  private static void checkEachNameIsUnique(Set<MetaItem> m) {
-    HashSet<String> unique = new HashSet<String>();
-    HashSet<String> dup = new HashSet<String>();
-    for (MetaItem item : m) {
-      String name = item.getName();
-      if (unique.contains(name)) {
-        dup.add(name);
-      } else {
-        unique.add(name);
-      }
-    }
-    if (0 < dup.size()) {
-      throw new IllegalArgumentException("duplicate names: " + dup);
-    }
+    checkBothOrNoneAcls(allMeta);
+    checkPublicIsBoolean(allMeta);
   }
 
   /** Either have public indicator or ACLs, but not both. */
-  private static void checkNandPublicAndAcls(Map<String, String> m) {
+  private static void checkNandPublicAndAcls(Map<String, MetaItem> m) {
     boolean hasPublicName = m.containsKey("google:ispublic");
     boolean hasAcls = m.containsKey("google:aclusers")
         || m.containsKey("google:aclgroups");
@@ -116,7 +90,7 @@
   }
 
   /** Cannot provide users without groups and vice-versa. */
-  private static void checkBothOrNoneAcls(Map<String, String> m) {
+  private static void checkBothOrNoneAcls(Map<String, MetaItem> m) {
     boolean hasUserAcls = m.containsKey("google:aclusers");
     boolean hasGroupAcls = m.containsKey("google:aclgroups");
     if (hasUserAcls && !hasGroupAcls) {
@@ -124,8 +98,8 @@
     } else if (hasGroupAcls && !hasUserAcls) {
       throw new IllegalArgumentException("has groups, but not users");
     } else if (hasGroupAcls && hasUserAcls) {
-      String userLine = m.get("google:aclusers").trim();
-      String groupLine = m.get("google:aclgroups").trim();
+      String userLine = m.get("google:aclusers").getValue().trim();
+      String groupLine = m.get("google:aclgroups").getValue().trim();
       if (userLine.isEmpty() && groupLine.isEmpty()) {
         throw new IllegalArgumentException("both users and groups empty");
       }
@@ -133,12 +107,52 @@
   }
 
   /** If has public indicator value is acceptable. */
-  private static void checkPublicIsBoolean(Map<String, String> m) {
-    String value = m.get("google:ispublic");
-    if (null != value) {
+  private static void checkPublicIsBoolean(Map<String, MetaItem> m) {
+    MetaItem item = m.get("google:ispublic");
+    if (null != item) {
+      String value = item.getValue();
       if (!"true".equals(value) && !"false".equals(value)) {
         throw new IllegalArgumentException("ispublic is not true nor false");
       }
     }
   }
+
+  /**
+   * Builder for instances of {@link Metadata}.
+   */
+  public static class Builder {
+    private Map<String, MetaItem> items = new TreeMap<String, MetaItem>();
+
+    /**
+     * Create new empty builder.
+     */
+    public Builder() {}
+
+    /**
+     * Initialize builder with {@code MetaItems} from {@code iterable}. Useful
+     * to make tweaked copies of {@link Metadata}.
+     */
+    public Builder(Iterable<MetaItem> iterable) {
+      for (MetaItem item : iterable) {
+        items.put(item.getName(), item);
+      }
+    }
+
+    /**
+     * Add a new {@code MetaItem} to the builder, replacing any previously-added
+     * {@code MetaItem} with the same name.
+     */
+    public Builder add(MetaItem item) {
+      items.put(item.getName(), item);
+      return this;
+    }
+
+    /**
+     * Returns a metadata instance that reflects current builder state. It does
+     * not reset the builder.
+     */
+    public Metadata build() {
+      return new Metadata(items);
+    }
+  }
 }
diff --git a/src/adaptorlib/Request.java b/src/adaptorlib/Request.java
index 41a20ba..25846fe 100644
--- a/src/adaptorlib/Request.java
+++ b/src/adaptorlib/Request.java
@@ -49,8 +49,8 @@
    * Provides the document ID for the document that is being requested. {@code
    * DocId} was not necessarily provided previously by the Adaptor; <b>it is
    * client-provided and must not be trusted</b>. If the document does not
-   * exist, then {@link Adaptor#getDocContent} must throw {@link java.io.
-   * FileNotFoundException}.
+   * exist, then {@link Adaptor#getDocContent} must call {@link
+   * Response#respondNotFound}.
    */
   public DocId getDocId();
 }
diff --git a/src/adaptorlib/Response.java b/src/adaptorlib/Response.java
index e53e469..a671c24 100644
--- a/src/adaptorlib/Response.java
+++ b/src/adaptorlib/Response.java
@@ -18,13 +18,12 @@
 
 /**
  * Interface provided to {@link Adaptor#getDocContent} for performing the
- * actions needed to satisfy a request. If the {@code DocId} provided by {@link
- * Request#getDocId} does not exist, a {@link java.io.FileNotFoundException}
- * should be thrown.
+ * actions needed to satisfy a request.
  *
  * <p>There are several ways that a request can be processed. In the simplest
  * case an Adaptor always sets different pieces of metadata, calls {@link
- * #getOutputStream}, and writes the document contents.
+ * #getOutputStream}, and writes the document contents. If the document does not
+ * exist, it should call {@link #respondNotFound} instead.
  *
  * <p>For improved efficiency during recrawl by the GSA, an Adaptor should check
  * {@link Request#hasChangedSinceLastAccess} and call {@link
@@ -37,22 +36,31 @@
    * of a file and its metadata. If you have called other methods on this object
    * to provide various metadata, the effects of those methods will be ignored.
    *
-   * <p>If called, this must be the last call to this interface. If the document
-   * does not exist, you must throw {@link java.io.FileNotFoundException} and
-   * not call this method. Once you call this method, for the rest of the
-   * processing, exceptions may no longer be communicated to clients cleanly.
+   * <p>If called, this must be the last call to this interface. Once you call
+   * this method, for the rest of the processing, exceptions may no longer be
+   * communicated to clients cleanly.
    */
   public void respondNotModified() throws IOException;
 
   /**
+   * Respond to the GSA or other client that the request document does not
+   * exist. If you have called other methods on this object, the effects of
+   * those methods will be ignored.
+   *
+   * <p>If called, this must be the last call to this interface. Once you call
+   * this method, for the rest of the processing, exceptions may no longer be
+   * communicated to the clients cleanly.
+   */
+  public void respondNotFound() throws IOException;
+
+  /**
    * Get stream to write document contents to. There is no need to flush or
    * close the {@code OutputStream} when done.
    *
    * <p>If called, this must be the last call to this interface (although, for
-   * convenience, you may call this method multiple times). If the document
-   * does not exist, you must throw {@link java.io.FileNotFoundException} and
-   * not call this method. Once you call this method, for the rest of the
-   * processing, exceptions may no longer be communicated to clients cleanly.
+   * convenience, you may call this method multiple times). Once you call this
+   * method, for the rest of the processing, exceptions may no longer be
+   * communicated to clients cleanly.
    */
   public OutputStream getOutputStream() throws IOException;
 
diff --git a/src/adaptorlib/TransformPipeline.java b/src/adaptorlib/TransformPipeline.java
index aa729ee..af1f8d1 100644
--- a/src/adaptorlib/TransformPipeline.java
+++ b/src/adaptorlib/TransformPipeline.java
@@ -75,12 +75,13 @@
         transform.transform(new UnmodifiableWrapperByteArrayOutputStream(contentInTransit),
                             contentOutTransit, metadataOutTransit, paramsOutTransit);
       } catch (TransformException e) {
-        if (transform.errorHaltsPipeline()) {
-          log.log(Level.WARNING, "Transform Exception. Aborting '" + transform.name() + "'", e);
+        if (transform.isRequired()) {
+          log.log(Level.WARNING, "Transform Exception. Aborting '"
+                  + transform.getName() + "'", e);
           throw e;
         } else {
-          log.log(Level.WARNING,
-                  "Transform Exception. Ignoring transform '" + transform.name() + "'", e);
+          log.log(Level.WARNING, "Transform Exception. Ignoring transform '"
+                  + transform.getName() + "'", e);
           continue;
         }
       }
diff --git a/src/adaptorlib/WrapperAdaptor.java b/src/adaptorlib/WrapperAdaptor.java
index b9fe991..3caf30d 100644
--- a/src/adaptorlib/WrapperAdaptor.java
+++ b/src/adaptorlib/WrapperAdaptor.java
@@ -106,6 +106,11 @@
     }
 
     @Override
+    public void respondNotFound() throws IOException {
+      response.respondNotFound();
+    }
+
+    @Override
     public OutputStream getOutputStream() throws IOException {
       return response.getOutputStream();
     }
@@ -151,12 +156,14 @@
 
   /**
    * Counterpart of {@link GetContentsRequest} that allows easy calling of an
-   * {@link Adaptor}. It does not support {@link #respondNotModified}.
+   * {@link Adaptor}. It does not support {@link #respondNotModified}. Be sure
+   * to check {@link #isNotFound()}.
    */
   public static class GetContentsResponse implements Response {
     private OutputStream os;
     private String contentType;
     private Metadata metadata;
+    private boolean notFound;
 
     public GetContentsResponse(OutputStream os) {
       this.os = os;
@@ -168,6 +175,11 @@
     }
 
     @Override
+    public void respondNotFound() {
+      notFound = true;
+    }
+
+    @Override
     public OutputStream getOutputStream() {
       return os;
     }
@@ -189,6 +201,10 @@
     public Metadata getMetadata() {
       return metadata;
     }
+
+    public boolean isNotFound() {
+      return notFound;
+    }
   }
 
   /**
diff --git a/src/adaptorlib/examples/AdaptorTemplate.java b/src/adaptorlib/examples/AdaptorTemplate.java
index d3130b4..23c8f10 100644
--- a/src/adaptorlib/examples/AdaptorTemplate.java
+++ b/src/adaptorlib/examples/AdaptorTemplate.java
@@ -51,10 +51,9 @@
     } else if ("1002".equals(id.getUniqueId())) {
       str = "Document 1002 says hello and banana strawberry";
     } else {
-      throw new FileNotFoundException(id.getUniqueId());
+      resp.respondNotFound();
+      return;
     }
-    // Must get the OutputStream after any possibility of throwing a
-    // FileNotFoundException.
     OutputStream os = resp.getOutputStream();
     os.write(str.getBytes(encoding));
   }
diff --git a/src/adaptorlib/examples/AdaptorWithCrawlTimeMetadataTemplate.java b/src/adaptorlib/examples/AdaptorWithCrawlTimeMetadataTemplate.java
index ed62362..a03d15e 100644
--- a/src/adaptorlib/examples/AdaptorWithCrawlTimeMetadataTemplate.java
+++ b/src/adaptorlib/examples/AdaptorWithCrawlTimeMetadataTemplate.java
@@ -48,36 +48,33 @@
     String str;
     if ("1001".equals(id.getUniqueId())) {
       str = "Document 1001 says hello and apple orange";
-      // Make set to accumulate meta items.
-      Set<MetaItem> metaItems = new TreeSet<MetaItem>();
-      // Add user ACL.
       List<String> users1001 = Arrays.asList("peter", "bart", "simon");
-      metaItems.add(MetaItem.permittedUsers(users1001));
-      // Add group ACL.
       List<String> groups1001 = Arrays.asList("support", "sales");
-      metaItems.add(MetaItem.permittedGroups(groups1001));
-      // Add custom meta items.
-      metaItems.add(MetaItem.raw("my-special-key", "my-custom-value"));
-      metaItems.add(MetaItem.raw("date", "not soon enough"));
       // Make metadata object, which checks items for consistency.
       // Must set metadata before getting OutputStream
-      resp.setMetadata(new Metadata(metaItems));
+      resp.setMetadata(new Metadata.Builder()
+          // Add user ACL.
+          .add(MetaItem.permittedUsers(users1001))
+          // Add group ACL.
+          .add(MetaItem.permittedGroups(groups1001))
+          // Add custom meta items.
+          .add(MetaItem.raw("my-special-key", "my-custom-value"))
+          .add(MetaItem.raw("date", "not soon enough"))
+          .build());
     } else if ("1002".equals(id.getUniqueId())) {
       str = "Document 1002 says hello and banana strawberry";
-      // Another example.
-      Set<MetaItem> metaItems = new TreeSet<MetaItem>();
-      // A document that's not public and has no ACLs causes head requests.
-      metaItems.add(MetaItem.isNotPublic());
-      // Add custom meta items.
-      metaItems.add(MetaItem.raw("date", "better never than late"));
       // Make metadata object, which checks items for consistency.
       // Must set metadata before getting OutputStream
-      resp.setMetadata(new Metadata(metaItems));
+      resp.setMetadata(new Metadata.Builder()
+          // A document that's not public and has no ACLs causes head requests.
+          .add(MetaItem.isNotPublic())
+          // Add custom meta items.
+          .add(MetaItem.raw("date", "better never than late"))
+          .build());
     } else {
-      throw new FileNotFoundException(id.getUniqueId());
+      resp.respondNotFound();
+      return;
     }
-    // Must get the OutputStream after any possibility of throwing a
-    // FileNotFoundException.
     OutputStream os = resp.getOutputStream();
     os.write(str.getBytes(encoding));
   }
diff --git a/src/adaptorlib/examples/CalaisNERTransform.java b/src/adaptorlib/examples/CalaisNERTransform.java
index b483b93..da2ff01 100644
--- a/src/adaptorlib/examples/CalaisNERTransform.java
+++ b/src/adaptorlib/examples/CalaisNERTransform.java
@@ -14,7 +14,7 @@
 
 package adaptorlib.examples;
 
-import adaptorlib.DocumentTransform;
+import adaptorlib.AbstractDocumentTransform;
 import adaptorlib.TransformException;
 
 import mx.bigdata.jcalais.CalaisClient;
@@ -34,7 +34,7 @@
  * extracts named entities. We then inject this info as metadata.
  * We currently make the assumption that the incoming content is HTML.
  */
-public class CalaisNERTransform extends DocumentTransform {
+public class CalaisNERTransform extends AbstractDocumentTransform {
 
   interface CalaisClientFactory {
     CalaisClient makeClient(String apiKey);
@@ -43,7 +43,6 @@
   private final CalaisClientFactory clientFactory;
 
   CalaisNERTransform(CalaisClientFactory factory) {
-    super("CalaisNERTransform");
     this.clientFactory = factory;
   }
 
@@ -117,4 +116,10 @@
     content = content.replaceFirst("</(HEAD|head)", "\n" + sb.toString() + "</HEAD");
     contentOut.write(content.getBytes());
   }
+
+  public static CalaisNERTransform create(Map<String, String> config) {
+    CalaisNERTransform transform = new CalaisNERTransform();
+    transform.configure(config);
+    return transform;
+  }
 }
diff --git a/src/adaptorlib/examples/DbAdaptorTemplate.java b/src/adaptorlib/examples/DbAdaptorTemplate.java
index 554f7b8..15733a7 100644
--- a/src/adaptorlib/examples/DbAdaptorTemplate.java
+++ b/src/adaptorlib/examples/DbAdaptorTemplate.java
@@ -91,7 +91,8 @@
       // First handle cases with no data to return.
       boolean hasResult = rs.next();
       if (!hasResult) {
-        throw new FileNotFoundException("no document with id: " + id);
+        resp.respondNotFound();
+        return;
       }
       ResultSetMetaData rsMetaData = rs.getMetaData();
       int numberOfColumns = rsMetaData.getColumnCount();
diff --git a/src/adaptorlib/examples/FileSystemAdaptor.java b/src/adaptorlib/examples/FileSystemAdaptor.java
index 23af277..b06d0d0 100644
--- a/src/adaptorlib/examples/FileSystemAdaptor.java
+++ b/src/adaptorlib/examples/FileSystemAdaptor.java
@@ -77,9 +77,16 @@
     // The DocId provided by Request.getDocId() MUST NOT be trusted. Here we
     // try to verify that this file is allowed to be served.
     if (!isFileDescendantOfServeDir(file)) {
-      throw new FileNotFoundException();
+      resp.respondNotFound();
+      return;
     }
-    InputStream input = new FileInputStream(file);
+    InputStream input;
+    try {
+      input = new FileInputStream(file);
+    } catch (FileNotFoundException ex) {
+      resp.respondNotFound();
+      return;
+    }
     try {
       IOHelper.copyStream(input, resp.getOutputStream());
     } finally {
diff --git a/src/adaptorlib/examples/MetaTaggerTransform.java b/src/adaptorlib/examples/MetaTaggerTransform.java
index 4c9c97a..7f5de6b 100644
--- a/src/adaptorlib/examples/MetaTaggerTransform.java
+++ b/src/adaptorlib/examples/MetaTaggerTransform.java
@@ -14,7 +14,7 @@
 
 package adaptorlib.examples;
 
-import adaptorlib.DocumentTransform;
+import adaptorlib.AbstractDocumentTransform;
 import adaptorlib.TransformException;
 
 import java.io.ByteArrayOutputStream;
@@ -26,7 +26,6 @@
 import java.util.Scanner;
 import java.util.SortedMap;
 import java.util.TreeMap;
-import java.util.logging.Level;
 import java.util.logging.Logger;
 import java.util.regex.Pattern;
 
@@ -35,21 +34,13 @@
  * the associated metadata is inserted at the end of the HEAD section of the
  * HTML. If no HEAD section exists, nothing gets inserted.
  */
-public class MetaTaggerTransform extends DocumentTransform {
+public class MetaTaggerTransform extends AbstractDocumentTransform {
   private static final Logger log = Logger.getLogger(MetaTaggerTransform.class.getName());
 
-  public MetaTaggerTransform() {
-    super("MetaTaggerTransform");
-  }
+  public MetaTaggerTransform() {}
 
-  public MetaTaggerTransform(String patternFile) {
-    super("MetaTaggerTransform");
-    try {
-      loadPatternFile(patternFile);
-    } catch (IOException ex) {
-      log.log(Level.SEVERE, "MetaTaggerTransform encountered an error while " +
-              "loading pattern file: " + patternFile, ex);
-    }
+  public MetaTaggerTransform(String patternFile) throws IOException {
+    loadPatternFile(patternFile);
   }
 
   @Override
@@ -111,4 +102,17 @@
       return p1.toString().equals(p2.toString());
     }
   }
+
+  public static MetaTaggerTransform create(Map<String, String> config)
+      throws IOException {
+    String patternFile = config.get("patternFile");
+    MetaTaggerTransform transform;
+    if (patternFile == null) {
+      transform = new MetaTaggerTransform();
+    } else {
+      transform = new MetaTaggerTransform(patternFile);
+    }
+    transform.configure(config);
+    return transform;
+  }
 }
diff --git a/src/adaptorlib/examples/TableGeneratorTransform.java b/src/adaptorlib/examples/TableGeneratorTransform.java
index 15c02d2..86682c0 100644
--- a/src/adaptorlib/examples/TableGeneratorTransform.java
+++ b/src/adaptorlib/examples/TableGeneratorTransform.java
@@ -14,7 +14,7 @@
 
 package adaptorlib.examples;
 
-import adaptorlib.DocumentTransform;
+import adaptorlib.AbstractDocumentTransform;
 import adaptorlib.TransformException;
 
 import au.com.bytecode.opencsv.CSVReader;
@@ -30,7 +30,6 @@
 import java.nio.charset.Charset;
 import java.util.List;
 import java.util.Map;
-import java.util.logging.Level;
 import java.util.logging.Logger;
 
 /**
@@ -39,21 +38,13 @@
  * In the template HTML file, place <code>&amp;#0;</code> where you'd like the table
  * to be inserted.
  */
-public class TableGeneratorTransform extends DocumentTransform {
+public class TableGeneratorTransform extends AbstractDocumentTransform {
   private static final Logger log = Logger.getLogger(TableGeneratorTransform.class.getName());
 
-  public TableGeneratorTransform() {
-    super("TableGeneratorTransform");
-  }
+  public TableGeneratorTransform() {}
 
-  public TableGeneratorTransform(String templateFile) {
-    super("TableGeneratorTransform");
-    try {
-      loadTemplateFile(templateFile);
-    } catch (IOException e) {
-      log.log(Level.WARNING, "TableGeneratorTransform could not load templateFile: " +
-              templateFile, e);
-    }
+  public TableGeneratorTransform(String templateFile) throws IOException {
+    loadTemplateFile(templateFile);
   }
 
   @Override
@@ -98,4 +89,17 @@
    * the escaped null character, because it is explicitly disallowed in HTML.
    */
   private static final String SIGIL = "&#0;";
+
+  public static TableGeneratorTransform create(Map<String, String> config)
+      throws IOException {
+    String templateFile = config.get("templateFile");
+    TableGeneratorTransform transform;
+    if (templateFile == null) {
+      transform = new TableGeneratorTransform();
+    } else {
+      transform = new TableGeneratorTransform(templateFile);
+    }
+    transform.configure(config);
+    return transform;
+  }
 }
diff --git a/src/adaptorlib/prebuilt/CommandLineAdaptor.java b/src/adaptorlib/prebuilt/CommandLineAdaptor.java
index 61cba07..73662ad 100644
--- a/src/adaptorlib/prebuilt/CommandLineAdaptor.java
+++ b/src/adaptorlib/prebuilt/CommandLineAdaptor.java
@@ -22,10 +22,8 @@
 import adaptorlib.Response;
 
 import java.io.ByteArrayInputStream;
-import java.io.FileNotFoundException;
 import java.io.IOException;
 import java.nio.charset.Charset;
-import java.util.ArrayList;
 import java.util.Date;
 import java.util.logging.Logger;
 
@@ -96,9 +94,8 @@
           + "document  " + retrieverInfo.getDocId() + ".");
     }
     if (retrieverInfo.notFound()) {
-      throw new FileNotFoundException("Could not find file '" + retrieverInfo.getDocId());
-    }
-    else if (retrieverInfo.isUpToDate()) {
+      resp.respondNotFound();
+    } else if (retrieverInfo.isUpToDate()) {
       log.finest("Retriever: " + id.getUniqueId() + " is up to date.");
       resp.respondNotModified();
 
@@ -107,12 +104,12 @@
         log.finest("Retriever: " + id.getUniqueId() + " has mime-type "
             + retrieverInfo.getMimeType());
         resp.setContentType(retrieverInfo.getMimeType());
-      };
+      }
       if (retrieverInfo.getMetadata() != null) {
         log.finest("Retriever: " + id.getUniqueId() + " has metadata "
             + retrieverInfo.getMetadata());
         resp.setMetadata(retrieverInfo.getMetadata());
-      };
+      }
       if (retrieverInfo.getContents() != null) {
         resp.getOutputStream().write(retrieverInfo.getContents());
       } else {
diff --git a/src/adaptorlib/prebuilt/CommandLineTransform.java b/src/adaptorlib/prebuilt/CommandLineTransform.java
index ee1c22a..9797b85 100644
--- a/src/adaptorlib/prebuilt/CommandLineTransform.java
+++ b/src/adaptorlib/prebuilt/CommandLineTransform.java
@@ -14,7 +14,7 @@
 
 package adaptorlib.prebuilt;
 
-import adaptorlib.DocumentTransform;
+import adaptorlib.AbstractDocumentTransform;
 import adaptorlib.IOHelper;
 import adaptorlib.TransformException;
 
@@ -27,47 +27,61 @@
  * A conduit that allows a simple way to create a document transform based on
  * a command line program.
  */
-public class CommandLineTransform extends DocumentTransform {
+public class CommandLineTransform extends AbstractDocumentTransform {
   private static final Logger log
       = Logger.getLogger(CommandLineTransform.class.getName());
   private static final int STDERR_BUFFER_SIZE = 51200; // 50 kB
 
   private final Charset charset = Charset.forName("UTF-8");
+  private boolean commandAcceptsParameters = true;
+  private List<String> transformCommand;
+  private File workingDirectory;
 
-  public CommandLineTransform(String name) {
-    super(name);
-  }
+  public CommandLineTransform() {}
 
-  public CommandLineTransform(Map<String, String> config) {
-    super("CommandLineTransform");
-    List<String> cmd = new ArrayList<String>();
-    for (Map.Entry<String, String> me : config.entrySet()) {
-      String key = me.getKey();
-      String value = me.getValue();
-      if ("cmd".equals(key)) {
-        cmd.add(value);
-      } else if ("workingDirectory".equals(key)) {
-        workingDirectory(new File(value));
-      } else if ("errorHaltsPipeline".equals(key)) {
-        errorHaltsPipeline(Boolean.parseBoolean(value));
-      }
+  /**
+   * Accepts keys {@code "cmd"}, {@code "workingDirectory"}, {@code "arg?"}, and
+   * any keys accepted by the super class. The {@code "arg?"} configuration
+   * values should be numerically increasing starting from one: {@code "arg1"},
+   * {@code "arg2"}, {@code "arg3}, ...
+   */
+  protected void configure(Map<String, String> config) {
+    super.configure(config);
+
+    List<String> cmdList = new ArrayList<String>();
+    String cmd = config.get("cmd");
+    if (cmd != null) {
+      cmdList.add(cmd);
+    } else {
+      throw new RuntimeException("'cmd' not defined in configuration");
     }
-    if (cmd.size() == 0) {
-      throw new RuntimeException("cmd configuration property must be set");
+
+    String workingDirectory = config.get("workingDirectory");
+    if (workingDirectory != null) {
+      setWorkingDirectory(new File(workingDirectory));
     }
+
+    String cmdAcceptsParameters = config.get("cmdAcceptsParameters");
+    if (cmdAcceptsParameters != null) {
+      this.commandAcceptsParameters
+          = Boolean.parseBoolean(cmdAcceptsParameters);
+    }
+
     for (int i = 1;; i++) {
       String value = config.get("arg" + i);
       if (value == null) {
         break;
       }
-      cmd.add(value);
+      cmdList.add(value);
     }
-    transformCommand = cmd;
+    transformCommand = cmdList;
   }
 
   @Override
-  public void transform(ByteArrayOutputStream contentIn, OutputStream contentOut,
-                        Map<String, String> metadata, Map<String, String> params)
+  public void transform(ByteArrayOutputStream contentIn,
+                        OutputStream contentOut,
+                        Map<String, String> metadata,
+                        Map<String, String> params)
       throws TransformException, IOException {
     if (transformCommand == null) {
       throw new NullPointerException("transformCommand must not be null");
@@ -100,7 +114,8 @@
       // Handle stderr
       if (exitCode != 0) {
         String errorOutput = new String(command.getStderr(), charset);
-        throw new TransformException("Exit code " + exitCode + ". Stderr: " + errorOutput);
+        throw new TransformException("Exit code " + exitCode + ". Stderr: "
+                                     + errorOutput);
       }
 
       if (command.getStderr().length > 0) {
@@ -125,15 +140,17 @@
     }
   }
 
-  private File writeMapToTempFile(Map<String, String> map) throws IOException, TransformException {
+  private File writeMapToTempFile(Map<String, String> map)
+      throws IOException, TransformException {
     StringBuilder sb = new StringBuilder();
     for (Map.Entry<String, String> me : map.entrySet()) {
       if (me.getKey().contains("\0")) {
-        throw new TransformException("Key cannot contain the null character: " + me.getKey());
+        throw new TransformException("Key cannot contain the null character: "
+                                     + me.getKey());
       }
       if (me.getValue().contains("\0")) {
-        throw new TransformException("Value for key '" + me.getKey() + "' cannot contain the null "
-                                   + "character: " + me.getKey());
+        throw new TransformException("Value for key '" + me.getKey()
+            + "' cannot contain the null " + "character: " + me.getKey());
       }
       sb.append(me.getKey()).append('\0');
       sb.append(me.getValue()).append('\0');
@@ -163,11 +180,11 @@
    * along to the actual call to the command. This is useful in the case where a
    * binary might return erros when unexpected command line flags are passed in.
    */
-  public void commandAcceptsParameters(boolean commandAcceptsParameters) {
+  public void setCommandAcceptsParameters(boolean commandAcceptsParameters) {
     this.commandAcceptsParameters = commandAcceptsParameters;
   }
 
-  public boolean commandAcceptsParameters() {
+  public boolean getCommandAcceptsParameters() {
     return commandAcceptsParameters;
   }
 
@@ -179,11 +196,11 @@
    * Errors should be printed to stderr. If anything is printed to stderr, it
    * will cause a failure for this transform operation.
    */
-  public void transformCommand(List<String> transformCommand) {
+  public void setTransformCommand(List<String> transformCommand) {
     this.transformCommand = new ArrayList<String>(transformCommand);
   }
 
-  public List<String> transformCommand() {
+  public List<String> getTransformCommand() {
     return Collections.unmodifiableList(transformCommand);
   }
 
@@ -192,7 +209,7 @@
    *
    * @throws IllegalArgumentException if {@code dir} is not a directory
    */
-  public void workingDirectory(File dir) {
+  public void setWorkingDirectory(File dir) {
     if (!dir.isDirectory()) {
       throw new IllegalArgumentException("File must be a directory");
     }
@@ -202,11 +219,23 @@
   /**
    * @return The working directory for the command line process.
    */
-  public File workingDirectory() {
+  public File getWorkingDirectory() {
     return workingDirectory;
   }
 
-  private boolean commandAcceptsParameters = true;
-  private List<String> transformCommand;
-  private File workingDirectory;
+  @Override
+  public void setName(String name) {
+    super.setName(name);
+  }
+
+  @Override
+  public void setRequired(boolean required) {
+    super.setRequired(required);
+  }
+
+  public static CommandLineTransform create(Map<String, String> config) {
+    CommandLineTransform transform = new CommandLineTransform();
+    transform.configure(config);
+    return transform;
+  }
 }
diff --git a/src/adaptorlib/prebuilt/FileSystemAdaptor.java b/src/adaptorlib/prebuilt/FileSystemAdaptor.java
index 659299d..4a0c5c8 100644
--- a/src/adaptorlib/prebuilt/FileSystemAdaptor.java
+++ b/src/adaptorlib/prebuilt/FileSystemAdaptor.java
@@ -99,13 +99,20 @@
     File file = new File(serveDir, id.getUniqueId()).getCanonicalFile();
     if (!file.exists() || !isFileDescendantOfServeDir(file)
         || !isFileAllowed(file)) {
-      throw new FileNotFoundException();
+      resp.respondNotFound();
+      return;
     }
     if (!req.hasChangedSinceLastAccess(new Date(file.lastModified()))) {
       resp.respondNotModified();
       return;
     }
-    InputStream input = new FileInputStream(file);
+    InputStream input;
+    try {
+      input = new FileInputStream(file);
+    } catch (FileNotFoundException ex) {
+      resp.respondNotFound();
+      return;
+    }
     try {
       IOHelper.copyStream(input, resp.getOutputStream());
     } finally {
diff --git a/test/adaptorlib/AutoUnzipAdaptorTest.java b/test/adaptorlib/AutoUnzipAdaptorTest.java
index 47b885d..83eac4f 100644
--- a/test/adaptorlib/AutoUnzipAdaptorTest.java
+++ b/test/adaptorlib/AutoUnzipAdaptorTest.java
@@ -114,7 +114,8 @@
       public void getDocContent(Request req, Response resp) throws IOException {
         DocId docId = req.getDocId();
         if (!"!test.zip".equals(docId.getUniqueId())) {
-          throw new FileNotFoundException(docId.getUniqueId());
+          resp.respondNotFound();
+          return;
         }
         ZipOutputStream zos = new ZipOutputStream(resp.getOutputStream());
         for (String entry : original) {
diff --git a/test/adaptorlib/DocumentHandlerTest.java b/test/adaptorlib/DocumentHandlerTest.java
index e1d4b8e..1f70f9c 100644
--- a/test/adaptorlib/DocumentHandlerTest.java
+++ b/test/adaptorlib/DocumentHandlerTest.java
@@ -226,7 +226,7 @@
     final byte[] golden = new byte[] {2, 3, 4};
     final String key = "testing key";
     TransformPipeline transform = new TransformPipeline();
-    transform.add(new DocumentTransform("testing") {
+    transform.add(new AbstractDocumentTransform() {
       @Override
       public void transform(ByteArrayOutputStream contentIn,
                             OutputStream contentOut,
@@ -242,9 +242,8 @@
       @Override
       public void getDocContent(Request request, Response response)
           throws IOException {
-        Set<MetaItem> metaSet = new HashSet<MetaItem>();
-        metaSet.add(MetaItem.raw(key, "testing value"));
-        response.setMetadata(new Metadata(metaSet));
+        response.setMetadata(new Metadata.Builder()
+            .add(MetaItem.raw(key, "testing value")).build());
         super.getDocContent(request, response);
       }
     };
@@ -266,7 +265,7 @@
   @Test
   public void testTransformDocumentTooLarge() throws Exception {
     TransformPipeline transform = new TransformPipeline();
-    transform.add(new DocumentTransform("testing") {
+    transform.add(new AbstractDocumentTransform() {
       @Override
       public void transform(ByteArrayOutputStream contentIn,
                             OutputStream contentOut,
@@ -349,7 +348,7 @@
           @Override
           public void getDocContent(Request request, Response response)
               throws IOException {
-            throw new FileNotFoundException();
+            response.respondNotFound();
           }
         };
     DocumentHandler handler = createDefaultHandlerForAdaptor(adaptor);
@@ -494,7 +493,8 @@
           public void getDocContent(Request request, Response response)
               throws IOException {
             if (!request.getDocId().equals(new DocId("http://localhost/"))) {
-              throw new FileNotFoundException();
+              response.respondNotFound();
+              return;
             }
             if (!request.hasChangedSinceLastAccess(new Date(1 * 1000))) {
               response.respondNotModified();
@@ -535,8 +535,8 @@
           @Override
           public void getDocContent(Request request, Response response)
               throws IOException {
-            response.setMetadata(new Metadata(Collections.singleton(
-                MetaItem.raw("test", "ing"))));
+            response.setMetadata(new Metadata.Builder()
+                .add(MetaItem.raw("test", "ing")).build());
             response.getOutputStream();
           }
         };
@@ -559,20 +559,19 @@
 
   @Test
   public void testFormMetadataHeader() {
-    Set<MetaItem> items = new HashSet<MetaItem>();
-    items.add(MetaItem.isPublic());
-    items.add(MetaItem.raw("test", "ing"));
-    items.add(MetaItem.raw("another", "item"));
-    items.add(MetaItem.raw("equals=", "=="));
-    String result = DocumentHandler.formMetadataHeader(new Metadata(items));
+    String result = DocumentHandler.formMetadataHeader(new Metadata.Builder()
+        .add(MetaItem.isPublic())
+        .add(MetaItem.raw("test", "ing"))
+        .add(MetaItem.raw("another", "item"))
+        .add(MetaItem.raw("equals=", "=="))
+        .build());
     assertEquals("another=item,equals%3D=%3D%3D,google%3Aispublic=true,"
                  + "test=ing", result);
   }
 
   @Test
   public void testFormMetadataHeaderEmpty() {
-    String header = DocumentHandler.formMetadataHeader(
-        new Metadata(new HashSet<MetaItem>()));
+    String header = DocumentHandler.formMetadataHeader(Metadata.EMPTY);
     assertEquals("", header);
   }
 
diff --git a/test/adaptorlib/GsaCommunicationHandlerTest.java b/test/adaptorlib/GsaCommunicationHandlerTest.java
index 2d8958d..1255363 100644
--- a/test/adaptorlib/GsaCommunicationHandlerTest.java
+++ b/test/adaptorlib/GsaCommunicationHandlerTest.java
@@ -19,6 +19,7 @@
 import org.junit.*;
 import org.junit.rules.ExpectedException;
 
+import java.io.*;
 import java.net.*;
 import java.util.*;
 import java.util.concurrent.*;
@@ -104,14 +105,14 @@
     {
       Map<String, String> map = new HashMap<String, String>();
       map.put("name", "testing");
-      map.put("class", InstantiatableTransform.class.getName());
+      map.put("factoryMethod", getClass().getName() + ".factoryMethod");
       config.add(map);
     }
     TransformPipeline pipeline
         = GsaCommunicationHandler.createTransformPipeline(config);
     assertEquals(1, pipeline.size());
-    assertEquals(InstantiatableTransform.class, pipeline.get(0).getClass());
-    assertEquals("testing", pipeline.get(0).name());
+    assertEquals(IdentityTransform.class, pipeline.get(0).getClass());
+    assertEquals("testing", pipeline.get(0).getName());
   }
 
   @Test
@@ -139,7 +140,7 @@
     {
       Map<String, String> map = new HashMap<String, String>();
       map.put("name", "testing");
-      map.put("class", "adaptorlib.NotARealClass");
+      map.put("factoryMethod", "adaptorlib.NotARealClass.fakeMethod");
       config.add(map);
     }
     thrown.expect(RuntimeException.class);
@@ -153,7 +154,7 @@
     {
       Map<String, String> map = new HashMap<String, String>();
       map.put("name", "testing");
-      map.put("class", WrongConstructorTransform.class.getName());
+      map.put("factoryMethod", getClass().getName() + ".wrongFactoryMethod");
       config.add(map);
     }
     thrown.expect(RuntimeException.class);
@@ -167,7 +168,8 @@
     {
       Map<String, String> map = new HashMap<String, String>();
       map.put("name", "testing");
-      map.put("class", CantInstantiateTransform.class.getName());
+      map.put("factoryMethod",
+          getClass().getName() + ".cantInstantiateFactoryMethod");
       config.add(map);
     }
     thrown.expect(RuntimeException.class);
@@ -181,7 +183,22 @@
     {
       Map<String, String> map = new HashMap<String, String>();
       map.put("name", "testing");
-      map.put("class", WrongTypeTransform.class.getName());
+      map.put("factoryMethod",
+          getClass().getName() + ".wrongTypeFactoryMethod");
+      config.add(map);
+    }
+    thrown.expect(ClassCastException.class);
+    TransformPipeline pipeline
+        = GsaCommunicationHandler.createTransformPipeline(config);
+  }
+
+  @Test
+  public void testCreateTransformPipelineNoMethod() throws Exception {
+    List<Map<String, String>> config = new ArrayList<Map<String, String>>();
+    {
+      Map<String, String> map = new HashMap<String, String>();
+      map.put("name", "testing");
+      map.put("factoryMethod", "noFactoryMethodToBeFound");
       config.add(map);
     }
     thrown.expect(RuntimeException.class);
@@ -213,26 +230,32 @@
     }
   }
 
-  static class InstantiatableTransform extends DocumentTransform {
-    public InstantiatableTransform(Map<String, String> config) {
-      super("Test");
+  static class IdentityTransform extends AbstractDocumentTransform {
+    @Override
+    public void transform(ByteArrayOutputStream contentIn,
+                          OutputStream contentOut,
+                          Map<String, String> metadata,
+                          Map<String, String> params) throws IOException {
+      contentIn.writeTo(contentOut);
     }
   }
 
-  static class WrongConstructorTransform extends DocumentTransform {
-    public WrongConstructorTransform() {
-      super("Test");
-    }
+  public static IdentityTransform factoryMethod(Map<String, String> config) {
+    IdentityTransform transform = new IdentityTransform();
+    transform.configure(config);
+    return transform;
   }
 
-  static class CantInstantiateTransform extends DocumentTransform {
-    public CantInstantiateTransform(Map<String, String> config) {
-      super("Test");
-      throw new RuntimeException("This always seems to happen");
-    }
+  public static IdentityTransform wrongFactoryMethod() {
+    return factoryMethod(Collections.<String, String>emptyMap());
   }
 
-  static class WrongTypeTransform {
-    public WrongTypeTransform(Map<String, String> config) {}
+  public static IdentityTransform cantInstantiateFactoryMethod(
+      Map<String, String> config) {
+    throw new RuntimeException("This always seems to happen");
+  }
+
+  public static Object wrongTypeFactoryMethod(Map<String, String> config) {
+    return new Object();
   }
 }
diff --git a/test/adaptorlib/MetadataTest.java b/test/adaptorlib/MetadataTest.java
index edb25e5..fc0e817 100644
--- a/test/adaptorlib/MetadataTest.java
+++ b/test/adaptorlib/MetadataTest.java
@@ -19,24 +19,22 @@
 import org.junit.*;
 import org.junit.rules.ExpectedException;
 
-import java.util.Set;
-import java.util.TreeSet;
-
 /** Tests for {@link MetaItem}. */
 public class MetadataTest {
   @Rule
   public ExpectedException thrown = ExpectedException.none();
   
-  private Metadata couple = new Metadata(makeCouple());
-  private Metadata coupleB = new Metadata(makeCouple());
-  private Metadata triple = new Metadata(makeTriple());
-  private Metadata tripleB = new Metadata(makeTriple());
+  private Metadata couple = makeCouple();
+  private Metadata coupleB = makeCouple();
+  private Metadata triple = makeTriple();
+  private Metadata tripleB = makeTriple();
 
   @Test
   public void testEquals() {
     assertEquals(couple, coupleB);
+    assertEquals(couple, new Metadata.Builder(couple).build());
     assertEquals(triple, tripleB);
-    assertEquals(Metadata.EMPTY, new Metadata(new TreeSet<MetaItem>()));
+    assertEquals(Metadata.EMPTY, new Metadata.Builder().build());
     assertFalse(couple.equals(triple));
     assertFalse(triple.equals(couple));
     assertFalse(Metadata.EMPTY.equals(new Object()));
@@ -63,91 +61,82 @@
   }
 
   @Test
-  public void testEachNameUnique() {
-    Set<MetaItem> items = new TreeSet<MetaItem>();
-    items.add(MetaItem.raw("a", "barney"));
-    items.add(MetaItem.raw("a", "frank"));
-    thrown.expect(IllegalArgumentException.class);
-    Metadata box = new Metadata(items); 
-  }
-
-  @Test
   public void testWithPublicAndWithAcls() {
-    Set<MetaItem> items = new TreeSet<MetaItem>();
-    items.add(MetaItem.raw("google:aclusers", "peter,mathew"));
-    items.add(MetaItem.raw("google:aclgroups", "apostles"));
-    items.add(MetaItem.raw("google:ispublic", "true"));
+    Metadata.Builder builder = new Metadata.Builder()
+        .add(MetaItem.raw("google:aclusers", "peter,mathew"))
+        .add(MetaItem.raw("google:aclgroups", "apostles"))
+        .add(MetaItem.raw("google:ispublic", "true"));
     thrown.expect(IllegalArgumentException.class);
-    Metadata box = new Metadata(items); 
+    Metadata box = builder.build(); 
   }
 
   @Test
   public void testNoPublicNoAcls() {
-    Set<MetaItem> items = new TreeSet<MetaItem>();
-    items.add(MetaItem.raw("X", "peter,mathew"));
-    items.add(MetaItem.raw("Y", "apostles"));
-    items.add(MetaItem.raw("Z", "true"));
-    Metadata box = new Metadata(items); 
+    Metadata box = new Metadata.Builder()
+        .add(MetaItem.raw("X", "peter,mathew"))
+        .add(MetaItem.raw("Y", "apostles"))
+        .add(MetaItem.raw("Z", "true"))
+        .build();
   }
 
   @Test
   public void testNoPublicWithAcls() {
-    Set<MetaItem> items = new TreeSet<MetaItem>();
-    items.add(MetaItem.raw("google:aclusers", "peter,mathew"));
-    items.add(MetaItem.raw("google:aclgroups", "apostles"));
-    items.add(MetaItem.raw("Z", "true"));
-    Metadata box = new Metadata(items); 
+    Metadata box = new Metadata.Builder()
+        .add(MetaItem.raw("google:aclusers", "peter,mathew"))
+        .add(MetaItem.raw("google:aclgroups", "apostles"))
+        .add(MetaItem.raw("Z", "true"))
+        .build();
   }
 
   @Test
   public void testWithPublicNoAcls() {
-    Set<MetaItem> items = new TreeSet<MetaItem>();
-    items.add(MetaItem.raw("X", "peter,mathew"));
-    items.add(MetaItem.raw("Y", "apostles"));
-    items.add(MetaItem.raw("google:ispublic", "true"));
-    Metadata box = new Metadata(items); 
+    Metadata box = new Metadata.Builder()
+        .add(MetaItem.raw("X", "peter,mathew"))
+        .add(MetaItem.raw("Y", "apostles"))
+        .add(MetaItem.raw("google:ispublic", "true"))
+        .build();
   }
 
   @Test
   public void testWithUsersNoGroups() {
-    Set<MetaItem> items = new TreeSet<MetaItem>();
-    items.add(MetaItem.raw("google:aclusers", "peter,mathew"));
-    items.add(MetaItem.raw("Y", "apostles"));
-    items.add(MetaItem.raw("Z", "true"));
+    Metadata.Builder builder = new Metadata.Builder()
+        .add(MetaItem.raw("google:aclusers", "peter,mathew"))
+        .add(MetaItem.raw("Y", "apostles"))
+        .add(MetaItem.raw("Z", "true"));
     thrown.expect(IllegalArgumentException.class);
-    Metadata box = new Metadata(items); 
+    Metadata box = builder.build(); 
   }
 
   @Test
   public void testNoUsersWithGroups() {
-    Set<MetaItem> items = new TreeSet<MetaItem>();
-    items.add(MetaItem.raw("X", "peter,mathew"));
-    items.add(MetaItem.raw("google:aclgroups", "apostles"));
-    items.add(MetaItem.raw("Z", "true"));
+    Metadata.Builder builder = new Metadata.Builder()
+        .add(MetaItem.raw("X", "peter,mathew"))
+        .add(MetaItem.raw("google:aclgroups", "apostles"))
+        .add(MetaItem.raw("Z", "true"));
     thrown.expect(IllegalArgumentException.class);
-    Metadata box = new Metadata(items); 
+    Metadata box = builder.build(); 
   }
 
   @Test
   public void testPublicCanBeTrue() {
-    Set<MetaItem> items = new TreeSet<MetaItem>();
-    items.add(MetaItem.raw("google:ispublic", "true"));
-    Metadata box = new Metadata(items); 
+    Metadata box = new Metadata.Builder()
+        .add(MetaItem.raw("google:ispublic", "true"))
+        .build();
   }
 
   @Test
   public void testPublicCanBeFalse() {
-    Set<MetaItem> items = new TreeSet<MetaItem>();
-    items.add(MetaItem.raw("google:ispublic", "false"));
-    Metadata box = new Metadata(items); 
+    Metadata box = new Metadata.Builder()
+        .add(MetaItem.raw("google:ispublic", "false"))
+        .build();
   }
 
   @Test
   public void testPublicMustBeBoolean() {
-    Set<MetaItem> items = new TreeSet<MetaItem>();
-    items.add(MetaItem.raw("google:ispublic", "dog"));
+    Metadata.Builder builder = new Metadata.Builder()
+        .add(MetaItem.raw("google:ispublic", "dog"));
     thrown.expect(IllegalArgumentException.class);
-    Metadata box = new Metadata(items); 
+    Metadata box = builder.build(); 
   }
 
   @Test
@@ -161,18 +150,18 @@
     assertEquals(true, Metadata.EMPTY.isEmpty());
   }
 
-  private static Set<MetaItem> makeCouple() {
-    Set<MetaItem> coupleItems = new TreeSet<MetaItem>();
-    coupleItems.add(MetaItem.raw("google:ispublic", "true"));
-    coupleItems.add(MetaItem.raw("author", "iceman"));
-    return coupleItems;
+  private static Metadata makeCouple() {
+    return new Metadata.Builder()
+        .add(MetaItem.raw("google:ispublic", "true"))
+        .add(MetaItem.raw("author", "iceman"))
+        .build();
   }
 
-  private static Set<MetaItem> makeTriple() {
-    Set<MetaItem> tripleItems = new TreeSet<MetaItem>();
-    tripleItems.add(MetaItem.raw("google:aclusers", "peter,mathew"));
-    tripleItems.add(MetaItem.raw("google:aclgroups", "apostles"));
-    tripleItems.add(MetaItem.raw("where", "there"));
-    return tripleItems;
+  private static Metadata makeTriple() {
+    return new Metadata.Builder()
+        .add(MetaItem.raw("google:aclusers", "peter,mathew"))
+        .add(MetaItem.raw("google:aclgroups", "apostles"))
+        .add(MetaItem.raw("where", "there"))
+        .build();
   }
 }
diff --git a/test/adaptorlib/TestHelper.java b/test/adaptorlib/TestHelper.java
index b97785a..9438f7b 100644
--- a/test/adaptorlib/TestHelper.java
+++ b/test/adaptorlib/TestHelper.java
@@ -16,8 +16,7 @@
 
 import static org.junit.Assume.*;
 
-import java.io.ByteArrayOutputStream;
-import java.io.IOException;
+import java.io.*;
 import java.util.List;
 
 /**
@@ -56,8 +55,12 @@
   public static byte[] getDocContent(Adaptor adaptor, DocId docId) throws IOException,
       InterruptedException {
     ByteArrayOutputStream baos = new ByteArrayOutputStream();
-    adaptor.getDocContent(new WrapperAdaptor.GetContentsRequest(docId),
-                          new WrapperAdaptor.GetContentsResponse(baos));
+    WrapperAdaptor.GetContentsResponse resp
+        = new WrapperAdaptor.GetContentsResponse(baos);
+    adaptor.getDocContent(new WrapperAdaptor.GetContentsRequest(docId), resp);
+    if (resp.isNotFound()) {
+      throw new FileNotFoundException("Could not find " + docId);
+    }
     return baos.toByteArray();
   }
 
diff --git a/test/adaptorlib/TransformPipelineTest.java b/test/adaptorlib/TransformPipelineTest.java
index eca603f..c6f56ee 100644
--- a/test/adaptorlib/TransformPipelineTest.java
+++ b/test/adaptorlib/TransformPipelineTest.java
@@ -68,7 +68,7 @@
     params.put("key2", "value2");
 
     TransformPipeline pipeline = new TransformPipeline();
-    pipeline.add(new DocumentTransform("Metadata/Param Transform") {
+    pipeline.add(new AbstractDocumentTransform() {
         @Override
         public void transform(ByteArrayOutputStream cIn, OutputStream cOut, Map<String, String> m,
                               Map<String, String> p) throws TransformException, IOException {
@@ -125,8 +125,7 @@
   public void testNotLastTransformError() throws IOException, TransformException {
     TransformPipeline pipeline = new TransformPipeline();
     pipeline.add(new IncrementTransform());
-    pipeline.add(new ErroringTransform());
-    pipeline.get(1).errorHaltsPipeline(false);
+    pipeline.add(new ErroringTransform(false));
     ByteArrayOutputStream out = new ByteArrayOutputStream();
     Map<String, String> metadata = new HashMap<String, String>();
     metadata.put("int", "0");
@@ -143,8 +142,7 @@
   @Test
   public void testLastTransformError() throws IOException, TransformException {
     TransformPipeline pipeline = new TransformPipeline();
-    pipeline.add(new ErroringTransform());
-    pipeline.get(0).errorHaltsPipeline(false);
+    pipeline.add(new ErroringTransform(false));
     pipeline.add(new IncrementTransform());
     ByteArrayOutputStream out = new ByteArrayOutputStream();
     Map<String, String> metadata = new HashMap<String, String>();
@@ -163,8 +161,7 @@
   public void testTransformErrorFatal() throws IOException, TransformException {
     TransformPipeline pipeline = new TransformPipeline();
     pipeline.add(new IncrementTransform());
-    pipeline.add(new ErroringTransform());
-    pipeline.get(1).errorHaltsPipeline(true);
+    pipeline.add(new ErroringTransform(true));
     ByteArrayOutputStream out = new ByteArrayOutputStream();
     Map<String, String> metadata = new HashMap<String, String>();
     metadata.put("int", "0");
@@ -181,11 +178,7 @@
     }
   }
 
-  private static class IncrementTransform extends DocumentTransform {
-    public IncrementTransform() {
-      super("incrementTransform");
-    }
-
+  private static class IncrementTransform extends AbstractDocumentTransform {
     @Override
     public void transform(ByteArrayOutputStream contentIn, OutputStream contentOut,
                           Map<String, String> metadata, Map<String, String> p)
@@ -200,11 +193,10 @@
     }
   }
 
-  private static class ProductTransform extends DocumentTransform {
+  private static class ProductTransform extends AbstractDocumentTransform {
     private int factor;
 
     public ProductTransform(int factor) {
-      super("productTransform");
       this.factor = factor;
     }
 
@@ -222,9 +214,9 @@
     }
   }
 
-  private static class ErroringTransform extends DocumentTransform {
-    public ErroringTransform() {
-      super("erroringTransform");
+  private static class ErroringTransform extends AbstractDocumentTransform {
+    public ErroringTransform(boolean required) {
+      super(null, required);
     }
 
     @Override
diff --git a/test/adaptorlib/prebuilt/CommandLineAdaptorTest.java b/test/adaptorlib/prebuilt/CommandLineAdaptorTest.java
index bde4961..d8fa8d0 100644
--- a/test/adaptorlib/prebuilt/CommandLineAdaptorTest.java
+++ b/test/adaptorlib/prebuilt/CommandLineAdaptorTest.java
@@ -14,13 +14,13 @@
 
 package adaptorlib.prebuilt;
 
+import static adaptorlib.TestHelper.getDocIds;
+
 import static org.junit.Assert.assertArrayEquals;
 import static org.junit.Assert.assertEquals;
 
 import adaptorlib.*;
 
-import static adaptorlib.TestHelper.getDocIds;
-
 import org.junit.Test;
 
 import java.io.ByteArrayOutputStream;
@@ -31,10 +31,8 @@
 import java.util.Collections;
 import java.util.Date;
 import java.util.HashMap;
-import java.util.HashSet;
 import java.util.List;
 import java.util.Map;
-import java.util.Set;
 
 /**
  * Tests for {@link CommandLineAdaptor}.
@@ -99,13 +97,13 @@
       ID_TO_LAST_CRAWLED = Collections.unmodifiableMap(idToLastCrawled);
 
       Map<String, Metadata> idToMetadata = new HashMap<String, Metadata>();
-      Set<MetaItem> metadataSet1002 = new HashSet<MetaItem>();
-      metadataSet1002.add(MetaItem.raw("metaname-1002a", "metavalue-1002a"));
-      metadataSet1002.add(MetaItem.raw("metaname-1002b", "metavalue-1002b"));
-      idToMetadata.put("1002", new adaptorlib.Metadata(metadataSet1002));
-      Set<MetaItem> metadataSet1003 = new HashSet<MetaItem>();
-      metadataSet1003.add(MetaItem.raw("metaname-1003", "metavalue-1003"));
-      idToMetadata.put("1003", new adaptorlib.Metadata(metadataSet1003));
+      idToMetadata.put("1002", new Metadata.Builder()
+          .add(MetaItem.raw("metaname-1002a", "metavalue-1002a"))
+          .add(MetaItem.raw("metaname-1002b", "metavalue-1002b"))
+          .build());
+      idToMetadata.put("1003", new Metadata.Builder()
+          .add(MetaItem.raw("metaname-1003", "metavalue-1003"))
+          .build());
       ID_TO_METADATA = Collections.unmodifiableMap(idToMetadata);
     }
 
@@ -193,11 +191,12 @@
     }
   }
 
-  public static class ContentsResponseTestMock implements Response {
+  private static class ContentsResponseTestMock implements Response {
     private OutputStream os;
     private String contentType;
     private Metadata metadata;
     private boolean notModified;
+    private boolean notFound;
 
     public ContentsResponseTestMock(OutputStream os) {
       this.os = os;
@@ -210,6 +209,11 @@
     }
 
     @Override
+    public void respondNotFound() {
+      notFound = true;
+    }
+
+    @Override
     public OutputStream getOutputStream() {
       return os;
     }
@@ -235,6 +239,10 @@
     public boolean getNotModified() {
       return notModified;
     }
+
+    public boolean getNotFound() {
+      return notFound;
+    }
   }
 
 
@@ -278,4 +286,3 @@
 
 
 }
-
diff --git a/test/adaptorlib/prebuilt/CommandLineTransformTest.java b/test/adaptorlib/prebuilt/CommandLineTransformTest.java
index 22114b9..d4f27a4 100644
--- a/test/adaptorlib/prebuilt/CommandLineTransformTest.java
+++ b/test/adaptorlib/prebuilt/CommandLineTransformTest.java
@@ -43,9 +43,9 @@
     Map<String, String> params = new HashMap<String, String>();
     params.put("key1", "value1");
 
-    CommandLineTransform cmd = new CommandLineTransform("regex replace");
-    cmd.transformCommand(Arrays.asList(new String[] {"sed", "s/i/1/"}));
-    cmd.commandAcceptsParameters(false);
+    CommandLineTransform cmd = new CommandLineTransform();
+    cmd.setTransformCommand(Arrays.asList(new String[] {"sed", "s/i/1/"}));
+    cmd.setCommandAcceptsParameters(false);
     pipeline.add(cmd);
     pipeline.transform(testStr.getBytes(), contentOut, new HashMap<String, String>(), params);
 
@@ -69,8 +69,8 @@
     Map<String, String> params = new HashMap<String, String>();
     params.put("key1", "value1");
 
-    CommandLineTransform cmd = new CommandLineTransform("regex replace");
-    cmd.transformCommand(Arrays.asList(new String[] {"/bin/sh", "-c",
+    CommandLineTransform cmd = new CommandLineTransform();
+    cmd.setTransformCommand(Arrays.asList(new String[] {"/bin/sh", "-c",
       // Process content.
       "sed s/i/1/; META=\"$0\"; PARAM=\"$1\"; TMPFILE=$(tempfile);"
       // Process metadata.
@@ -80,7 +80,7 @@
       // Cleanup.
       + "rm \"$TMPFILE\" >&2;"
     }));
-    cmd.commandAcceptsParameters(true);
+    cmd.setCommandAcceptsParameters(true);
     pipeline.add(cmd);
     pipeline.transform(testStr.getBytes(), contentOut, metadata, params);