folly/folly/logging/test/ConfigParserTest.cpp

/*
 * Copyright (c) Meta Platforms, Inc. and affiliates.
 *
 * Licensed under the Apache License, Version 2.0 (the "License");
 * you may not use this file except in compliance with the License.
 * You may obtain a copy of the License at
 *
 *     http://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an "AS IS" BASIS,
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 * See the License for the specific language governing permissions and
 * limitations under the License.
 */

#include <folly/String.h>
#include <folly/json/dynamic.h>
#include <folly/json/json.h>
#include <folly/logging/LogCategory.h>
#include <folly/logging/LogConfig.h>
#include <folly/logging/LogConfigParser.h>
#include <folly/logging/test/ConfigHelpers.h>
#include <folly/portability/GMock.h>
#include <folly/portability/GTest.h>
#include <folly/test/TestUtils.h>

using namespace folly;

using ::testing::Pair;
using ::testing::UnorderedElementsAre;

TEST(LogConfig, parseBasic) {
  auto config = parseLogConfig("");
  EXPECT_THAT(config.getCategoryConfigs(), UnorderedElementsAre());
  EXPECT_THAT(config.getHandlerConfigs(), UnorderedElementsAre());

  config = parseLogConfig("   ");
  EXPECT_THAT(config.getCategoryConfigs(), UnorderedElementsAre());
  EXPECT_THAT(config.getHandlerConfigs(), UnorderedElementsAre());

  config = parseLogConfig(".=ERROR,folly=DBG2");
  EXPECT_THAT(
      config.getCategoryConfigs(),
      UnorderedElementsAre(
          Pair("", LogCategoryConfig{LogLevel::ERR, true}),
          Pair("folly", LogCategoryConfig{LogLevel::DBG2, true})));
  EXPECT_THAT(config.getHandlerConfigs(), UnorderedElementsAre());

  config = parseLogConfig(" INFO , folly  := FATAL   ");
  EXPECT_THAT(
      config.getCategoryConfigs(),
      UnorderedElementsAre(
          Pair("", LogCategoryConfig{LogLevel::INFO, true}),
          Pair("folly", LogCategoryConfig{LogLevel::FATAL, false})));
  EXPECT_THAT(config.getHandlerConfigs(), UnorderedElementsAre());

  config =
      parseLogConfig("my.category:=INFO , my.other.stuff  := 19,foo.bar=DBG7");
  EXPECT_THAT(
      config.getCategoryConfigs(),
      UnorderedElementsAre(
          Pair("my.category", LogCategoryConfig{LogLevel::INFO, false}),
          Pair(
              "my.other.stuff",
              LogCategoryConfig{static_cast<LogLevel>(19), false}),
          Pair("foo.bar", LogCategoryConfig{LogLevel::DBG7, true})));
  EXPECT_THAT(config.getHandlerConfigs(), UnorderedElementsAre());

  config = parseLogConfig(" ERR ");
  EXPECT_THAT(
      config.getCategoryConfigs(),
      UnorderedElementsAre(Pair("", LogCategoryConfig{LogLevel::ERR, true})));
  EXPECT_THAT(config.getHandlerConfigs(), UnorderedElementsAre());

  config = parseLogConfig(" ERR: ");
  EXPECT_THAT(
      config.getCategoryConfigs(),
      UnorderedElementsAre(
          Pair("", LogCategoryConfig{LogLevel::ERR, true, {}})));
  EXPECT_THAT(config.getHandlerConfigs(), UnorderedElementsAre());

  config = parseLogConfig(" ERR:stderr; stderr=stream:stream=stderr ");
  EXPECT_THAT(
      config.getCategoryConfigs(),
      UnorderedElementsAre(
          Pair("", LogCategoryConfig{LogLevel::ERR, true, {"stderr"}})));
  EXPECT_THAT(
      config.getHandlerConfigs(),
      UnorderedElementsAre(
          Pair("stderr", LogHandlerConfig{"stream", {{"stream", "stderr"}}})));

  config = parseLogConfig(
      "ERR:myfile:custom, folly=DBG2, folly.io:=WARN:other;"
      "myfile=file:path=/tmp/x.log; "
      "custom=custom:foo=bar,hello=world,a = b = c; "
      "other=custom2");
  EXPECT_THAT(
      config.getCategoryConfigs(),
      UnorderedElementsAre(
          Pair(
              "", LogCategoryConfig{LogLevel::ERR, true, {"myfile", "custom"}}),
          Pair("folly", LogCategoryConfig{LogLevel::DBG2, true}),
          Pair(
              "folly.io",
              LogCategoryConfig{LogLevel::WARN, false, {"other"}})));
  EXPECT_THAT(
      config.getHandlerConfigs(),
      UnorderedElementsAre(
          Pair("myfile", LogHandlerConfig{"file", {{"path", "/tmp/x.log"}}}),
          Pair(
              "custom",
              LogHandlerConfig{
                  "custom",
                  {{"foo", "bar"}, {"hello", "world"}, {"a", "b = c"}}}),
          Pair("other", LogHandlerConfig{"custom2"})));

  // Test updating existing handler configs, with no handler type
  config = parseLogConfig("ERR;foo");
  EXPECT_THAT(
      config.getCategoryConfigs(),
      UnorderedElementsAre(Pair("", LogCategoryConfig{LogLevel::ERR, true})));
  EXPECT_THAT(
      config.getHandlerConfigs(),
      UnorderedElementsAre(Pair("foo", LogHandlerConfig{})));

  config = parseLogConfig("ERR;foo:a=b,c=d");
  EXPECT_THAT(
      config.getCategoryConfigs(),
      UnorderedElementsAre(Pair("", LogCategoryConfig{LogLevel::ERR, true})));
  EXPECT_THAT(
      config.getHandlerConfigs(),
      UnorderedElementsAre(Pair(
          "foo", LogHandlerConfig{folly::none, {{"a", "b"}, {"c", "d"}}})));

  config = parseLogConfig("ERR;test=file:path=/tmp/test.log;foo:a=b,c=d");
  EXPECT_THAT(
      config.getCategoryConfigs(),
      UnorderedElementsAre(Pair("", LogCategoryConfig{LogLevel::ERR, true})));
  EXPECT_THAT(
      config.getHandlerConfigs(),
      UnorderedElementsAre(
          Pair("foo", LogHandlerConfig{folly::none, {{"a", "b"}, {"c", "d"}}}),
          Pair("test", LogHandlerConfig{"file", {{"path", "/tmp/test.log"}}})));

  // Log handler changes with no category changes
  config = parseLogConfig("; myhandler=custom:foo=bar");
  EXPECT_THAT(config.getCategoryConfigs(), UnorderedElementsAre());
  EXPECT_THAT(
      config.getHandlerConfigs(),
      UnorderedElementsAre(
          Pair("myhandler", LogHandlerConfig{"custom", {{"foo", "bar"}}})));
}

