feat(group): add 'madb group add' command

This is the first pass of implementing the 'madb group' commands, and
it adds 'madb group add <group_name> <members...>' command. The group
definition is stored in the config file.

Change-Id: I55d76c3f5912136ce38b13f498b7dd2f9c1d12d1
Ref: #2
diff --git a/doc.go b/doc.go
index c8e6186..76df444 100644
--- a/doc.go
+++ b/doc.go
@@ -19,6 +19,7 @@
    exec        Run the provided adb command on all devices and emulators
                concurrently
    extern      Run the provided external command for all devices
+   group       Manage device groups
    install     Install your app on all devices
    name        Manage device nicknames
    shell       Run the provided adb shell command on all devices and emulators
@@ -220,6 +221,39 @@
  -seq=false
    Run the command sequentially, instead of running it in parallel.
 
+Madb group - Manage device groups
+
+Manages device groups, each of which can have one or more device members. The
+device groups can be used for specifying the target devices of other madb
+commands.
+
+Usage:
+   madb group [flags] <command>
+
+The madb group commands are:
+   add         Add devices to a device group
+
+Madb group add - Add devices to a device group
+
+Adds devices to a device group. This command also creates the group, if the
+group does not exist yet. The device group can be used when specifying devices
+in any madb commands.
+
+When creating a new device group with this command, the provided name must not
+conflict with an existing device nickname (see: madb help name set).
+
+A group can contain another device group, in which case all the members of the
+other group will also be considered as members of the current group.
+
+Usage:
+   madb group add [flags] <group_name> <member1> [<member2> ...]
+
+<group_name> is an alpha-numeric string with no special characters or spaces.
+This name must not be an existing device nickname.
+
+<member> is a member specifier, which can be one of device serial, qualifier,
+device index (e.g., '@1', '@2'), device nickname, or another device group.
+
 Madb install - Install your app on all devices
 
 Installs your app on all devices.
