Merge "Add named destinations support"
diff --git a/Documentation/user-named-destinations.txt b/Documentation/user-named-destinations.txt
new file mode 100644
index 0000000..1b6f143
--- /dev/null
+++ b/Documentation/user-named-destinations.txt
@@ -0,0 +1,32 @@
+= Gerrit Code Review - Named Destinations
+
+[[user-named-destinations]]
+== User Named Destinations
+It is possible to define named destination sets on a user level.
+To do this, define the named destination sets in files named after
+each destination set in the `destinations` directory of the user's
+account ref in the `All-Users` project.  The user's account ref is
+based on the user's account id which is an integer.  The account
+refs are sharded by the last two digits (`+nn+`) in the refname,
+leading to refs of the format `+refs/users/nn/accountid+`.  The
+user's destination files are a 2 column tab delimited file.  Each
+row in a destination file represents a single destination in the
+named set.  The left column represents the ref of the destination,
+and the right column represents the project of the destination.
+
+Example destination file named `destinations/myreviews`:
+
+----
+# Ref            	Project
+#
+refs/heads/master	gerrit
+refs/heads/stable-2.11	gerrit
+refs/heads/master	plugins/cookbook-plugin
+----
+
+GERRIT
+------
+Part of link:index.html[Gerrit Code Review]
+
+SEARCHBOX
+---------
diff --git a/Documentation/user-search.txt b/Documentation/user-search.txt
index 8476f7e..adf77cf 100644
--- a/Documentation/user-search.txt
+++ b/Documentation/user-search.txt
@@ -88,6 +88,12 @@
 as a legacy numerical 'ID' such as 15183, or a newer style Change-Id
 that was scraped out of the commit message.
 
