Search code examples
app-config.net-4.8system.commandline

Best way to fallback on app.config when System.CommandLine options are not supplied?


Besides pre-processing args directly, is there any way to determine if an Option was NOT supplied (while still allowing a default value), in order to potentially fall back on reading <appsettings> from the app.config.

Here is a sample Option I want to create a fallback for:

static async Task<Int32> Main(String[] args) {

  Option<Int32> pollingRateOption = new Option<Int32>(
    aliases: new[] { "-r", "--Polling-Rate" },
    description: "Rate at which to poll, in milliseconds.",
    isDefault: true,
    parseArgument: result => {
      if (Int32.TryParse(result.Tokens.Single().Value, out Int32 pr)) {
        if (pr < 1 || pr > 1000) {
          result.ErrorMessage = "Polling-Rate must be an integer from 1-1000";
        }
        return pr;
      } else {
        result.ErrorMessage = "Polling-Rate must be an integer from 1-1000";
        return 0; // Ignored.
      }
    }
  );
  pollingRateOption.ArgumentHelpName = "1-1000";
  pollingRateOption.Arity = ArgumentArity.ExactlyOne;
  pollingRateOption.SetDefaultValue(5);

  RootCommand rootCommand = new RootCommand($"{AppName}");
  rootCommand.AddOption(pollingRateOption);
  rootCommand.SetHandler((pollingRateValue) => { Run(pollingRateValue); }, pollingRateOption);

  return await rootCommand.InvokeAsync(args);
}

Solution

  • Okay so, while this seems a bit hacky, the solution seems to be to remove the SetDefaultValue(), keep isDefault: true, and then check for the missing option in parseArgument using if (!result.Tokens.Any()).

    This has one caveat though, whatever value is set in the parseArgument handler will show up as the default in the --help text. So if dynamically pulling that from an app.config, the [default: ] will be variable. Which may be undesirable.

    The fix for that seems to be to override the help text using the CommandLineBuilder() and just hard code the default text through .UseHelp(). Below is a working example of the final solution.

    static async Task<Int32> Main(String[] args) {
    
      Option<Int32> pollingRateOption = new Option<Int32>(
        aliases: new[] { "-r", "--Polling-Rate" },
      //description: "Rate at which to poll, in milliseconds.", //No longer needed, added through .UseHelp() below
        isDefault: true,
        parseArgument: result => {
          Int32 pr;
          if (!result.Tokens.Any()) { //If commandline option not specified, attempt to get it from app.config
            if (!Int32.TryParse(ConfigurationManager.AppSettings["Polling-Rate"], out pr)) {
              pr = 5; //Actual default if no option on commandline or in app.config
            }
          } else {
            if (!Int32.TryParse(result.Tokens.Single().Value, out pr)) {
              result.ErrorMessage = "Polling-Rate must be an integer from 1-1000";
            }
          }
          if (pr < 1 || pr > 1000) {
            result.ErrorMessage = "Polling-Rate must be an integer from 1-1000";
          }
          return pr;
        }
      );
      pollingRateOption.ArgumentHelpName = "1-1000";
      pollingRateOption.Arity = ArgumentArity.ExactlyOne;
    //pollingRateOption.SetDefaultValue(5); //parseArgument handler will not fire on missing option if default is set
    
      RootCommand rootCommand = new RootCommand($"{AppName}");
      rootCommand.AddOption(pollingRateOption);
      rootCommand.SetHandler((pollingRateValue) => { Run(pollingRateValue); }, pollingRateOption);
    
      Parser parser = new CommandLineBuilder(rootCommand)
        .UseDefaults()
        .UseHelp(ctx => {
          ctx.HelpBuilder.CustomizeSymbol(pollingRateOption, secondColumnText: "Rate at which to poll, in milliseconds. [default: 5]");
        })
      .Build();
    
      return await parser.InvokeAsync(args);
    }