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

Change-Id: Idbeb00d7d092c87aa02db849660a81450a516e6e
Ref: #2
diff --git a/doc.go b/doc.go
index 76df444..31b6310 100644
--- a/doc.go
+++ b/doc.go
@@ -231,11 +231,12 @@
    madb group [flags] <command>
 
 The madb group commands are:
-   add         Add devices to a device group
+   add         Add members to a device group
+   remove      Remove members from a device group
 
-Madb group add - Add devices to a device group
+Madb group add - Add members to a device group
 
-Adds devices to a device group. This command also creates the group, if the
+Adds members 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.
 
@@ -254,6 +255,20 @@
 <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 group remove - Remove members from a device group
+
+Removes members from an existing device group. If there are no remaining members
+after that, the group gets deleted.
+
+Usage:
+   madb group remove [flags] <group_name> <member1> [<member2> ...]
+
+<group_name> is an alpha-numeric string with no special characters or spaces.
+This name must be an existing device group name.
+
+<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
index 6224be5..dc1e563 100644
--- a/group.go
+++ b/group.go
@@ -13,7 +13,6 @@
 )
 
 // 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
@@ -22,7 +21,7 @@
 // TODO(youngseokyoon): use the groups for filtering devices.
 
 var cmdMadbGroup = &cmdline.Command{
-	Children:         []*cmdline.Command{cmdMadbGroupAdd},
+	Children:         []*cmdline.Command{cmdMadbGroupAdd, cmdMadbGroupRemove},
 	Name:             "group",
 	DontInheritFlags: true,
 	Short:            "Manage device groups",
@@ -36,9 +35,9 @@
 var cmdMadbGroupAdd = &cmdline.Command{
 	Runner: subCommandRunnerWithFilepath{runMadbGroupAdd, getDefaultConfigFilePath},
 	Name:   "add",
-	Short:  "Add devices to a device group",
+	Short:  "Add members to a device group",
 	Long: `
-Adds devices to a device group. This command also creates the group, if the
+Adds members 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.
 
@@ -93,6 +92,54 @@
 	return writeConfig(cfg, filename)
 }
 
+var cmdMadbGroupRemove = &cmdline.Command{
+	Runner: subCommandRunnerWithFilepath{runMadbGroupRemove, getDefaultConfigFilePath},
+	Name:   "remove",
+	Short:  "Remove members from a device group",
+	Long: `
+Removes members from an existing device group. If there are no remaining members
+after that, the group gets deleted.
+`,
+	ArgsName: "<group_name> <member1> [<member2> ...]",
+	ArgsLong: `
+<group_name> is an alpha-numeric string with no special characters or spaces.
+This name must be an existing device group name.
+
+<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 runMadbGroupRemove(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 !isGroupName(groupName, cfg) {
+		return fmt.Errorf("Not an existing group name: %q", groupName)
+	}
+
+	members := removeDuplicates(args[1:])
+	oldMembers := cfg.Groups[groupName]
+	cfg.Groups[groupName] = subtractSlices(oldMembers, members)
+
+	if len(cfg.Groups[groupName]) == 0 {
+		delete(cfg.Groups, groupName)
+	}
+
+	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.
@@ -125,3 +172,22 @@
 
 	return result
 }
+
+// subtractSlices takes two slices and returns a new slice formed by removing
+// all the elements in s2 from s1.
+func subtractSlices(s1, s2 []string) []string {
+	result := make([]string, 0, len(s1))
+
+	m := map[string]bool{}
+	for _, e2 := range s2 {
+		m[e2] = true
+	}
+
+	for _, e1 := range s1 {
+		if !m[e1] {
+			result = append(result, e1)
+		}
+	}
+
+	return result
+}
diff --git a/group_test.go b/group_test.go
index 49dd946..a364714 100644
--- a/group_test.go
+++ b/group_test.go
@@ -8,72 +8,26 @@
 	"os"
 	"reflect"
 	"testing"
+
+	"v.io/x/lib/cmdline"
 )
 
-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,
-			},
-		},
-	}
+type testCase struct {
+	runnerFunc  func(*cmdline.Env, []string, string) error
+	input       []string
+	want        map[string][]string
+	errExpected bool
+}
 
