Create a mechanism for marking @Option fields as mandatory

Bug: 5151102
Change-Id: Id1c32b5aa8aeba42b3f5f683f8ecb10d3436cbd0
diff --git a/src/com/android/tradefed/command/CommandScheduler.java b/src/com/android/tradefed/command/CommandScheduler.java
index f07019a..8584197 100644
--- a/src/com/android/tradefed/command/CommandScheduler.java
+++ b/src/com/android/tradefed/command/CommandScheduler.java
@@ -408,8 +408,10 @@
                 return true;
             }
         } catch (ConfigurationException e) {
-            System.out.println(String.format("Unrecognized arguments: %s", e.getMessage()));
-            getConfigFactory().printHelpForConfig(args, true, System.out);
+            // FIXME: do this with jline somehow for ANSI support
+            System.out.println();
+            System.out.println(e.getMessage());
+            System.out.println();
         }
         return false;
     }
diff --git a/src/com/android/tradefed/config/ArgsOptionParser.java b/src/com/android/tradefed/config/ArgsOptionParser.java
index 5c6d065..563681b 100644
--- a/src/com/android/tradefed/config/ArgsOptionParser.java
+++ b/src/com/android/tradefed/config/ArgsOptionParser.java
@@ -16,6 +16,7 @@
 package com.android.tradefed.config;
 
 import com.android.ddmlib.Log;
+import com.android.tradefed.util.ArrayUtil;
 
 import java.lang.reflect.Field;
 import java.util.ArrayList;
@@ -199,6 +200,13 @@
             }
         }
 
+        // Make sure that all mandatory options have been specified
+        List<String> missingOptions = new ArrayList(getUnsetMandatoryOptions());
+        if (!missingOptions.isEmpty()) {
+            throw new ConfigurationException(String.format("Found missing mandatory options: %s",
+                    ArrayUtil.join(", ", missingOptions)));
+        }
+
         // Package up the leftovers.
         while (args.hasNext()) {
             leftovers.add(args.next());
diff --git a/src/com/android/tradefed/config/Option.java b/src/com/android/tradefed/config/Option.java
index 3e5fec1..e1eea18 100644
--- a/src/com/android/tradefed/config/Option.java
+++ b/src/com/android/tradefed/config/Option.java
@@ -46,7 +46,7 @@
      * For example, an {@link Option} with name 'help' would be specified with '--help' on the
      * command line.
      * <p/>
-     * Names cannot contain a colon eg ':'.
+     * Names may not contain a colon eg ':'.
      */
     String name();
 
@@ -71,4 +71,16 @@
      * unimportant option will only be displayed in the full help text.
      */
     Importance importance() default Importance.NEVER;
+
+    /**
+     * Whether the option is mandatory or optional.
+     * <p />
+     * The configuration framework will throw a {@code ConfigurationException} if either of the
+     * following is true of a mandatory field after options have been parsed from all sources:
+     * <ul>
+     *   <li>The field is {@code null}.</li>
+     *   <li>The field is an empty {@link java.util.Collection}.</li>
+     * </ul>
+     */
+     boolean mandatory() default false;
 }
diff --git a/src/com/android/tradefed/config/OptionSetter.java b/src/com/android/tradefed/config/OptionSetter.java
index 54d3884..3a7774f 100644
--- a/src/com/android/tradefed/config/OptionSetter.java
+++ b/src/com/android/tradefed/config/OptionSetter.java
@@ -27,6 +27,7 @@
 import java.util.Arrays;
 import java.util.Collection;
 import java.util.HashMap;
+import java.util.HashSet;
 import java.util.Iterator;
 import java.util.Locale;
 import java.util.Map;
