Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 30 additions & 0 deletions xff/config/BUILD.bazel
Original file line number Diff line number Diff line change
Expand Up @@ -87,3 +87,33 @@ cc_test(
"@googletest//:gtest_main",
],
)

# Discovery: locates and reads the config files (system /etc/xff.ini, user
# .xffrc, explicit --xffrc) through an injectable FileReader, parsing them into
# ConfigInputs for ResolveConfig. No file IO of its own (run.cc supplies the
# reader); the project cascade is phase E.
cc_library(
name = "loader_cc",
srcs = ["loader.cc"],
hdrs = ["loader.h"],
visibility = ["//xff:__subpackages__"],
deps = [
":config_cc",
":ini_cc",
":xffrc_cc",
"@abseil-cpp//absl/functional:function_ref",
"@abseil-cpp//absl/strings",
],
)

cc_test(
name = "loader_test",
size = "small",
srcs = ["loader_test.cc"],
deps = [
":config_cc",
":loader_cc",
"@googletest//:gtest",
"@googletest//:gtest_main",
],
)
82 changes: 82 additions & 0 deletions xff/config/loader.cc
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
// SPDX-FileCopyrightText: Copyright (c) The helly25 authors (helly25.com)
// SPDX-License-Identifier: Apache-2.0
//
// 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 "xff/config/loader.h"

#include <optional>
#include <string>
#include <string_view>
#include <vector>

#include "absl/strings/str_cat.h"
#include "xff/config/config.h"
#include "xff/config/ini.h"
#include "xff/config/xffrc.h"

namespace xff::config {
namespace {

// Parses `text` in the .xffrc grammar and appends its lines to `out`.
void AppendXffrc(std::vector<RcLine>& out, std::string_view text) {
const std::vector<RcLine> lines = ParseXffrc(text);
out.insert(out.end(), lines.begin(), lines.end());
}

} // namespace

std::string UserConfigPath(const DiscoveryOptions& opts) {
if (opts.xff_config.has_value() && !opts.xff_config->empty()) {
return *opts.xff_config;
}
if (opts.xdg_config_home.has_value() && !opts.xdg_config_home->empty()) {
return absl::StrCat(*opts.xdg_config_home, "/xff/config");
}
if (opts.home.has_value() && !opts.home->empty()) {
return absl::StrCat(*opts.home, "/.config/xff/config");
}
return "";
}

ConfigInputs Discover(const DiscoveryOptions& opts, FileReader read) {
ConfigInputs inputs;
inputs.no_config = opts.no_config;
inputs.configs = opts.configs;

// System: always read. Its [policy] is never skipped by --no-config (the gate,
// phase C, needs it); ResolveConfig drops the [defaults] under --no-config.
if (const std::optional<std::string> text = read("/etc/xff.ini"); text.has_value()) {
inputs.system = ParseIni(*text);
}
if (opts.no_config) {
return inputs; // user + explicit files skipped; system [defaults] dropped by ResolveConfig
}

// User: the first existing of $XFF_CONFIG / $XDG_CONFIG_HOME/xff/config / ~/.config/xff/config.
if (const std::string user_path = UserConfigPath(opts); !user_path.empty()) {
if (const std::optional<std::string> text = read(user_path); text.has_value()) {
AppendXffrc(inputs.user, *text);
}
}
// Explicit --xffrc files arm into the user layer, in order.
for (const std::string& path : opts.xffrc_files) {
if (const std::optional<std::string> text = read(path); text.has_value()) {
AppendXffrc(inputs.user, *text);
}
}
// Project cascade: phase E.
return inputs;
}

} // namespace xff::config
62 changes: 62 additions & 0 deletions xff/config/loader.h
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
// SPDX-FileCopyrightText: Copyright (c) The helly25 authors (helly25.com)
// SPDX-License-Identifier: Apache-2.0
//
// 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.

#ifndef XFF_CONFIG_LOADER_H_
#define XFF_CONFIG_LOADER_H_

#include <optional>
#include <string>
#include <string_view>
#include <vector>

#include "absl/functional/function_ref.h"
#include "xff/config/config.h"

namespace xff::config {

// Reads the file at `path`, returning its contents, or nullopt if the file is
// missing or unreadable. Injected so discovery is testable without touching the
// real filesystem; run.cc supplies a std::ifstream-backed reader.
using FileReader = absl::FunctionRef<std::optional<std::string>(std::string_view path)>;

// Inputs to Discover: the CLI selectors plus the environment values that locate
// the user config (injected rather than read from getenv here, for testability).
struct DiscoveryOptions {
bool no_config = false; // --no-config
std::vector<std::string> configs; // --config=NAME, in order
std::vector<std::string> xffrc_files; // --xffrc=FILE, in order
std::optional<std::string> xff_config; // $XFF_CONFIG
std::optional<std::string> xdg_config_home; // $XDG_CONFIG_HOME
std::optional<std::string> home; // $HOME
};

// The user config path per the discovery order: $XFF_CONFIG, else
// $XDG_CONFIG_HOME/xff/config, else $HOME/.config/xff/config. Empty if none of
// those is set.
std::string UserConfigPath(const DiscoveryOptions& opts);

// Discovers and parses the config layers into ConfigInputs (ready for
// ResolveConfig), reading every file through `read`:
// - system: /etc/xff.ini (always read; its [policy] is never skipped),
// - user: UserConfigPath(opts), in the .xffrc grammar,
// - --xffrc=FILE: appended to the user layer (naming the file is the consent).
// --no-config skips the user layer and the explicit files (ResolveConfig then
// also drops the system [defaults]); the system file is still read so its policy
// is available to the gate (phase C). The project cascade is phase E.
ConfigInputs Discover(const DiscoveryOptions& opts, FileReader read);

} // namespace xff::config

