devtools/madb: initial version of the 'madb name' command

The 'madb name' command provides several child commands used for human
friendly device nicknames. For now, these device nicknames are not
used by other commands, and the mapping information is just stored
under ~/.madb_names file.

Change-Id: Id5765e3eca5080d0881a2379be355f9c62bc98f4
diff --git a/doc.go b/doc.go
index d218c7c..c5201ad 100644
--- a/doc.go
+++ b/doc.go
@@ -17,6 +17,7 @@
 The madb commands are:
    exec        Run the provided adb command on all the specified devices
                concurrently
+   name        Manage device nicknames
    help        Display help for commands or topics
 
 The global flags are:
@@ -44,6 +45,78 @@
 <command> is a normal adb command, which will be executed on all the specified
 devices.
 
+Madb name - Manage device nicknames
+
+Manages device nicknames, which are meant to be more human-friendly compared to
+the device serials provided by adb tool.
+
+Usage:
+   madb name [flags] <command>
+
+The madb name commands are:
+   set         Set a nickname to be used in place of the device serial.
+   unset       Unset a nickname set by the 'madb name set' command.
+   list        List all the existing nicknames.
+   clear-all   Clear all the existing nicknames.
+
+Madb name set
+
+Sets a human-friendly nickname that can be used when specifying the device in
+any madb commands.
+
+The device serial can be obtain using the 'adb devices -l' command. For example,
+consider the following example output:
+
+    HT4BVWV00023           device usb:3-3.4.2 product:volantisg model:Nexus_9 device:flounder_lte
+
+The first value, 'HT4BVWV00023', is the device serial. To assign a nickname for
+this device, run the following command:
+
+    madb name set HT4BVWV00023 MyTablet
+
+and it will assign the 'MyTablet' nickname to the device serial 'HT4BVWV00023'.
+The alternative device specifiers (e.g., 'usb:3-3.4.2', 'product:volantisg') can
+also have nicknames.
+
+When a nickname is set for a device serial, the nickname can be used to specify
+the device within madb commands.
+
+There can only be one nickname for a device serial. When the 'madb name set'
+command is invoked with a device serial with an already assigned nickname, the
+old one will be replaced with the newly provided one.
+
+Usage:
+   madb name set [flags] <device_serial> <nickname>
+
+<device_serial> is a device serial (e.g., 'HT4BVWV00023') or an alternative
+device specifier (e.g., 'usb:3-3.4.2') obtained from 'adb devices -l' command
+<nickname> is an alpha-numeric string with no special characters or spaces.
+
+Madb name unset
+
+Unsets a nickname assigned by the 'madb name set' command. Either the device
+serial or the assigned nickname can be specified to remove the mapping.
+
+Usage:
+   madb name unset [flags] <device_serial | nickname>
+
+There should be only one argument, which is either the device serial or the
+nickname.
+
+Madb name list
+
+Lists all the currently stored nicknames of device serials.
+
+Usage:
+   madb name list [flags]
+
+Madb name clear-all
+
+Clears all the currently stored nicknames of device serials.
+
+Usage:
+   madb name clear-all [flags]
+
 Madb help - Display help for commands or topics
 
 Help with no args displays the usage of the parent command.
