Merge branch 'master' of https://code.google.com/p/plexi
diff --git a/src/com/google/enterprise/adaptor/CommandStreamParser.java b/src/com/google/enterprise/adaptor/CommandStreamParser.java
index b505b8d..23aec8c 100644
--- a/src/com/google/enterprise/adaptor/CommandStreamParser.java
+++ b/src/com/google/enterprise/adaptor/CommandStreamParser.java
@@ -277,13 +277,13 @@
     private boolean notFound;
     private DocId docId;
     private String mimeType;
-    private Map<String, String> metadata;
+    private Metadata metadata;
     private byte[] contents;
 
-    RetrieverInfo(DocId docId, Map<String, String> metadata, byte[] contents, boolean upToDate,
+    RetrieverInfo(DocId docId, Metadata metadata, byte[] contents, boolean upToDate,
         String mimeType, boolean notFound) {
       this.docId = docId;
-      this.metadata = Collections.unmodifiableMap(metadata);
+      this.metadata = metadata.unmodifiableView();
       this.contents = contents;
       this.upToDate = upToDate;
       this.mimeType = mimeType;
@@ -306,7 +306,8 @@
       return docId;
     }
 
-    public Map<String, String> getMetadata() {
+    /** Returns unmodifiable view of Metadata. */
+    public Metadata getMetadata() {
       return metadata;
     }
 
@@ -406,7 +407,7 @@
 
   public RetrieverInfo readFromRetriever() throws IOException {
 
-    Map<String, String> metadata = new HashMap<String, String>();
+    Metadata metadata = new Metadata();
     byte[] content = null;
     boolean upToDate = false;
     boolean notFound = false;
@@ -435,7 +436,7 @@
           if (command == null || command.getOperation() != Operation.META_VALUE) {
             throw new IOException("meta-name must be immediately followed by meta-value");
           }
-          metadata.put(metaName, command.getArgument());
+          metadata.add(metaName, command.getArgument());
           break;
         case UP_TO_DATE:
           upToDate = true;
diff --git a/src/com/google/enterprise/adaptor/DocumentHandler.java b/src/com/google/enterprise/adaptor/DocumentHandler.java
index 304bd66..ffb1ee3 100644
--- a/src/com/google/enterprise/adaptor/DocumentHandler.java
+++ b/src/com/google/enterprise/adaptor/DocumentHandler.java
@@ -14,6 +14,8 @@
 
 package com.google.enterprise.adaptor;
 
+import static java.util.Map.Entry;
+
 import com.sun.net.httpserver.HttpExchange;
 import com.sun.net.httpserver.HttpHandler;
 import com.sun.net.httpserver.HttpsExchange;
@@ -278,9 +280,9 @@
   /**
    * Format the GSA-specific metadata header value for crawl-time metadata.
    */
-  static String formMetadataHeader(Map<String, String> metadata) {
+  static String formMetadataHeader(Metadata metadata) {
     StringBuilder sb = new StringBuilder();
-    for (Map.Entry<String, String> item : metadata.entrySet()) {
+    for (Entry<String, String> item : metadata) {
       percentEncodeMapEntryPair(sb, item.getKey(), item.getValue());
     }
     return (sb.length() == 0) ? "" : sb.substring(0, sb.length() - 1);
@@ -446,7 +448,7 @@
     private OutputStream os;
     private CountingOutputStream countingOs;
     private String contentType;
-    private Map<String, String> metadata = Collections.emptyMap();
+    private Metadata metadata = new Metadata();
     private Acl acl = Acl.EMPTY;
     private List<URI> anchorUris = new ArrayList<URI>();
     private List<String> anchorTexts = new ArrayList<String>();
@@ -522,17 +524,11 @@
     }
 
     @Override
-    public void setMetadata(Map<String, String> metadata) {
+    public void addMetadata(String key, String value) {
       if (state != State.SETUP) {
         throw new IllegalStateException("Already responded");
       }
-      setMetadataInternal(metadata);
-    }
-
-    private void setMetadataInternal(Map<String, String> metadata) {
-      // TODO(ejona): check for valid keys and values.
-      this.metadata = Collections.unmodifiableMap(
-          new TreeMap<String, String>(metadata));
+      metadata.add(key, value);
     }
 
     @Override
@@ -664,16 +660,14 @@
 
     private ByteArrayOutputStream transform(byte[] content) throws IOException {
       ByteArrayOutputStream contentOut = new ByteArrayOutputStream();
-      Map<String, String> metadataCopy = new HashMap<String, String>(metadata);
       Map<String, String> params = new HashMap<String, String>();
       params.put("DocId", docId.getUniqueId());
       params.put("Content-Type", contentType);
       try {
-        transform.transform(content, contentOut, metadataCopy, params);
+        transform.transform(content, contentOut, metadata, params);
       } catch (TransformException e) {
         throw new IOException(e);
       }
-      setMetadataInternal(metadataCopy);
       contentType = params.get("Content-Type");
       return contentOut;
     }
diff --git a/src/com/google/enterprise/adaptor/DocumentTransform.java b/src/com/google/enterprise/adaptor/DocumentTransform.java
index 0dcacfc..3d3249f 100644
--- a/src/com/google/enterprise/adaptor/DocumentTransform.java
+++ b/src/com/google/enterprise/adaptor/DocumentTransform.java
@@ -38,7 +38,7 @@
    */
   public void transform(ByteArrayOutputStream contentIn,
                         OutputStream contentOut,
-                        Map<String, String> metadata,
+                        Metadata metadata,
                         Map<String, String> params)
       throws TransformException, IOException;
 
diff --git a/src/com/google/enterprise/adaptor/Metadata.java b/src/com/google/enterprise/adaptor/Metadata.java
new file mode 100644
index 0000000..520c5e7
--- /dev/null
+++ b/src/com/google/enterprise/adaptor/Metadata.java
@@ -0,0 +1,259 @@
+// Copyright 2012 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 com.google.enterprise.adaptor;
+
+import static java.util.AbstractMap.SimpleImmutableEntry;
+import static java.util.Map.Entry;
+
+import java.util.Collections;
+import java.util.Iterator;
+import java.util.Map;
+import java.util.NoSuchElementException;
+import java.util.Set;
+import java.util.SortedMap;
+import java.util.TreeMap;
+import java.util.TreeSet;
+
+/**
+ * Allows storing multiple metadata values to a single key.
+ * <p>
+ * Null keys are invalid as arguments.  Null values are
+ * invalid as arguments.
+ * <p>
+ * This class is mutable and not thread-safe.
+ */
+public class Metadata implements Iterable<Entry<String, String>> {
+  private Map<String, Set<String>> mappings 
+      = new TreeMap<String, Set<String>>();
+
+  /** Create empty instance. */
+  public Metadata() {
+  }
+
+  /** Duplicate. */
+  public Metadata(Iterable<Entry<String, String>> m) {
+    for (Entry<String, String> e : m) {
+      add(e.getKey(), e.getValue());
+    }    
+  }
+
+  /** Make v be only value associated with key. */
+  public void set(String k, String v) {
+    if (null == k) {
+      throw new NullPointerException();
+    }
+    if (null == v) {
+      throw new NullPointerException();
+    }
+    TreeSet<String> single = new TreeSet<String>();
+    single.add(v);
+    mappings.put(k, single);
+  }
+
+  /** Throws NullPointerException if a null is found. */
+  private static void assureNoNulls(Set<String> items) {
+    if (items.contains(null)) {
+      throw new NullPointerException();
+    }
+  }
+
+  /** Make copy of v be the values associated with key. */
+  public void set(String k, Set<String> v) {
+    if (null == k) {
+      throw new NullPointerException();
+    }
+    if (null == v) {
+      throw new NullPointerException();
+    }
+    assureNoNulls(v);
+    if (v.isEmpty()) {
+      mappings.remove(k);
+    } else {
+      v = new TreeSet<String>(v);
+      mappings.put(k, v);
+    }
+  }
+
+  /** Increases values mapped to k with v. */
+  public void add(String k, String v) {
+    if (null == k) {
+      throw new NullPointerException();
+    }
+    if (null == v) {
+      throw new NullPointerException();
+    }
+    Set<String> found = mappings.get(k);
+    if (null == found) {
+      set(k, v);
+    } else {
+      found.add(v);
+    }
+  }
+
+  /** Replaces entries inside of this metadata with provided ones. */
+  public void set(Iterable<Entry<String, String>> it) {
+    mappings.clear();
+    for (Entry<String, String> e : it) {
+      add(e.getKey(), e.getValue());
+    }    
+  }
+
+  /** Gives unmodifiable reference to inserted values for key, empty if none. */
+  public Set<String> getAllValues(String key) {
+    Set<String> found = mappings.get(key);
+    if (null == found) {
+      found = Collections.emptySet(); 
+    }
+    return Collections.unmodifiableSet(found);
+  }
+
+  /** One of the inserted values, or null if none. */
+  public String getOneValue(String key) {
+    Set<String> found = mappings.get(key);
+    String first = null;
+    if (null != found) {
+      if (found.isEmpty()) {
+        throw new AssertionError();
+      }
+      first = found.iterator().next(); 
+    }
+    return first;
+  }
+
+  /** Get modifiable set of all keys with at least one value. */
+  public Set<String> getKeys() {
+    return mappings.keySet();
+  }
+
+  /**
+   * Provides every key and value in immutable entries sorted
+   * alphabetically, first by key, and secondly by value.
+   * <p>
+   * Behaviour is undefined if backing Metadata instance is modified
+   * during iteration.
+   * <p>
+   * remove() is unsupported on returned iterator.
+   */
+  public Iterator<Entry<String, String>> iterator() {
+    return new EntriesIterator();
+  }
+
+  /** Loops through keys and for each key all values. */
+  private class EntriesIterator implements Iterator<Entry<String, String>> {
+    private Iterator<Entry<String, Set<String>>> byKey
+        = mappings.entrySet().iterator();
+    private String currentKey;
+    private Iterator<String> currentValues
+        = Collections.<String>emptyList().iterator();
+
+    @Override
+    public boolean hasNext() {
+      if (currentValues.hasNext()) {
+        return true;
+      }
+      if (!byKey.hasNext()) {
+        return false;
+      }
+      Entry<String, Set<String>> currentEntry = byKey.next();
+      currentKey = currentEntry.getKey();
+      currentValues = currentEntry.getValue().iterator();
+      return hasNext();
+    }
+
+    @Override
+    public Entry<String, String> next() {
+      if (!hasNext()) {
+        throw new NoSuchElementException();
+      }
+      String k = currentKey, v = currentValues.next();
+      return new SimpleImmutableEntry<String, String>(k, v);
+    }
+
+    /** Not supported. */
+    @Override
+    public void remove() {
+      throw new UnsupportedOperationException();
+    }
+  }
+
+  /** True if exactly the same key-values are represented. */
+  public boolean equals(Object o) {
+    if (!(o instanceof Metadata)) {
+      return false;
+    }
+    if (this == o) {
+      return true;
+    }
+    Metadata other = (Metadata) o;
+    return mappings.equals(other.mappings);
+  }
+
+  /** True with 0 entries. */
+  public boolean isEmpty() {
+    return mappings.isEmpty();
+  }
+
+  /** Contains every key and value pair; useful for debugging. */
+  public String toString() {
+    String sep = ", ";
+    StringBuilder builder = new StringBuilder();
+    for (Entry<String, String> e : this) {
+      builder.append(sep);
+      builder.append(e.getKey()).append("=").append(e.getValue());
+    }
+    String body = "";
+    if (0 != builder.length()) {
+      body = builder.substring(sep.length());
+    }
+    return "[" + body + "]";
+  }
+
+  /** Does not allow any mutating operations. */
+  private static class ReadableMetadata extends Metadata {
+    @Override
+    public void set(String k, String v) {
+      throw new UnsupportedOperationException();
+    }
+
+    @Override
+    public void set(String k, Set<String> v) {
+      throw new UnsupportedOperationException();
+    }
+
+    @Override
+    public void add(String k, String v) {
+      throw new UnsupportedOperationException();
+    }
+
+    @Override
+    public void set(Iterable<Entry<String, String>> it) {
+      throw new UnsupportedOperationException();
+    }
+    
+    @Override
+    public Set<String> getKeys() {
+      return Collections.unmodifiableSet(super.getKeys());
+    }
+  };
+
+  /** Get a reference to an unmodifiable view of this object. */
+  public Metadata unmodifiableView() {
+    Metadata unmodifiable = new ReadableMetadata();
+    // Extra precaution against mappings use, but not against moding
+    // sets that are values inside it.
+    unmodifiable.mappings = Collections.unmodifiableMap(this.mappings); 
+    return unmodifiable;
+  }
+}
diff --git a/src/com/google/enterprise/adaptor/Response.java b/src/com/google/enterprise/adaptor/Response.java
index 34182ab..599d2de 100644
--- a/src/com/google/enterprise/adaptor/Response.java
+++ b/src/com/google/enterprise/adaptor/Response.java
@@ -16,7 +16,6 @@
 
 import java.io.*;
 import java.net.URI;
-import java.util.Map;
 
 /**
  * Interface provided to {@link Adaptor#getDocContent} for performing the
@@ -72,12 +71,14 @@
   public void setContentType(String contentType);
 
   /**
-   * Provide metadata that applies to the document.
+   * Add metadata element that applies to the document.
    *
-   * @throws IllegalArgumentException if metadata contains {@code null} or empty
-   *     keys or {@code null} values
+   * @param key the key of metadata element
+   * @param value the value of metadata element
+   * @throws NullPointerException if {@code key} or {@code value}
+   *     is {@code null}
    */
-  public void setMetadata(Map<String, String> metadata);
+  public void addMetadata(String key, String value);
 
   /**
    * Provide the document's ACLs for early-binding security on the GSA.
diff --git a/src/com/google/enterprise/adaptor/TransformPipeline.java b/src/com/google/enterprise/adaptor/TransformPipeline.java
index c4edb19..c187e9d 100644
--- a/src/com/google/enterprise/adaptor/TransformPipeline.java
+++ b/src/com/google/enterprise/adaptor/TransformPipeline.java
@@ -50,7 +50,7 @@
    */
   public void transform(byte[] contentIn,
                         OutputStream contentOut,
-                        Map<String, String> metadata,
+                        Metadata metadata,
                         Map<String, String> params) throws TransformException, IOException {
     if (transformList.isEmpty()) {
       contentOut.write(contentIn);
@@ -59,23 +59,19 @@
 
     ByteArrayOutputStream contentInTransit = new ByteArrayOutputStream(contentIn.length);
     ByteArrayOutputStream contentOutTransit = new ByteArrayOutputStream(contentIn.length);
-    Map<String, String> metadataInTransit = Collections.checkedMap(
-        new HashMap<String, String>(metadata.size() * 2), String.class, String.class);
-    Map<String, String> metadataOutTransit = Collections.checkedMap(
-        new HashMap<String, String>(metadata.size() * 2), String.class, String.class);
+    Metadata metadataInTransit = new Metadata(metadata);
     Map<String, String> paramsInTransit = Collections.checkedMap(
         new HashMap<String, String>(params.size() * 2), String.class, String.class);
     Map<String, String> paramsOutTransit = Collections.checkedMap(
         new HashMap<String, String>(params.size() * 2), String.class, String.class);
 
     contentInTransit.write(contentIn);
-    metadataInTransit.putAll(metadata);
     paramsInTransit.putAll(params);
 
     for (DocumentTransform transform : transformList) {
       contentOutTransit.reset();
-      metadataOutTransit.clear();
-      metadataOutTransit.putAll(metadataInTransit);
+      // Invariant: metadataInTransit changes after good transform only.
+      Metadata metadataOutTransit = new Metadata(metadataInTransit);
       paramsOutTransit.clear();
       paramsOutTransit.putAll(paramsInTransit);
 
@@ -93,20 +89,18 @@
           continue;
         }
       }
+      metadataInTransit = metadataOutTransit;
+      metadataOutTransit = null;
       // Swap input and output. The input is reused as the output for effeciency.
       ByteArrayOutputStream tmp = contentInTransit;
       contentInTransit = contentOutTransit;
       contentOutTransit = tmp;
-      Map<String, String> tmpMap = metadataInTransit;
-      metadataInTransit = metadataOutTransit;
-      metadataOutTransit = tmpMap;
-      tmpMap = paramsInTransit;
+      Map<String, String> tmpMap = paramsInTransit;
       paramsInTransit = paramsOutTransit;
       paramsOutTransit = tmpMap;
     }
     contentInTransit.writeTo(contentOut);
-    metadata.clear();
-    metadata.putAll(metadataInTransit);
+    metadata.set(metadataInTransit);
     params.clear();
     params.putAll(paramsInTransit);
   }
diff --git a/src/com/google/enterprise/adaptor/WrapperAdaptor.java b/src/com/google/enterprise/adaptor/WrapperAdaptor.java
index 0314d04..ad7d6a1 100644
--- a/src/com/google/enterprise/adaptor/WrapperAdaptor.java
+++ b/src/com/google/enterprise/adaptor/WrapperAdaptor.java
@@ -119,8 +119,8 @@
     }
 
     @Override
-    public void setMetadata(Map<String, String> m) {
-      response.setMetadata(m);
+    public void addMetadata(String key, String value) {
+      response.addMetadata(key, value);
     }
 
     @Override
@@ -185,7 +185,7 @@
   public static class GetContentsResponse implements Response {
     private OutputStream os;
     private String contentType;
-    private Map<String, String> metadata;
+    private Metadata metadata = new Metadata();
     private Acl acl;
     private List<URI> anchorUris = new ArrayList<URI>();
     private List<String> anchorTexts = new ArrayList<String>();
@@ -219,9 +219,8 @@
     }
 
     @Override
-    public void setMetadata(Map<String, String> m) {
-      this.metadata = Collections.unmodifiableMap(
-          new HashMap<String, String>(m));
+    public void addMetadata(String key, String value) {
+      this.metadata.add(key, value);
     }
 
     @Override
@@ -254,7 +253,8 @@
       return contentType;
     }
 
-    public Map<String, String> getMetadata() {
+    /** Returns reference to modifiable accumulated metadata. */
+    public Metadata getMetadata() {
       return metadata;
     }
 
diff --git a/src/com/google/enterprise/adaptor/examples/AdaptorWithCrawlTimeMetadataTemplate.java b/src/com/google/enterprise/adaptor/examples/AdaptorWithCrawlTimeMetadataTemplate.java
index e28c14f..b830160 100644
--- a/src/com/google/enterprise/adaptor/examples/AdaptorWithCrawlTimeMetadataTemplate.java
+++ b/src/com/google/enterprise/adaptor/examples/AdaptorWithCrawlTimeMetadataTemplate.java
@@ -56,12 +56,10 @@
       str = "Document 1001 says hello and apple orange";
       List<String> users1001 = Arrays.asList("peter", "bart", "simon");
       List<String> groups1001 = Arrays.asList("support", "sales");
-      Map<String, String> metadata = new HashMap<String, String>();
       // Add custom meta items.
-      metadata.put("my-special-key", "my-custom-value");
-      metadata.put("date", "not soon enough");
+      resp.addMetadata("my-special-key", "my-custom-value");
+      resp.addMetadata("date", "not soon enough");
       // Must set metadata before getting OutputStream
-      resp.setMetadata(metadata);
       resp.setAcl(new Acl.Builder()
           // Add user ACL.
           .setPermitUsers(users1001)
@@ -70,9 +68,8 @@
           .build());
     } else if ("1002".equals(id.getUniqueId())) {
       str = "Document 1002 says hello and banana strawberry";
-      // Must set metadata before getting OutputStream
-      resp.setMetadata(
-          Collections.singletonMap("date", "better never than late"));
+      // Must add metadata before getting OutputStream
+      resp.addMetadata("date", "never than late");
     } else {
       resp.respondNotFound();
       return;
diff --git a/src/com/google/enterprise/adaptor/examples/CalaisNERTransform.java b/src/com/google/enterprise/adaptor/examples/CalaisNERTransform.java
index bc73c59..ecc759c 100644
--- a/src/com/google/enterprise/adaptor/examples/CalaisNERTransform.java
+++ b/src/com/google/enterprise/adaptor/examples/CalaisNERTransform.java
@@ -15,6 +15,7 @@
 package com.google.enterprise.adaptor.examples;
 
 import com.google.enterprise.adaptor.AbstractDocumentTransform;
+import com.google.enterprise.adaptor.Metadata;
 import com.google.enterprise.adaptor.TransformException;
 
 import mx.bigdata.jcalais.CalaisClient;
@@ -82,7 +83,7 @@
    */
   @Override
   public void transform(ByteArrayOutputStream contentIn, OutputStream contentOut,
-                        Map<String, String> metadata, Map<String, String> params)
+                        Metadata metadata, Map<String, String> params)
       throws TransformException, IOException {
     String apiKey = params.get("OpenCalaisApiKey");
     if (apiKey == null) {
diff --git a/src/com/google/enterprise/adaptor/examples/MetaTaggerTransform.java b/src/com/google/enterprise/adaptor/examples/MetaTaggerTransform.java
index ee77d20..ce7bd56 100644
--- a/src/com/google/enterprise/adaptor/examples/MetaTaggerTransform.java
+++ b/src/com/google/enterprise/adaptor/examples/MetaTaggerTransform.java
@@ -15,6 +15,7 @@
 package com.google.enterprise.adaptor.examples;
 
 import com.google.enterprise.adaptor.AbstractDocumentTransform;
+import com.google.enterprise.adaptor.Metadata;
 import com.google.enterprise.adaptor.TransformException;
 
 import java.io.ByteArrayOutputStream;
@@ -45,7 +46,7 @@
 
   @Override
   public void transform(ByteArrayOutputStream contentIn, OutputStream contentOut,
-                        Map<String, String> metadata, Map<String, String> params)
+                        Metadata metadata, Map<String, String> params)
       throws TransformException, IOException {
     String content = contentIn.toString();
     StringBuilder sb = new StringBuilder();
diff --git a/src/com/google/enterprise/adaptor/examples/TableGeneratorTransform.java b/src/com/google/enterprise/adaptor/examples/TableGeneratorTransform.java
index f05d372..038205d 100644
--- a/src/com/google/enterprise/adaptor/examples/TableGeneratorTransform.java
+++ b/src/com/google/enterprise/adaptor/examples/TableGeneratorTransform.java
@@ -15,6 +15,7 @@
 package com.google.enterprise.adaptor.examples;
 
 import com.google.enterprise.adaptor.AbstractDocumentTransform;
+import com.google.enterprise.adaptor.Metadata;
 import com.google.enterprise.adaptor.TransformException;
 
 import au.com.bytecode.opencsv.CSVReader;
@@ -49,7 +50,7 @@
 
   @Override
   public void transform(ByteArrayOutputStream contentIn, OutputStream contentOut,
-                        Map<String, String> metadata, Map<String, String> params)
+                        Metadata metadata, Map<String, String> params)
       throws TransformException, IOException {
     String csv = contentIn.toString();
     List<String[]> records = new CSVReader(new StringReader(csv)).readAll();
diff --git a/src/com/google/enterprise/adaptor/prebuilt/CommandLineAdaptor.java b/src/com/google/enterprise/adaptor/prebuilt/CommandLineAdaptor.java
index cb15b9d..7dfdc0b 100644
--- a/src/com/google/enterprise/adaptor/prebuilt/CommandLineAdaptor.java
+++ b/src/com/google/enterprise/adaptor/prebuilt/CommandLineAdaptor.java
@@ -35,6 +35,7 @@
 import java.util.Date;
 import java.util.List;
 import java.util.Map;
+import java.util.logging.Level;
 import java.util.logging.Logger;
 
 /**
@@ -194,9 +195,11 @@
         resp.setContentType(retrieverInfo.getMimeType());
       }
       if (retrieverInfo.getMetadata() != null) {
-        log.finest("Retriever: " + id.getUniqueId() + " has metadata "
-            + retrieverInfo.getMetadata());
-        resp.setMetadata(retrieverInfo.getMetadata());
+        log.log(Level.FINEST, "Retriever: {0} has metadata {1}",
+            new Object[] {id.getUniqueId(), retrieverInfo.getMetadata()});
+        for (Map.Entry<String, String> e : retrieverInfo.getMetadata()) {
+          resp.addMetadata(e.getKey(), e.getValue());
+        }
       }
       if (retrieverInfo.getContents() != null) {
         resp.getOutputStream().write(retrieverInfo.getContents());
diff --git a/src/com/google/enterprise/adaptor/prebuilt/CommandLineTransform.java b/src/com/google/enterprise/adaptor/prebuilt/CommandLineTransform.java
index 217130b..39507f4 100644
--- a/src/com/google/enterprise/adaptor/prebuilt/CommandLineTransform.java
+++ b/src/com/google/enterprise/adaptor/prebuilt/CommandLineTransform.java
@@ -14,8 +14,11 @@
 
 package com.google.enterprise.adaptor.prebuilt;
 
+import static java.util.AbstractMap.SimpleEntry;
+
 import com.google.enterprise.adaptor.AbstractDocumentTransform;
 import com.google.enterprise.adaptor.IOHelper;
+import com.google.enterprise.adaptor.Metadata;
 import com.google.enterprise.adaptor.TransformException;
 
 import java.io.*;
@@ -80,7 +83,7 @@
   @Override
   public void transform(ByteArrayOutputStream contentIn,
                         OutputStream contentOut,
-                        Map<String, String> metadata,
+                        Metadata metadata,
                         Map<String, String> params)
       throws TransformException, IOException {
     if (transformCommand == null) {
@@ -91,7 +94,7 @@
     try {
       String[] commandLine;
       if (commandAcceptsParameters) {
-        metadataFile = writeMapToTempFile(metadata);
+        metadataFile = writeIterableToTempFile(metadata);
         paramsFile = writeMapToTempFile(params);
 
         commandLine = new String[transformCommand.size() + 2];
@@ -125,8 +128,7 @@
 
       contentOut.write(command.getStdout());
       if (commandAcceptsParameters) {
-        metadata.clear();
-        metadata.putAll(readMapFromFile(metadataFile));
+        metadata.set(readSetFromFile(metadataFile));
         params.clear();
         params.putAll(readMapFromFile(paramsFile));
       }
@@ -142,8 +144,13 @@
 
   private File writeMapToTempFile(Map<String, String> map)
       throws IOException, TransformException {
+    return writeIterableToTempFile(map.entrySet());
+  }
+
+  private File writeIterableToTempFile(Iterable<Map.Entry<String, String>> it)
+      throws IOException, TransformException {
     StringBuilder sb = new StringBuilder();
-    for (Map.Entry<String, String> me : map.entrySet()) {
+    for (Map.Entry<String, String> me : it) {
       if (me.getKey().contains("\0")) {
         throw new TransformException("Key cannot contain the null character: "
                                      + me.getKey());
@@ -158,7 +165,7 @@
     return IOHelper.writeToTempFile(sb.toString(), charset);
   }
 
-  private Map<String, String> readMapFromFile(File file) throws IOException {
+  private List<Map.Entry<String, String>> readListFromFile(File file) throws IOException {
     InputStream is = new FileInputStream(file);
     String str;
     try {
@@ -168,9 +175,23 @@
     }
 
     String[] list = str.split("\0");
-    Map<String, String> map = new HashMap<String, String>(list.length);
+    List<Map.Entry<String, String>> all = new ArrayList<Map.Entry<String, String>>();
     for (int i = 0; i + 1 < list.length; i += 2) {
-      map.put(list[i], list[i + 1]);
+      all.add(new SimpleEntry<String, String>(list[i], list[i + 1]));
+    }
+    return all;
+  }
+
+  private Set<Map.Entry<String, String>> readSetFromFile(File file) throws IOException {
+    List<Map.Entry<String, String>> all = readListFromFile(file);
+    Set<Map.Entry<String, String>> set = new HashSet<Map.Entry<String, String>>(all);
+    return set;
+  }
+
+  private Map<String, String> readMapFromFile(File file) throws IOException {
+    Map<String, String> map = new HashMap<String, String>();
+    for (Map.Entry<String, String> e : readListFromFile(file)) {
+      map.put(e.getKey(), e.getValue());
     }
     return map;
   }
diff --git a/test/com/google/enterprise/adaptor/CommandStreamParserTest.java b/test/com/google/enterprise/adaptor/CommandStreamParserTest.java
index c875567..d63e05d 100644
--- a/test/com/google/enterprise/adaptor/CommandStreamParserTest.java
+++ b/test/com/google/enterprise/adaptor/CommandStreamParserTest.java
@@ -30,7 +30,9 @@
 import java.net.URISyntaxException;
 import java.util.ArrayList;
 import java.util.HashMap;
+import java.util.HashSet;
 import java.util.Map;
+import java.util.Set;
 
 /**
  * Tests for {@link CommandStreamParser}.
@@ -163,9 +165,35 @@
     assertEquals("123", info.getDocId().getUniqueId());
     assertTrue(info.isUpToDate());
     assertArrayEquals("2468".getBytes(), info.getContents());
-    Map<String, String> metadata = info.getMetadata();
-    assertEquals(1, metadata.size());
-    assertEquals("plexi", metadata.get("project"));
+    Metadata metadata = info.getMetadata();
+    assertEquals(1, metadata.getKeys().size());
+    assertEquals("plexi", metadata.getOneValue("project"));
+  }
+
+  @Test
+  public void testRetrieverMultipleMetadataValuesSameKey() throws IOException {
+    String source = "GSA Adaptor Data Version 1 [\n]\n" +
+        "id=123\n" +
+        "up-to-date\n" +
+        "UNKNOWN_COMMAND=abcdefghi\n" +
+        "meta-name=project\nmeta-value=plexi\n" +
+        "meta-name=project\nmeta-value=klexa\ncontent\n2468";
+
+    InputStream inputStream = new ByteArrayInputStream(source.getBytes("UTF-8"));
+    CommandStreamParser parser = new CommandStreamParser(inputStream);
+    int version = parser.getVersionNumber();
+    assertEquals(1, version);
+
+    CommandStreamParser.RetrieverInfo info = parser.readFromRetriever();
+    assertEquals("123", info.getDocId().getUniqueId());
+    assertTrue(info.isUpToDate());
+    assertArrayEquals("2468".getBytes(), info.getContents());
+    Metadata metadata = info.getMetadata();
+    assertEquals(1, metadata.getKeys().size());
+    Set<String> projectNames = new HashSet<String>();
+    projectNames.add("plexi");
+    projectNames.add("klexa");
+    assertEquals(projectNames, metadata.getAllValues("project"));
   }
 
   @Test
@@ -216,7 +244,7 @@
 
   @Test
   public void testRepositoryUnavailable() throws IOException {
-    String source = "GSA Adaptor Data Version 1 [\n]\nrepository-unavailable" ;
+    String source = "GSA Adaptor Data Version 1 [\n]\nrepository-unavailable";
 
     InputStream inputStream = new ByteArrayInputStream(source.getBytes("UTF-8"));
     CommandStreamParser parser = new CommandStreamParser(inputStream);
diff --git a/test/com/google/enterprise/adaptor/DocumentHandlerTest.java b/test/com/google/enterprise/adaptor/DocumentHandlerTest.java
index b2c3aea..63bad29 100644
--- a/test/com/google/enterprise/adaptor/DocumentHandlerTest.java
+++ b/test/com/google/enterprise/adaptor/DocumentHandlerTest.java
@@ -337,12 +337,12 @@
       @Override
       public void transform(ByteArrayOutputStream contentIn,
                             OutputStream contentOut,
-                            Map<String, String> metadata,
+                            Metadata metadata,
                             Map<String, String> params) throws IOException {
         assertArrayEquals(mockAdaptor.documentBytes, contentIn.toByteArray());
         contentOut.write(golden);
-        metadata.put(key, metadata.get(key).toUpperCase());
-        metadata.put("docid", params.get("DocId"));
+        metadata.set(key, metadata.getOneValue(key).toUpperCase());
+        metadata.set("docid", params.get("DocId"));
       }
     });
     TransformPipeline transform = new TransformPipeline(transforms);
@@ -350,7 +350,7 @@
       @Override
       public void getDocContent(Request request, Response response)
           throws IOException {
-        response.setMetadata(Collections.singletonMap(key, "testing value"));
+        response.addMetadata(key, "testing value");
         super.getDocContent(request, response);
       }
     };
@@ -375,7 +375,7 @@
       @Override
       public void transform(ByteArrayOutputStream contentIn,
                             OutputStream contentOut,
-                            Map<String, String> metadata,
+                            Metadata metadata,
                             Map<String, String> params) throws IOException {
         // This is not the content we are looking for.
         contentOut.write(new byte[] {2, 3, 4});
@@ -588,7 +588,7 @@
           public void getDocContent(Request request, Response response)
               throws IOException {
             response.getOutputStream();
-            response.setMetadata(Collections.<String, String>emptyMap());
+            response.addMetadata("not", "important");
           }
         };
     DocumentHandler handler = createDefaultHandlerForAdaptor(adaptor);
@@ -704,7 +704,7 @@
               return;
             }
             response.setContentType("text/plain");
-            response.setMetadata(Collections.<String, String>emptyMap());
+            response.addMetadata("not", "important");
             response.setAcl(Acl.EMPTY);
             response.getOutputStream();
             // It is free to get it multiple times
@@ -739,7 +739,7 @@
           @Override
           public void getDocContent(Request request, Response response)
               throws IOException {
-            response.setMetadata(Collections.singletonMap("test", "ing"));
+            response.addMetadata("test", "ing");
             response.setAcl(new Acl.Builder()
                 .setInheritFrom(new DocId("testing")).build());
             response.addAnchor(URI.create("http://test/"), null);
@@ -777,7 +777,7 @@
           @Override
           public void getDocContent(Request request, Response response)
               throws IOException {
-            response.setMetadata(Collections.singletonMap("test", "ing"));
+            response.addMetadata("test", "ing");
             response.setAcl(new Acl.Builder()
                 .setInheritFrom(new DocId("testing")).build());
             response.getOutputStream();
@@ -802,18 +802,17 @@
 
   @Test
   public void testFormMetadataHeader() {
-    Map<String, String> metadata = new HashMap<String, String>();
-    metadata.put("test", "ing");
-    metadata.put("another", "item");
-    metadata.put("equals=", "==");
+    Metadata metadata = new Metadata();
+    metadata.add("test", "ing");
+    metadata.add("another", "item");
+    metadata.add("equals=", "==");
     String result = DocumentHandler.formMetadataHeader(metadata);
     assertEquals("another=item,equals%3D=%3D%3D,test=ing", result);
   }
 
   @Test
   public void testFormMetadataHeaderEmpty() {
-    String header = DocumentHandler.formMetadataHeader(
-        Collections.<String, String>emptyMap());
+    String header = DocumentHandler.formMetadataHeader(new Metadata());
     assertEquals("", header);
   }
 
diff --git a/test/com/google/enterprise/adaptor/GsaCommunicationHandlerTest.java b/test/com/google/enterprise/adaptor/GsaCommunicationHandlerTest.java
index 249c985..a10f1e3 100644
--- a/test/com/google/enterprise/adaptor/GsaCommunicationHandlerTest.java
+++ b/test/com/google/enterprise/adaptor/GsaCommunicationHandlerTest.java
@@ -312,7 +312,7 @@
     @Override
     public void transform(ByteArrayOutputStream contentIn,
                           OutputStream contentOut,
-                          Map<String, String> metadata,
+                          Metadata metadata,
                           Map<String, String> params) throws IOException {
       contentIn.writeTo(contentOut);
     }
diff --git a/test/com/google/enterprise/adaptor/MetadataTest.java b/test/com/google/enterprise/adaptor/MetadataTest.java
new file mode 100644
index 0000000..d882058
--- /dev/null
+++ b/test/com/google/enterprise/adaptor/MetadataTest.java
@@ -0,0 +1,419 @@
+// Copyright 2012 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 com.google.enterprise.adaptor;
+
+import static java.util.AbstractMap.SimpleEntry;
+import static java.util.Map.Entry;
+
+import static org.junit.Assert.*;
+
+import org.junit.*;
+import org.junit.rules.ExpectedException;
+
+import java.util.Arrays;
+import java.util.Iterator;
+import java.util.HashSet;
+import java.util.Set;
+
+/** Test cases for {@link Metadata}. */
+public class MetadataTest {
+  @Rule
+  public ExpectedException thrown = ExpectedException.none();
+
+  @Test
+  public void testInitiallyEmpty() {
+    Metadata m = new Metadata();
+    assertTrue(m.isEmpty());
+    assertTrue(m.getKeys().isEmpty());
+    assertFalse(m.iterator().hasNext());
+  }
+
+  @Test
+  public void testDupConstructor() {
+    Metadata m1 = new Metadata();
+    m1.add("foo", "home");
+    m1.add("bar", "far");
+    Metadata m2 = new Metadata(m1);
+    assertEquals(m1, m2);
+    m1.set("shoes", makeSet("bing", "bongo"));
+    m2 = new Metadata(m1);
+    assertEquals(m1, m2);
+    // New values.
+    m1.set("shoes", makeSet("bongo", "bing"));
+    assertEquals(m1, m2);
+  }
+
+  @Test
+  public void testSingleSetAndGet() {
+    Metadata m = new Metadata();
+    assertEquals(null, m.getOneValue("foo"));
+    m.set("foo", "bar");
+    assertEquals("bar", m.getOneValue("foo"));
+    m.set("foo", "foo");
+    assertEquals("foo", m.getOneValue("foo"));
+    m.set("foo", "bar");
+    assertEquals("bar", m.getOneValue("foo"));
+  }
+
+  @Test
+  public void testSingleSetNullValue() {
+    Metadata m = new Metadata();
+    thrown.expect(NullPointerException.class);
+    m.set("foo", (String) null);
+  }
+
+  @Test
+  public void testSingleSetNullKey() {
+    Metadata m = new Metadata();
+    thrown.expect(NullPointerException.class);
+    m.set(null, "bar");
+  }
+
+  @Test
+  public void testSingleSetEffectOnKeys() {
+    Metadata m = new Metadata();
+    assertEquals(0, m.getKeys().size());
+    m.set("foo", "bar");
+    assertEquals(1, m.getKeys().size());
+    m.set("foo", "bar");
+    assertEquals(1, m.getKeys().size());
+    m.set("bar", "foo");
+    assertEquals(2, m.getKeys().size());
+  }
+
+  private static Set<String> makeSet(String ... s) {
+    return new HashSet<String>(Arrays.asList(s));
+  }
+
+  @Test
+  public void testMultipleSetAndGet() {
+    Metadata m = new Metadata();
+    assertEquals(null, m.getOneValue("foo"));
+    m.set("foo", makeSet("bar", "home"));
+    assertTrue(makeSet("bar", "home").contains(m.getOneValue("foo")));
+    assertEquals(makeSet("bar", "home"), m.getAllValues("foo"));
+    assertEquals(makeSet("home", "bar"), m.getAllValues("foo"));
+    m.set("foo", makeSet("foo"));
+    assertEquals("foo", m.getOneValue("foo"));
+    assertEquals(makeSet("foo"), m.getAllValues("foo"));
+    m.set("foo", makeSet("barf", "floor"));
+    assertTrue(makeSet("barf", "floor").contains(m.getOneValue("foo")));
+    assertEquals(makeSet("barf", "floor"), m.getAllValues("foo"));
+  }
+
+  @Test
+  public void testMultipleGetNoKey() {
+    Metadata m = new Metadata();
+    assertEquals(makeSet(), m.getAllValues("foo"));
+  }
+
+  @Test
+  public void testMultipleSetEmptySetRemoves() {
+    Metadata m = new Metadata();
+    m.set("foo", makeSet("bar", "home"));
+    assertFalse(m.isEmpty());
+    m.set("foo", makeSet());
+    assertTrue(m.isEmpty());
+  }
+
+  @Test
+  public void testMultipleSetNullValue() {
+    Metadata m = new Metadata();
+    assertTrue(m.isEmpty());
+    Set<String> nullSet = null;
+    thrown.expect(NullPointerException.class);
+    m.set("foo", nullSet);
+  }
+
+  @Test
+  public void testMultipleSetEmptySet() {
+    Metadata m = new Metadata();
+    assertTrue(m.isEmpty());
+    Set<String> emptySet = makeSet();
+    m.set("foo", emptySet);
+    assertTrue(m.isEmpty());
+    m.set("bar", makeSet("bar"));
+    assertEquals(1, m.getKeys().size());
+    m.set("foo", emptySet);
+    assertEquals(1, m.getKeys().size());
+    m.set("bar", emptySet);
+    assertTrue(m.isEmpty());
+  }
+
+  @Test
+  public void testSetEntriesIterable() {
+    HashSet<Entry<String, String>> e1 = new HashSet<Entry<String, String>>();
+    HashSet<Entry<String, String>> e2 = new HashSet<Entry<String, String>>();
+    e1.add(new SimpleEntry<String, String>("a", "b"));
+    e1.add(new SimpleEntry<String, String>("b", "q"));
+    e2.add(new SimpleEntry<String, String>("a", "b"));
+    e2.add(new SimpleEntry<String, String>("b", "q"));
+    Metadata m1 = new Metadata();
+    m1.set(e1);
+    Metadata m2 = new Metadata();
+    m2.set(e2);
+    assertEquals(m1, m2);
+  }
+
+  @Test
+  public void testSetEntriesNull() {
+    Metadata m = new Metadata();
+    thrown.expect(NullPointerException.class);
+    m.set(null);
+  }
+
+  @Test
+  public void testSetMetadata() {
+    Metadata m1 = new Metadata();
+    Metadata m2 = new Metadata();
+    m1.set("foo", makeSet("home", "floor"));
+    m2.set(m1);
+    assertEquals(m1, m2);
+    m1.set("foo", makeSet("home", "floor"));
+    assertEquals(m1, m2);
+    Set<String> vals = new HashSet<String>();
+    vals.add("pigeon");
+    vals.add("eagle");
+    m1.set("foo", vals);
+    m2.set(m1);
+    assertTrue(m1.equals(m2));
+    // Should not change m2.
+    m1.add("foo", "bird");
+    assertFalse(m1.equals(m2));
+  }
+
+  @Test
+  public void testReturningUnmodifiableSetsA() {
+    Metadata m = new Metadata();
+    m.set("foo", makeSet("bar", "home", "villa"));
+    Set<String> all = m.getAllValues("foo");
+    thrown.expect(UnsupportedOperationException.class);
+    all.add("newnew");
+  }
+
+  @Test
+  public void testReturningUnmodifiableSetsB() {
+    Metadata m = new Metadata();
+    m.set("foo", makeSet("bar", "home", "villa"));
+    Set<String> all = m.getAllValues("foo");
+    thrown.expect(UnsupportedOperationException.class);
+    all.remove("bar");
+  }
+
+  @Test
+  public void testEasyToWriteModificationLoopOverValues() {
+    Metadata m = new Metadata();
+    m.set("foo", makeSet("bar", "home", "villa"));
+    // End setup.
+    // Loop should be idiomatic to write.
+    Set<String> dest = new HashSet<String>();
+    for (String v : m.getAllValues("foo")) {
+      dest.add(v.toUpperCase());
+    }
+    m.set("foo", dest);
+    // Double check function.
+    assertEquals(makeSet("BAR", "HOME", "VILLA"), m.getAllValues("foo"));
+  }
+
+  @Test
+  public void testReturningRemovalKeys() {
+    Metadata m = new Metadata();
+    m.set("foo", makeSet("bar", "home"));
+    m.set("sna", makeSet("fu"));
+    Set<String> keys = m.getKeys();
+    assertEquals(2, keys.size());
+    keys.remove("sna");
+    assertEquals(1, keys.size());
+    assertEquals(1, m.getKeys().size());
+    assertEquals(keys, m.getKeys());
+  }
+
+  @Test
+  public void testIteratingOverImmutableEntries() {
+    Metadata m = new Metadata();
+    m.set("foo", makeSet("bar", "home"));
+    thrown.expect(UnsupportedOperationException.class);
+    m.iterator().next().setValue("HOME");
+  }
+
+  private static Entry<String, String> ne(String k, String v) {
+    return new SimpleEntry<String, String>(k, v);
+  }
+
+  @Test
+  public void testEntriesGivenSorted() {
+    Metadata m = new Metadata();
+    m.set("foo", makeSet("home", "bar"));
+    m.set("early", makeSet("bird"));
+    m.set("cleary", makeSet("obfuscated"));
+    m.set("dearly", makeSet("beloved"));
+    m.set("badly", makeSet("traversed", "implied"));
+    Iterator<Entry<String, String>> it = m.iterator();
+    assertEquals(ne("badly", "implied"), it.next());
+    assertEquals(ne("badly", "traversed"), it.next());
+    assertEquals(ne("cleary", "obfuscated"), it.next());
+    assertEquals(ne("dearly", "beloved"), it.next());
+    assertEquals(ne("early", "bird"), it.next());
+    assertEquals(ne("foo", "bar"), it.next());
+    assertEquals(ne("foo", "home"), it.next());
+    assertFalse(it.hasNext());
+  }
+
+  @Test
+  public void testEmptyIterator() {
+    Metadata m = new Metadata();
+    assertFalse(m.iterator().hasNext());
+  }
+
+  @Test
+  public void testNormalAdd() {
+    Metadata m = new Metadata();
+    m.add("foo", "home");
+    assertEquals("home", m.getOneValue("foo"));
+    m.add("foo", "bar");
+    assertTrue(makeSet("bar", "home").contains(m.getOneValue("foo")));
+    assertEquals(makeSet("home", "bar"), m.getAllValues("foo"));
+    assertEquals(1, m.getKeys().size());
+    m.add("foo", "few");
+    assertEquals(1, m.getKeys().size());
+    m.add("bar", "mor");
+    assertEquals(makeSet("mor"), m.getAllValues("bar"));
+    m.add("bar", "far");
+    assertEquals(2, m.getKeys().size());
+    assertEquals(makeSet("far", "mor"), m.getAllValues("bar"));
+    m.add("bar", "far");
+    assertEquals(makeSet("far", "mor"), m.getAllValues("bar"));
+    m.add("bar", "mor");
+    assertEquals(makeSet("far", "mor"), m.getAllValues("bar"));
+  }
+
+  @Test
+  public void testNullAddA() {
+    Metadata m = new Metadata();
+    thrown.expect(NullPointerException.class);
+    m.add("foo", null);
+  }
+
+  @Test
+  public void testNullAddB() {
+    Metadata m = new Metadata();
+    m.add("foo", "bar");
+    thrown.expect(NullPointerException.class);
+    m.add("foo", null);
+  }
+
+  @Test
+  public void testNullKeysNotAllowedInAdd() {
+    Metadata m = new Metadata();
+    thrown.expect(NullPointerException.class);
+    m.add(null, "can-no-add");
+  }
+
+  @Test
+  public void testEquals() {
+    Metadata m1 = new Metadata();
+    m1.add("foo", "home");
+    Metadata m2 = new Metadata();
+    m2.add("foo", "home");
+    assertEquals(m1, m2);
+
+    m1.add("foo", "bar");
+    m2.add("foo", "bar");
+    assertEquals(m1, m2);
+
+    m1.set("foo", "high");
+    m2.set("foo", "low");
+    assertFalse(m1.equals(m2));
+
+    m2.set("foo", "high");
+    assertEquals(m1, m2);
+
+    m1.set("bar", makeSet("floor", "door"));
+    m2.set("bar", makeSet("floor", "door"));
+    assertEquals(m1, m2);
+
+    m1.set("bar", makeSet("near", "far"));
+    assertFalse(m1.equals(m2));
+    m2.set("bar", makeSet("near", "far"));
+    assertEquals(m1, m2);
+  }
+
+  @Test
+  public void testUnmodifiableSetSingle() {
+    Metadata m = new Metadata().unmodifiableView();
+    thrown.expect(UnsupportedOperationException.class);
+    m.set("foo", "bar");
+  }
+
+  @Test
+  public void testUnmodifiableSetMultiple() {
+    Metadata m = new Metadata().unmodifiableView();
+    thrown.expect(UnsupportedOperationException.class);
+    m.set("foo", makeSet("bar", "home"));
+  }
+
+  @Test
+  public void testUnmodifiableAdd() {
+    Metadata m = new Metadata().unmodifiableView();
+    thrown.expect(UnsupportedOperationException.class);
+    m.add("foo", "bar");
+  }
+
+  @Test
+  public void testUnmodifiableSetIterable() {
+    Metadata m = new Metadata().unmodifiableView();
+    thrown.expect(UnsupportedOperationException.class);
+    m.set(new Metadata());
+  }
+  @Test
+  public void testUnmodifiableSetSingleB() {
+    Metadata m = new Metadata().unmodifiableView();
+    thrown.expect(UnsupportedOperationException.class);
+    m.set("foo", (String) null);
+  }
+
+  @Test
+  public void testUnmodifiableSetMultipleB() {
+    Metadata m = new Metadata().unmodifiableView();
+    thrown.expect(UnsupportedOperationException.class);
+    m.set("foo", (Set<String>) null);
+  }
+
+  @Test
+  public void testUnmodifiableAddB() {
+    Metadata m = new Metadata().unmodifiableView();
+    thrown.expect(UnsupportedOperationException.class);
+    m.add("foo", null);
+  }
+
+  @Test
+  public void testUnmodifiableSetIterableB() {
+    Metadata m = new Metadata().unmodifiableView();
+    thrown.expect(UnsupportedOperationException.class);
+    m.set(null);
+  }
+
+  @Test
+  public void testUnmodifiableDoesNotAllowKeyRemoval() {
+    Metadata m = new Metadata();
+    m.set("foo", makeSet("bar", "home"));
+    m.set("sna", makeSet("fu"));
+    m = m.unmodifiableView();
+    Set<String> keys = m.getKeys();
+    assertEquals(2, keys.size());
+    thrown.expect(UnsupportedOperationException.class);
+    keys.remove("sna");
+  }
+}
diff --git a/test/com/google/enterprise/adaptor/TransformPipelineTest.java b/test/com/google/enterprise/adaptor/TransformPipelineTest.java
index 0d66e15..c60b293 100644
--- a/test/com/google/enterprise/adaptor/TransformPipelineTest.java
+++ b/test/com/google/enterprise/adaptor/TransformPipelineTest.java
@@ -35,12 +35,12 @@
   public void testNoOpEmpty() throws IOException, TransformException {
     TransformPipeline pipeline = new TransformPipeline(Collections.<DocumentTransform>emptyList());
     ByteArrayOutputStream contentOut = new ByteArrayOutputStream();
-    Map<String, String> metadata = new HashMap<String, String>();
+    Metadata metadata = new Metadata();
     Map<String, String> params = new HashMap<String, String>();
     pipeline.transform(new byte[0], contentOut, metadata, params);
 
     assertEquals(0, contentOut.size());
-    assertEquals(Collections.emptyMap(), metadata);
+    assertEquals(new Metadata(), metadata);
     assertEquals(Collections.emptyMap(), params);
   }
 
@@ -48,216 +48,58 @@
   public void testNoOpWithInput() throws IOException, TransformException {
     TransformPipeline pipeline = new TransformPipeline(Collections.<DocumentTransform>emptyList());
     ByteArrayOutputStream contentOut = new ByteArrayOutputStream();
-    Map<String, String> metadata = new HashMap<String, String>();
-    metadata.put("key1", "value1");
+    Metadata metadata = new Metadata();
+    metadata.add("key1", "value1");
     Map<String, String> params = new HashMap<String, String>();
     params.put("key2", "value2");
     String testString = "Here is some input";
     pipeline.transform(testString.getBytes(), contentOut, metadata, params);
 
     assertEquals(testString, contentOut.toString());
-    assertEquals(Collections.singletonMap("key1", "value1"), metadata);
+    Metadata goldenMetadata = new Metadata();
+    goldenMetadata.add("key1", "value1");
+    assertEquals(goldenMetadata, metadata);
     assertEquals(Collections.singletonMap("key2", "value2"), params);
   }
 
   @Test
   public void testAddMetadataAndParams() throws IOException, TransformException {
-    Map<String, String> metadata = new HashMap<String, String>();
-    metadata.put("key1", "value1");
+    Metadata metadata = new Metadata();
+    metadata.add("key1", "value1");
     Map<String, String> params = new HashMap<String, String>();
     params.put("key2", "value2");
 
     List<DocumentTransform> transforms = new ArrayList<DocumentTransform>();
     transforms.add(new AbstractDocumentTransform() {
         @Override
-        public void transform(ByteArrayOutputStream cIn, OutputStream cOut, Map<String, String> m,
+        public void transform(ByteArrayOutputStream cIn, OutputStream cOut, Metadata m,
                               Map<String, String> p) throws TransformException, IOException {
-          m.put("newMeta", "metaValue");
+          m.set("newMeta", "metaValue");
           p.put("newKey", "newValue");
         }
       });
     TransformPipeline pipeline = new TransformPipeline(transforms);
     pipeline.transform(new byte[0], new ByteArrayOutputStream(), metadata, params);
 
-    assertEquals("value1", metadata.get("key1"));
-    assertEquals("metaValue", metadata.get("newMeta"));
-    assertEquals(2, metadata.size());
+    assertEquals("value1", metadata.getOneValue("key1"));
+    assertEquals("metaValue", metadata.getOneValue("newMeta"));
+    assertEquals(2, metadata.getKeys().size());
     assertEquals("value2", params.get("key2"));
     assertEquals("newValue", params.get("newKey"));
     assertEquals(2, params.size());
   }
 
-  @Test
-  public void testTransform() throws IOException, TransformException {
-    TransformPipeline pipeline = new TransformPipeline(Arrays.asList(new IncrementTransform()));
-    ByteArrayOutputStream out = new ByteArrayOutputStream();
-    Map<String, String> metadata = new HashMap<String, String>();
-    metadata.put("int", "0");
-    Map<String, String> params = new HashMap<String, String>();
-    params.put("int", "1");
-
-    pipeline.transform(new byte[] {1, 2, 3}, out, metadata, params);
-
-    assertArrayEquals(new byte[] {2, 3, 4}, out.toByteArray());
-    assertEquals(Collections.singletonMap("int", "1"), metadata);
-    assertEquals(Collections.singletonMap("int", "2"), params);
-  }
-
-  @Test
-  public void testMultipleTransforms() throws IOException, TransformException {
-    TransformPipeline pipeline = new TransformPipeline(Arrays.asList(
-        new IncrementTransform(), new ProductTransform(2)));
-
-    ByteArrayOutputStream out = new ByteArrayOutputStream();
-    Map<String, String> metadata = new HashMap<String, String>();
-    metadata.put("int", "0");
-    Map<String, String> params = new HashMap<String, String>();
-    params.put("int", "1");
-
-    pipeline.transform(new byte[] {1, 2, 3}, out, metadata, params);
-
-    assertArrayEquals(new byte[] {4, 6, 8}, out.toByteArray());
-    assertEquals(Collections.singletonMap("int", "2"), metadata);
-    assertEquals(Collections.singletonMap("int", "4"), params);
-  }
-
-  @Test
-  public void testNotLastTransformError() throws IOException, TransformException {
-    TransformPipeline pipeline = new TransformPipeline(Arrays.asList(
-        new IncrementTransform(), new ErroringTransform(false)));
-    ByteArrayOutputStream out = new ByteArrayOutputStream();
-    Map<String, String> metadata = new HashMap<String, String>();
-    metadata.put("int", "0");
-    Map<String, String> params = new HashMap<String, String>();
-    params.put("int", "1");
-
-    pipeline.transform(new byte[] {1, 2, 3}, out, metadata, params);
-
-    assertArrayEquals(new byte[] {2, 3, 4}, out.toByteArray());
-    assertEquals(Collections.singletonMap("int", "1"), metadata);
-    assertEquals(Collections.singletonMap("int", "2"), params);
-  }
-
-  @Test
-  public void testLastTransformError() throws IOException, TransformException {
-    TransformPipeline pipeline = new TransformPipeline(Arrays.asList(
-        new ErroringTransform(false), new IncrementTransform()));
-    ByteArrayOutputStream out = new ByteArrayOutputStream();
-    Map<String, String> metadata = new HashMap<String, String>();
-    metadata.put("int", "0");
-    Map<String, String> params = new HashMap<String, String>();
-    params.put("int", "1");
-
-    pipeline.transform(new byte[] {1, 2, 3}, out, metadata, params);
-
-    assertArrayEquals(new byte[] {2, 3, 4}, out.toByteArray());
-    assertEquals(Collections.singletonMap("int", "1"), metadata);
-    assertEquals(Collections.singletonMap("int", "2"), params);
-  }
-
-  @Test
-  public void testTransformErrorFatal() throws IOException, TransformException {
-    TransformPipeline pipeline = new TransformPipeline(Arrays.asList(
-        new IncrementTransform(), new ErroringTransform(true)));
-    ByteArrayOutputStream out = new ByteArrayOutputStream();
-    Map<String, String> metadata = new HashMap<String, String>();
-    metadata.put("int", "0");
-    Map<String, String> params = new HashMap<String, String>();
-    params.put("int", "1");
-
-    thrown.expect(TransformException.class);
-    try {
-      pipeline.transform(new byte[] {1, 2, 3}, out, metadata, params);
-    } finally {
-      assertArrayEquals(new byte[] {}, out.toByteArray());
-      assertEquals(Collections.singletonMap("int", "0"), metadata);
-      assertEquals(Collections.singletonMap("int", "1"), params);
-    }
-  }
-
-  @Test
-  public void testResetTransform() throws Exception {
-    List<DocumentTransform> transforms = new ArrayList<DocumentTransform>();
-    transforms.add(new AbstractDocumentTransform() {
-      @Override
-      public void transform(ByteArrayOutputStream contentIn, OutputStream contentOut,
-                            Map<String, String> metadata, Map<String, String> p)
-          throws IOException {
-        // Modifying contentIn is not allowed.
-        contentIn.reset();
-      }
-    });
-    TransformPipeline pipeline = new TransformPipeline(transforms);
-    thrown.expect(UnsupportedOperationException.class);
-    pipeline.transform(new byte[] {1, 2, 3}, new ByteArrayOutputStream(),
-        new HashMap<String, String>(), new HashMap<String, String>());
-  }
-
-  @Test
-  public void testWriteTransform1() throws Exception {
-    List<DocumentTransform> transforms = new ArrayList<DocumentTransform>();
-    transforms.add(new AbstractDocumentTransform() {
-      @Override
-      public void transform(ByteArrayOutputStream contentIn, OutputStream contentOut,
-                            Map<String, String> metadata, Map<String, String> p)
-          throws IOException {
-        // Modifying contentIn is not allowed.
-        contentIn.write(new byte[1], 0, 1);
-      }
-    });
-    TransformPipeline pipeline = new TransformPipeline(transforms);
-    thrown.expect(UnsupportedOperationException.class);
-    pipeline.transform(new byte[] {1, 2, 3}, new ByteArrayOutputStream(),
-        new HashMap<String, String>(), new HashMap<String, String>());
-  }
-
-  @Test
-  public void testWriteTransform2() throws Exception {
-    List<DocumentTransform> transforms = new ArrayList<DocumentTransform>();
-    transforms.add(new AbstractDocumentTransform() {
-      @Override
-      public void transform(ByteArrayOutputStream contentIn, OutputStream contentOut,
-                            Map<String, String> metadata, Map<String, String> p)
-          throws IOException {
-        // Modifying contentIn is not allowed.
-        contentIn.write(0);
-      }
-    });
-    TransformPipeline pipeline = new TransformPipeline(transforms);
-    thrown.expect(UnsupportedOperationException.class);
-    pipeline.transform(new byte[] {1, 2, 3}, new ByteArrayOutputStream(),
-        new HashMap<String, String>(), new HashMap<String, String>());
-  }
-
-  @Test
-  public void testWriteTransform3() throws Exception {
-    List<DocumentTransform> transforms = new ArrayList<DocumentTransform>();
-    transforms.add(new AbstractDocumentTransform() {
-      @Override
-      public void transform(ByteArrayOutputStream contentIn, OutputStream contentOut,
-                            Map<String, String> metadata, Map<String, String> p)
-          throws IOException {
-        // Modifying contentIn is not allowed.
-        contentIn.write(new byte[1]);
-      }
-    });
-    TransformPipeline pipeline = new TransformPipeline(transforms);
-    thrown.expect(UnsupportedOperationException.class);
-    pipeline.transform(new byte[] {1, 2, 3}, new ByteArrayOutputStream(),
-        new HashMap<String, String>(), new HashMap<String, String>());
-  }
-
   private static class IncrementTransform extends AbstractDocumentTransform {
     @Override
     public void transform(ByteArrayOutputStream contentIn, OutputStream contentOut,
-                          Map<String, String> metadata, Map<String, String> p)
+                          Metadata metadata, Map<String, String> p)
         throws TransformException, IOException {
       byte[] content = contentIn.toByteArray();
       for (int i = 0; i < content.length; i++) {
         content[i]++;
       }
       contentOut.write(content);
-      metadata.put("int", "" + (Integer.parseInt(metadata.get("int")) + 1));
+      metadata.set("int", "" + (Integer.parseInt(metadata.getOneValue("int")) + 1));
       p.put("int", "" + (Integer.parseInt(p.get("int")) + 1));
     }
   }
@@ -271,14 +113,14 @@
 
     @Override
     public void transform(ByteArrayOutputStream contentIn, OutputStream contentOut,
-                          Map<String, String> metadata, Map<String, String> p)
+                          Metadata metadata, Map<String, String> p)
         throws TransformException, IOException {
       byte[] content = contentIn.toByteArray();
       for (int i = 0; i < content.length; i++) {
         content[i] *= factor;
       }
       contentOut.write(content);
-      metadata.put("int", "" + (Integer.parseInt(metadata.get("int")) * factor));
+      metadata.set("int", "" + (Integer.parseInt(metadata.getOneValue("int")) * factor));
       p.put("int", "" + (Integer.parseInt(p.get("int")) * factor));
     }
   }
@@ -287,16 +129,185 @@
     public ErroringTransform(boolean required) {
       super(null, required);
     }
-
     @Override
     public void transform(ByteArrayOutputStream contentIn, OutputStream contentOut,
-                          Map<String, String> metadata, Map<String, String> p)
+                          Metadata metadata, Map<String, String> p)
         throws TransformException, IOException {
       // Do some work, but don't complete.
       contentOut.write(new byte[] {1});
-      metadata.put("trash", "value");
+      metadata.set("trash", "value");
       p.put("more trash", "values");
       throw new TransformException("test exception");
     }
   }
+
+  @Test
+  public void testTransform() throws IOException, TransformException {
+    TransformPipeline pipeline = new TransformPipeline(Arrays.asList(new IncrementTransform()));
+    ByteArrayOutputStream out = new ByteArrayOutputStream();
+    Metadata metadata = new Metadata();
+    metadata.add("int", "0");
+    Map<String, String> params = new HashMap<String, String>();
+    params.put("int", "1");
+
+    pipeline.transform(new byte[] {1, 2, 3}, out, metadata, params);
+
+    assertArrayEquals(new byte[] {2, 3, 4}, out.toByteArray());
+    Metadata goldenMetadata = new Metadata();
+    goldenMetadata.add("int", "1");
+    assertEquals(goldenMetadata, metadata);
+    assertEquals(Collections.singletonMap("int", "2"), params);
+  }
+
+  @Test
+  public void testMultipleTransforms() throws IOException, TransformException {
+    TransformPipeline pipeline = new TransformPipeline(Arrays.asList(
+        new IncrementTransform(), new ProductTransform(2)));
+
+    ByteArrayOutputStream out = new ByteArrayOutputStream();
+    Metadata metadata = new Metadata();
+    metadata.set("int", "0");
+    Map<String, String> params = new HashMap<String, String>();
+    params.put("int", "1");
+
+    pipeline.transform(new byte[] {1, 2, 3}, out, metadata, params);
+
+    assertArrayEquals(new byte[] {4, 6, 8}, out.toByteArray());
+    Metadata goldenMetadata = new Metadata();
+    goldenMetadata.set("int", "2");
+    assertEquals(goldenMetadata, metadata);
+    assertEquals(Collections.singletonMap("int", "4"), params);
+  }
+
+  @Test
+  public void testNotLastTransformError() throws IOException, TransformException {
+    TransformPipeline pipeline = new TransformPipeline(Arrays.asList(
+        new IncrementTransform(), new ErroringTransform(false)));
+    ByteArrayOutputStream out = new ByteArrayOutputStream();
+    Metadata metadata = new Metadata();
+    metadata.set("int", "0");
+    Map<String, String> params = new HashMap<String, String>();
+    params.put("int", "1");
+
+    pipeline.transform(new byte[] {1, 2, 3}, out, metadata, params);
+
+    assertArrayEquals(new byte[] {2, 3, 4}, out.toByteArray());
+    Metadata goldenMetadata = new Metadata();
+    goldenMetadata.add("int", "1");
+    assertEquals(goldenMetadata, metadata);
+    assertEquals(Collections.singletonMap("int", "2"), params);
+  }
+
+  @Test
+  public void testLastTransformError() throws IOException, TransformException {
+    TransformPipeline pipeline = new TransformPipeline(Arrays.asList(
+        new ErroringTransform(false), new IncrementTransform()));
+    ByteArrayOutputStream out = new ByteArrayOutputStream();
+    Metadata metadata = new Metadata();
+    metadata.set("int", "0");
+    Map<String, String> params = new HashMap<String, String>();
+    params.put("int", "1");
+
+    pipeline.transform(new byte[] {1, 2, 3}, out, metadata, params);
+
+    assertArrayEquals(new byte[] {2, 3, 4}, out.toByteArray());
+    Metadata goldenMetadata = new Metadata();
+    goldenMetadata.set("int", "1");
+    assertEquals(goldenMetadata, metadata);
+    assertEquals(Collections.singletonMap("int", "2"), params);
+  }
+
+  @Test
+  public void testTransformErrorFatal() throws IOException, TransformException {
+    TransformPipeline pipeline = new TransformPipeline(Arrays.asList(
+        new IncrementTransform(), new ErroringTransform(true)));
+    ByteArrayOutputStream out = new ByteArrayOutputStream();
+    Metadata metadata = new Metadata();
+    metadata.set("int", "0");
+    Map<String, String> params = new HashMap<String, String>();
+    params.put("int", "1");
+
+    thrown.expect(TransformException.class);
+    try {
+      pipeline.transform(new byte[] {1, 2, 3}, out, metadata, params);
+    } finally {
+      assertArrayEquals(new byte[] {}, out.toByteArray());
+      Metadata goldenMetadata = new Metadata();
+      goldenMetadata.set("int", "0");
+      assertEquals(goldenMetadata, metadata);
+      assertEquals(Collections.singletonMap("int", "1"), params);
+    }
+  }
+
+  @Test
+  public void testResetTransform() throws Exception {
+    List<DocumentTransform> transforms = new ArrayList<DocumentTransform>();
+    transforms.add(new AbstractDocumentTransform() {
+      @Override
+      public void transform(ByteArrayOutputStream contentIn, OutputStream contentOut,
+                            Metadata metadata, Map<String, String> p)
+          throws IOException {
+        // Modifying contentIn is not allowed.
+        contentIn.reset();
+      }
+    });
+    TransformPipeline pipeline = new TransformPipeline(transforms);
+    thrown.expect(UnsupportedOperationException.class);
+    pipeline.transform(new byte[] {1, 2, 3}, new ByteArrayOutputStream(),
+        new Metadata(), new HashMap<String, String>());
+  }
+
+  @Test
+  public void testWriteTransform1() throws Exception {
+    List<DocumentTransform> transforms = new ArrayList<DocumentTransform>();
+    transforms.add(new AbstractDocumentTransform() {
+      @Override
+      public void transform(ByteArrayOutputStream contentIn, OutputStream contentOut,
+                            Metadata metadata, Map<String, String> p)
+          throws IOException {
+        // Modifying contentIn is not allowed.
+        contentIn.write(new byte[1], 0, 1);
+      }
+    });
+    TransformPipeline pipeline = new TransformPipeline(transforms);
+    thrown.expect(UnsupportedOperationException.class);
+    pipeline.transform(new byte[] {1, 2, 3}, new ByteArrayOutputStream(),
+        new Metadata(), new HashMap<String, String>());
+  }
+
+  @Test
+  public void testWriteTransform2() throws Exception {
+    List<DocumentTransform> transforms = new ArrayList<DocumentTransform>();
+    transforms.add(new AbstractDocumentTransform() {
+      @Override
+      public void transform(ByteArrayOutputStream contentIn, OutputStream contentOut,
+                            Metadata metadata, Map<String, String> p)
+          throws IOException {
+        // Modifying contentIn is not allowed.
+        contentIn.write(0);
+      }
+    });
+    TransformPipeline pipeline = new TransformPipeline(transforms);
+    thrown.expect(UnsupportedOperationException.class);
+    pipeline.transform(new byte[] {1, 2, 3}, new ByteArrayOutputStream(),
+        new Metadata(), new HashMap<String, String>());
+  }
+
+  @Test
+  public void testWriteTransform3() throws Exception {
+    List<DocumentTransform> transforms = new ArrayList<DocumentTransform>();
+    transforms.add(new AbstractDocumentTransform() {
+      @Override
+      public void transform(ByteArrayOutputStream contentIn, OutputStream contentOut,
+                            Metadata metadata, Map<String, String> p)
+          throws IOException {
+        // Modifying contentIn is not allowed.
+        contentIn.write(new byte[1]);
+      }
+    });
+    TransformPipeline pipeline = new TransformPipeline(transforms);
+    thrown.expect(UnsupportedOperationException.class);
+    pipeline.transform(new byte[] {1, 2, 3}, new ByteArrayOutputStream(),
+        new Metadata(), new HashMap<String, String>());
+  }
 }
diff --git a/test/com/google/enterprise/adaptor/examples/CalaisNERTransformTest.java b/test/com/google/enterprise/adaptor/examples/CalaisNERTransformTest.java
index 1079de7..aa76a4e 100644
--- a/test/com/google/enterprise/adaptor/examples/CalaisNERTransformTest.java
+++ b/test/com/google/enterprise/adaptor/examples/CalaisNERTransformTest.java
@@ -16,6 +16,7 @@
 
 import static org.junit.Assert.*;
 
+import com.google.enterprise.adaptor.Metadata;
 import com.google.enterprise.adaptor.TransformException;
 
 import mx.bigdata.jcalais.CalaisClient;
@@ -134,7 +135,7 @@
     CalaisNERTransform transform = new CalaisNERTransform(new Factory());
     ByteArrayOutputStream contentIn = new ByteArrayOutputStream();
     ByteArrayOutputStream contentOut = new ByteArrayOutputStream();
-    Map<String, String> metadata = new HashMap<String, String>();
+    Metadata metadata = new Metadata();
     Map<String, String> params = new HashMap<String, String>();
     params.put("OpenCalaisApiKey", "4ydv87zawg7tf29jzex22d9u");
     params.put("UseCalaisEntity:Person", "True");
@@ -167,7 +168,7 @@
     CalaisNERTransform transform = new CalaisNERTransform(new Factory());
     ByteArrayOutputStream contentIn = new ByteArrayOutputStream();
     ByteArrayOutputStream contentOut = new ByteArrayOutputStream();
-    Map<String, String> metadata = new HashMap<String, String>();
+    Metadata metadata = new Metadata();
     Map<String, String> params = new HashMap<String, String>();
     params.put("OpenCalaisApiKey", "4ydv87zawg7tf29jzex22d9u");
     params.put("UseCalaisEntity:All", "True");
diff --git a/test/com/google/enterprise/adaptor/examples/MetaTaggerTransformTest.java b/test/com/google/enterprise/adaptor/examples/MetaTaggerTransformTest.java
index 267a924..886af4b 100644
--- a/test/com/google/enterprise/adaptor/examples/MetaTaggerTransformTest.java
+++ b/test/com/google/enterprise/adaptor/examples/MetaTaggerTransformTest.java
@@ -16,6 +16,7 @@
 
 import static org.junit.Assert.*;
 
+import com.google.enterprise.adaptor.Metadata;
 import com.google.enterprise.adaptor.TransformException;
 
 import org.junit.Test;
@@ -36,7 +37,7 @@
     MetaTaggerTransform transform = new MetaTaggerTransform();
     ByteArrayOutputStream contentIn = new ByteArrayOutputStream();
     ByteArrayOutputStream contentOut = new ByteArrayOutputStream();
-    Map<String, String> metadata = new HashMap<String, String>();
+    Metadata metadata = new Metadata();
     Map<String, String> params = new HashMap<String, String>();
     params.put("key1", "value1");
 
@@ -46,7 +47,7 @@
 
     assertEquals(testString, contentIn.toString());
     assertEquals(testString, contentOut.toString());
-    assertEquals(0, metadata.size());
+    assertTrue(metadata.isEmpty());
     assertEquals("value1", params.get("key1"));
     assertEquals(1, params.keySet().size());
   }
@@ -56,7 +57,7 @@
     MetaTaggerTransform transform = new MetaTaggerTransform();
     ByteArrayOutputStream contentIn = new ByteArrayOutputStream();
     ByteArrayOutputStream contentOut = new ByteArrayOutputStream();
-    Map<String, String> metadata = new HashMap<String, String>();
+    Metadata metadata = new Metadata();
     Map<String, String> params = new HashMap<String, String>();
     params.put("key1", "value1");
 
@@ -66,7 +67,7 @@
 
     assertEquals(testString, contentIn.toString());
     assertEquals(testString, contentOut.toString());
-    assertEquals(0, metadata.size());
+    assertTrue(metadata.isEmpty());
     assertEquals("value1", params.get("key1"));
     assertEquals(1, params.keySet().size());
   }