TEST(LogConfig, parseBasicErrors) {
  // Errors in the log category settings
  EXPECT_THROW_RE(
      parseLogConfig("=="),
      LogConfigParseError,
      R"(invalid log level "=" for category "")");
  EXPECT_THROW_RE(
      parseLogConfig("bogus_level"),
      LogConfigParseError,
      R"(invalid log level "bogus_level" for category ".")");
  EXPECT_THROW_RE(
      parseLogConfig("foo=bogus_level"),
      LogConfigParseError,
      R"(invalid log level "bogus_level" for category "foo")");
  EXPECT_THROW_RE(
      parseLogConfig("foo=WARN,bar=invalid"),
      LogConfigParseError,
      R"(invalid log level "invalid" for category "bar")");
  EXPECT_THROW_RE(
      parseLogConfig("foo=WARN,bar="),
      LogConfigParseError,
      R"(invalid log level "" for category "bar")");
  EXPECT_THROW_RE(
      parseLogConfig("foo=WARN,bar:="),
      LogConfigParseError,
      R"(invalid log level "" for category "bar")");
  EXPECT_THROW_RE(
      parseLogConfig("foo:=,bar:=WARN"),
      LogConfigParseError,
      R"(invalid log level "" for category "foo")");
  EXPECT_THROW_RE(
      parseLogConfig("x"),
      LogConfigParseError,
      R"(invalid log level "x" for category ".")");
  EXPECT_THROW_RE(
      parseLogConfig("x,y,z"),
      LogConfigParseError,
      R"(invalid log level "x" for category ".")");
  EXPECT_THROW_RE(
      parseLogConfig("foo=WARN,"),
      LogConfigParseError,
      R"(invalid log level "" for category ".")");
  EXPECT_THROW_RE(
      parseLogConfig("="),
      LogConfigParseError,
      R"(invalid log level "" for category "")");
  EXPECT_THROW_RE(
      parseLogConfig(":="),
      LogConfigParseError,
      R"(invalid log level "" for category "")");
  EXPECT_THROW_RE(
      parseLogConfig("foo=bar=ERR"),
      LogConfigParseError,
      R"(invalid log level "bar=ERR" for category "foo")");
  EXPECT_THROW_RE(
      parseLogConfig("foo.bar=ERR,foo..bar=INFO"),
      LogConfigParseError,
      R"(category "foo\.bar" listed multiple times under different names: )"
      R"("foo\.+bar" and "foo\.+bar")");
  EXPECT_THROW_RE(
      parseLogConfig("=ERR,.=INFO"),
      LogConfigParseError,
      R"(category "" listed multiple times under different names: )"
      R"("\.?" and "\.?")");

  // Errors in the log handler settings
  EXPECT_THROW_RE(
      parseLogConfig("ERR;"),
      LogConfigParseError,
      "error parsing log handler configuration: empty log handler name");
  EXPECT_THROW_RE(
      parseLogConfig("ERR;foo="),
      LogConfigParseError,
      R"(error parsing configuration for log handler "foo": )"
      "empty log handler type");
  EXPECT_THROW_RE(
      parseLogConfig("ERR;=file"),
      LogConfigParseError,
      "error parsing log handler configuration: empty log handler name");
  EXPECT_THROW_RE(
      parseLogConfig("ERR;handler1=file;"),
      LogConfigParseError,
      "error parsing log handler configuration: empty log handler name");
  EXPECT_THROW_RE(
      parseLogConfig("ERR;test=file,path=/tmp/test.log;foo:a=b,c=d"),
      LogConfigParseError,
      R"(error parsing configuration for log handler "test": )"
      R"(invalid type "file,path=/tmp/test.log": type name cannot contain )"
      "a comma when using the basic config format");
  EXPECT_THROW_RE(
      parseLogConfig("ERR;test,path=/tmp/test.log;foo:a=b,c=d"),
      LogConfigParseError,
      R"(error parsing configuration for log handler "test,path": )"
      "name cannot contain a comma when using the basic config format");
}