@@ -391,6 +392,63 @@
     }
 
     /**
+     * Returns the names of all of the {@link Option}s that are marked as {@code mandatory} but
+     * remain unset.
+     *
+     * @return A {@link Collection} of {@link String}s containing the (unqualified) names of unset
+     *         mandatory options.
+     * @throws ConfigurationException if a field to be checked is inaccessible
+     */
+    protected Collection<String> getUnsetMandatoryOptions() throws ConfigurationException {
+        Collection<String> unsetOptions = new HashSet<String>();
+        for (Map.Entry<String, OptionFieldsForName> optionPair : mOptionMap.entrySet()) {
+            final String optName = optionPair.getKey();
+            final OptionFieldsForName optionFields = optionPair.getValue();
+            if (optName.indexOf(NAMESPACE_SEPARATOR) >= 0) {
+                // Only return unqualified option names
+                continue;
+            }
+
+            for (Map.Entry<Object, Field> fieldEntry : optionFields) {
+                final Object obj = fieldEntry.getKey();
+                final Field field = fieldEntry.getValue();
+                final Option option = field.getAnnotation(Option.class);
+                if (option == null) {
+                    continue;
+                } else if (!option.mandatory()) {
+                    continue;
+                }
+
+                // At this point, we know this is a mandatory field; make sure it's set
+                field.setAccessible(true);
+                final Object value;
+                try {
+                    value = field.get(obj);
+                } catch (IllegalAccessException e) {
+                    throw new ConfigurationException(String.format("internal error: %s",
+                            e.getMessage()));
+                }
+
+                final String realOptName = String.format("--%s", option.name());
+                if (value == null) {
+                    unsetOptions.add(realOptName);
+                } else if (value instanceof Collection) {
+                    Collection c = (Collection) value;
+                    if (c.isEmpty()) {
+                        unsetOptions.add(realOptName);
+                    }
+                } else if (value instanceof Map) {
+                    Map m = (Map) value;
+                    if (m.isEmpty()) {
+                        unsetOptions.add(realOptName);
+                    }
+                }
+            }
+        }
+        return unsetOptions;
+    }
+
+    /**
      * Gets a list of all {@link Option} fields (both declared and inherited) for given class.
      *
      * @param optionClass the {@link Class} to search
diff --git a/tests/src/com/android/tradefed/config/ArgsOptionParserTest.java b/tests/src/com/android/tradefed/config/ArgsOptionParserTest.java
index 721d75a..967aaa8 100644
--- a/tests/src/com/android/tradefed/config/ArgsOptionParserTest.java
+++ b/tests/src/com/android/tradefed/config/ArgsOptionParserTest.java
@@ -19,6 +19,8 @@
 
 import junit.framework.TestCase;
 
+import java.util.ArrayList;
+import java.util.Collection;
 import java.util.HashMap;
 import java.util.List;
 import java.util.Map;
@@ -125,6 +127,38 @@
     }
 
     /**
+     * Option source with mandatory options
+     */
+    private static class MandatoryOptionSourceNoDefault {
+        @Option(name = "no-default", mandatory = true)
+        private String mNoDefaultOption;
+    }
+
+    /**
+     * Option source with mandatory options
+     */
+    private static class MandatoryOptionSourceNull {
+        @Option(name = "null", mandatory = true)
+        private String mNullOption = null;
+    }
+
+    /**
+     * Option source with mandatory options
+     */
+    private static class MandatoryOptionSourceEmptyCollection {
+        @Option(name = "empty-collection", mandatory = true)
+        private Collection<String> mEmptyCollection = new ArrayList<String>(0);
+    }
+
+    /**
+     * Option source with mandatory options
+     */
+    private static class MandatoryOptionSourceEmptyMap {
+        @Option(name = "empty-map", mandatory = true)
+        private Map<String, String> mEmptyMap = new HashMap<String, String>();
+    }
+
+    /**
     * Test passing an empty argument list for an object that has one option specified.
     * <p/>
     * Expected that the option field should retain its default value.
@@ -387,4 +421,48 @@
         assertFalse(help.contains(ImportantOptionSource.IMPORTANT_UNSET_OPTION_NAME));
         assertFalse(help.contains(ImportantOptionSource.UNIMPORTANT_OPTION_NAME));
     }
+
+    public void testMandatoryOption_noDefault() throws Exception {
+        MandatoryOptionSourceNoDefault object = new MandatoryOptionSourceNoDefault();
+        ArgsOptionParser parser = new ArgsOptionParser(object);
+        try {
+            parser.parse(new String[] {});
+            fail("ConfigurationException not thrown");
+        } catch (ConfigurationException e) {
+            // expected
+        }
+    }
+
+    public void testMandatoryOption_null() throws Exception {
+        MandatoryOptionSourceNull object = new MandatoryOptionSourceNull();
+        ArgsOptionParser parser = new ArgsOptionParser(object);
+        try {
+            parser.parse(new String[] {});
+            fail("ConfigurationException not thrown");
+        } catch (ConfigurationException e) {
+            // expected
+        }
+    }
+
+    public void testMandatoryOption_emptyCollection() throws Exception {
+        MandatoryOptionSourceEmptyCollection object = new MandatoryOptionSourceEmptyCollection();
+        ArgsOptionParser parser = new ArgsOptionParser(object);
+        try {
+            parser.parse(new String[] {});
+            fail("ConfigurationException not thrown");
+        } catch (ConfigurationException e) {
+            // expected
+        }
+    }
+
+    public void testMandatoryOption_emptyMap() throws Exception {
+        MandatoryOptionSourceEmptyMap object = new MandatoryOptionSourceEmptyMap();
+        ArgsOptionParser parser = new ArgsOptionParser(object);
+        try {
+            parser.parse(new String[] {});
+            fail("ConfigurationException not thrown");
+        } catch (ConfigurationException e) {
+            // expected
+        }
+    }
 }