@@ -76,7 +77,7 @@
     MetaTaggerTransform transform = new MetaTaggerTransform(TEST_DIR + "testPattern1.txt");
     ByteArrayOutputStream contentIn = new ByteArrayOutputStream();
     ByteArrayOutputStream contentOut = new ByteArrayOutputStream();
-    Map<String, String> metadata = new HashMap<String, String>();
+    Metadata metadata = new Metadata();
     Map<String, String> params = new HashMap<String, String>();
     params.put("key1", "value1");
     String content =
@@ -100,7 +101,7 @@
     transform.transform(contentIn, contentOut, metadata, params);
 
     assertEquals(goldenContent, contentOut.toString());
-    assertEquals(0, metadata.size());
+    assertTrue(metadata.isEmpty());
     assertEquals("value1", params.get("key1"));
     assertEquals(1, params.keySet().size());
   }
@@ -110,7 +111,7 @@
     MetaTaggerTransform transform = new MetaTaggerTransform(TEST_DIR + "testPattern1.txt");
     ByteArrayOutputStream contentIn = new ByteArrayOutputStream();
     ByteArrayOutputStream contentOut = new ByteArrayOutputStream();
-    Map<String, String> metadata = new HashMap<String, String>();
+    Metadata metadata = new Metadata();
     Map<String, String> params = new HashMap<String, String>();
     params.put("key1", "value1");
     String content =
