// Round-trip and edge-case tests for CrossTUCache on-disk // serialization. Phase 1 of cross-TU cache persistence; the // serializer must round-trip every CalleeProperties field and // reject mismatched schema versions / corrupt files gracefully. #include #include #include #include #include #include #include #include #include "fold/detect/cross_tu_cache.hpp" #include "fold/detect/cross_tu_cache_io.hpp" namespace { std::filesystem::path makeTempFile(const std::string& tag) { auto p = std::filesystem::temp_directory_path() / ("fold-cross-tu-cache-test-" + tag + "-" + std::to_string(::getpid()) + ".json"); std::filesystem::remove(p); return p; } fold::detect::CalleeProperties makeProps(std::initializer_list derefed, std::initializer_list divisor, std::initializer_list bounds, std::initializer_list arithmetic, bool returns_null) { fold::detect::CalleeProperties p; p.derefed_params.insert(derefed.begin(), derefed.end()); p.divisor_params.insert(divisor.begin(), divisor.end()); p.bounds_params.insert(bounds.begin(), bounds.end()); p.arithmetic_params.insert(arithmetic.begin(), arithmetic.end()); p.returns_null = returns_null; return p; } } // namespace TEST_CASE("CrossTUCache I/O: round-trip preserves every field", "[cross-tu-cache][io][roundtrip]") { using namespace fold::detect; CrossTUCacheSnapshot snap; snap.tu_path = "/abs/path/to/altsvc.c"; snap.source_mtime_ns = 1747938472123456789LL; snap.flags_hash = "ab12cd34ef56"; snap.entries.push_back({"Curl_altsvc_init", makeProps({0, 1}, {}, {}, {2}, false)}); snap.entries.push_back({"Curl_altsvc_cleanup", makeProps({0}, {}, {}, {}, true)}); snap.entries.push_back({"Curl_altsvc_parse_line", makeProps({1, 2}, {3}, {4}, {}, false)}); auto path = makeTempFile("roundtrip"); REQUIRE(saveSnapshot(path, snap)); auto loaded = loadSnapshot(path); REQUIRE(loaded.has_value()); REQUIRE(loaded->tu_path == snap.tu_path); REQUIRE(loaded->source_mtime_ns == snap.source_mtime_ns); REQUIRE(loaded->flags_hash == snap.flags_hash); REQUIRE(loaded->entries.size() == snap.entries.size()); for (size_t i = 0; i < snap.entries.size(); ++i) { REQUIRE(loaded->entries[i].name == snap.entries[i].name); REQUIRE(loaded->entries[i].props.derefed_params == snap.entries[i].props.derefed_params); REQUIRE(loaded->entries[i].props.divisor_params == snap.entries[i].props.divisor_params); REQUIRE(loaded->entries[i].props.bounds_params == snap.entries[i].props.bounds_params); REQUIRE(loaded->entries[i].props.arithmetic_params == snap.entries[i].props.arithmetic_params); REQUIRE(loaded->entries[i].props.returns_null == snap.entries[i].props.returns_null); } std::filesystem::remove(path); } TEST_CASE("CrossTUCache I/O: empty entries vector round-trips", "[cross-tu-cache][io][edge]") { using namespace fold::detect; CrossTUCacheSnapshot snap{"/abs/empty.c", 0, "0", {}}; auto path = makeTempFile("empty"); REQUIRE(saveSnapshot(path, snap)); auto loaded = loadSnapshot(path); REQUIRE(loaded.has_value()); REQUIRE(loaded->entries.empty()); std::filesystem::remove(path); } TEST_CASE("CrossTUCache I/O: load returns nullopt on missing file", "[cross-tu-cache][io][miss]") { auto missing = std::filesystem::temp_directory_path() / "fold-cross-tu-cache-does-not-exist.json"; std::filesystem::remove(missing); auto loaded = fold::detect::loadSnapshot(missing); REQUIRE_FALSE(loaded.has_value()); } TEST_CASE("CrossTUCache I/O: load returns nullopt on malformed JSON", "[cross-tu-cache][io][malformed]") { auto path = makeTempFile("malformed"); { std::ofstream out(path); out << "{not even valid json,,,"; } auto loaded = fold::detect::loadSnapshot(path); REQUIRE_FALSE(loaded.has_value()); std::filesystem::remove(path); } TEST_CASE("CrossTUCache I/O: load returns nullopt on schema version mismatch", "[cross-tu-cache][io][version]") { auto path = makeTempFile("version"); { std::ofstream out(path); // version 999 is in the future; loader must reject without crashing. out << R"({"version": 999, "tu_path": "/x.c", "source_mtime_ns": 0, "flags_hash": "h", "entries": []})"; } auto loaded = fold::detect::loadSnapshot(path); REQUIRE_FALSE(loaded.has_value()); std::filesystem::remove(path); } TEST_CASE("CrossTUCache I/O: load returns nullopt on missing required field", "[cross-tu-cache][io][partial]") { auto path = makeTempFile("partial"); { std::ofstream out(path); // Missing source_mtime_ns — loader must reject, not silently default. out << R"({"version": 1, "tu_path": "/x.c", "flags_hash": "h", "entries": []})"; } auto loaded = fold::detect::loadSnapshot(path); REQUIRE_FALSE(loaded.has_value()); std::filesystem::remove(path); } TEST_CASE("CrossTUCache I/O: save creates parent directory if missing", "[cross-tu-cache][io][mkdir]") { using namespace fold::detect; auto nested = std::filesystem::temp_directory_path() / ("fold-cross-tu-cache-nested-" + std::to_string(::getpid())) / "sub" / "deeper.json"; std::filesystem::remove_all(nested.parent_path().parent_path()); CrossTUCacheSnapshot snap{"/x.c", 1, "h", {{"foo", {}}}}; REQUIRE(saveSnapshot(nested, snap)); REQUIRE(std::filesystem::exists(nested)); auto loaded = loadSnapshot(nested); REQUIRE(loaded.has_value()); REQUIRE(loaded->entries.size() == 1); std::filesystem::remove_all(nested.parent_path().parent_path()); } TEST_CASE("CrossTUCache I/O: canonicalizeFlagsHash is order-stable for -I/-isystem", "[cross-tu-cache][io][flags-hash]") { using namespace fold::detect; // Two argv differing only in -I/-isystem order must hash identically; // the same with a real semantic change must NOT. std::vector a{"-DFOO", "-I/a", "-I/b", "-isystem", "/sys/x", "-O2"}; std::vector b{"-DFOO", "-I/b", "-I/a", "-isystem", "/sys/x", "-O2"}; std::vector c{"-DFOO", "-I/a", "-I/b", "-isystem", "/sys/y", "-O2"}; auto ha = canonicalizeFlagsHash(a); auto hb = canonicalizeFlagsHash(b); auto hc = canonicalizeFlagsHash(c); REQUIRE_FALSE(ha.empty()); REQUIRE(ha == hb); REQUIRE(ha != hc); } TEST_CASE("CrossTUCache I/O: canonicalizeFlagsHash strips output-only flags", "[cross-tu-cache][io][flags-hash]") { using namespace fold::detect; // -o, -MD, -MF, -MMD, -MT affect build output only, not semantics; // they must not change the hash. std::vector with_out{"-DFOO", "-I/a", "-o", "x.o", "-MD", "-MF", "x.d", "-MMD", "-MT", "x.o"}; std::vector without{"-DFOO", "-I/a"}; REQUIRE(canonicalizeFlagsHash(with_out) == canonicalizeFlagsHash(without)); } TEST_CASE("CrossTUCache I/O: canonicalizeFlagsHash distinguishes -D macros", "[cross-tu-cache][io][flags-hash]") { using namespace fold::detect; std::vector a{"-DFOO=1", "-I/a"}; std::vector b{"-DFOO=2", "-I/a"}; REQUIRE(canonicalizeFlagsHash(a) != canonicalizeFlagsHash(b)); } TEST_CASE("CrossTUCache I/O: defaultCrossTUCacheDir uses XDG_CACHE_HOME when set", "[cross-tu-cache][io][cache-dir]") { using namespace fold::detect; auto sentinel = std::filesystem::temp_directory_path() / ("fold-xdg-sentinel-" + std::to_string(::getpid())); ::setenv("XDG_CACHE_HOME", sentinel.string().c_str(), 1); auto dir = defaultCrossTUCacheDir("/abs/path/to/compile_commands.json"); REQUIRE_FALSE(dir.empty()); REQUIRE(dir.string().find(sentinel.string()) == 0); REQUIRE(dir.string().find("/fold/cross_tu/") != std::string::npos); ::unsetenv("XDG_CACHE_HOME"); } TEST_CASE("CrossTUCache I/O: defaultCrossTUCacheDir is stable for same db path", "[cross-tu-cache][io][cache-dir]") { using namespace fold::detect; auto a = defaultCrossTUCacheDir("/abs/path/to/compile_commands.json"); auto b = defaultCrossTUCacheDir("/abs/path/to/compile_commands.json"); auto c = defaultCrossTUCacheDir("/other/path/compile_commands.json"); REQUIRE(a == b); REQUIRE(a != c); } TEST_CASE("CrossTUCache I/O: tuCacheFile returns /.json", "[cross-tu-cache][io][cache-dir]") { using namespace fold::detect; auto base = std::filesystem::path("/tmp/some-cache"); auto a = tuCacheFile(base, "/abs/altsvc.c"); auto b = tuCacheFile(base, "/abs/altsvc.c"); auto c = tuCacheFile(base, "/abs/urlapi.c"); REQUIRE(a == b); REQUIRE(a != c); REQUIRE(a.parent_path() == base); REQUIRE(a.extension() == ".json"); } TEST_CASE("CrossTUCache I/O: tuCacheFile on empty cache_dir returns empty", "[cross-tu-cache][io][cache-dir]") { REQUIRE(fold::detect::tuCacheFile({}, "/abs/x.c").empty()); } TEST_CASE("CrossTUCache I/O: fileMtimeNs returns >0 for existing file", "[cross-tu-cache][io][mtime]") { auto path = std::filesystem::temp_directory_path() / ("fold-mtime-" + std::to_string(::getpid()) + ".txt"); { std::ofstream out(path); out << "x"; } auto t = fold::detect::fileMtimeNs(path.string()); REQUIRE(t > 0); std::filesystem::remove(path); } TEST_CASE("CrossTUCache I/O: fileMtimeNs returns 0 for missing file", "[cross-tu-cache][io][mtime]") { auto missing = std::filesystem::temp_directory_path() / "fold-mtime-does-not-exist.txt"; std::filesystem::remove(missing); REQUIRE(fold::detect::fileMtimeNs(missing.string()) == 0); } TEST_CASE("CrossTUCache I/O v2: call-graph snapshot round-trips", "[cross-tu-cache][io][callgraph]") { using namespace fold::detect; CrossTUCacheSnapshot snap; snap.tu_path = "/abs/altsvc.c"; snap.source_mtime_ns = 100; snap.flags_hash = "h"; snap.call_graph.functions.push_back( {"Curl_altsvc_init", "/abs/altsvc.c", true, {{"malloc", "/sys/stdlib.h", false}, {"Curl_strdup", "/abs/strdup.c", true}}}); snap.call_graph.functions.push_back( {"Curl_altsvc_cleanup", "/abs/altsvc.c", true, {{"free", "/sys/stdlib.h", false}}}); auto path = makeTempFile("callgraph-roundtrip"); REQUIRE(saveSnapshot(path, snap)); auto loaded = loadSnapshot(path); REQUIRE(loaded.has_value()); REQUIRE(loaded->call_graph.functions.size() == 2); REQUIRE(loaded->call_graph.functions[0].name == "Curl_altsvc_init"); REQUIRE(loaded->call_graph.functions[0].source_file == "/abs/altsvc.c"); REQUIRE(loaded->call_graph.functions[0].is_defined); REQUIRE(loaded->call_graph.functions[0].callees.size() == 2); REQUIRE(loaded->call_graph.functions[0].callees[0].name == "malloc"); REQUIRE_FALSE(loaded->call_graph.functions[0].callees[0].is_defined); REQUIRE(loaded->call_graph.functions[0].callees[1].name == "Curl_strdup"); REQUIRE(loaded->call_graph.functions[0].callees[1].is_defined); REQUIRE(loaded->call_graph.functions[1].name == "Curl_altsvc_cleanup"); REQUIRE(loaded->call_graph.functions[1].callees.size() == 1); std::filesystem::remove(path); } TEST_CASE("CrossTUCache I/O v2: empty call-graph round-trips", "[cross-tu-cache][io][callgraph]") { using namespace fold::detect; CrossTUCacheSnapshot snap; snap.tu_path = "/abs/header-only.c"; snap.source_mtime_ns = 1; snap.flags_hash = "h"; // entries empty, call_graph empty auto path = makeTempFile("callgraph-empty"); REQUIRE(saveSnapshot(path, snap)); auto loaded = loadSnapshot(path); REQUIRE(loaded.has_value()); REQUIRE(loaded->call_graph.functions.empty()); std::filesystem::remove(path); } TEST_CASE("CrossTUCache I/O v2: v1 schema is rejected (now requires v2)", "[cross-tu-cache][io][callgraph][version]") { // v1 files written by Phase 3 v1 lack the call_graph block. v2 loader // must reject them so they get re-primed and re-saved with v2 shape. auto path = makeTempFile("v1-reject"); { std::ofstream out(path); out << R"({"version": 1, "tu_path": "/x.c", "source_mtime_ns": 0, "flags_hash": "h", "entries": []})"; } auto loaded = fold::detect::loadSnapshot(path); REQUIRE_FALSE(loaded.has_value()); std::filesystem::remove(path); }