TEST(LogConfig, parseJson) {
  auto config = parseLogConfig("{}");
  EXPECT_THAT(config.getCategoryConfigs(), UnorderedElementsAre());
  config = parseLogConfig("  {}   ");
  EXPECT_THAT(config.getCategoryConfigs(), UnorderedElementsAre());

  config = parseLogConfig(R"JSON({
    "categories": {
      ".": "ERROR",
      "folly": "DBG2",
    }
  })JSON");
  EXPECT_THAT(
      config.getCategoryConfigs(),
      UnorderedElementsAre(
          Pair("", LogCategoryConfig{LogLevel::ERR, true}),
          Pair("folly", LogCategoryConfig{LogLevel::DBG2, true})));
  EXPECT_THAT(config.getHandlerConfigs(), UnorderedElementsAre());

  config = parseLogConfig(R"JSON({
    "categories": {
      "": "ERROR",
      "folly": "DBG2",
    }
  })JSON");
  EXPECT_THAT(
      config.getCategoryConfigs(),
      UnorderedElementsAre(
          Pair("", LogCategoryConfig{LogLevel::ERR, true}),
          Pair("folly", LogCategoryConfig{LogLevel::DBG2, true})));
  EXPECT_THAT(config.getHandlerConfigs(), UnorderedElementsAre());

  config = parseLogConfig(R"JSON({
    "categories": {
      ".": { "level": "INFO" },
      "folly": { "level": "FATAL", "inherit": false },
    }
  })JSON");
  EXPECT_THAT(
      config.getCategoryConfigs(),
      UnorderedElementsAre(
          Pair("", LogCategoryConfig{LogLevel::INFO, true}),
          Pair("folly", LogCategoryConfig{LogLevel::FATAL, false})));
  EXPECT_THAT(config.getHandlerConfigs(), UnorderedElementsAre());

  config = parseLogConfig(R"JSON({
    "categories": {
      ".": { "level": "INFO" },
      "folly": { "level": "DEBUG", "inherit": false, "propagate": "WARN" },
    }
  })JSON");
  {
    LogCategoryConfig expectedFolly{LogLevel::DBG, false};
    expectedFolly.propagateLevelMessagesToParent = LogLevel::WARN;
    EXPECT_THAT(
        config.getCategoryConfigs(),
        UnorderedElementsAre(
            Pair("", LogCategoryConfig{LogLevel::INFO, true}),
            Pair("folly", expectedFolly)));
  }
  EXPECT_THAT(config.getHandlerConfigs(), UnorderedElementsAre());

  config = parseLogConfig(R"JSON({
    "categories": {
      ".": { "level": "INFO" },
      "folly": { "level": "DEBUG", "inherit": false, "propagate": 9 },
    }
  })JSON");
  {
    LogCategoryConfig expectedFolly{LogLevel::DBG, false};
    expectedFolly.propagateLevelMessagesToParent = static_cast<LogLevel>(9);
    EXPECT_THAT(
        config.getCategoryConfigs(),
        UnorderedElementsAre(
            Pair("", LogCategoryConfig{LogLevel::INFO, true}),
            Pair("folly", expectedFolly)));
  }
  EXPECT_THAT(config.getHandlerConfigs(), UnorderedElementsAre());

  config = parseLogConfig(R"JSON({
    "categories": {
      "my.category": { "level": "INFO", "inherit": true },
      // comments are allowed
      "my.other.stuff": { "level": 19, "inherit": false },
      "foo.bar": { "level": "DBG7" },
    },
    "handlers": {
      "h1": { "type": "custom", "options": {"foo": "bar", "a": "z"} }
    }
  })JSON");
  EXPECT_THAT(
      config.getCategoryConfigs(),
      UnorderedElementsAre(
          Pair("my.category", LogCategoryConfig{LogLevel::INFO, true}),
          Pair(
              "my.other.stuff",
              LogCategoryConfig{static_cast<LogLevel>(19), false}),
          Pair("foo.bar", LogCategoryConfig{LogLevel::DBG7, true})));
  EXPECT_THAT(
      config.getHandlerConfigs(),
      UnorderedElementsAre(Pair(
          "h1", LogHandlerConfig{"custom", {{"foo", "bar"}, {"a", "z"}}})));

  // The JSON config parsing should allow unusual log category names
  // containing whitespace, equal signs, and other characters not allowed in
  // the basic config style.
  config = parseLogConfig(R"JSON({
    "categories": {
      "  my.category  ": { "level": "INFO" },
      " foo; bar=asdf, test": { "level": "DBG1" },
    },
    "handlers": {
      "h1;h2,h3= ": { "type": " x;y " }
    }
  })JSON");
  EXPECT_THAT(
      config.getCategoryConfigs(),
      UnorderedElementsAre(
          Pair("  my.category  ", LogCategoryConfig{LogLevel::INFO, true}),
          Pair(
              " foo; bar=asdf, test",
              LogCategoryConfig{LogLevel::DBG1, true})));
  EXPECT_THAT(
      config.getHandlerConfigs(),
      UnorderedElementsAre(Pair("h1;h2,h3= ", LogHandlerConfig{" x;y "})));
}