@@ -122,7 +123,7 @@
     transform.transform(contentIn, contentOut, metadata, params);
 
     assertEquals(content, contentOut.toString());
-    assertEquals(0, metadata.size());
+    assertTrue(metadata.isEmpty());
     assertEquals("value1", params.get("key1"));
     assertEquals(1, params.keySet().size());
   }
@@ -132,7 +133,7 @@
     MetaTaggerTransform transform = new MetaTaggerTransform(TEST_DIR + "testPatternDup.txt");
     ByteArrayOutputStream contentIn = new ByteArrayOutputStream();
     ByteArrayOutputStream contentOut = new ByteArrayOutputStream();
-    Map<String, String> metadata = new HashMap<String, String>();
+    Metadata metadata = new Metadata();
     Map<String, String> params = new HashMap<String, String>();
     params.put("key1", "value1");
 
@@ -157,7 +158,7 @@
     transform.transform(contentIn, contentOut, metadata, params);
 
     assertEquals(goldenContent, contentOut.toString());
-    assertEquals(0, metadata.size());
+    assertTrue(metadata.isEmpty());
     assertEquals("value1", params.get("key1"));
     assertEquals(1, params.keySet().size());
   }
diff --git a/test/com/google/enterprise/adaptor/examples/TableGeneratorTransformTest.java b/test/com/google/enterprise/adaptor/examples/TableGeneratorTransformTest.java
index 5ea1b4b..47d5077 100644
--- a/test/com/google/enterprise/adaptor/examples/TableGeneratorTransformTest.java
+++ b/test/com/google/enterprise/adaptor/examples/TableGeneratorTransformTest.java
@@ -16,6 +16,7 @@
 
 import static org.junit.Assert.*;
 