+[[destination]]
+destination:'NAME'::
++
+Changes which match the current user's destination named 'NAME'.
+(see link:user-named-destinations.html[Named Destinations]).
+
 [[owner]]
 owner:'USER', o:'USER'::
 +
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/account/VersionedAccountDestinations.java b/gerrit-server/src/main/java/com/google/gerrit/server/account/VersionedAccountDestinations.java
new file mode 100644
index 0000000..d928bec
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/account/VersionedAccountDestinations.java
@@ -0,0 +1,80 @@
+// Copyright (C) 2015 The Android Open Source Project
+//
+// 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.gerrit.server.account;
+
+import com.google.gerrit.reviewdb.client.Account;
+import com.google.gerrit.reviewdb.client.RefNames;
+import com.google.gerrit.server.git.DestinationList;
+import com.google.gerrit.server.git.ValidationError;
+import com.google.gerrit.server.git.VersionedMetaData;
+
+import org.eclipse.jgit.errors.ConfigInvalidException;
+import org.eclipse.jgit.lib.CommitBuilder;
+import org.eclipse.jgit.lib.FileMode;
+
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import java.io.IOException;
+
+/** Preferences for user accounts. */
+public class VersionedAccountDestinations extends VersionedMetaData {
+  private static final Logger log = LoggerFactory.getLogger(VersionedAccountDestinations.class);
+
+  public static VersionedAccountDestinations forUser(Account.Id id) {
+    return new VersionedAccountDestinations(RefNames.refsUsers(id));
+  }
+
+  private final String ref;
+  private final DestinationList destinations = new DestinationList();
+
+  private VersionedAccountDestinations(String ref) {
+    this.ref = ref;
+  }
+
+  @Override
+  protected String getRefName() {
+    return ref;
+  }
+
+  public DestinationList getDestinationList() {
+    return destinations;
+  }
+
+  @Override
+  protected void onLoad() throws IOException, ConfigInvalidException {
+    String prefix = DestinationList.DIR_NAME + "/";
+    for (PathInfo p : getPathInfos(true)) {
+      if (p.fileMode == FileMode.REGULAR_FILE) {
+        String path = p.path;
+        if (path.startsWith(prefix)) {
+          String label = path.substring(prefix.length());
+          ValidationError.Sink errors = destinations.createLoggerSink(path, log);
+          destinations.parseLabel(label, readUTF8(path), errors);
+        }
+      }
+    }
+  }
+
+  public ValidationError.Sink createSink(String file) {
+    return ValidationError.createLoggerSink(file, log);
+  }
+
+  @Override
+  protected boolean onSave(CommitBuilder commit) throws IOException,
+      ConfigInvalidException {
+    throw new UnsupportedOperationException("Cannot yet save destinations");
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/git/DestinationList.java b/gerrit-server/src/main/java/com/google/gerrit/server/git/DestinationList.java
new file mode 100644
index 0000000..ca1f705c
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/git/DestinationList.java
@@ -0,0 +1,61 @@
+// Copyright (C) 2015 The Android Open Source Project
+//
+// 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.gerrit.server.git;
+
+import com.google.common.collect.HashMultimap;
+import com.google.common.collect.Lists;
+import com.google.common.collect.SetMultimap;
+import com.google.common.collect.Sets;
+import com.google.gerrit.reviewdb.client.Branch;
+import com.google.gerrit.reviewdb.client.Project;
+
+import java.io.IOException;
+import java.util.List;
+import java.util.Set;
+
+public class DestinationList extends TabFile {
+  public static final String DIR_NAME = "destinations";
+  private SetMultimap<String, Branch.NameKey> destinations = HashMultimap.create();
+
+  public Set<Branch.NameKey> getDestinations(String label) {
+    return destinations.get(label);
+  }
+
+  public void parseLabel(String label, String text,
+      ValidationError.Sink errors) throws IOException {
+    destinations.replaceValues(label,
+        toSet(parse(text, DIR_NAME + label, TRIM, null, errors)));
+  }
+
+  public String asText(String label) {
+    Set<Branch.NameKey> dests = destinations.get(label);
+    if (dests == null) {
+      return null;
+    }
+    List<Row> rows = Lists.newArrayListWithCapacity(dests.size());
+    for (Branch.NameKey dest : sort(dests)) {
+      rows.add(new Row(dest.get(), dest.getParentKey().get()));
+    }
+    return asText("Ref", "Project", rows);
+  }
+
+  protected static Set<Branch.NameKey> toSet(List<Row> destRows) {
+    Set<Branch.NameKey> dests = Sets.newHashSetWithExpectedSize(destRows.size());
+    for(Row row : destRows) {
+      dests.add(new Branch.NameKey(new Project.NameKey(row.right), row.left));
+    }
+    return dests;
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/git/GroupList.java b/gerrit-server/src/main/java/com/google/gerrit/server/git/GroupList.java
index d07572b..1477f6a 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/git/GroupList.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/git/GroupList.java
@@ -36,7 +36,7 @@
 
   public static GroupList parse(String text, ValidationError.Sink errors)
       throws IOException {
-    List<Row> rows = parse(text, FILE_NAME, errors);
+    List<Row> rows = parse(text, FILE_NAME, TRIM, TRIM, errors);
     Map<AccountGroup.UUID, GroupReference> groupsByUUID =
         new HashMap<>(rows.size());
     for(Row row : rows) {
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/git/QueryList.java b/gerrit-server/src/main/java/com/google/gerrit/server/git/QueryList.java
index 0df866d..dffb18a 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/git/QueryList.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/git/QueryList.java
@@ -28,7 +28,7 @@
 
   public static QueryList parse(String text, ValidationError.Sink errors)
       throws IOException {
-    return new QueryList(parse(text, FILE_NAME, errors));
+    return new QueryList(parse(text, FILE_NAME, TRIM, TRIM, errors));
   }
 
   public String getQuery(String name) {
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/git/TabFile.java b/gerrit-server/src/main/java/com/google/gerrit/server/git/TabFile.java
index 13d2b1e..87f9a23 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/git/TabFile.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/git/TabFile.java
@@ -27,6 +27,17 @@
 import java.util.Map;
 
 public class TabFile {
+  public interface Parser {
+    public String parse(String str);
+  }
+
+  public static Parser TRIM = new Parser() {
+        public String parse(String str) {
+           return str.trim();
+        }
+      };
+
+
   protected static class Row {
     public String left;
     public String right;
@@ -37,9 +48,9 @@
     }
   }
 
-  protected static List<Row> parse(String text, String filename,
-      ValidationError.Sink errors) throws IOException {
-    List<Row> rows = new ArrayList<>();
+  protected static List<Row> parse(String text, String filename, Parser left,
+      Parser right, ValidationError.Sink errors) throws IOException {
+    List<Row> rows = new ArrayList<Row>();
     BufferedReader br = new BufferedReader(new StringReader(text));
     String s;
     for (int lineNumber = 1; (s = br.readLine()) != null; lineNumber++) {
@@ -54,8 +65,15 @@
         continue;
       }
 
-      rows.add(new Row(s.substring(0, tab).trim(),
-          s.substring(tab + 1).trim()));
+      Row row = new Row(s.substring(0, tab), s.substring(tab + 1));
+      rows.add(row);
+
+      if (left != null) {
+        row.left = left.parse(row.left);
+      }
+      if (right != null) {
+        row.right = right.parse(row.right);
+      }
     }
     return rows;
   }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/git/VersionedMetaData.java b/gerrit-server/src/main/java/com/google/gerrit/server/git/VersionedMetaData.java
index 17f51ef..dfde5d5 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/git/VersionedMetaData.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/git/VersionedMetaData.java
@@ -15,6 +15,7 @@
 package com.google.gerrit.server.git;
 
 import com.google.common.base.MoreObjects;
+import com.google.common.collect.Lists;
 
 import org.eclipse.jgit.dircache.DirCache;
 import org.eclipse.jgit.dircache.DirCacheBuilder;
@@ -50,6 +51,7 @@
 import java.io.IOException;
 import java.io.StringReader;
 import java.util.Objects;
+import java.util.List;
 
 /**
  * Support for metadata stored within a version controlled branch.
@@ -59,6 +61,23 @@
  * later be written back to the repository.
  */
 public abstract class VersionedMetaData {
+  /**
+   * Path information that does not hold references to any repository
+   * data structures, allowing the application to retain this object
+   * for long periods of time.
+   */
+  public static class PathInfo {
+    public final FileMode fileMode;
+    public final String path;
+    public final ObjectId objectId;
+
+    protected PathInfo(TreeWalk tw) {
+      fileMode = tw.getFileMode(0);
+      path = tw.getPathString();
+      objectId = tw.getObjectId(0);
+    }
+  }
+
   private RevCommit revision;
   protected ObjectReader reader;
   protected ObjectInserter inserter;
@@ -439,6 +458,17 @@
     return null;
   }
 
+  public List<PathInfo> getPathInfos(boolean recursive) throws IOException {
+    TreeWalk tw = new TreeWalk(reader);
+    tw.addTree(revision.getTree());
+    tw.setRecursive(recursive);
+    List<PathInfo> paths = Lists.newArrayList();
+    while (tw.next()) {
+      paths.add(new PathInfo(tw));
+    }
+    return paths;
+  }
+
   protected static void set(Config rc, String section, String subsection,
       String name, String value) {
     if (value != null) {
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/query/change/ChangeQueryBuilder.java b/gerrit-server/src/main/java/com/google/gerrit/server/query/change/ChangeQueryBuilder.java
index 88bb942..c6ffc27 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/query/change/ChangeQueryBuilder.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/query/change/ChangeQueryBuilder.java
@@ -23,6 +23,7 @@
 import com.google.gerrit.common.errors.NotSignedInException;
 import com.google.gerrit.reviewdb.client.Account;
 import com.google.gerrit.reviewdb.client.AccountGroup;
+import com.google.gerrit.reviewdb.client.Branch;
 import com.google.gerrit.reviewdb.client.Change;
 import com.google.gerrit.reviewdb.client.RefNames;
 import com.google.gerrit.reviewdb.server.ReviewDb;
@@ -34,6 +35,7 @@
 import com.google.gerrit.server.account.GroupBackend;
 import com.google.gerrit.server.account.GroupBackends;
 import com.google.gerrit.server.account.VersionedAccountQueries;
+import com.google.gerrit.server.account.VersionedAccountDestinations;
 import com.google.gerrit.server.change.ChangeTriplet;
 import com.google.gerrit.server.config.AllProjectsName;
 import com.google.gerrit.server.config.AllUsersName;
@@ -98,6 +100,7 @@
   public static final String FIELD_CONFLICTS = "conflicts";
   public static final String FIELD_DELETED = "deleted";
   public static final String FIELD_DELTA = "delta";
+  public static final String FIELD_DESTINATION = "destination";
   public static final String FIELD_DRAFTBY = "draftby";
   public static final String FIELD_EDITBY = "editby";
   public static final String FIELD_FILE = "file";
@@ -818,6 +821,28 @@
     return IsReviewedPredicate.create(args.getSchema(), parseAccount(who));
   }
 
+  @Operator
+  public Predicate<ChangeData> destination(String name)
+      throws QueryParseException {
+    AllUsersName allUsers = args.allUsersName.get();
+    try (Repository git = args.repoManager.openRepository(allUsers)) {
+      VersionedAccountDestinations d =
+          VersionedAccountDestinations.forUser(self());
+      d.load(git);
+      Set<Branch.NameKey> destinations =
+          d.getDestinationList().getDestinations(name);
+      if (destinations != null) {
+        return new DestinationPredicate(destinations, name);
+      }
+    } catch (RepositoryNotFoundException e) {
+      throw new QueryParseException("Unknown named destination (no " +
+          allUsers.get() +" repo): " + name, e);
+    } catch (IOException | ConfigInvalidException e) {
+      throw new QueryParseException("Error parsing named destination: " + name, e);
+    }
+    throw new QueryParseException("Unknown named destination: " + name);
+  }
+
   @Override
   protected Predicate<ChangeData> defaultField(String query) throws QueryParseException {
     if (query.startsWith("refs/")) {
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/query/change/DestinationPredicate.java b/gerrit-server/src/main/java/com/google/gerrit/server/query/change/DestinationPredicate.java
new file mode 100644
index 0000000..25fa09f
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/query/change/DestinationPredicate.java
@@ -0,0 +1,45 @@
+// Copyright (C) 2015 The Android Open Source Project
+//
+// 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.gerrit.server.query.change;
+
+import com.google.gerrit.reviewdb.client.Branch;
+import com.google.gerrit.reviewdb.client.Change;
+import com.google.gerrit.server.query.OperatorPredicate;
+import com.google.gwtorm.server.OrmException;
+
+import java.util.Set;
+
+class DestinationPredicate extends OperatorPredicate<ChangeData> {
+  Set<Branch.NameKey> destinations;
+
+  DestinationPredicate(Set<Branch.NameKey> destinations, String value) {
+    super(ChangeQueryBuilder.FIELD_DESTINATION, value);
+    this.destinations = destinations;
+  }
+
+  @Override
+  public boolean match(final ChangeData object) throws OrmException {
+    Change change = object.change();
+    if (change == null) {
+      return false;
+    }
+    return destinations.contains(change.getDest());
+  }
+
+  @Override
+  public int getCost() {
+    return 1;
+  }
+}
diff --git a/gerrit-server/src/test/java/com/google/gerrit/server/git/DestinationListTest.java b/gerrit-server/src/test/java/com/google/gerrit/server/git/DestinationListTest.java
new file mode 100644
index 0000000..2304ece
--- /dev/null
+++ b/gerrit-server/src/test/java/com/google/gerrit/server/git/DestinationListTest.java
@@ -0,0 +1,164 @@
+// Copyright (C) 2015 The Android Open Source Project
+//
+// 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.gerrit.server.git;
+
+import static com.google.common.truth.Truth.assertThat;
+import static org.easymock.EasyMock.createNiceMock;
+import static org.easymock.EasyMock.replay;
+
+import com.google.common.collect.Sets;
+import com.google.gerrit.reviewdb.client.Branch;
+import com.google.gerrit.reviewdb.client.Project;
+
+import junit.framework.TestCase;
+
+import org.junit.Test;
+
+import java.io.IOException;
+import java.util.Set;
+
+public class DestinationListTest extends TestCase {
+  public static final String R_FOO = "refs/heads/foo";
+  public static final String R_BAR = "refs/heads/bar";
+
+  public static final String P_MY = "myproject";
+  public static final String P_SLASH = "my/project/with/slashes";
+  public static final String P_COMPLEX = " a/project/with spaces and \ttabs ";
+
+  public static final String L_FOO = R_FOO + "\t" + P_MY + "\n";
+  public static final String L_BAR = R_BAR + "\t" + P_SLASH + "\n";
+  public static final String L_FOO_PAD_F = " " + R_FOO + "\t" + P_MY + "\n";
+  public static final String L_FOO_PAD_E = R_FOO + " \t" + P_MY + "\n";
+  public static final String L_COMPLEX = R_FOO + "\t" + P_COMPLEX + "\n";
+  public static final String L_BAD = R_FOO + "\n";
+
+  public static final String HEADER = "# Ref\tProject\n";
+  public static final String HEADER_PROPER = "# Ref         \tProject\n";
+  public static final String C1 = "# A Simple Comment\n";
+  public static final String C2 = "# Comment with a tab\t and multi # # #\n";
+
+  public static final String F_SIMPLE = L_FOO + L_BAR;
+  public static final String F_PROPER = L_BAR + L_FOO; // alpha order
+  public static final String F_PAD_F = L_FOO_PAD_F + L_BAR;
+  public static final String F_PAD_E = L_FOO_PAD_E + L_BAR;
+
+  public static final String LABEL = "label";
+  public static final String LABEL2 = "another";
+
+  public static final Branch.NameKey B_FOO = dest(P_MY, R_FOO);
+  public static final Branch.NameKey B_BAR = dest(P_SLASH, R_BAR);
+  public static final Branch.NameKey B_COMPLEX = dest(P_COMPLEX, R_FOO);
+
+  public static final Set<Branch.NameKey> D_SIMPLE = Sets.newHashSet();
+  static {
+    D_SIMPLE.clear();
+    D_SIMPLE.add(B_FOO);
+    D_SIMPLE.add(B_BAR);
+  }
+
+  private static Branch.NameKey dest(String project, String ref) {
+    return new Branch.NameKey(new Project.NameKey(project), ref);
+  }
+
+  @Test
+  public void testParseSimple() throws Exception {
+    DestinationList dl = new DestinationList();
+    dl.parseLabel(LABEL, F_SIMPLE, null);
+    Set<Branch.NameKey> branches = dl.getDestinations(LABEL);
+    assertThat(branches).containsExactlyElementsIn(D_SIMPLE);
+  }
+
+  @Test
+  public void testParseWHeader() throws Exception {
+    DestinationList dl = new DestinationList();
+    dl.parseLabel(LABEL, HEADER + F_SIMPLE, null);
+    Set<Branch.NameKey> branches = dl.getDestinations(LABEL);
+    assertThat(branches).containsExactlyElementsIn(D_SIMPLE);
+  }
+
+  @Test
+  public void testParseWComments() throws Exception {
+    DestinationList dl = new DestinationList();
+    dl.parseLabel(LABEL, C1 + F_SIMPLE + C2, null);
+    Set<Branch.NameKey> branches = dl.getDestinations(LABEL);
+    assertThat(branches).containsExactlyElementsIn(D_SIMPLE);
+  }
+
+  @Test
+  public void testParseFooComment() throws Exception {
+    DestinationList dl = new DestinationList();
+    dl.parseLabel(LABEL, "#" + L_FOO + L_BAR, null);
+    Set<Branch.NameKey> branches = dl.getDestinations(LABEL);
+    assertThat(branches).doesNotContain(B_FOO);
+    assertThat(branches).contains(B_BAR);
+  }
+
+  @Test
+  public void testParsePaddedFronts() throws Exception {
+    DestinationList dl = new DestinationList();
+    dl.parseLabel(LABEL, F_PAD_F, null);
+    Set<Branch.NameKey> branches = dl.getDestinations(LABEL);
+    assertThat(branches).containsExactlyElementsIn(D_SIMPLE);
+  }
+
+  @Test
+  public void testParsePaddedEnds() throws Exception {
+    DestinationList dl = new DestinationList();
+    dl.parseLabel(LABEL, F_PAD_E, null);
+    Set<Branch.NameKey> branches = dl.getDestinations(LABEL);
+    assertThat(branches).containsExactlyElementsIn(D_SIMPLE);
+  }
+
+  @Test
+  public void testParseComplex() throws Exception {
+    DestinationList dl = new DestinationList();
+    dl.parseLabel(LABEL, L_COMPLEX, null);
+    Set<Branch.NameKey> branches = dl.getDestinations(LABEL);
+    assertThat(branches).contains(B_COMPLEX);
+  }
+
+  @Test(expected = IOException.class)
+  public void testParseBad() throws IOException {
+    ValidationError.Sink sink = createNiceMock(ValidationError.Sink.class);
+    replay(sink);
+    new DestinationList().parseLabel(LABEL, L_BAD, sink);
+  }
+
+  @Test
+  public void testParse2Labels() throws Exception {
+    DestinationList dl = new DestinationList();
+    dl.parseLabel(LABEL, F_SIMPLE, null);
+    Set<Branch.NameKey> branches = dl.getDestinations(LABEL);
+    assertThat(branches).containsExactlyElementsIn(D_SIMPLE);
+
+    dl.parseLabel(LABEL2, L_COMPLEX, null);
+    branches = dl.getDestinations(LABEL);
+    assertThat(branches).containsExactlyElementsIn(D_SIMPLE);
+    branches = dl.getDestinations(LABEL2);
+    assertThat(branches).contains(B_COMPLEX);
+  }
+
+  @Test
+  public void testAsText() throws Exception {
+    String text = HEADER_PROPER + "#\n" + F_PROPER;
+    DestinationList dl = new DestinationList();
+    dl.parseLabel(LABEL, F_SIMPLE, null);
+    String asText = dl.asText(LABEL);
+    assertThat(text).isEqualTo(asText);
+
+    dl.parseLabel(LABEL2, asText, null);
+    assertThat(text).isEqualTo(dl.asText(LABEL2));
+  }
+}