TEST(LogConfig, parseJsonErrors) {
  EXPECT_THROW_RE(
      parseLogConfigJson("5"),
      LogConfigParseError,
      "JSON config input must be an object");
  EXPECT_THROW_RE(
      parseLogConfigJson("true"),
      LogConfigParseError,
      "JSON config input must be an object");
  EXPECT_THROW_RE(
      parseLogConfigJson(R"("hello")"),
      LogConfigParseError,
      "JSON config input must be an object");
  EXPECT_THROW_RE(
      parseLogConfigJson("[1, 2, 3]"),
      LogConfigParseError,
      "JSON config input must be an object");
  EXPECT_THROW_RE(
      parseLogConfigJson(
          "{\n\"N\":\"X\",\n\"id\":\"1\",\n\"T\",:[\n\"A\",\n\"B\",\n]\n}\n"),
      std::runtime_error,
      "json parse error on line 3 near");
  EXPECT_THROW_RE(
      parseLogConfigJson(
          "{\n\"N\":\"X\",\n\"id\":\"1\",\n\"T\":[\n\"A\",\n\"B\",\n\n}\n"),
      std::runtime_error,
      "json parse error on line 7 near");
  EXPECT_THROW_RE(
      parseLogConfigJson(""), std::runtime_error, "json parse error");
  EXPECT_THROW_RE(
      parseLogConfigJson("{"), std::runtime_error, "json parse error");
  EXPECT_THROW_RE(parseLogConfig("{"), std::runtime_error, "json parse error");
  EXPECT_THROW_RE(
      parseLogConfig("{}}"), std::runtime_error, "json parse error");

  StringPiece input = R"JSON({
    "categories": 5
  })JSON";
  EXPECT_THROW_RE(
      parseLogConfig(input),
      LogConfigParseError,
      "unexpected data type for log categories config: "
      "got integer, expected an object");
  input = R"JSON({
    "categories": {
      "foo": true,
    }
  })JSON";
  EXPECT_THROW_RE(
      parseLogConfig(input),
      LogConfigParseError,
      R"(unexpected data type for configuration of category "foo": )"
      "got boolean, expected an object, string, or integer");

  input = R"JSON({
    "categories": {
      "foo": [1, 2, 3],
    }
  })JSON";
  EXPECT_THROW_RE(
      parseLogConfig(input),
      LogConfigParseError,
      R"(unexpected data type for configuration of category "foo": )"
      "got array, expected an object, string, or integer");

  input = R"JSON({
    "categories": {
      ".": { "level": "INFO" },
      "folly": { "level": "FATAL", "inherit": 19 },
    }
  })JSON";
  EXPECT_THROW_RE(
      parseLogConfig(input),
      LogConfigParseError,
      R"(unexpected data type for inherit field of category "folly": )"
      "got integer, expected a boolean");
  input = R"JSON({
    "categories": {
      "folly": { "level": [], },
    }
  })JSON";
  EXPECT_THROW_RE(
      parseLogConfig(input),
      LogConfigParseError,
      R"(unexpected data type for level field of category "folly": )"
      "got array, expected a string or integer");
  input = R"JSON({
    "categories": {
      "folly": { "level": "INFO", "propagate": [], },
    }
  })JSON";
  EXPECT_THROW_RE(
      parseLogConfig(input),
      LogConfigParseError,
      R"(unexpected data type for propagate field of category "folly": )"
      "got array, expected a string or integer");
  input = R"JSON({
    "categories": {
      "folly": { "level": "INFO", "propagate": "FOO", },
    }
  })JSON";
  EXPECT_THROW_RE(
      parseLogConfig(input),
      LogConfigParseError,
      R"MSG(invalid log level "FOO" for category "folly")MSG");
  input = R"JSON({
    "categories": {
      5: {}
    }
  })JSON";
  EXPECT_THROW_RE(
      parseLogConfig(input), std::runtime_error, "json parse error");

  input = R"JSON({
    "categories": {
      "foo...bar": { "level": "INFO", },
      "foo..bar": { "level": "INFO", },
    }
  })JSON";
  EXPECT_THROW_RE(
      parseLogConfig(input),
      LogConfigParseError,
      R"(category "foo\.bar" listed multiple times under different names: )"
      R"("foo\.\.+bar" and "foo\.+bar")");
  input = R"JSON({
    "categories": {
      "...": { "level": "ERR", },
      "": { "level": "INFO", },
    }
  })JSON";
  EXPECT_THROW_RE(
      parseLogConfig(input),
      LogConfigParseError,
      R"(category "" listed multiple times under different names: )"
      R"X("(\.\.\.|)" and "(\.\.\.|)")X");

  input = R"JSON({
    "categories": { "folly": { "level": "ERR" } },
    "handlers": 9.8
  })JSON";
  EXPECT_THROW_RE(
      parseLogConfig(input),
      LogConfigParseError,
      "unexpected data type for log handlers config: "
      "got double, expected an object");

  input = R"JSON({
    "categories": { "folly": { "level": "ERR" } },
    "handlers": {
      "foo": "test"
    }
  })JSON";
  EXPECT_THROW_RE(
      parseLogConfig(input),
      LogConfigParseError,
      R"(unexpected data type for configuration of handler "foo": )"
      "got string, expected an object");

  input = R"JSON({
    "categories": { "folly": { "level": "ERR" } },
    "handlers": {
      "foo": {}
    }
  })JSON";
  EXPECT_THROW_RE(
      parseLogConfig(input),
      LogConfigParseError,
      R"(no handler type specified for log handler "foo")");

  input = R"JSON({
    "categories": { "folly": { "level": "ERR" } },
    "handlers": {
      "foo": {
        "type": 19
      }
    }
  })JSON";
  EXPECT_THROW_RE(
      parseLogConfig(input),
      LogConfigParseError,
      R"(unexpected data type for "type" field of handler "foo": )"
      "got integer, expected a string");

  input = R"JSON({
    "categories": { "folly": { "level": "ERR" } },
    "handlers": {
      "foo": {
        "type": "custom",
        "options": true
      }
    }
  })JSON";
  EXPECT_THROW_RE(
      parseLogConfig(input),
      LogConfigParseError,
      R"(unexpected data type for "options" field of handler "foo": )"
      "got boolean, expected an object");

  input = R"JSON({
    "categories": { "folly": { "level": "ERR" } },
    "handlers": {
      "foo": {
        "type": "custom",
        "options": ["foo", "bar"]
      }
    }
  })JSON";
  EXPECT_THROW_RE(
      parseLogConfig(input),
      LogConfigParseError,
      R"(unexpected data type for "options" field of handler "foo": )"
      "got array, expected an object");

  input = R"JSON({
    "categories": { "folly": { "level": "ERR" } },
    "handlers": {
      "foo": {
        "type": "custom",
        "options": {"bar": 5}
      }
    }
  })JSON";
  EXPECT_THROW_RE(
      parseLogConfig(input),
      LogConfigParseError,
      R"(unexpected data type for option "bar" of handler "foo": )"
      "got integer, expected a string");
}

