diff options
| -rw-r--r-- | meson.build | 39 | ||||
| -rw-r--r-- | src/cfg.cc | 197 | ||||
| -rw-r--r-- | src/cfg.hh | 40 | ||||
| -rw-r--r-- | test/cfg.cc | 190 | ||||
| -rw-r--r-- | test/testdir.cc | 37 | ||||
| -rw-r--r-- | test/testdir.hh | 22 |
6 files changed, 523 insertions, 2 deletions
diff --git a/meson.build b/meson.build index 291b3f9..9507b31 100644 --- a/meson.build +++ b/meson.build @@ -97,6 +97,20 @@ paths_dep = declare_dependency( dependencies: [str_dep], ) +cfg_lib = library( + 'cfg', + sources: [ + 'src/cfg.cc', + 'src/cfg.hh', + ], + include_directories: inc, + dependencies: [io_dep, paths_dep, str_dep], +) +cfg_dep = declare_dependency( + link_with: cfg_lib, + dependencies: [io_dep, paths_dep, str_dep], +) + bluetooth_jukebox = executable( 'bluetooth-jukebox', sources: [ @@ -106,8 +120,7 @@ bluetooth_jukebox = executable( install : true, dependencies : [ args_dep, - io_dep, - str_dep, + cfg_dep, ], ) @@ -141,6 +154,16 @@ io_test_helper_dep = declare_dependency( dependencies: io_dep, ) +testdir_lib = library( + 'testdir', + sources: [ + 'test/testdir.cc', + 'test/testdir.hh', + ], + include_directories: inc, +) +testdir_dep = declare_dependency(link_with: testdir_lib) + testenv_lib = library( 'testenv', sources: [ @@ -204,6 +227,18 @@ test('paths', executable( ], )) +test('cfg', executable( + 'test_cfg', + sources: ['test/cfg.cc'], + include_directories: inc, + dependencies : [ + cfg_dep, + test_dependencies, + testdir_dep, + testenv_dep, + ], +)) + run_clang_tidy = find_program('run-clang-tidy', required: false) if run_clang_tidy.found() diff --git a/src/cfg.cc b/src/cfg.cc new file mode 100644 index 0000000..0196bde --- /dev/null +++ b/src/cfg.cc @@ -0,0 +1,197 @@ +#include "cfg.hh" + +#include "io.hh" +#include "line.hh" +#include "paths.hh" +#include "str.hh" + +#include <charconv> +#include <cstdint> +#include <format> +#include <functional> +#include <map> +#include <memory> +#include <optional> +#include <string> +#include <string_view> +#include <system_error> +#include <utility> +#include <vector> + +namespace cfg { + +namespace { + +inline char ascii_lowercase(char c) { + if (c >= 'A' && c <= 'Z') { + // NOLINTNEXTLINE(bugprone-narrowing-conversions) + return c | 0x20; + } + return c; +} + +bool ascii_lowercase_eq(std::string_view a, std::string_view b) { + if (a.size() != b.size()) + return false; + auto it_a = a.begin(); + auto it_b = b.begin(); + for (; it_a != a.end(); ++it_a, ++it_b) { + if (ascii_lowercase(*it_a) != *it_b) + return false; + } + return true; +} + +class ConfigSingleImpl : public Config { + public: + ConfigSingleImpl() = default; + + bool load(std::filesystem::path const& path, + std::vector<std::string>& errors) { + auto io_reader = io::open(std::string(path)); + if (!io_reader.has_value()) { + errors.push_back( + std::format("Unable to open {} for reading", path.string())); + return false; + } + bool all_ok = true; + auto line_reader = line::open(std::move(io_reader.value())); + while (true) { + auto line = line_reader->read(); + if (line.has_value()) { + auto trimmed = str::trim(line.value()); + if (trimmed.empty() || trimmed.front() == '#') + continue; + auto eq = trimmed.find('='); + if (eq == std::string_view::npos) { + errors.push_back( + std::format("{}:{}: Invalid line, expected key = value.", + path.string(), line_reader->number())); + all_ok = false; + continue; + } + auto key = str::trim(trimmed.substr(0, eq)); + auto value = str::trim(trimmed.substr(eq + 1)); + auto ret = values_.emplace(key, value); + if (!ret.second) { + errors.push_back(std::format("{}:{}: Duplicate key {} ignored.", + path.string(), line_reader->number(), + key)); + all_ok = false; + continue; + } + } else { + switch (line.error()) { + case io::ReadError::Eof: + break; + default: + errors.push_back(std::format("{}: Read error", path.string())); + all_ok = false; + break; + } + break; + } + } + return all_ok; + } + + [[nodiscard]] + std::optional<std::string_view> get(std::string_view name) const override { + auto it = values_.find(name); + if (it == values_.end()) + return std::nullopt; + return it->second; + } + + private: + std::map<std::string, std::string, std::less<>> values_; +}; + +class ConfigXdgImpl : public Config { + public: + ConfigXdgImpl() = default; + + bool load(std::string_view name, std::vector<std::string>& errors) { + bool all_ok = true; + for (auto const& dir : paths::config_dirs()) { + auto file = dir / name; + if (std::filesystem::exists(file)) { + auto cfg = std::make_unique<ConfigSingleImpl>(); + if (!cfg->load(file, errors)) + all_ok = false; + configs_.push_back(std::move(cfg)); + } + } + return all_ok; + } + + [[nodiscard]] + std::optional<std::string_view> get(std::string_view name) const override { + for (auto const& config : configs_) { + auto ret = config->get(name); + if (ret.has_value()) + return ret; + } + return std::nullopt; + } + + private: + std::vector<std::unique_ptr<ConfigSingleImpl>> configs_; +}; + +} // namespace + +bool Config::has(std::string_view name) const { return get(name).has_value(); } + +std::optional<int64_t> Config::get_int64(std::string_view name) const { + auto str = get(name); + if (str.has_value()) { + auto* const end = str->data() + str->size(); + int64_t ret; + // NOLINTNEXTLINE(bugprone-suspicious-stringview-data-usage) + auto [ptr, ec] = std::from_chars(str->data(), end, ret); + if (ec == std::errc() && ptr == end) + return ret; + } + return std::nullopt; +} + +std::optional<uint64_t> Config::get_uint64(std::string_view name) const { + auto str = get(name); + if (str.has_value()) { + auto* const end = str->data() + str->size(); + uint64_t ret; + // NOLINTNEXTLINE(bugprone-suspicious-stringview-data-usage) + auto [ptr, ec] = std::from_chars(str->data(), end, ret); + if (ec == std::errc() && ptr == end) + return ret; + } + return std::nullopt; +} + +std::optional<bool> Config::get_bool(std::string_view name) const { + auto str = get(name); + if (str.has_value()) { + if (ascii_lowercase_eq(str.value(), "true") || + ascii_lowercase_eq(str.value(), "yes")) + return true; + if (ascii_lowercase_eq(str.value(), "false") || + ascii_lowercase_eq(str.value(), "no")) + return false; + } + return std::nullopt; +} + +std::unique_ptr<Config> Config::load(std::string_view name, + std::vector<std::string>& errors) { + if (name.empty() || name.front() == '/') { + auto ret = std::make_unique<ConfigSingleImpl>(); + ret->load(name, errors); + return ret; + } + auto ret = std::make_unique<ConfigXdgImpl>(); + ret->load(name, errors); + return ret; +} + +} // namespace cfg diff --git a/src/cfg.hh b/src/cfg.hh new file mode 100644 index 0000000..a4a7865 --- /dev/null +++ b/src/cfg.hh @@ -0,0 +1,40 @@ +#ifndef CFG_HH +#define CFG_HH + +#include <memory> +#include <optional> +#include <string_view> +#include <vector> + +namespace cfg { + +class Config { + public: + virtual ~Config() = default; + + [[nodiscard]] + virtual std::optional<std::string_view> get(std::string_view name) const = 0; + + [[nodiscard]] + bool has(std::string_view name) const; + [[nodiscard]] + std::optional<int64_t> get_int64(std::string_view name) const; + [[nodiscard]] + std::optional<uint64_t> get_uint64(std::string_view name) const; + [[nodiscard]] + std::optional<bool> get_bool(std::string_view name) const; + + [[nodiscard]] + static std::unique_ptr<Config> load(std::string_view name, + std::vector<std::string>& errors); + + protected: + Config() = default; + + Config(Config const&) = delete; + Config& operator=(Config const&) = delete; +}; + +} // namespace cfg + +#endif // CFG_HH diff --git a/test/cfg.cc b/test/cfg.cc new file mode 100644 index 0000000..80ebba9 --- /dev/null +++ b/test/cfg.cc @@ -0,0 +1,190 @@ +#include "cfg.hh" + +#include "testdir.hh" +#include "testenv.hh" + +#include <fstream> +#include <gtest/gtest.h> +#include <string> +#include <vector> + +namespace { + +class ConfigTest : public TestEnv { + protected: + void SetUp() override { ASSERT_TRUE(dir_.good()); } + + TestDir dir_; +}; + +} // namespace + +TEST_F(ConfigTest, empty) { + auto does_not_exist = dir_.path() / "does-not-exist"; + std::vector<std::string> errors; + auto cfg = cfg::Config::load(does_not_exist.string(), errors); + ASSERT_TRUE(cfg); + EXPECT_EQ(1, errors.size()); + + EXPECT_FALSE(cfg->has("")); + EXPECT_FALSE(cfg->get("").has_value()); + + EXPECT_FALSE(cfg->has("foo")); + EXPECT_FALSE(cfg->get("foo").has_value()); +} + +TEST_F(ConfigTest, values) { + auto file = dir_.path() / "file"; + { + std::ofstream out(file); + out << "# Comment\n" + << "key=value\n" + << " foo = bar \n" + << "i1 = 12\n" + << "b1 = true\n" + << "b2=FaLSe\n" + << "i2 = -12313\n"; + } + std::vector<std::string> errors; + auto cfg = cfg::Config::load(file.string(), errors); + ASSERT_TRUE(cfg); + EXPECT_EQ(0, errors.size()); + + EXPECT_FALSE(cfg->has("")); + EXPECT_FALSE(cfg->get("").has_value()); + + EXPECT_TRUE(cfg->has("key")); + EXPECT_EQ("value", cfg->get("key").value_or("")); + + EXPECT_TRUE(cfg->has("foo")); + EXPECT_EQ("bar", cfg->get("foo").value_or("")); + EXPECT_FALSE(cfg->get_int64("foo").has_value()); + + EXPECT_TRUE(cfg->has("i1")); + EXPECT_EQ("12", cfg->get("i1").value_or("")); + EXPECT_EQ(12, cfg->get_int64("i1").value_or(0)); + EXPECT_EQ(12, cfg->get_uint64("i1").value_or(0)); + + EXPECT_TRUE(cfg->has("b1")); + EXPECT_EQ("true", cfg->get("b1").value_or("")); + EXPECT_TRUE(cfg->get_bool("b1").value_or(false)); + + EXPECT_TRUE(cfg->has("b2")); + EXPECT_EQ("FaLSe", cfg->get("b2").value_or("")); + EXPECT_FALSE(cfg->get_bool("b2").value_or(true)); + + EXPECT_TRUE(cfg->has("i2")); + EXPECT_EQ("-12313", cfg->get("i2").value_or("")); + EXPECT_EQ(-12313, cfg->get_int64("i2").value_or(0)); + EXPECT_EQ(0, cfg->get_uint64("i2").value_or(0)); +} + +TEST_F(ConfigTest, bools) { + auto file = dir_.path() / "file"; + { + std::ofstream out(file); + out << "key1=True\n" + << "key2=yES\n" + << "key3=false\n" + << "key4=NO\n" + << "key5=ja\n"; + } + std::vector<std::string> errors; + auto cfg = cfg::Config::load(file.string(), errors); + ASSERT_TRUE(cfg); + EXPECT_EQ(0, errors.size()); + + EXPECT_TRUE(cfg->get_bool("key1").value_or(false)); + EXPECT_TRUE(cfg->get_bool("key2").value_or(false)); + EXPECT_FALSE(cfg->get_bool("key3").value_or(true)); + EXPECT_FALSE(cfg->get_bool("key4").value_or(true)); + EXPECT_FALSE(cfg->get_bool("key5").has_value()); +} + +TEST_F(ConfigTest, errors) { + auto file = dir_.path() / "file"; + { + std::ofstream out(file); + out << "bad line\n" + << "key=value\n" + << "key=duplicate\n"; + } + std::vector<std::string> errors; + auto cfg = cfg::Config::load(file.string(), errors); + ASSERT_TRUE(cfg); + EXPECT_EQ(2, errors.size()); +} + +TEST_F(ConfigTest, merge) { + auto dir1 = dir_.path() / "dir1"; + auto dir2 = dir_.path() / "dir2"; + auto dir3 = dir_.path() / "dir3"; + std::filesystem::create_directory(dir1); + std::filesystem::create_directory(dir2); + std::filesystem::create_directory(dir3); + setenv("XDG_CONFIG_HOME", dir1); + setenv("XDG_CONFIG_DIRS", dir2.string() + ":" + dir3.string()); + auto file1 = dir1 / "file"; + auto file2 = dir2 / "file"; + { + std::ofstream out(file2); + out << "key1 = value1\n" + << "key2 = value2\n"; + } + { + std::ofstream out(file1); + out << "key1 = 12\n" + << "key3 = value3"; + } + std::vector<std::string> errors; + auto cfg = cfg::Config::load("file", errors); + ASSERT_TRUE(cfg); + EXPECT_EQ(0, errors.size()); + + EXPECT_FALSE(cfg->has("")); + EXPECT_FALSE(cfg->get("").has_value()); + + EXPECT_TRUE(cfg->has("key1")); + EXPECT_EQ("12", cfg->get("key1").value_or("")); + + EXPECT_TRUE(cfg->has("key2")); + EXPECT_EQ("value2", cfg->get("key2").value_or("")); + + EXPECT_TRUE(cfg->has("key3")); + EXPECT_EQ("value3", cfg->get("key3").value_or("")); +} + +TEST_F(ConfigTest, merge_errors) { + auto dir1 = dir_.path() / "dir1"; + auto dir2 = dir_.path() / "dir2"; + std::filesystem::create_directory(dir1); + std::filesystem::create_directory(dir2); + setenv("XDG_CONFIG_HOME", dir1); + setenv("XDG_CONFIG_DIRS", dir2); + auto file1 = dir1 / "file"; + auto file2 = dir2 / "file"; + { + std::ofstream out(file2); + out << "key1 = value1\n" + << "key2 = value2\n"; + } + { + std::ofstream out(file1); + out << "invalid line"; + } + std::vector<std::string> errors; + auto cfg = cfg::Config::load("file", errors); + ASSERT_TRUE(cfg); + EXPECT_EQ(1, errors.size()); + + EXPECT_FALSE(cfg->has("")); + EXPECT_FALSE(cfg->get("").has_value()); + + EXPECT_TRUE(cfg->has("key1")); + EXPECT_EQ("value1", cfg->get("key1").value_or("")); + + EXPECT_TRUE(cfg->has("key2")); + EXPECT_EQ("value2", cfg->get("key2").value_or("")); + + EXPECT_FALSE(cfg->has("key3")); +} diff --git a/test/testdir.cc b/test/testdir.cc new file mode 100644 index 0000000..d5aef22 --- /dev/null +++ b/test/testdir.cc @@ -0,0 +1,37 @@ +#include "testdir.hh" + +#include <cstdlib> +#include <filesystem> +#include <format> +#include <gtest/gtest.h> +#include <system_error> + +namespace { + +std::filesystem::path mktmpdir() { + std::error_code ec; + auto base = std::filesystem::temp_directory_path(ec); + if (ec) + return {}; + for (int i = 0; i < 10; ++i) { + auto name = std::format( + "{}-{}", + ::testing::UnitTest::GetInstance()->current_test_info()->name(), + rand()); + auto tmpdir = base / name; + if (std::filesystem::exists(tmpdir)) + continue; + if (std::filesystem::create_directory(tmpdir, ec)) + return tmpdir; + } + return {}; +} + +} // namespace + +TestDir::TestDir() : path_(mktmpdir()) {} + +TestDir::~TestDir() { + if (!path_.empty()) + std::filesystem::remove_all(path_); +} diff --git a/test/testdir.hh b/test/testdir.hh new file mode 100644 index 0000000..b59e09a --- /dev/null +++ b/test/testdir.hh @@ -0,0 +1,22 @@ +#ifndef TESTDIR_HH +#define TESTDIR_HH + +#include <filesystem> // IWYU pragma: export + +class TestDir { + public: + TestDir(); + ~TestDir(); + + TestDir(TestDir const&) = delete; + TestDir& operator=(TestDir const&) = delete; + + bool good() const { return !path_.empty(); }; + + std::filesystem::path const& path() const { return path_; }; + + private: + std::filesystem::path path_; +}; + +#endif // TESTDIR_HH |
