tools: Allow batch command processing by nfdc

Change-Id: Ia6f70fed88f2d4c918e2ca2b786222840dbd9076
Refs: #5169
diff --git a/docs/manpages/nfdc.rst b/docs/manpages/nfdc.rst
index d80d6dd..1e77830 100644
--- a/docs/manpages/nfdc.rst
+++ b/docs/manpages/nfdc.rst
@@ -7,6 +7,7 @@
 | nfdc help [COMMAND]
 | nfdc [-h|--help]
 | nfdc -V|--version
+| nfdc -f|--batch BATCH-FILE
 
 DESCRIPTION
 -----------
@@ -31,6 +32,19 @@
 ``-V`` or ``--version``
     Show version information and exit.
 
+``-f BATCH-FILE`` or ``--batch BATCH-FILE``
+   Process arguments specified in the ``BATCH-FILE`` as if they would have appeared
+   in the command line (but without ``nfdc``).  When necessary, arguments should be
+   escaped using backslash ``\``, single ``'``, or double quotes ``"``.  If any of
+   the command fails, the processing will be stopped at that command with error
+   code 2. Empty lines and lines that start with ``#`` character will be ignored.
+   Note that the batch file does not support empty string arguments
+   (``""`` or ``''``), even if they are supported by the regular command line ``nfdc``.
+
+   When running a sequence of commands in rapid succession from a script, this
+   option ensures that the commands are properly timestamped and can therefore
+   be accepted by NFD.
+
 EXAMPLES
 --------
 nfdc