diff --git a/madb.go b/madb.go
index 11020fd..0eb7037 100644
--- a/madb.go
+++ b/madb.go
@@ -17,7 +17,7 @@
 )
 
 var cmdMadb = &cmdline.Command{
-	Children: []*cmdline.Command{cmdMadbExec},
+	Children: []*cmdline.Command{cmdMadbExec, cmdMadbName},
 	Name:     "madb",
 	Short:    "Multi-device Android Debug Bridge",
 	Long: `
diff --git a/name.go b/name.go
new file mode 100644
index 0000000..6d1d5d7
--- /dev/null
+++ b/name.go
@@ -0,0 +1,264 @@
+// Copyright 2015 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 (
+	"bufio"
+	"fmt"
+	"os"
+	"path/filepath"
+	"regexp"
+	"strings"
+
+	"v.io/x/lib/cmdline"
+)
+
+var cmdMadbName = &cmdline.Command{
+	Children: []*cmdline.Command{cmdMadbNameSet, cmdMadbNameUnset, cmdMadbNameList, cmdMadbNameClearAll},
+	Name:     "name",
+	Short:    "Manage device nicknames",
+	Long: `
+Manages device nicknames, which are meant to be more human-friendly compared to
+the device serials provided by adb tool.
+`,
+}
+
+var cmdMadbNameSet = &cmdline.Command{
+	Runner: runnerFuncWithFilepath(runMadbNameSet),
+	Name:   "set",
+	Short:  "Set a nickname to be used in place of the device serial.",
+	Long: `
+Sets a human-friendly nickname that can be used when specifying the device in
+any madb commands.
+
+The device serial can be obtain using the 'adb devices -l' command.
+For example, consider the following example output:
+
+    HT4BVWV00023           device usb:3-3.4.2 product:volantisg model:Nexus_9 device:flounder_lte
+
+The first value, 'HT4BVWV00023', is the device serial.
+To assign a nickname for this device, run the following command:
+
+    madb name set HT4BVWV00023 MyTablet
+
+and it will assign the 'MyTablet' nickname to the device serial 'HT4BVWV00023'.
+The alternative device specifiers (e.g., 'usb:3-3.4.2', 'product:volantisg')
+can also have nicknames.
+
+When a nickname is set for a device serial, the nickname can be used to specify
+the device within madb commands.
+
+There can only be one nickname for a device serial.
+When the 'madb name set' command is invoked with a device serial with an already
+assigned nickname, the old one will be replaced with the newly provided one.
+`,
+	ArgsName: "<device_serial> <nickname>",
+	ArgsLong: `
+<device_serial> is a device serial (e.g., 'HT4BVWV00023') or an alternative device specifier (e.g., 'usb:3-3.4.2') obtained from 'adb devices -l' command
+<nickname> is an alpha-numeric string with no special characters or spaces.
+`,
+}
+
+func runMadbNameSet(env *cmdline.Env, args []string, filename string) error {
+	// Check if the arguments are valid.
+	if len(args) != 2 {
+		return env.UsageErrorf("There must be exactly two arguments.")
+	}
+
+	serial, nickname := args[0], args[1]
+	if !isValidDeviceSerial(serial) {
+		return env.UsageErrorf("Not a valid device serial: %v", serial)
+	}
+
+	if !isValidNickname(nickname) {
+		return env.UsageErrorf("Not a valid nickname: %v", nickname)
+	}
+
+	nsm, err := readNicknameSerialMap(filename)
+	if err != nil {
+		return err
+	}
+
+	// If the nickname is already in use, don't allow it at all.
+	if _, present := nsm[nickname]; present {
+		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 nn, present := reverseMap(nsm)[serial]; present {
+		delete(nsm, nn)
+	}
+
+	// Add the nickname serial mapping.
+	nsm[nickname] = serial
+
+	return writeNicknameSerialMap(nsm, filename)
+}
+
+var cmdMadbNameUnset = &cmdline.Command{
+	Runner: runnerFuncWithFilepath(runMadbNameUnset),
+	Name:   "unset",
+	Short:  "Unset a nickname set by the 'madb name set' command.",
+	Long: `
+Unsets a nickname assigned by the 'madb name set' command. Either the device
+serial or the assigned nickname can be specified to remove the mapping.
+`,
+	ArgsName: "<device_serial | nickname>",
+	ArgsLong: `
+There should be only one argument, which is either the device serial or the nickname.
+`,
+}
+
+func runMadbNameUnset(env *cmdline.Env, args []string, filename string) error {
+	// Check if the arguments are valid.
+	if len(args) != 1 {
+		return env.UsageErrorf("There must be exactly one argument.")
+	}
+
+	name := args[0]
+	if !isValidDeviceSerial(name) && !isValidNickname(name) {
+		return env.UsageErrorf("Not a valid device serial or name: %v", name)
+	}
+
+	nsm, err := readNicknameSerialMap(filename)
+	if err != nil {
+		return err
+	}
+
+	found := false
+	for nickname, serial := range nsm {
+		if nickname == name || serial == name {
+			delete(nsm, nickname)
+			found = true
+			break
+		}
+	}
+
+	if !found {
+		return fmt.Errorf("The provided argument is neither a known nickname nor a device serial.")
+	}
+
+	return writeNicknameSerialMap(nsm, filename)
+}
+
+var cmdMadbNameList = &cmdline.Command{
+	Runner: runnerFuncWithFilepath(runMadbNameList),
+	Name:   "list",
+	Short:  "List all the existing nicknames.",
+	Long: `
+Lists all the currently stored nicknames of device serials.
+`,
+}
+
+func runMadbNameList(env *cmdline.Env, args []string, filename string) error {
+	nsm, err := readNicknameSerialMap(filename)
+	if err != nil {
+		return err
+	}
+
+	// TODO(youngseokyoon): pretty print this.
+	fmt.Println("Serial          Nickname")
+	fmt.Println("========================")
+
+	for nickname, serial := range nsm {
+		fmt.Printf("%v\t%v\n", serial, nickname)
+	}
+
+	return nil
+}
+
+var cmdMadbNameClearAll = &cmdline.Command{
+	Runner: runnerFuncWithFilepath(runMadbNameClearAll),
+	Name:   "clear-all",
+	Short:  "Clear all the existing nicknames.",
+	Long: `
+Clears all the currently stored nicknames of device serials.
+`,
+}
+
+func runMadbNameClearAll(env *cmdline.Env, args []string, filename string) error {
+	return os.Remove(filename)
+}
+
+func getDefaultNameFilePath() string {
+	return filepath.Join(os.Getenv("HOME"), ".madb_names")
+}
+
+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 {
+	if source == nil {
+		return nil
+	}
+
+	reversed := make(map[string]string, len(source))
+	for k, v := range source {
+		reversed[v] = k
+	}
+
+	return reversed
+}
+
+// readNicknameSerialMap reads the provided file and reconstructs the nickname => serial map.
+// The mapping is written one per each line, in the form of "<nickname> <serial>".
+func readNicknameSerialMap(filename string) (map[string]string, error) {
+	result := make(map[string]string)
+
+	f, err := os.Open(filename)
+	if err != nil {
+		return nil, err
+	}
+	defer f.Close()
+
+	// TODO(youngseokyoon): use encoding/json for serialization and deserialization.
+	scanner := bufio.NewScanner(f)
+	for scanner.Scan() {
+		line := scanner.Text()
+		fields := strings.Fields(line)
+		if len(fields) == 2 {
+			result[fields[0]] = fields[1]
+		} else {
+			return nil, fmt.Errorf("Unexpected number of columns in the nickname file.")
+		}
+	}
+
+	return result, nil
+}
+
+// writeNicknameSerialmap takes a nickname => serial map and writes it into the provided file name.
+// The mapping is written one per each line, in the form of "<nickname> <serial>".
+func writeNicknameSerialMap(nsm map[string]string, filename string) error {
+	f, err := os.Create(filename)
+	if err != nil {
+		return err
+	}
+	defer f.Close()
+
+	for nickname, serial := range nsm {
+		fmt.Fprintln(f, nickname, serial)
+	}
+
+	return nil
+}
+
+// runnerFuncWithFilepath is an adapter that turns the madb name subcommand functions into cmdline.Runners.
+type runnerFuncWithFilepath func(*cmdline.Env, []string, string) error
+
+// Run implements the cmdline.Runner interface by providing the default name file path
+// as the third string argument of the underlying run function.
+func (f runnerFuncWithFilepath) Run(env *cmdline.Env, args []string) error {
+	return f(env, args, getDefaultNameFilePath())
+}
diff --git a/name_test.go b/name_test.go
new file mode 100644
index 0000000..21f002b
--- /dev/null
+++ b/name_test.go
@@ -0,0 +1,242 @@
+// Copyright 2015 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"
+	"io/ioutil"
+	"os"
+	"reflect"
+	"testing"
+)
+
+type stringBoolPair struct {
+	s string
+	b bool
+}
+
+func tempFilename(t *testing.T) string {
+	f, err := ioutil.TempFile("", "madb_test")
+	if err != nil {
+		t.Fatalf("could not open a temp file: %v", err)
+	}
+	f.Close()
+
+	return f.Name()
+}
+
+func TestMadbNameSet(t *testing.T) {
+	filename := tempFilename(t)
+	defer os.Remove(filename)
+
+	var got, want map[string]string
+	var err error
+
+	// Set a new nickname
+	if err = runMadbNameSet(nil, []string{"SERIAL1", "NICKNAME1"}, filename); err != nil {
+		t.Fatal(err)
+	}
+
+	if got, err = readNicknameSerialMap(filename); err != nil {
+		t.Fatal(err)
+	}
+	want = map[string]string{"NICKNAME1": "SERIAL1"}
+	if !reflect.DeepEqual(got, want) {
+		t.Fatalf("unmatched results: got %v, want %v", got, want)
+	}
+
+	// Set a second nickname
+	if err = runMadbNameSet(nil, []string{"SERIAL2", "NICKNAME2"}, filename); err != nil {
+		t.Fatal(err)
+	}
+
+	if got, err = readNicknameSerialMap(filename); err != nil {
+		t.Fatal(err)
+	}
+	want = map[string]string{"NICKNAME1": "SERIAL1", "NICKNAME2": "SERIAL2"}
+	if !reflect.DeepEqual(got, want) {
+		t.Fatalf("unmatched results: got %v, want %v", got, want)
+	}
+
+	// Override an existing nickname to another
+	if err = runMadbNameSet(nil, []string{"SERIAL1", "NN1"}, filename); err != nil {
+		t.Fatal(err)
+	}
+
+	if got, err = readNicknameSerialMap(filename); err != nil {
+		t.Fatal(err)
+	}
+	want = map[string]string{"NN1": "SERIAL1", "NICKNAME2": "SERIAL2"}
+	if !reflect.DeepEqual(got, want) {
+		t.Fatalf("unmatched results: got %v, want %v", got, want)
+	}
+
+	// Try an existing nickname and see if it fails.
+	if err = runMadbNameSet(nil, []string{"SERIAL3", "NN1"}, filename); err == nil {
+		t.Fatalf("expected an error but succeeded.")
+	}
+}
+
+func TestMadbNameUnset(t *testing.T) {
+	filename := tempFilename(t)
+	defer os.Remove(filename)
+
+	// Set up some nicknames first.
+	runMadbNameSet(nil, []string{"SERIAL1", "NICKNAME1"}, filename)
+	runMadbNameSet(nil, []string{"SERIAL2", "NICKNAME2"}, filename)
+	runMadbNameSet(nil, []string{"SERIAL3", "NICKNAME3"}, filename)
+
+	var got, want map[string]string
+	var err error
+
+	// Unset by serial number.
+	if err = runMadbNameUnset(nil, []string{"SERIAL1"}, filename); err != nil {
+		t.Fatal(err)
+	}
+	if got, err = readNicknameSerialMap(filename); err != nil {
+		t.Fatal(err)
+	}
+	want = map[string]string{"NICKNAME2": "SERIAL2", "NICKNAME3": "SERIAL3"}
+	if !reflect.DeepEqual(got, want) {
+		t.Fatalf("unmatched results: got %v, want %v", got, want)
+	}
+
+	// Unset by nickname.
+	if err = runMadbNameUnset(nil, []string{"NICKNAME2"}, filename); err != nil {
+		t.Fatal(err)
+	}
+	if got, err = readNicknameSerialMap(filename); err != nil {
+		t.Fatal(err)
+	}
+	want = map[string]string{"NICKNAME3": "SERIAL3"}
+	if !reflect.DeepEqual(got, want) {
+		t.Fatalf("unmatched results: got %v, want %v", got, want)
+	}
+
+	// When the input is not found anywhere.
+	if err = runMadbNameUnset(nil, []string{"UnrecognizedName"}, filename); err == nil {
+		t.Fatalf("expected an error but succeeded.")
+	}
+}
+
+func TestMadbNameList(t *testing.T) {
+	// TODO(youngseokyoon): add some tests for the list command.
+}
+
+func TestMadbNameClearAll(t *testing.T) {
+	filename := tempFilename(t)
+	defer os.Remove(filename)
+
+	// Set up some nicknames first.
+	runMadbNameSet(nil, []string{"SERIAL1", "NICKNAME1"}, filename)
+	runMadbNameSet(nil, []string{"SERIAL2", "NICKNAME2"}, filename)
+	runMadbNameSet(nil, []string{"SERIAL3", "NICKNAME3"}, filename)
+
+	// Run the clear-all command. The file should be empty after running the command.
+	runMadbNameClearAll(nil, []string{}, filename)
+
+	// Check if the file is successfully deleted.
+	if _, err := os.Stat(filename); !os.IsNotExist(err) {
+		t.Fatalf("failed to delete file %q", filename)
+	}
+}
+
+func TestIsValidDeviceSerial(t *testing.T) {
+	testCases := []stringBoolPair{
+		// 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 _, tc := range testCases {
+		if got, want := isValidDeviceSerial(tc.s), tc.b; got != want {
+			t.Fatalf("unmatched results for serial '%v': got %v, want %v", tc.s, got, want)
+		}
+	}
+}
+
+func TestIsValidNickname(t *testing.T) {
+	testCases := []stringBoolPair{
+		// 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 _, tc := range testCases {
+		if got, want := isValidNickname(tc.s), tc.b; got != want {
+			t.Fatalf("unmatched results for nickname '%v': got %v, want %v", tc.s, got, want)
+		}
+	}
+}
+
+func TestReadNicknameSerialMap(t *testing.T) {
+	filename := tempFilename(t)
+	defer os.Remove(filename)
+
+	f, err := os.Create(filename)
+	if err != nil {
+		t.Fatal(err)
+	}
+	fmt.Fprintln(f, "PHONE1 SERIAL1")
+	fmt.Fprintln(f, "PHONE2 SERIAL2")
+	fmt.Fprintln(f, "PHONE3 SERIAL3")
+	f.Close()
+
+	var got map[string]string
+	if got, err = readNicknameSerialMap(filename); err != nil {
+		t.Fatal(err)
+	}
+	want := map[string]string{
+		"PHONE1": "SERIAL1",
+		"PHONE2": "SERIAL2",
+		"PHONE3": "SERIAL3",
+	}
+
+	if !reflect.DeepEqual(got, want) {
+		t.Fatalf("unmatched results: got %v, want %v", got, want)
+	}
+}
+
+func TestWriteNicknameSerialMap(t *testing.T) {
+	filename := tempFilename(t)
+	defer os.Remove(filename)
+
+	want := map[string]string{
+		"PHONE1": "SERIAL1",
+		"PHONE2": "SERIAL2",
+		"PHONE3": "SERIAL3",
+	}
+
+	if err := writeNicknameSerialMap(want, filename); err != nil {
+		t.Fatalf("could not write the map to file: %v", err)
+	}
+
+	got, err := readNicknameSerialMap(filename)
+	if err != nil {
+		t.Fatal(err)
+	}
+
+	if !reflect.DeepEqual(got, want) {
+		t.Fatalf("unmatched results: got %v, want %v", got, want)
+	}
+}