+import com.google.enterprise.adaptor.Metadata;
 import com.google.enterprise.adaptor.TransformException;
 
 import org.junit.Test;
@@ -34,14 +35,14 @@
     TableGeneratorTransform transform = new TableGeneratorTransform();
     ByteArrayOutputStream contentIn = new ByteArrayOutputStream();
     ByteArrayOutputStream contentOut = new ByteArrayOutputStream();
-    Map<String, String> metadata = new HashMap<String, String>();
+    Metadata metadata = new Metadata();
     Map<String, String> params = new HashMap<String, String>();
     params.put("key1", "value1");
     transform.transform(contentIn, contentOut, metadata, params);
 
     String actualOutput = contentOut.toString();
     assertEquals("<HTML><HEAD></HEAD><BODY></BODY></HTML>", actualOutput);
-    assertEquals(0, metadata.size());
+    assertTrue(metadata.isEmpty());
     assertEquals("value1", params.get("key1"));
     assertEquals(1, params.keySet().size());
   }
@@ -51,7 +52,7 @@
     TableGeneratorTransform transform = new TableGeneratorTransform();
     ByteArrayOutputStream contentIn = new ByteArrayOutputStream();
     ByteArrayOutputStream contentOut = new ByteArrayOutputStream();
-    Map<String, String> metadata = new HashMap<String, String>();
+    Metadata metadata = new Metadata();
     Map<String, String> params = new HashMap<String, String>();
     params.put("key1", "value1");
 
