yams/tests/unit/cli/config_command_catch2_test.cpp
trvon 9c7f780d4c
refactor(symbols): remove symbol extraction subsystem and add migration 40
Drop legacy Tree-sitter symbol extraction subsystem, associated database
tables, triggers, and AST edge types:
- Migration 40 drops symbol_metadata, document_symbol_extraction_state,
  symbol_metadata_fts, associated triggers, and prunes historical bloated
  topology snapshots while preserving active snapshot topology,
  semantic_neighbor edges, and non-symbol observed_as provenance edges.
  Exclude nl_entity:% nodes from kg_nodes / kg_edges pruning to preserve
  canonicalized method-type technology entities.
  Provides complete downSQL restoring symbol tables, indexes, FTS virtual table,
  and sync triggers for clean v39 rollbacks.
- In EntityGraphService, ensure locally resolved documentDbId propagates to
  WriteBatch for knowledgeGraphDocumentId to ensure accurate enrichment
  token completion, and only populate batch->documentIdToDelete for snapshot
  documents with a resolved document database ID.
- In SymbolEnricher and SearchService, restore one-directional symbol-contains-query
  matching with non-empty checks to prevent short substrings matching broad queries.
- In tests/unit/metadata/symbol_metadata_schema_test.cpp, migrate test DB up to
  version 39 so tests pass against their target version prior to v40 table drops.
- Remove Tree-sitter grammar download and compile workflows from CLI init
- Default plugin-symbols to false.
- Fix macOS daemon client and CLI executable resolution using
  _NSGetExecutablePath with candidate fallbacks.
- Add migration validation tests and symbol enrichment unit tests.

Signed-off-by: trvon <me@trevon.dev>
2026-09-22 07:37:30 -06:00

457 lines
18 KiB
C++

// 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 <catch2/catch_test_macros.hpp>
#include <yams/cli/result_helpers.h>
#include <yams/cli/yams_cli.h>
#include <yams/config/config_helpers.h>
#include <chrono>
#include <cstdlib>
#include <filesystem>
#include <fstream>
#include <iostream>
#include <optional>
#include <sstream>
#include <string>
#include <vector>
#include "../../common/test_helpers_catch2.h"
#ifdef _WIN32
#include <process.h>
#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<yams::test::ScopedEnvVar> configEnv;
std::optional<yams::test::ScopedEnvVar> dataEnv;
std::optional<yams::test::ScopedEnvVar> nonInteractiveEnv;
std::optional<yams::test::ScopedEnvVar> xdgDataEnv;
std::optional<yams::test::ScopedEnvVar> xdgConfigEnv;
std::optional<yams::test::ScopedEnvVar> 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<std::string>("1"));
// Disable daemon autostart to prevent cleanup crashes
disableDaemonEnv.emplace(std::string("YAMS_CLI_DISABLE_DAEMON_AUTOSTART"),
std::optional<std::string>("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<std::string>& args) {
auto cli = std::make_unique<yams::cli::YamsCLI>();
std::vector<char*> argv;
argv.reserve(args.size());
for (const auto& arg : args) {
argv.push_back(const_cast<char*>(arg.c_str()));
}
return cli->run(static_cast<int>(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<fs::path> 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<fs::path> bases = {
cwd,
cwd.parent_path(),
cwd.parent_path().parent_path(),
};
const std::vector<fs::path> 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 - embeddings tune writes the runtime batch size the daemon reads",
"[cli][config][embeddings][catch2]") {
ConfigCommandFixture fixture;
int rc = fixture.runCommand({"yams", "config", "embeddings", "tune", "performance"});
REQUIRE(rc == 0);
const auto cfg = yams::config::parse_simple_toml(fixture.testConfigHome / "config.toml");
REQUIRE(cfg.count("embeddings.runtime.batch_size") == 1);
CHECK(cfg.at("embeddings.runtime.batch_size") == "32");
// Keys no daemon code reads must not be written any more.
CHECK(cfg.count("embeddings.batch_size") == 0);
CHECK(cfg.count("embeddings.generation_delay_ms") == 0);
CHECK(cfg.count("embeddings.auto_generate") == 0);
}
TEST_CASE("ConfigCommand - embeddings enable and disable are no longer subcommands",
"[cli][config][embeddings][catch2]") {
ConfigCommandFixture fixture;
// Automatic embedding generation is not configurable; the daemon always embeds on
// ingest. A toggle that wrote an unread key is gone rather than kept as a no-op.
CHECK(fixture.runCommand({"yams", "config", "embeddings", "enable"}) != 0);
CHECK(fixture.runCommand({"yams", "config", "embeddings", "disable"}) != 0);
}
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<char>(input)),
std::istreambuf_iterator<char>());
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 - 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);
}