#endif // XFF_CONFIG_LOADER_H_
116 changes: 116 additions & 0 deletions xff/config/loader_test.cc
Original file line number Diff line number Diff line change
@@ -0,0 +1,116 @@
// SPDX-FileCopyrightText: Copyright (c) The helly25 authors (helly25.com)
// SPDX-License-Identifier: Apache-2.0
//
// 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 "xff/config/loader.h"

#include <map>
#include <optional>
#include <string>
#include <string_view>

#include "gmock/gmock.h"
#include "gtest/gtest.h"
#include "xff/config/config.h"

namespace xff::config {
namespace {

using ::testing::AllOf;
using ::testing::ElementsAre;
using ::testing::Field;
using ::testing::IsEmpty;
using ::testing::SizeIs;

// A FileReader backed by an in-memory path->contents map; absent paths read as
// nullopt (missing file).
struct FakeFs {
std::map<std::string, std::string> files;

std::optional<std::string> Read(std::string_view path) const {
if (const auto it = files.find(std::string(path)); it != files.end()) {
return it->second;
}
return std::nullopt;
}
};

testing::Matcher<ResolvedFlag> FlagIs(const std::string& flag, Source source) {
return AllOf(Field("flag", &ResolvedFlag::flag, flag), Field("source", &ResolvedFlag::source, source));
}

struct LoaderTest : ::testing::Test {};

TEST_F(LoaderTest, UserConfigPathPrefersXffConfigThenXdgThenHome) {
DiscoveryOptions opts;
opts.home = "/home/u";
EXPECT_THAT(UserConfigPath(opts), "/home/u/.config/xff/config");
opts.xdg_config_home = "/xdg";
EXPECT_THAT(UserConfigPath(opts), "/xdg/xff/config");
opts.xff_config = "/explicit/rc";
EXPECT_THAT(UserConfigPath(opts), "/explicit/rc");
}

TEST_F(LoaderTest, UserConfigPathEmptyWhenNoEnv) {
EXPECT_THAT(UserConfigPath(DiscoveryOptions{}), IsEmpty());
}

TEST_F(LoaderTest, DiscoverAppliesSystemThenUserLayersWithActiveConfig) {
FakeFs fs;
fs.files["/etc/xff.ini"] = "[defaults]\n--color=auto\n";
fs.files["/home/u/.config/xff/config"] = "common: --sort\nxff: --feature=long\nfind: --warn\n";
DiscoveryOptions opts;
opts.home = "/home/u";
opts.configs = {"xff"}; // the find: line stays inert
const ConfigInputs in = Discover(opts, [&fs](std::string_view p) { return fs.Read(p); });
EXPECT_THAT(
ResolveConfig(in), ElementsAre(
FlagIs("--color=auto", Source::kSystem), FlagIs("--sort", Source::kUser),
FlagIs("--feature=long", Source::kUser)));
}

TEST_F(LoaderTest, ExplicitXffrcFilesAppendToUserLayerInOrder) {
FakeFs fs;
fs.files["/proj/.xffrc"] = "common: --threads=2\n";
fs.files["/extra.rc"] = "common: --color=never\n";
DiscoveryOptions opts;
opts.xffrc_files = {"/proj/.xffrc", "/extra.rc"};
const ConfigInputs in = Discover(opts, [&fs](std::string_view p) { return fs.Read(p); });
EXPECT_THAT(
ResolveConfig(in), ElementsAre(FlagIs("--threads=2", Source::kUser), FlagIs("--color=never", Source::kUser)));
}

TEST_F(LoaderTest, NoConfigSkipsUserAndDefaultsButStillReadsSystemPolicy) {
FakeFs fs;
fs.files["/etc/xff.ini"] = "[defaults]\n--color=auto\n[policy]\nproject.deny = @sensitive\n";
fs.files["/home/u/.config/xff/config"] = "common: --sort\n";
DiscoveryOptions opts;
opts.home = "/home/u";
opts.no_config = true;
const ConfigInputs in = Discover(opts, [&fs](std::string_view p) { return fs.Read(p); });
EXPECT_THAT(in.system.policy, SizeIs(1)); // policy still parsed for the gate (phase C)
EXPECT_THAT(ResolveConfig(in), IsEmpty()); // defaults + user dropped
}

TEST_F(LoaderTest, MissingFilesYieldEmptyLayers) {
FakeFs fs; // nothing on disk
DiscoveryOptions opts;
opts.home = "/home/u";
const ConfigInputs in = Discover(opts, [&fs](std::string_view p) { return fs.Read(p); });
EXPECT_THAT(in.system.defaults, IsEmpty());
EXPECT_THAT(ResolveConfig(in), IsEmpty());
}

} // namespace
} // namespace xff::config
Loading