@@ -80,7 +81,7 @@
     transform.transform(contentIn, contentOut, metadata, params);
 
     assertEquals(goldenOutput, contentOut.toString());
-    assertEquals(0, metadata.size());
+    assertTrue(metadata.isEmpty());
     assertEquals("value1", params.get("key1"));
     assertEquals(1, params.keySet().size());
   }
diff --git a/test/com/google/enterprise/adaptor/prebuilt/CommandLineAdaptorTest.java b/test/com/google/enterprise/adaptor/prebuilt/CommandLineAdaptorTest.java
index 676cc50..66afb2b 100644
--- a/test/com/google/enterprise/adaptor/prebuilt/CommandLineAdaptorTest.java
+++ b/test/com/google/enterprise/adaptor/prebuilt/CommandLineAdaptorTest.java
@@ -14,17 +14,21 @@
 
 package com.google.enterprise.adaptor.prebuilt;
 
+import static com.google.enterprise.adaptor.TestHelper.getDocIds;
+
+import static org.junit.Assert.assertArrayEquals;
+import static org.junit.Assert.assertEquals;
+
+import static java.util.Map.Entry;
+
 import com.google.enterprise.adaptor.Acl;
 import com.google.enterprise.adaptor.Adaptor;
 import com.google.enterprise.adaptor.AuthnIdentity;
 import com.google.enterprise.adaptor.AuthzStatus;
 import com.google.enterprise.adaptor.DocId;