diff --git a/group.go b/group.go
new file mode 100644
index 0000000..6224be5
--- /dev/null
+++ b/group.go
@@ -0,0 +1,127 @@
+// Copyright 2016 The Vanadium Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style
+// license that can be found in the LICENSE file.
+
+package main
+
+import (
+	"fmt"
+	"strconv"
+	"strings"
+
+	"v.io/x/lib/cmdline"
+)
+
+// TODO(youngseokyoon): implement the following sub-commands.
+//  - remove:    remove members from an existing group
+//  - rename:    rename a group
+//  - delete:    delete a group
+//  - list:      list all the groups and their members
+//  - clear-all: delete all the existing device groups
+
+// TODO(youngseokyoon): use the groups for filtering devices.
+
+var cmdMadbGroup = &cmdline.Command{
+	Children:         []*cmdline.Command{cmdMadbGroupAdd},
+	Name:             "group",
+	DontInheritFlags: true,
+	Short:            "Manage device groups",
+	Long: `
+Manages device groups, each of which can have one or more device members. The
+device groups can be used for specifying the target devices of other madb
+commands.
+`,
+}
+
+var cmdMadbGroupAdd = &cmdline.Command{
+	Runner: subCommandRunnerWithFilepath{runMadbGroupAdd, getDefaultConfigFilePath},
+	Name:   "add",
+	Short:  "Add devices to a device group",
+	Long: `
+Adds devices to a device group. This command also creates the group, if the
+group does not exist yet. The device group can be used when specifying devices
+in any madb commands.
+
+When creating a new device group with this command, the provided name must not
+conflict with an existing device nickname (see: madb help name set).
+
+A group can contain another device group, in which case all the members of the
+other group will also be considered as members of the current group.
+`,
+	ArgsName: "<group_name> <member1> [<member2> ...]",
+	ArgsLong: `
+<group_name> is an alpha-numeric string with no special characters or spaces.
+This name must not be an existing device nickname.
+
+<member> is a member specifier, which can be one of device serial, qualifier,
+device index (e.g., '@1', '@2'), device nickname, or another device group.
+`,
+}
+
+func runMadbGroupAdd(env *cmdline.Env, args []string, filename string) error {
+	// Check if the arguments are valid.
+	if len(args) < 2 {
+		return env.UsageErrorf("There must be at least two arguments.")
+	}
+
+	groupName := args[0]
+	if !isValidName(groupName) {
+		return fmt.Errorf("Not a valid group name: %q", groupName)
+	}
+
+	cfg, err := readConfig(filename)
+	if err != nil {
+		return err
+	}
+	if isDeviceNickname(groupName, cfg) {
+		return fmt.Errorf("The group name %q conflicts with a device nickname.", groupName)
+	}
+
+	members := removeDuplicates(args[1:])
+	for _, member := range members {
+		if err := isValidMember(member, cfg); err != nil {
+			return fmt.Errorf("Invalid member %q: %v", member, err)
+		}
+	}
+
+	oldMembers, ok := cfg.Groups[groupName]
+	if !ok {
+		oldMembers = []string{}
+	}
+
+	cfg.Groups[groupName] = removeDuplicates(append(oldMembers, members...))
+	return writeConfig(cfg, filename)
+}
+
+// isValidMember takes a member string given as an argument, and returns nil
+// when the member string is valid. Otherwise, an error is returned inicating
+// the reason why the given member string is not valid.
+// TODO(youngseokyoon): reuse this function in madb.go.
+func isValidMember(member string, cfg *config) error {
+	if strings.HasPrefix(member, "@") {
+		index, err := strconv.Atoi(member[1:])
+		if err != nil || index <= 0 {
+			return fmt.Errorf("Invalid device specifier %q. '@' sign must be followed by a numeric device index starting from 1.", member)
+		}
+		return nil
+	} else if !isValidSerial(member) && !isValidName(member) {
+		return fmt.Errorf("Invalid device specifier %q. Not a valid serial or a nickname.", member)
+	}
+
+	return nil
+}
+
+// removeDuplicates takes a string slice and removes all the duplicates.
+func removeDuplicates(s []string) []string {
+	result := make([]string, 0, len(s))
+
+	used := map[string]bool{}
+	for _, elem := range s {
+		if !used[elem] {
+			result = append(result, elem)
+			used[elem] = true
+		}
+	}
+
+	return result
+}
diff --git a/group_test.go b/group_test.go
new file mode 100644
index 0000000..49dd946
--- /dev/null
+++ b/group_test.go
@@ -0,0 +1,105 @@
+// Copyright 2016 The Vanadium Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style
+// license that can be found in the LICENSE file.
+
+package main
+
+import (
+	"os"
+	"reflect"
+	"testing"
+)
+
+func TestMadbGroupAdd(t *testing.T) {
+	tests := [][]struct {
+		input       []string
+		want        map[string][]string
+		errExpected bool
+	}{
+		{
+			{
+				[]string{"GROUP1", "SERIAL1", "NICKNAME1", "NICKNAME2"},
+				map[string][]string{"GROUP1": []string{"SERIAL1", "NICKNAME1", "NICKNAME2"}},
+				false,
+			},
+			{
+				[]string{"GROUP1", "SERIAL2", "NICKNAME3", "NICKNAME1"},
+				map[string][]string{"GROUP1": []string{"SERIAL1", "NICKNAME1", "NICKNAME2", "SERIAL2", "NICKNAME3"}},
+				false,
+			},
+			{
+				[]string{"GROUP2", "SERIAL1", "SERIAL4", "NICKNAME1"},
+				map[string][]string{
+					"GROUP1": []string{"SERIAL1", "NICKNAME1", "NICKNAME2", "SERIAL2", "NICKNAME3"},
+					"GROUP2": []string{"SERIAL1", "SERIAL4", "NICKNAME1"},
+				},
+				false,
+			},
+			{
+				[]string{"GROUP2", "GROUP1"},
+				map[string][]string{
+					"GROUP1": []string{"SERIAL1", "NICKNAME1", "NICKNAME2", "SERIAL2", "NICKNAME3"},
+					"GROUP2": []string{"SERIAL1", "SERIAL4", "NICKNAME1", "GROUP1"},
+				},
+				false,
+			},
+		},
+		{
+			// Duplicate members should be removed even within the arguments.
+			{
+				[]string{"GROUP1", "SERIAL1", "NICKNAME1", "NICKNAME1"},
+				map[string][]string{"GROUP1": []string{"SERIAL1", "NICKNAME1"}},
+				false,
+			},
+		},
+		{
+			// Invalid mamber name
+			{
+				[]string{"GROUP1", "#INVALID_NAME#"},
+				map[string][]string{},
+				true,
+			},
+			// Invalid member index
+			{
+				[]string{"GROUP1", "@ABC"},
+				map[string][]string{},
+				true,
+			},
+		},
+	}
+
+	for i, testSuite := range tests {
+		filename := tempFilename(t)
+		defer os.Remove(filename)
+
+		for j, test := range testSuite {
+			err := runMadbGroupAdd(nil, test.input, filename)
+			if test.errExpected != (err != nil) {
+				t.Fatalf("error expected for tests[%v][%v]: %v, got: %v", i, j, test.errExpected, err)
+			}
+
+			cfg, err := readConfig(filename)
+			if err != nil {
+				t.Fatal(err)
+			}
+			if got, want := cfg.Groups, test.want; !reflect.DeepEqual(got, want) {
+				t.Fatalf("unmatched results for tests[%v][%v]: got %v, want %v", i, j, got, want)
+			}
+		}
+	}
+}
+
+func TestMadbGroupAddNameConflict(t *testing.T) {
+	filename := tempFilename(t)
+	defer os.Remove(filename)
+
+	err := runMadbNameSet(nil, []string{"SERIAL1", "NICKNAME1"}, filename)
+	if err != nil {
+		t.Fatal(err)
+	}
+
+	err = runMadbGroupAdd(nil, []string{"NICKNAME1", "SERIAL2"}, filename)
+	if err == nil {
+		t.Fatalf("error expected but got: %v", err)
+	}
+}
diff --git a/madb.go b/madb.go
index 5ccbb5b..562436f 100644
--- a/madb.go
+++ b/madb.go
@@ -85,6 +85,7 @@
 		cmdMadbClearData,
 		cmdMadbExec,
 		cmdMadbExtern,
