// CLI Config Command tests // Catch2 migration from GTest (yams-3s4 / yams-cli) // // Tests for config embeddings command. // Note: Original tests used ASSERT_EXIT (death tests) which don't have // a direct Catch2 equivalent. These tests validate the command setup // and model discovery logic without using process exit checks. #include #include #include #include #include #include #include #include #include #include #include #include #include #include "../../common/test_helpers_catch2.h" #ifdef _WIN32 #include #define getpid _getpid #endif namespace fs = std::filesystem; namespace { TEST_CASE("config parse_bool handles common truthy and falsy values", "[cli][config]") { CHECK(yams::config::parse_bool("true", false)); CHECK(yams::config::parse_bool(" YES ", false)); CHECK(yams::config::parse_bool("on", false)); CHECK(yams::config::parse_bool("1", false)); CHECK_FALSE(yams::config::parse_bool("false", true)); CHECK_FALSE(yams::config::parse_bool(" NO ", true)); CHECK_FALSE(yams::config::parse_bool("off", true)); CHECK_FALSE(yams::config::parse_bool("0", true)); CHECK(yams::config::parse_bool("maybe", true)); CHECK_FALSE(yams::config::parse_bool("maybe", false)); CHECK(yams::config::parse_bool("", true)); } TEST_CASE("CLI config write helper returns Result on write", "[cli][config]") { auto dir = fs::temp_directory_path() / ("yams_config_write_result_" + std::to_string(std::chrono::steady_clock::now().time_since_epoch().count())); auto path = dir / "config.toml"; auto result = yams::cli::writeConfigValueResult(path, "daemon.plugin_dir_strict", "true"); REQUIRE(result.has_value()); auto parsed = yams::config::parse_simple_toml(path); CHECK(parsed["daemon.plugin_dir_strict"] == "true"); std::error_code ec; fs::remove_all(dir, ec); } /** * Test fixture for config command embeddings tests with model directory setup. */ struct ConfigCommandFixture { fs::path testHome; fs::path testDataHome; fs::path testConfigHome; std::optional configEnv; std::optional dataEnv; std::optional nonInteractiveEnv; std::optional xdgDataEnv; std::optional xdgConfigEnv; std::optional disableDaemonEnv; ConfigCommandFixture() { // A unique directory for all test artifacts const auto now = std::chrono::steady_clock::now().time_since_epoch().count(); testHome = fs::temp_directory_path() / ("yams_config_test_" + std::to_string(::getpid()) + "_" + std::to_string(now)); fs::create_directories(testHome); testDataHome = testHome / "data"; fs::create_directories(testDataHome); testConfigHome = testHome / "config"; fs::create_directories(testConfigHome); // Use YAMS_CONFIG and YAMS_DATA_DIR for cross-platform isolation // These are checked first by get_config_path() and get_data_dir() configEnv.emplace("YAMS_CONFIG", (testConfigHome / "config.toml").string()); dataEnv.emplace("YAMS_DATA_DIR", testDataHome.string()); // Skip interactive prompts (e.g., migration confirmation) nonInteractiveEnv.emplace(std::string("YAMS_NON_INTERACTIVE"), std::optional("1")); // Disable daemon autostart to prevent cleanup crashes disableDaemonEnv.emplace(std::string("YAMS_CLI_DISABLE_DAEMON_AUTOSTART"), std::optional("1")); // Also set XDG vars for Unix compatibility xdgDataEnv.emplace("XDG_DATA_HOME", testDataHome.string()); xdgConfigEnv.emplace("XDG_CONFIG_HOME", testConfigHome.string()); // Pre-seed a v3 config so command tests are not coupled to migration behavior. const fs::path configPath = testConfigHome / "config.toml"; std::ofstream configFile(configPath, std::ios::trunc); configFile << "[version]\n"; configFile << "config_version = 3\n"; configFile.close(); } ~ConfigCommandFixture() { configEnv.reset(); dataEnv.reset(); nonInteractiveEnv.reset(); disableDaemonEnv.reset(); xdgDataEnv.reset(); xdgConfigEnv.reset(); std::error_code ec; fs::remove_all(testHome, ec); } void createModel(const std::string& modelName) { // get_data_dir() returns YAMS_DATA_DIR directly (without /yams suffix) // getAvailableModels() searches get_data_dir() / "models" fs::path modelsRoot = testDataHome / "models"; fs::path modelDir = modelsRoot / modelName; fs::create_directories(modelDir); std::ofstream(modelDir / "model.onnx").close(); } void overwriteConfig(const std::string& content) { std::ofstream out(testConfigHome / "config.toml", std::ios::trunc); out << content; } int runCommand(const std::vector& args) { auto cli = std::make_unique(); std::vector argv; argv.reserve(args.size()); for (const auto& arg : args) { argv.push_back(const_cast(arg.c_str())); } return cli->run(static_cast(argv.size()), argv.data()); } }; class CaptureStdout { public: CaptureStdout() : oldCout_(std::cout.rdbuf(buffer_.rdbuf())) {} ~CaptureStdout() { std::cout.rdbuf(oldCout_); } std::string str() const { return buffer_.str(); } CaptureStdout(const CaptureStdout&) = delete; CaptureStdout& operator=(const CaptureStdout&) = delete; private: std::ostringstream buffer_; std::streambuf* oldCout_; }; auto findBuiltYamsCli() -> fs::path { std::vector candidates; if (const char* buildRoot = std::getenv("MESON_BUILD_ROOT")) { candidates.emplace_back(fs::path(buildRoot) / "tools" / "yams-cli" / "yams-cli"); } const fs::path cwd = fs::current_path(); const std::vector bases = { cwd, cwd.parent_path(), cwd.parent_path().parent_path(), }; const std::vector suffixes = { fs::path("tools") / "yams-cli" / "yams-cli", fs::path("builddir-local") / "tools" / "yams-cli" / "yams-cli", fs::path("builddir-asan-local") / "tools" / "yams-cli" / "yams-cli", fs::path("builddir-ubsan-local") / "tools" / "yams-cli" / "yams-cli", fs::path("builddir-tsan-local") / "tools" / "yams-cli" / "yams-cli", fs::path("builddir-nosan-local") / "tools" / "yams-cli" / "yams-cli", fs::path("builddir") / "tools" / "yams-cli" / "yams-cli", fs::path("builddir-asan") / "tools" / "yams-cli" / "yams-cli", fs::path("builddir-ubsan") / "tools" / "yams-cli" / "yams-cli", fs::path("builddir-tsan") / "tools" / "yams-cli" / "yams-cli", fs::path("builddir-nosan") / "tools" / "yams-cli" / "yams-cli", fs::path("build") / "coverage" / "tools" / "yams-cli" / "yams-cli", fs::path("build") / "release" / "tools" / "yams-cli" / "yams-cli", fs::path("build") / "debug" / "tools" / "yams-cli" / "yams-cli", }; for (const auto& base : bases) { for (const auto& suffix : suffixes) { candidates.push_back(base / suffix); } } for (const auto& candidate : candidates) { if (fs::exists(candidate)) { return candidate; } } return {}; } } // namespace TEST_CASE("ConfigCommand - set model finds model in XDG data home", "[cli][config][embeddings][catch2]") { ConfigCommandFixture fixture; fixture.createModel("test-model"); // Run the config embeddings model command // Note: Original test used ASSERT_EXIT to check for exit code 0 // In Catch2, we just run and check return code directly int rc = fixture.runCommand({"yams", "config", "embeddings", "model", "test-model"}); CHECK(rc == 0); } TEST_CASE("ConfigCommand - embeddings backend writes ONNX Runtime selection", "[cli][config][embeddings][catch2]") { ConfigCommandFixture fixture; int rc = fixture.runCommand({"yams", "config", "embeddings", "backend", "onnx"}); REQUIRE(rc == 0); const auto cfg = yams::config::parse_simple_toml(fixture.testConfigHome / "config.toml"); REQUIRE(cfg.count("embeddings.backend") == 1); CHECK(cfg.at("embeddings.backend") == "onnxruntime"); } TEST_CASE("ConfigCommand - set model fails for missing model", "[cli][config][embeddings][catch2][.death_test]") { ConfigCommandFixture fixture; const fs::path yamsCli = findBuiltYamsCli(); REQUIRE_FALSE(yamsCli.empty()); const fs::path out = fixture.testHome / "missing-model.out"; const std::string cmd = "'" + yamsCli.string() + "' config embeddings model no-model-here > '" + out.string() + "' 2>&1"; const int rc = std::system(cmd.c_str()); REQUIRE(rc != 0); std::ifstream input(out); REQUIRE(input.good()); const std::string output((std::istreambuf_iterator(input)), std::istreambuf_iterator()); CHECK((output.find("not found") != std::string::npos || output.find("No local embedding model directories were found") != std::string::npos)); CHECK(output.find("Available models:") != std::string::npos); } TEST_CASE("ConfigCommand - config command parses without crash", "[cli][config][catch2]") { ConfigCommandFixture fixture; // Just verify the command parses and runs without crashing // Return code may vary based on setup (void)fixture.runCommand({"yams", "config", "--help"}); SUCCEED(); } TEST_CASE("ConfigCommand - storage command writes S3 fallback settings", "[cli][config][storage][catch2]") { ConfigCommandFixture fixture; const fs::path fallbackDir = fixture.testDataHome / "s3-fallback"; const int rc = fixture.runCommand({"yams", "config", "storage", "--engine", "s3", "--s3-url", "s3://example-bucket/prefix", "--s3-region", "us-west-2", "--s3-fallback-policy", "fallback_local_if_configured", "--s3-fallback-local-data-dir", fallbackDir.string()}); REQUIRE(rc == 0); const auto cfg = yams::config::parse_simple_toml(fixture.testConfigHome / "config.toml"); REQUIRE(cfg.count("storage.engine") == 1); CHECK(cfg.at("storage.engine") == "s3"); REQUIRE(cfg.count("storage.s3.url") == 1); CHECK(cfg.at("storage.s3.url") == "s3://example-bucket/prefix"); REQUIRE(cfg.count("storage.s3.region") == 1); CHECK(cfg.at("storage.s3.region") == "us-west-2"); REQUIRE(cfg.count("storage.s3.fallback_policy") == 1); CHECK(cfg.at("storage.s3.fallback_policy") == "fallback_local_if_configured"); REQUIRE(cfg.count("storage.s3.fallback_local_data_dir") == 1); CHECK(cfg.at("storage.s3.fallback_local_data_dir") == fallbackDir.string()); } TEST_CASE("ConfigCommand - storage clear fallback local data dir", "[cli][config][storage][catch2]") { ConfigCommandFixture fixture; const fs::path fallbackDir = fixture.testDataHome / "to-clear"; const int setRc = fixture.runCommand( {"yams", "config", "storage", "--s3-fallback-local-data-dir", fallbackDir.string()}); REQUIRE(setRc == 0); const int clearRc = fixture.runCommand({"yams", "config", "storage", "--s3-clear-fallback-local-data-dir"}); REQUIRE(clearRc == 0); const auto cfg = yams::config::parse_simple_toml(fixture.testConfigHome / "config.toml"); REQUIRE(cfg.count("storage.s3.fallback_local_data_dir") == 1); CHECK(cfg.at("storage.s3.fallback_local_data_dir").empty()); } TEST_CASE("ConfigCommand - auto-migrates v2 to v3 and preserves storage settings", "[cli][config][storage][migration][catch2]") { ConfigCommandFixture fixture; fixture.overwriteConfig("[version]\n" "config_version = 2\n" "migrated_from = 1\n" "[storage]\n" "engine = \"s3\"\n" "[storage.s3]\n" "url = \"s3://bucket/prefix\"\n" "region = \"us-east-1\"\n"); const int rc = fixture.runCommand({"yams", "config", "list"}); REQUIRE(rc == 0); const auto cfg = yams::config::parse_simple_toml(fixture.testConfigHome / "config.toml"); REQUIRE(cfg.count("version.config_version") == 1); CHECK(cfg.at("version.config_version") == "3"); REQUIRE(cfg.count("storage.engine") == 1); CHECK(cfg.at("storage.engine") == "s3"); REQUIRE(cfg.count("storage.s3.url") == 1); CHECK(cfg.at("storage.s3.url") == "s3://bucket/prefix"); REQUIRE(cfg.count("storage.s3.region") == 1); CHECK(cfg.at("storage.s3.region") == "us-east-1"); REQUIRE(cfg.count("storage.s3.fallback_policy") == 1); CHECK(cfg.at("storage.s3.fallback_policy") == "strict"); } TEST_CASE("ConfigCommand - storage command writes explicit R2 temp credential settings", "[cli][config][storage][r2][catch2]") { ConfigCommandFixture fixture; const int rc = fixture.runCommand( {"yams", "config", "storage", "--s3-r2-auth-mode", "temp_credentials", "--s3-r2-api-token", "cf-test-token-not-real-1234567890-abcdef", "--s3-r2-account-id", "00000000000000000000000000000000", "--s3-r2-parent-access-key-id", "11111111111111111111111111111111", "--s3-r2-permission", "object-read-only", "--s3-r2-ttl-seconds", "1800"}); REQUIRE(rc == 0); const auto cfg = yams::config::parse_simple_toml(fixture.testConfigHome / "config.toml"); REQUIRE(cfg.count("storage.s3.r2.auth_mode") == 1); CHECK(cfg.at("storage.s3.r2.auth_mode") == "temp_credentials"); REQUIRE(cfg.count("storage.s3.r2.api_token") == 1); CHECK(cfg.at("storage.s3.r2.api_token") == "cf-test-token-not-real-1234567890-abcdef"); REQUIRE(cfg.count("storage.s3.r2.account_id") == 1); CHECK(cfg.at("storage.s3.r2.account_id") == "00000000000000000000000000000000"); REQUIRE(cfg.count("storage.s3.r2.parent_access_key_id") == 1); CHECK(cfg.at("storage.s3.r2.parent_access_key_id") == "11111111111111111111111111111111"); REQUIRE(cfg.count("storage.s3.r2.permission") == 1); CHECK(cfg.at("storage.s3.r2.permission") == "object-read-only"); REQUIRE(cfg.count("storage.s3.r2.ttl_seconds") == 1); CHECK(cfg.at("storage.s3.r2.ttl_seconds") == "1800"); } TEST_CASE("ConfigCommand - tuning profile persists and status reports it", "[cli][config][tuning][catch2]") { ConfigCommandFixture fixture; const int setRc = fixture.runCommand({"yams", "config", "tuning", "profile", "aggressive"}); REQUIRE(setRc == 0); const auto cfg = yams::config::parse_simple_toml(fixture.testConfigHome / "config.toml"); REQUIRE(cfg.count("tuning.profile") == 1); CHECK(cfg.at("tuning.profile") == "aggressive"); CaptureStdout capture; const int statusRc = fixture.runCommand({"yams", "config", "tuning", "status"}); REQUIRE(statusRc == 0); const std::string output = capture.str(); CHECK(output.find("Tuning Configuration") != std::string::npos); CHECK(output.find("Profile: aggressive") != std::string::npos); } TEST_CASE("ConfigCommand - path-tree enable and mode update config", "[cli][config][search][path-tree][catch2]") { ConfigCommandFixture fixture; const int enableRc = fixture.runCommand( {"yams", "config", "search", "path-tree", "enable", "--mode", "preferred"}); REQUIRE(enableRc == 0); auto cfg = yams::config::parse_simple_toml(fixture.testConfigHome / "config.toml"); REQUIRE(cfg.count("search.path_tree.enable") == 1); CHECK(cfg.at("search.path_tree.enable") == "true"); REQUIRE(cfg.count("search.path_tree.mode") == 1); CHECK(cfg.at("search.path_tree.mode") == "preferred"); const int modeRc = fixture.runCommand({"yams", "config", "search", "path-tree", "mode", "fallback"}); REQUIRE(modeRc == 0); cfg = yams::config::parse_simple_toml(fixture.testConfigHome / "config.toml"); REQUIRE(cfg.count("search.path_tree.mode") == 1); CHECK(cfg.at("search.path_tree.mode") == "fallback"); CaptureStdout capture; const int statusRc = fixture.runCommand({"yams", "config", "search", "path-tree", "status"}); REQUIRE(statusRc == 0); const std::string output = capture.str(); CHECK(output.find("Path-tree traversal configuration") != std::string::npos); CHECK(output.find("Mode : fallback") != std::string::npos); } TEST_CASE("ConfigCommand - grammar auto download flags update config", "[cli][config][grammar][catch2]") { ConfigCommandFixture fixture; auto normalizedPathString = [](const fs::path& path) { auto normalized = path.lexically_normal().generic_string(); std::transform(normalized.begin(), normalized.end(), normalized.begin(), [](unsigned char c) { return static_cast(std::tolower(c)); }); return normalized; }; const int enableRc = fixture.runCommand({"yams", "config", "grammar", "auto-enable"}); REQUIRE(enableRc == 0); auto cfg = yams::config::parse_simple_toml(fixture.testConfigHome / "config.toml"); REQUIRE(cfg.count("plugins.symbol_extraction.auto_download_grammars") == 1); CHECK(cfg.at("plugins.symbol_extraction.auto_download_grammars") == "true"); CaptureStdout pathCapture; const int pathRc = fixture.runCommand({"yams", "config", "grammar", "path"}); REQUIRE(pathRc == 0); auto outputPath = pathCapture.str(); outputPath.erase(std::remove(outputPath.begin(), outputPath.end(), '\r'), outputPath.end()); outputPath.erase(std::remove(outputPath.begin(), outputPath.end(), '\n'), outputPath.end()); const bool containsExpectedPath = normalizedPathString(fs::path(outputPath)) .find(normalizedPathString(fixture.testDataHome / "grammars")) != std::string::npos; const bool matchesNormalizedPath = normalizedPathString(fs::path(outputPath)) == normalizedPathString(fixture.testDataHome / "grammars"); const bool pathMatched = containsExpectedPath || matchesNormalizedPath; CHECK(pathMatched); const int disableRc = fixture.runCommand({"yams", "config", "grammar", "auto-disable"}); REQUIRE(disableRc == 0); cfg = yams::config::parse_simple_toml(fixture.testConfigHome / "config.toml"); REQUIRE(cfg.count("plugins.symbol_extraction.auto_download_grammars") == 1); CHECK(cfg.at("plugins.symbol_extraction.auto_download_grammars") == "false"); } TEST_CASE("ConfigCommand - plugins status reports trust and defaults", "[cli][config][plugins][catch2]") { ConfigCommandFixture fixture; CaptureStdout capture; const int rc = fixture.runCommand({"yams", "config", "plugins", "status"}); REQUIRE(rc == 0); const std::string output = capture.str(); CHECK(output.find("Daemon Plugin Configuration") != std::string::npos); CHECK(output.find("Trust file: ") != std::string::npos); CHECK(output.find("Default plugin roots") != std::string::npos); CHECK(output.find("plugin_dir_strict: false") != std::string::npos); }