diff --git a/tests/tools/nfdc/README.md b/tests/tools/nfdc/README.md
new file mode 100644
index 0000000..647a012
--- /dev/null
+++ b/tests/tools/nfdc/README.md
@@ -0,0 +1,21 @@
+# Manual test for nfdc batch mode
+
+To run the test from this folder
+
+    nfdc -f nfdc-batch.t.txt
+
+    nfdc --batch nfdc-batch.t.txt
+
+If everything works, it should execute 3 commands with example output like this
+in both cases (can be different depending on the NFD runtime):
+
+    face-exists id=263 local=udp4://192.168.100.240:6363 remote=udp4://192.0.2.1:6363 persistency=persistent reliability=off congestion-marking=on congestion-marking-interval=100ms default-congestion-threshold=65536B mtu=8800
+    route-add-accepted prefix=/ nexthop=264 origin=static cost=0 flags=child-inherit expires=never
+    route-add-accepted prefix=/test2/foo%20bar nexthop=265 origin=static cost=0 flags=child-inherit expires=never
+    CS information:
+      capacity=65536
+         admit=on
+         serve=on
+      nEntries=14
+         nHits=0
+       nMisses=53
diff --git a/tests/tools/nfdc/help.t.cpp b/tests/tools/nfdc/help.t.cpp
index 6e899ba..c5d5012 100644
--- a/tests/tools/nfdc/help.t.cpp
+++ b/tests/tools/nfdc/help.t.cpp
@@ -1,6 +1,6 @@
 /* -*- Mode:C++; c-file-style:"gnu"; indent-tabs-mode:nil; -*- */
 /*
- * Copyright (c) 2014-2019,  Regents of the University of California,
+ * Copyright (c) 2014-2021,  Regents of the University of California,
  *                           Arizona Board of Regents,
  *                           Colorado State University,
  *                           University Pierre & Marie Curie, Sorbonne University,
@@ -47,7 +47,7 @@
   ExecuteCommand dummyExecute = [] (ExecuteContext&) { BOOST_ERROR("should not be called"); };
 
   boost::test_tools::output_test_stream out;
-  const std::string header("nfdc [-h|--help] [-V|--version] <command> [<args>]\n\n");
+  const std::string header("nfdc [-h|--help] [-V|--version] [-f|--batch <batch-file>] [<command> [<args>]]\n\n");
   const std::string trailer("\nSee 'nfdc help <command>' to read about a specific subcommand.\n");
 
   helpList(out, parser);
diff --git a/tests/tools/nfdc/nfdc-batch.t.txt b/tests/tools/nfdc/nfdc-batch.t.txt
new file mode 100644
index 0000000..22fc042
--- /dev/null
+++ b/tests/tools/nfdc/nfdc-batch.t.txt
@@ -0,0 +1,14 @@
+        "face" create "udp://192.0.2.1"
+
+route add / udp://192.0.2.2
+
+route    \
+         add \
+         "/test2/foo bar" \
+         'udp://192.0.2.3'
+
+# route test
+    ##### route test
+        # route test
+
+cs info
diff --git a/tools/nfdc/help.cpp b/tools/nfdc/help.cpp
index 88ebff2..3642a91 100644
--- a/tools/nfdc/help.cpp
+++ b/tools/nfdc/help.cpp
@@ -1,6 +1,6 @@
 /* -*- Mode:C++; c-file-style:"gnu"; indent-tabs-mode:nil; -*- */
 /*
- * Copyright (c) 2014-2018,  Regents of the University of California,
+ * Copyright (c) 2014-2021,  Regents of the University of California,
  *                           Arizona Board of Regents,
  *                           Colorado State University,
  *                           University Pierre & Marie Curie, Sorbonne University,
@@ -43,7 +43,7 @@
 void
 helpList(std::ostream& os, const CommandParser& parser, ParseMode mode, const std::string& noun)
 {
-  os << "nfdc [-h|--help] [-V|--version] <command> [<args>]\n\n";
+  os << "nfdc [-h|--help] [-V|--version] [-f|--batch <batch-file>] [<command> [<args>]]\n\n";
   if (noun.empty()) {
     os << "All subcommands:\n";
   }
diff --git a/tools/nfdc/main.cpp b/tools/nfdc/main.cpp
index 5a7ce13..ebe9d3c 100644
--- a/tools/nfdc/main.cpp
+++ b/tools/nfdc/main.cpp
@@ -1,6 +1,6 @@
 /* -*- Mode:C++; c-file-style:"gnu"; indent-tabs-mode:nil; -*- */
 /*
- * Copyright (c) 2014-2018,  Regents of the University of California,
+ * Copyright (c) 2014-2021,  Regents of the University of California,
  *                           Arizona Board of Regents,
  *                           Colorado State University,
  *                           University Pierre & Marie Curie, Sorbonne University,
@@ -24,9 +24,11 @@
  */
 
 #include "available-commands.hpp"
-#include "help.hpp"
 #include "core/version.hpp"
+#include "help.hpp"
 
+#include <boost/tokenizer.hpp>
+#include <fstream>
 #include <iostream>
 
 namespace nfd {
@@ -51,26 +53,132 @@
     return 0;
   }
 