+		cmdMadbGroup,
 		cmdMadbInstall,
 		cmdMadbName,
 		cmdMadbShell,
@@ -336,6 +337,10 @@
 	Version string
 	// Names keeps the mapping between device nicknames and their serials.
 	Names map[string]string
+	// Groups keeps the device group definitions. A group can contain multiple
+	// devices, each of which is denoted by its name, serial, or index. A group
+	// can also include other groups.
+	Groups map[string][]string
 	// UserIDs keeps the mapping between device serials and their default user
 	// IDs.
 	UserIDs map[string]string
@@ -375,6 +380,7 @@
 	// The file may not exist or be empty when there are no stored data.
 	if stat, err := os.Stat(filename); os.IsNotExist(err) || (err == nil && stat.Size() == 0) {
 		result.Names = make(map[string]string)
+		result.Groups = make(map[string][]string)
 		result.UserIDs = make(map[string]string)
 		return result, nil
 	}
@@ -401,6 +407,9 @@
 	if result.Names == nil {
 		result.Names = make(map[string]string)
 	}
+	if result.Groups == nil {
+		result.Groups = make(map[string][]string)
+	}
 	if result.UserIDs == nil {
 		result.UserIDs = make(map[string]string)
 	}
@@ -422,6 +431,30 @@
 	return encoder.Encode(*cfg)
 }
 