+import com.google.enterprise.adaptor.Metadata;
 import com.google.enterprise.adaptor.Request;
 import com.google.enterprise.adaptor.Response;
-import static com.google.enterprise.adaptor.TestHelper.getDocIds;
-
-import static org.junit.Assert.assertArrayEquals;
-import static org.junit.Assert.assertEquals;
 
 import org.junit.Test;
 
@@ -83,7 +87,7 @@
     private static final Map<String, String> ID_TO_MIME_TYPE;
     private static final Map<String, Date> ID_TO_LAST_MODIFIED;
     private static final Map<String, Date> ID_TO_LAST_CRAWLED;
-    private static final Map<String, Map<String, String>> ID_TO_METADATA;
+    private static final Map<String, Metadata> ID_TO_METADATA;
 
     static {
       Map<String, String> idToContent = new HashMap<String, String>();
@@ -109,21 +113,22 @@
       idToLastCrawled.put("1003", new Date(5000));
       ID_TO_LAST_CRAWLED = Collections.unmodifiableMap(idToLastCrawled);
 
-      Map<String, String> id1002Metadata = new HashMap<String, String>();
-      id1002Metadata.put("metaname-1002a", "metavalue-1002a");
-      id1002Metadata.put("metaname-1002b", "metavalue-1002b");
+      Metadata id1002Metadata = new Metadata();
+      id1002Metadata.add("metaname-1002a", "metavalue-1002a");
+      id1002Metadata.add("metaname-1002b", "metavalue-1002b");
+      Metadata id1003Metadata = new Metadata();
+      id1003Metadata.add("metaname-1003", "metavalue-1003");
 
-      Map<String, Map<String, String>> idToMetadata
-          = new HashMap<String, Map<String, String>>();
-      idToMetadata.put("1002", id1002Metadata);
-      idToMetadata.put("1003",
-          Collections.singletonMap("metaname-1003", "metavalue-1003"));
+      Map<String, Metadata> idToMetadata = new HashMap<String, Metadata>();
+      idToMetadata.put("1002", id1002Metadata.unmodifiableView());
+      idToMetadata.put("1003", id1003Metadata.unmodifiableView());
+
       ID_TO_METADATA = Collections.unmodifiableMap(idToMetadata);
     }
 
     private String docId;
     private String content;