+type testSequence []testCase
+
+func runGroupTests(t *testing.T, tests []testSequence) {
 	for i, testSuite := range tests {
 		filename := tempFilename(t)
 		defer os.Remove(filename)
 
 		for j, test := range testSuite {
-			err := runMadbGroupAdd(nil, test.input, filename)
+			err := test.runnerFunc(cmdline.EnvFromOS(), test.input, filename)
 			if test.errExpected != (err != nil) {
 				t.Fatalf("error expected for tests[%v][%v]: %v, got: %v", i, j, test.errExpected, err)
 			}
@@ -89,17 +43,184 @@
 	}
 }
 
+func TestMadbGroupAdd(t *testing.T) {
+	tests := []testSequence{
+		{
+			{
+				runMadbGroupAdd,
+				[]string{},
+				map[string][]string{},
+				true,
+			},
+		},
+		{
+			{
+				runMadbGroupAdd,
+				[]string{"GROUP1"},
+				map[string][]string{},
+				true,
+			},
+		},
+		{
+			{
+				runMadbGroupAdd,
+				[]string{"GROUP1", "SERIAL1", "NICKNAME1", "NICKNAME2"},
+				map[string][]string{"GROUP1": []string{"SERIAL1", "NICKNAME1", "NICKNAME2"}},
+				false,
+			},
+			{
+				runMadbGroupAdd,
+				[]string{"GROUP1", "SERIAL2", "NICKNAME3", "NICKNAME1"},
+				map[string][]string{"GROUP1": []string{"SERIAL1", "NICKNAME1", "NICKNAME2", "SERIAL2", "NICKNAME3"}},
+				false,
+			},
+			{
+				runMadbGroupAdd,
+				[]string{"GROUP2", "SERIAL1", "SERIAL4", "NICKNAME1"},
+				map[string][]string{
+					"GROUP1": []string{"SERIAL1", "NICKNAME1", "NICKNAME2", "SERIAL2", "NICKNAME3"},
+					"GROUP2": []string{"SERIAL1", "SERIAL4", "NICKNAME1"},
+				},
+				false,
+			},
+			{
+				runMadbGroupAdd,
+				[]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.
+			{
+				runMadbGroupAdd,
+				[]string{"GROUP1", "SERIAL1", "NICKNAME1", "NICKNAME1"},
+				map[string][]string{"GROUP1": []string{"SERIAL1", "NICKNAME1"}},
+				false,
+			},
+		},
+		{
+			// Invalid mamber name
+			{
+				runMadbGroupAdd,
+				[]string{"GROUP1", "#INVALID_NAME#"},
+				map[string][]string{},
+				true,
+			},
+			// Invalid member index
+			{
+				runMadbGroupAdd,
+				[]string{"GROUP1", "@ABC"},
+				map[string][]string{},
+				true,
+			},
+		},
+	}
+
+	runGroupTests(t, tests)
+}
+
 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)
+	tests := []testSequence{
+		{
+			{
+				runMadbNameSet,
+				[]string{"SERIAL1", "NICKNAME1"},
+				map[string][]string{},
+				false,
+			},
+			{
+				runMadbGroupAdd,
+				[]string{"NICKNAME1", "SERIAL2"},
+				map[string][]string{},
+				true,
+			},
+		},
 	}
 
-	err = runMadbGroupAdd(nil, []string{"NICKNAME1", "SERIAL2"}, filename)
-	if err == nil {
-		t.Fatalf("error expected but got: %v", err)
+	runGroupTests(t, tests)
+}
+
+func TestMadbGroupRemove(t *testing.T) {
+	tests := []testSequence{
+		{
+			{
+				runMadbGroupRemove,
+				[]string{},
+				map[string][]string{},
+				true,
+			},
+		},
+		{
+			{
+				runMadbGroupRemove,
+				[]string{"GROUP1"},
+				map[string][]string{},
+				true,
+			},
+		},
+		{
+			{
+				runMadbGroupAdd,
+				[]string{"GROUP1", "SERIAL1", "NICKNAME1", "NICKNAME2", "SERIAL2", "NICKNAME3"},
+				map[string][]string{"GROUP1": []string{"SERIAL1", "NICKNAME1", "NICKNAME2", "SERIAL2", "NICKNAME3"}},
+				false,
+			},
+			{
+				runMadbGroupAdd,
+				[]string{"GROUP2", "SERIAL3", "NICKNAME4"},
+				map[string][]string{
+					"GROUP1": []string{"SERIAL1", "NICKNAME1", "NICKNAME2", "SERIAL2", "NICKNAME3"},
+					"GROUP2": []string{"SERIAL3", "NICKNAME4"},
+				},
+				false,
+			},
+			{
+				runMadbGroupRemove,
+				[]string{"GROUP1", "SERIAL1", "NICKNAME3"},
+				map[string][]string{
+					"GROUP1": []string{"NICKNAME1", "NICKNAME2", "SERIAL2"},
+					"GROUP2": []string{"SERIAL3", "NICKNAME4"},
+				},
+				false,
+			},
+			{
+				runMadbGroupRemove,
+				[]string{"GROUP2", "SERIAL3"},
+				map[string][]string{
+					"GROUP1": []string{"NICKNAME1", "NICKNAME2", "SERIAL2"},
+					"GROUP2": []string{"NICKNAME4"},
+				},
+				false,
+			},
+			{
+				runMadbGroupRemove,
+				[]string{"GROUP3", "SERIAL3"},
+				map[string][]string{
+					"GROUP1": []string{"NICKNAME1", "NICKNAME2", "SERIAL2"},
+					"GROUP2": []string{"NICKNAME4"},
+				},
+				true,
+			},
+			{
+				runMadbGroupRemove,
+				[]string{"GROUP2", "NICKNAME4"},
+				map[string][]string{
+					"GROUP1": []string{"NICKNAME1", "NICKNAME2", "SERIAL2"},
+				},
+				false,
+			},
+			{
+				runMadbGroupRemove,
+				[]string{"GROUP1", "NICKNAME2", "SERIAL2", "NICKNAME1"},
+				map[string][]string{},
+				false,
+			},
+		},
 	}
+
+	runGroupTests(t, tests)
 }