summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorJoel Klinghed <the_jk@spawned.biz>2025-10-07 20:56:33 +0200
committerJoel Klinghed <the_jk@spawned.biz>2025-10-19 00:13:47 +0200
commit62a4abb9bf6551417130c3c6f9bba147930895ef (patch)
tree51c6359faa7193e82c6fa8ee5402edb24d450ab1
parent4f6e76493fb74f5385d5a14dce3a01c9901802ed (diff)
cfg: New module
Reads config files
-rw-r--r--meson.build39
-rw-r--r--src/cfg.cc197
-rw-r--r--src/cfg.hh40
-rw-r--r--test/cfg.cc190
-rw-r--r--test/testdir.cc37
-rw-r--r--test/testdir.hh22
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