-    private Map<String, String> metadata;
+    private Metadata metadata;
     private Date lastModified;
     private Date lastCrawled;
     private String mimeType;
@@ -155,7 +160,7 @@
         result.append("mime-type=").append(mimeType).append("\n");
       }
       if (metadata != null) {
-        for (Map.Entry<String, String> item : metadata.entrySet()) {
+        for (Map.Entry<String, String> item : metadata) {
           result.append("meta-name=").append(item.getKey()).append("\n");
           result.append("meta-value=").append(item.getValue()).append("\n");
         }
@@ -259,7 +264,7 @@
   private static class ContentsResponseTestMock implements Response {
     private OutputStream os;
     private String contentType;
-    private Map<String, String> metadata;
+    private Metadata metadata = new Metadata();
     private Acl acl;
     private List<URI> anchorUris = new ArrayList<URI>();
     private List<String> anchorTexts = new ArrayList<String>();
@@ -295,9 +300,8 @@
     }
 
     @Override
-    public void setMetadata(Map<String, String> m) {
-      this.metadata
-          = Collections.unmodifiableMap(new HashMap<String, String>(m));
+    public void addMetadata(String key, String value) {
+      this.metadata.add(key, value);
     }
 
     @Override
@@ -330,8 +334,9 @@
       return contentType;
     }
 
-    public Map<String, String> getMetadata() {
-      return metadata;
+    /** Returns unmodifibale view of metadata. */
+    Metadata getMetadata() {
+      return metadata.unmodifiableView();
     }
 
     public Acl getAcl() {
diff --git a/test/com/google/enterprise/adaptor/prebuilt/CommandLineTransformTest.java b/test/com/google/enterprise/adaptor/prebuilt/CommandLineTransformTest.java
index b5f0370..443e3bb 100644
--- a/test/com/google/enterprise/adaptor/prebuilt/CommandLineTransformTest.java
+++ b/test/com/google/enterprise/adaptor/prebuilt/CommandLineTransformTest.java
@@ -16,6 +16,7 @@
 
 import static org.junit.Assert.*;
 
+import com.google.enterprise.adaptor.Metadata;
 import com.google.enterprise.adaptor.TestHelper;
 import com.google.enterprise.adaptor.TransformException;
 import com.google.enterprise.adaptor.TransformPipeline;
@@ -37,8 +38,8 @@
     ByteArrayOutputStream contentOut = new ByteArrayOutputStream();
     // The newline causes the test to work with both BSD and GNU sed.
     String testStr = "testing\n";
-    Map<String, String> metadata = new HashMap<String, String>();
-    metadata.put("metaKey1", "metaValue1");
+    Metadata metadata = new Metadata();
+    metadata.add("metaKey1", "metaValue1");
     Map<String, String> params = new HashMap<String, String>();
     params.put("key1", "value1");
 
@@ -46,11 +47,11 @@
     cmd.setTransformCommand(Arrays.asList(new String[] {"sed", "s/i/1/"}));
     cmd.setCommandAcceptsParameters(false);
     TransformPipeline pipeline = new TransformPipeline(Arrays.asList(cmd));
-    pipeline.transform(testStr.getBytes(), contentOut, new HashMap<String, String>(), params);
+    pipeline.transform(testStr.getBytes(), contentOut, metadata, params);
 
     assertEquals(testStr.replace("i", "1"), contentOut.toString());
-    assertEquals("metaValue1", metadata.get("metaKey1"));
-    assertEquals(1, metadata.size());
+    assertEquals("metaValue1", metadata.getOneValue("metaKey1"));
+    assertEquals(1, metadata.getKeys().size());
     assertEquals("value1", params.get("key1"));
     assertEquals(1, params.keySet().size());
   }
@@ -62,8 +63,8 @@
     ByteArrayOutputStream contentOut = new ByteArrayOutputStream();
     // The newline causes the test to work with both BSD and GNU sed.
     String testStr = "testing\n";
-    Map<String, String> metadata = new HashMap<String, String>();
-    metadata.put("metaKey1", "metaValue1");
+    Metadata metadata = new Metadata();
+    metadata.add("metaKey1", "metaValue1");
     Map<String, String> params = new HashMap<String, String>();
     params.put("key1", "value1");
 
@@ -83,8 +84,8 @@
     pipeline.transform(testStr.getBytes(), contentOut, metadata, params);
 
     assertEquals(testStr.replace("i", "1"), contentOut.toString());
-    assertEquals("metaValue2", metadata.get("metaKey2"));
-    assertEquals(1, metadata.size());
+    assertEquals(1, metadata.getKeys().size());
+    assertEquals("metaValue2", metadata.getOneValue("metaKey2"));
     assertEquals("value3", params.get("key3"));
     assertEquals(1, params.size());
   }