+func isNameInUse(name string, cfg *config) bool {
+	return isDeviceNickname(name, cfg) || isGroupName(name, cfg)
+}
+
+func isDeviceNickname(name string, cfg *config) bool {
+	_, ok := cfg.Names[name]
+	return ok
+}
+
+func isGroupName(name string, cfg *config) bool {
+	_, ok := cfg.Groups[name]
+	return ok
+}
+
+func isValidSerial(serial string) bool {
+	r := regexp.MustCompile(`^([A-Za-z0-9:\-\._]+|@\d+)$`)
+	return r.MatchString(serial)
+}
+
+func isValidName(name string) bool {
+	r := regexp.MustCompile(`^\w+$`)
+	return r.MatchString(name)
+}
+
 type subCommandRunner struct {
 	// init is an optional function that does some initial work that should only
 	// be performed once, before directing the command to all the devices.
@@ -441,8 +474,9 @@
 // Invokes the sub command on all the devices in parallel.
 func (r subCommandRunner) Run(env *cmdline.Env, args []string) error {
 	prefixFlag = strings.ToLower(prefixFlag)
-	if prefixFlag != "name" && prefixFlag != "serial" && prefixFlag != "none" {
-		return fmt.Errorf(`The -prefix flag value must be one of "name", "serial", or "none".`)
+	allowed := []string{"name", "serial", "none"}
+	if !isStringInSlice(prefixFlag, allowed) {
+		return fmt.Errorf("The -prefix flag value must be one of %v", strings.Join(allowed, ", "))
 	}
 
 	if err := startAdbServer(); err != nil {
@@ -757,6 +791,17 @@
 	return result
 }
 
+// isStringInSlice determines whether the given string appears in the slice.
+func isStringInSlice(str string, slice []string) bool {
+	for _, elem := range slice {
+		if str == elem {
+			return true
+		}
+	}
+
+	return false
+}
+
 type pathProvider func() (string, error)
 
 // subCommandRunnerWithFilepath is an adapter that turns the madb name/user subcommand functions into cmdline.Runners.
diff --git a/madb_test.go b/madb_test.go
index 395c1d5..6731496 100644
--- a/madb_test.go
+++ b/madb_test.go
@@ -514,3 +514,55 @@
 		}
 	}
 }
+
+func TestIsValidSerial(t *testing.T) {
+	tests := []struct {
+		input string
+		want  bool
+	}{
+		// The following strings should be accepted
+		{"HT4BVWV00023", true},
+		{"01023f5e2fd2accf", true},
+		{"usb:3-3.4.2", true},
+		{"product:volantisg", true},
+		{"model:Nexus_9", true},
+		{"device:flounder_lte", true},
+		{"@1", true},
+		{"@2", true},
+		// The following strings should not be accepted
+		{"have spaces", false},
+		{"@abcd", false},
+		{"#not_allowed_chars~", false},
+	}
+
+	for _, test := range tests {
+		if got := isValidSerial(test.input); got != test.want {
+			t.Fatalf("unmatched results for serial '%v': got %v, want %v", test.input, got, test.want)
+		}
+	}
+}
+
+func TestIsValidName(t *testing.T) {
+	tests := []struct {
+		input string
+		want  bool
+	}{
+		// The following strings should be accepted
+		{"Nexus5X", true},
+		{"Nexus9", true},
+		{"P1", true},
+		{"P2", true},
+		{"Tablet", true},
+		// The following strings should not be accepted
+		{"have spaces", false},
+		{"@1", false},
+		{"@abcd", false},
+		{"#not_allowed_chars~", false},
+	}
+
+	for _, test := range tests {
+		if got := isValidName(test.input); got != test.want {
+			t.Fatalf("unmatched results for nickname '%v': got %v, want %v", test.input, got, test.want)
+		}
+	}
+}
diff --git a/name.go b/name.go
index aca6661..d73d855 100644
--- a/name.go
+++ b/name.go
@@ -6,7 +6,6 @@
 
 import (
 	"fmt"
-	"regexp"
 
 	"v.io/x/lib/cmdline"
 )
@@ -65,11 +64,11 @@
 	}
 
 	serial, nickname := args[0], args[1]