TEST(LogConfig, toJson) {
  auto config = parseLogConfig("");
  auto expectedJson = folly::parseJson(R"JSON({
  "categories": {},
  "handlers": {}
})JSON");
  EXPECT_EQ(expectedJson, logConfigToDynamic(config));

  config = parseLogConfig(
      "ERROR:h1,foo.bar:=FATAL,folly=INFO:; "
      "h1=custom:foo=bar");
  expectedJson = folly::parseJson(R"JSON({
  "categories" : {
    "" : {
      "inherit" : true,
      "level" : "ERR",
      "handlers" : ["h1"],
      "propagate": "NONE"
    },
    "folly" : {
      "inherit" : true,
      "level" : "INFO",
      "handlers" : [],
      "propagate": "NONE"
    },
    "foo.bar" : {
      "inherit" : false,
      "level" : "FATAL",
      "propagate": "NONE"
    }
  },
  "handlers" : {
    "h1": {
      "type": "custom",
      "options": { "foo": "bar" }
    }
  }
})JSON");
  EXPECT_EQ(expectedJson, logConfigToDynamic(config));
}

TEST(LogConfig, mergeConfigs) {
  auto config = parseLogConfig("bar=ERR:");
  config.update(parseLogConfig("foo:=INFO"));
  EXPECT_THAT(
      config.getCategoryConfigs(),
      UnorderedElementsAre(
          Pair("foo", LogCategoryConfig{LogLevel::INFO, false}),
          Pair("bar", LogCategoryConfig{LogLevel::ERR, true, {}})));
  EXPECT_THAT(config.getHandlerConfigs(), UnorderedElementsAre());

  config =
      parseLogConfig("WARN:default; default=custom:opt1=value1,opt2=value2");
  config.update(parseLogConfig("folly.io=DBG2,foo=INFO"));
  EXPECT_THAT(
      config.getCategoryConfigs(),
      UnorderedElementsAre(
          Pair("", LogCategoryConfig{LogLevel::WARN, true, {"default"}}),
          Pair("foo", LogCategoryConfig{LogLevel::INFO, true}),
          Pair("folly.io", LogCategoryConfig{LogLevel::DBG2, true})));
  EXPECT_THAT(
      config.getHandlerConfigs(),
      UnorderedElementsAre(Pair(
          "default",
          LogHandlerConfig(
              "custom", {{"opt1", "value1"}, {"opt2", "value2"}}))));

  // Updating the root category's log level without specifying
  // handlers should leave its current handler list intact
  config =
      parseLogConfig("WARN:default; default=custom:opt1=value1,opt2=value2");
  config.update(parseLogConfig("ERR"));
  EXPECT_THAT(
      config.getCategoryConfigs(),
      UnorderedElementsAre(
          Pair("", LogCategoryConfig{LogLevel::ERR, true, {"default"}})));
  EXPECT_THAT(
      config.getHandlerConfigs(),
      UnorderedElementsAre(Pair(
          "default",
          LogHandlerConfig(
              "custom", {{"opt1", "value1"}, {"opt2", "value2"}}))));

  config =
      parseLogConfig("WARN:default; default=custom:opt1=value1,opt2=value2");
  config.update(parseLogConfig(".:=ERR"));
  EXPECT_THAT(
      config.getCategoryConfigs(),
      UnorderedElementsAre(
          Pair("", LogCategoryConfig{LogLevel::ERR, false, {"default"}})));
  EXPECT_THAT(
      config.getHandlerConfigs(),
      UnorderedElementsAre(Pair(
          "default",
          LogHandlerConfig(
              "custom", {{"opt1", "value1"}, {"opt2", "value2"}}))));

  // Test clearing the root category's log handlers
  config =
      parseLogConfig("WARN:default; default=custom:opt1=value1,opt2=value2");
  config.update(parseLogConfig("FATAL:"));
  EXPECT_THAT(
      config.getCategoryConfigs(),
      UnorderedElementsAre(
          Pair("", LogCategoryConfig{LogLevel::FATAL, true, {}})));
  EXPECT_THAT(
      config.getHandlerConfigs(),
      UnorderedElementsAre(Pair(
          "default",
          LogHandlerConfig(
              "custom", {{"opt1", "value1"}, {"opt2", "value2"}}))));

  // Test updating the settings on a log handler
  config =
      parseLogConfig("WARN:default; default=stream:stream=stderr,async=false");
  config.update(parseLogConfig("INFO; default:async=true"));
  EXPECT_THAT(
      config.getCategoryConfigs(),
      UnorderedElementsAre(
          Pair("", LogCategoryConfig{LogLevel::INFO, true, {"default"}})));
  EXPECT_THAT(
      config.getHandlerConfigs(),
      UnorderedElementsAre(Pair(
          "default",
          LogHandlerConfig(
              "stream", {{"stream", "stderr"}, {"async", "true"}}))));

  // Updating the settings for a non-existent log handler should fail
  config =
      parseLogConfig("WARN:default; default=stream:stream=stderr,async=false");
  EXPECT_THROW_RE(
      config.update(parseLogConfig("INFO; other:async=true")),
      std::invalid_argument,
      "cannot update configuration for "
      R"(unknown log handler "other")");
}