-  std::string noun, verb;
-  CommandArguments ca;
-  ExecuteCommand execute;
-  try {
-    std::tie(noun, verb, ca, execute) = parser.parse(args, ParseMode::ONE_SHOT);
+  struct Command
+  {
+    std::string noun, verb;
+    CommandArguments ca;
+    ExecuteCommand execute;
+  };
+
+  auto processLine = [&parser] (const std::vector<std::string>& line) -> Command {
+    try {
+      Command cmd;
+      std::tie(cmd.noun, cmd.verb, cmd.ca, cmd.execute) = parser.parse(line, ParseMode::ONE_SHOT);
+      return cmd;
+    }
+    catch (const std::invalid_argument& e) {
+      int ret = help(std::cout, parser, line);
+      if (ret == 2)
+        std::cerr << e.what() << std::endl;
+      return {"", "", {}, nullptr};
+    }
+  };
+
+  std::list<Command> commands;
+
+  if (args[0] == "-f" || args[0] == "--batch") {
+    if (args.size() != 2) {
+      std::cerr << "ERROR: Invalid command line arguments: " << args[0] << " should follow with batch-file."
+                << " Use -h for more detail." << std::endl;
+      return 2;
+    }
+
+    auto processIstream = [&commands,&processLine] (std::istream& is, const std::string& inputFile) {
+      std::string line;
+      size_t lineCounter = 0;
+      while (std::getline(is, line)) {
+        ++lineCounter;
+
+        auto hasEscapeSlash = [] (const std::string& str) {
+          auto count = std::count(str.rbegin(), str.rend(), '\\');
+          return (count % 2) == 1;
+        };
+        while (!line.empty() && hasEscapeSlash(line)) {
+          std::string extraLine;
+          const auto& hasMore = std::getline(is, extraLine);
+          ++lineCounter;
+          line = line.substr(0, line.size() - 1) + extraLine;
+          if (!hasMore) {
+            break;
+          }
+        }
+        boost::tokenizer<boost::escaped_list_separator<char>> tokenizer(
+          line,
+          boost::escaped_list_separator<char>("\\", " \t", "\"'"));
+
+        auto firstNonEmptyToken = tokenizer.begin();
+        while (firstNonEmptyToken != tokenizer.end() && firstNonEmptyToken->empty()) {
+          ++firstNonEmptyToken;
+        }
+
+        // Ignore empty lines (or lines with just spaces) and lines that start with #
+        // Non empty lines with trailing comment are not allowed and may trigger syntax error
+        if (firstNonEmptyToken == tokenizer.end() || (*firstNonEmptyToken)[0] == '#') {
+          continue;
+        }
+
+        std::vector<std::string> lineArgs;
+        std::copy_if(firstNonEmptyToken, tokenizer.end(),
+                     std::back_inserter<std::vector<std::string>>(lineArgs),
+                     [] (const std::string& t) { return !t.empty(); });
+
+        auto cmd = processLine(lineArgs);
+        if (cmd.noun.empty()) {
+          std::cerr << "  >> Syntax error on line " << lineCounter << " of the batch in "
+                    << inputFile << std::endl;
+          return 2; // not exactly correct, but should be indication of an error, which already shown
+        }
+        commands.push_back(std::move(cmd));
+      }
+      return 0;
+    };
+
+    if (args[1] == "-") {
+      auto retval = processIstream(std::cin, "standard input");
+      if (retval != 0) {
+        return retval;
+      }
+    }
+    else {
+      std::ifstream iff(args[1]);
+      if (!iff) {
+        std::cerr << "ERROR: Could not open `" << args[1] << "` batch file "
+                  << "(" << strerror(errno) << ")" << std::endl;
+        return 2;
+      }
+      auto retval = processIstream(iff, args[1]);
+      if (retval != 0) {
+        return retval;
+      }
+    }
   }
-  catch (const std::invalid_argument& e) {
-    int ret = help(std::cout, parser, std::move(args));
-    if (ret == 2)
-      std::cerr << e.what() << std::endl;
-    return ret;
+  else {
+    commands.push_back(processLine(args));
   }
 
   try {
     Face face;
     KeyChain keyChain;
     Controller controller(face, keyChain);
-    ExecuteContext ctx{noun, verb, ca, 0, std::cout, std::cerr, face, keyChain, controller};
-    execute(ctx);
-    return ctx.exitCode;
+    size_t commandCounter = 0;
+    for (auto& command : commands) {
+      ++commandCounter;
+      ExecuteContext ctx{command.noun, command.verb, command.ca, 0,
+                         std::cout, std::cerr, face, keyChain, controller};
+      command.execute(ctx);
+
+      if (ctx.exitCode != 0) {
+        if (commands.size() > 1) {
+          std::cerr << "  >> Failed to execute command on line " << commandCounter
+                    << " of the batch file " << args[1] << std::endl;
+          std::cerr << "  Note that nfdc has executed all commands on previous lines and "
+                    << "stopped processing at this line" << std::endl;
+        }
+
+        return ctx.exitCode;
+      }
+    }
+    return 0;
   }
   catch (const std::exception& e) {
     std::cerr << e.what() << std::endl;