-	if !isValidDeviceSerial(serial) {
+	if !isValidSerial(serial) {
 		return env.UsageErrorf("Not a valid device serial: %v", serial)
 	}
 
-	if !isValidNickname(nickname) {
+	if !isValidName(nickname) {
 		return env.UsageErrorf("Not a valid nickname: %v", nickname)
 	}
 
@@ -79,14 +78,14 @@
 	}
 
 	// If the nickname is already in use, don't allow it at all.
-	if _, present := cfg.Names[nickname]; present {
+	if isNameInUse(nickname, cfg) {
 		return fmt.Errorf("The provided nickname %q is already in use.", nickname)
 	}
 
 	// If the serial number already has an assigned nickname, delete it first.
 	// Need to do this check, because the nickname-serial map should be a one-to-one mapping.
-	if nickname, present := reverseMap(cfg.Names)[serial]; present {
-		delete(cfg.Names, nickname)
+	if name, present := reverseMap(cfg.Names)[serial]; present {
+		delete(cfg.Names, name)
 	}
 
 	// Add the nickname serial mapping.
@@ -116,7 +115,7 @@
 	}
 
 	name := args[0]
-	if !isValidDeviceSerial(name) && !isValidNickname(name) {
+	if !isValidSerial(name) && !isValidName(name) {
 		return env.UsageErrorf("Not a valid device serial or name: %v", name)
 	}
 
@@ -186,16 +185,6 @@
 	return writeConfig(cfg, filename)
 }
 
-func isValidDeviceSerial(serial string) bool {
-	r := regexp.MustCompile(`^([A-Za-z0-9:\-\._]+|@\d+)$`)
-	return r.MatchString(serial)
-}
-
-func isValidNickname(nickname string) bool {
-	r := regexp.MustCompile(`^\w+$`)
-	return r.MatchString(nickname)
-}
-
 // reverseMap returns a new map which contains reversed key, value pairs in the original map.
 // The source map is assumed to be a one-to-one mapping between keys and values.
 func reverseMap(source map[string]string) map[string]string {
diff --git a/name_test.go b/name_test.go
index a8fc1cf..d6aeed4 100644
--- a/name_test.go
+++ b/name_test.go
@@ -57,6 +57,17 @@
 	if err = runMadbNameSet(nil, []string{"SERIAL3", "NN1"}, filename); err == nil {
 		t.Fatalf("expected an error but succeeded.")
 	}
+
+	// Try an existing group name and see if it fails.
+	err = runMadbGroupAdd(nil, []string{"GROUP1", "SERIAL1"}, filename)
+	if err != nil {
+		t.Fatal(err)
+	}
+
+	err = runMadbNameSet(nil, []string{"SERIAL4", "GROUP1"}, filename)
+	if err == nil {
+		t.Fatalf("error expected but got: %v", err)
+	}
 }
 
 func TestMadbNameUnset(t *testing.T) {
@@ -135,55 +146,3 @@
 		t.Fatalf("unmatched results: got %v, want %v", got, want)
 	}
 }
-
-func TestIsValidDeviceSerial(t *testing.T) {
-	tests := []struct {
-		input string
-		want  bool
-	}{
-		// The following strings should be accepted
-		{"HT4BVWV00023", true},
-		{"01023f5e2fd2accf", true},
-		{"usb:3-3.4.2", true},
-		{"product:volantisg", true},
-		{"model:Nexus_9", true},
-		{"device:flounder_lte", true},
-		{"@1", true},
-		{"@2", true},
-		// The following strings should not be accepted
-		{"have spaces", false},
-		{"@abcd", false},
-		{"#not_allowed_chars~", false},
-	}
-
-	for _, test := range tests {
-		if got := isValidDeviceSerial(test.input); got != test.want {
-			t.Fatalf("unmatched results for serial '%v': got %v, want %v", test.input, got, test.want)
-		}
-	}
-}
-
-func TestIsValidNickname(t *testing.T) {
-	tests := []struct {
-		input string
-		want  bool
-	}{
-		// The following strings should be accepted
-		{"Nexus5X", true},
-		{"Nexus9", true},
-		{"P1", true},
-		{"P2", true},
-		{"Tablet", true},
-		// The following strings should not be accepted
-		{"have spaces", false},
-		{"@1", false},
-		{"@abcd", false},
-		{"#not_allowed_chars~", false},
-	}
-
-	for _, test := range tests {
-		if got := isValidNickname(test.input); got != test.want {
-			t.Fatalf("unmatched results for nickname '%v': got %v, want %v", test.input, got, test.want)
-		}
-	}
-}
diff --git a/user.go b/user.go
index 75c87f4..6bab71d 100644
--- a/user.go
+++ b/user.go
@@ -92,7 +92,7 @@
 	// TODO(youngseokyoon): make it possible to specify the device using its nickname or index.
 	// Validate the device serial
 	serial := args[0]
-	if !isValidDeviceSerial(serial) {
+	if !isValidSerial(serial) {
 		return fmt.Errorf("Not a valid device serial: %v", serial)
 	}
 
@@ -137,7 +137,7 @@
 	// TODO(youngseokyoon): make it possible to specify the device using its nickname or index.
 	// Validate the device serial
 	serial := args[0]
-	if !isValidDeviceSerial(serial) {
+	if !isValidSerial(serial) {
 		return fmt.Errorf("Not a valid device serial: %v", serial)
 	}