diff --git a/CMakeLists.txt b/CMakeLists.txt index 4c0417b0..5f97e684 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -61,6 +61,7 @@ if(ONE_INDEX OR ONE_SERVER) add_subdirectory(src/shared) add_subdirectory(src/gzip) add_subdirectory(src/metapack) + add_subdirectory(src/search) endif() if(ONE_INDEX) @@ -124,6 +125,7 @@ if(ONE_TESTS) if(ONE_INDEX OR ONE_SERVER) add_subdirectory(test/unit/gzip) add_subdirectory(test/unit/metapack) + add_subdirectory(test/unit/search) endif() if(ONE_INDEX) diff --git a/src/index/CMakeLists.txt b/src/index/CMakeLists.txt index a484b0b6..0e2fd88c 100644 --- a/src/index/CMakeLists.txt +++ b/src/index/CMakeLists.txt @@ -14,6 +14,7 @@ endif() target_link_libraries(sourcemeta_one_index PRIVATE sourcemeta::one::resolver) target_link_libraries(sourcemeta_one_index PRIVATE sourcemeta::one::shared) target_link_libraries(sourcemeta_one_index PRIVATE sourcemeta::one::metapack) +target_link_libraries(sourcemeta_one_index PRIVATE sourcemeta::one::search) target_link_libraries(sourcemeta_one_index PRIVATE sourcemeta::one::configuration) target_link_libraries(sourcemeta_one_index PRIVATE sourcemeta::one::web) diff --git a/src/index/explorer.h b/src/index/explorer.h index f75fc844..308b6d36 100644 --- a/src/index/explorer.h +++ b/src/index/explorer.h @@ -4,6 +4,7 @@ #include #include #include +#include #include #include @@ -499,7 +500,7 @@ struct GENERATE_EXPLORER_SEARCH_INDEX { const sourcemeta::one::Configuration &, const sourcemeta::core::JSON &) -> void { const auto timestamp_start{std::chrono::steady_clock::now()}; - std::vector result; + std::vector entries; for (const auto &dependency : action.dependencies) { const auto directory_option{ @@ -515,48 +516,27 @@ struct GENERATE_EXPLORER_SEARCH_INDEX { continue; } - auto entry{sourcemeta::core::JSON::make_array()}; - entry.push_back( - sourcemeta::core::JSON{directory_entry.at("path").to_string()}); - entry.push_back(directory_entry.defines("title") - ? directory_entry.at("title") - : sourcemeta::core::JSON{""}); - entry.push_back(directory_entry.defines("description") - ? directory_entry.at("description") - : sourcemeta::core::JSON{""}); - result.push_back(std::move(entry)); + entries.push_back({directory_entry.at("path").to_string(), + directory_entry.defines("title") + ? directory_entry.at("title").to_string() + : "", + directory_entry.defines("description") + ? directory_entry.at("description").to_string() + : ""}); } } - std::sort(result.begin(), result.end(), - [](const sourcemeta::core::JSON &left, - const sourcemeta::core::JSON &right) { - assert(left.is_array() && left.size() == 3); - assert(right.is_array() && right.size() == 3); - - // Prioritise entries that have more meta-data filled in - const auto left_score = (!left.at(1).empty() ? 1 : 0) + - (!left.at(2).empty() ? 1 : 0); - const auto right_score = (!right.at(1).empty() ? 1 : 0) + - (!right.at(2).empty() ? 1 : 0); - if (left_score != right_score) { - return left_score > right_score; - } - - // Otherwise revert to lexicographic comparisons - // TODO: Ideally we sort based on schema health too, given - // lint results - if (left_score > 0) { - return left.at(0).to_string() < right.at(0).to_string(); - } - - return false; - }); - + const auto payload{sourcemeta::one::make_search(std::move(entries))}; const auto timestamp_end{std::chrono::steady_clock::now()}; - sourcemeta::one::metapack_write_jsonl( - action.destination, result, "application/jsonl", + const std::string_view payload_view{ + payload.empty() + ? std::string_view{} + // NOLINTNEXTLINE(cppcoreguidelines-pro-type-reinterpret-cast) + : std::string_view{reinterpret_cast(payload.data()), + payload.size()}}; + sourcemeta::one::metapack_write_text( + action.destination, payload_view, "application/jsonl", // We don't want to compress this one so we can // quickly skim through it while streaming it sourcemeta::one::MetapackEncoding::Identity, {}, diff --git a/src/metapack/include/sourcemeta/one/metapack.h b/src/metapack/include/sourcemeta/one/metapack.h index 678a1d18..40f9346d 100644 --- a/src/metapack/include/sourcemeta/one/metapack.h +++ b/src/metapack/include/sourcemeta/one/metapack.h @@ -15,7 +15,6 @@ #include // std::optional #include // std::span #include // std::string_view -#include // std::vector namespace sourcemeta::one { @@ -71,13 +70,6 @@ auto metapack_write_text(const std::filesystem::path &destination, std::span extension, std::chrono::milliseconds duration) -> void; -SOURCEMETA_ONE_METAPACK_EXPORT -auto metapack_write_jsonl(const std::filesystem::path &destination, - const std::vector &entries, - std::string_view mime, MetapackEncoding encoding, - std::span extension, - std::chrono::milliseconds duration) -> void; - SOURCEMETA_ONE_METAPACK_EXPORT auto metapack_write_file(const std::filesystem::path &destination, const std::filesystem::path &source, diff --git a/src/metapack/metapack.cc b/src/metapack/metapack.cc index 9d0c23b9..38d1de20 100644 --- a/src/metapack/metapack.cc +++ b/src/metapack/metapack.cc @@ -127,23 +127,6 @@ auto metapack_write_text(const std::filesystem::path &destination, write_metapack(destination, mime, encoding, extension, duration, content); } -auto metapack_write_jsonl(const std::filesystem::path &destination, - const std::vector &entries, - const std::string_view mime, - const MetapackEncoding encoding, - const std::span extension, - const std::chrono::milliseconds duration) -> void { - std::ostringstream buffer; - for (const auto &entry : entries) { - sourcemeta::core::stringify(entry, buffer); - buffer << '\n'; - } - - std::filesystem::create_directories(destination.parent_path()); - write_metapack(destination, mime, encoding, extension, duration, - buffer.str()); -} - auto metapack_write_file(const std::filesystem::path &destination, const std::filesystem::path &source, const std::string_view mime, diff --git a/src/search/CMakeLists.txt b/src/search/CMakeLists.txt new file mode 100644 index 00000000..ffc76c24 --- /dev/null +++ b/src/search/CMakeLists.txt @@ -0,0 +1,6 @@ +sourcemeta_library(NAMESPACE sourcemeta PROJECT one NAME search + SOURCES search.cc) + +target_link_libraries(sourcemeta_one_search PUBLIC sourcemeta::core::json) +target_link_libraries(sourcemeta_one_search PUBLIC sourcemeta::core::io) +target_link_libraries(sourcemeta_one_search PRIVATE sourcemeta::one::metapack) diff --git a/src/search/include/sourcemeta/one/search.h b/src/search/include/sourcemeta/one/search.h new file mode 100644 index 00000000..a4207fd5 --- /dev/null +++ b/src/search/include/sourcemeta/one/search.h @@ -0,0 +1,57 @@ +#ifndef SOURCEMETA_ONE_SEARCH_H_ +#define SOURCEMETA_ONE_SEARCH_H_ + +#ifndef SOURCEMETA_ONE_SEARCH_EXPORT +#include +#endif + +#include +#include + +#include // std::size_t +#include // std::uint8_t +#include // std::filesystem::path +#include // std::unique_ptr +#include // std::string +#include // std::string_view +#include // std::vector + +namespace sourcemeta::one { + +struct SearchEntry { + std::string path; + std::string title; + std::string description; +}; + +SOURCEMETA_ONE_SEARCH_EXPORT +auto make_search(std::vector &&entries) + -> std::vector; + +SOURCEMETA_ONE_SEARCH_EXPORT +auto search(const std::uint8_t *payload, std::size_t payload_size, + std::string_view query) -> sourcemeta::core::JSON; + +class SOURCEMETA_ONE_SEARCH_EXPORT SearchView { +public: + explicit SearchView(std::filesystem::path path); + ~SearchView(); + + SearchView(const SearchView &) = delete; + SearchView(SearchView &&) = delete; + auto operator=(const SearchView &) -> SearchView & = delete; + auto operator=(SearchView &&) -> SearchView & = delete; + + auto search(std::string_view query) -> sourcemeta::core::JSON; + +private: + std::filesystem::path path_; + std::unique_ptr view_; + const std::uint8_t *payload_{nullptr}; + std::size_t payload_size_{0}; + auto ensure_open() -> void; +}; + +} // namespace sourcemeta::one + +#endif diff --git a/src/search/search.cc b/src/search/search.cc new file mode 100644 index 00000000..40105f33 --- /dev/null +++ b/src/search/search.cc @@ -0,0 +1,123 @@ +#include + +#include + +#include // std::ranges::search +#include // assert +#include // std::tolower +#include // std::ostringstream +#include // std::move + +namespace sourcemeta::one { + +auto make_search(std::vector &&entries) + -> std::vector { + // Prioritise entries that have more metadata filled in, + // then sort lexicographically by path + std::ranges::sort(entries, [](const SearchEntry &left, + const SearchEntry &right) { + const auto left_score = + (!left.title.empty() ? 1 : 0) + (!left.description.empty() ? 1 : 0); + const auto right_score = + (!right.title.empty() ? 1 : 0) + (!right.description.empty() ? 1 : 0); + if (left_score != right_score) { + return left_score > right_score; + } + + // TODO: Ideally we sort based on schema health too, given + // lint results + return left.path < right.path; + }); + + std::ostringstream buffer; + for (const auto &entry : entries) { + auto json_entry{sourcemeta::core::JSON::make_array()}; + json_entry.push_back(sourcemeta::core::JSON{entry.path}); + json_entry.push_back(sourcemeta::core::JSON{entry.title}); + json_entry.push_back(sourcemeta::core::JSON{entry.description}); + sourcemeta::core::stringify(json_entry, buffer); + buffer << '\n'; + } + + const auto result{buffer.str()}; + return {result.begin(), result.end()}; +} + +auto search(const std::uint8_t *payload, const std::size_t payload_size, + const std::string_view query) -> sourcemeta::core::JSON { + auto result{sourcemeta::core::JSON::make_array()}; + if (payload_size == 0) { + return result; + } + + assert(payload != nullptr); + // NOLINTNEXTLINE(cppcoreguidelines-pro-type-reinterpret-cast) + const std::string_view data{reinterpret_cast(payload), + payload_size}; + + std::size_t line_start{0}; + while (line_start < data.size()) { + auto line_end{data.find('\n', line_start)}; + if (line_end == std::string_view::npos) { + line_end = data.size(); + } + + const auto line{data.substr(line_start, line_end - line_start)}; + line_start = line_end + 1; + + if (line.empty()) { + continue; + } + + if (std::ranges::search(line, query, [](const auto left, const auto right) { + return std::tolower(static_cast(left)) == + std::tolower(static_cast(right)); + }).empty()) { + continue; + } + + auto entry{sourcemeta::core::JSON::make_object()}; + const std::string line_string{line}; + auto line_json{sourcemeta::core::parse_json(line_string)}; + entry.assign("path", std::move(line_json.at(0))); + entry.assign("title", std::move(line_json.at(1))); + entry.assign("description", std::move(line_json.at(2))); + result.push_back(std::move(entry)); + + constexpr auto MAXIMUM_SEARCH_COUNT{10}; + if (result.array_size() >= MAXIMUM_SEARCH_COUNT) { + break; + } + } + + return result; +} + +SearchView::SearchView(std::filesystem::path path) : path_{std::move(path)} {} + +SearchView::~SearchView() = default; + +auto SearchView::ensure_open() -> void { + if (this->view_) { + return; + } + + assert(std::filesystem::exists(this->path_)); + assert(this->path_.is_absolute()); + this->view_ = std::make_unique(this->path_); + const auto payload_start_option{metapack_payload_offset(*this->view_)}; + assert(payload_start_option.has_value()); + const auto &payload_start{payload_start_option.value()}; + this->payload_size_ = this->view_->size() - payload_start; + if (this->payload_size_ > 0) { + this->payload_ = this->view_->as(payload_start); + } +} + +auto SearchView::search(const std::string_view query) + -> sourcemeta::core::JSON { + this->ensure_open(); + return sourcemeta::one::search(this->payload_, this->payload_size_, query); +} + +} // namespace sourcemeta::one diff --git a/src/server/CMakeLists.txt b/src/server/CMakeLists.txt index b8a62409..11fbdd4b 100644 --- a/src/server/CMakeLists.txt +++ b/src/server/CMakeLists.txt @@ -23,6 +23,7 @@ target_link_libraries(sourcemeta_one_server PRIVATE uNetworking::uWebSockets) target_link_libraries(sourcemeta_one_server PRIVATE sourcemeta::one::gzip) target_link_libraries(sourcemeta_one_server PRIVATE sourcemeta::one::shared) target_link_libraries(sourcemeta_one_server PRIVATE sourcemeta::one::metapack) +target_link_libraries(sourcemeta_one_server PRIVATE sourcemeta::one::search) target_link_libraries(sourcemeta_one_server PRIVATE sourcemeta::blaze::evaluator) target_link_libraries(sourcemeta_one_server PRIVATE sourcemeta::blaze::output) diff --git a/src/server/action_schema_search.h b/src/server/action_schema_search.h index bb2ab66a..3c1d6681 100644 --- a/src/server/action_schema_search.h +++ b/src/server/action_schema_search.h @@ -1,86 +1,18 @@ #ifndef SOURCEMETA_ONE_SERVER_ACTION_SCHEMA_SEARCH_H #define SOURCEMETA_ONE_SERVER_ACTION_SCHEMA_SEARCH_H -#include #include -#include -#include +#include #include "helpers.h" #include "request.h" #include "response.h" -#include // std::search -#include // assert -#include // std::filesystem #include // std::ostringstream -#include // std::runtime_error -#include // std::string #include // std::string_view -namespace sourcemeta::one { - -static auto search(const std::filesystem::path &search_index, - const std::string_view query) -> sourcemeta::core::JSON { - if (!std::filesystem::exists(search_index)) { - throw std::runtime_error("Search index not found"); - } - - assert(search_index.is_absolute()); - - sourcemeta::core::FileView view{search_index}; - const auto payload_start_option{metapack_payload_offset(view)}; - assert(payload_start_option.has_value()); - const auto &payload_start{payload_start_option.value()}; - const auto payload_size{view.size() - payload_start}; - // NOLINTNEXTLINE(cppcoreguidelines-pro-type-reinterpret-cast) - const std::string_view payload{ - reinterpret_cast(view.as(payload_start)), - payload_size}; - - auto result{sourcemeta::core::JSON::make_array()}; - std::size_t line_start{0}; - while (line_start < payload.size()) { - auto line_end{payload.find('\n', line_start)}; - if (line_end == std::string_view::npos) { - line_end = payload.size(); - } - - const auto line{payload.substr(line_start, line_end - line_start)}; - line_start = line_end + 1; - - if (line.empty()) { - continue; - } - - if (std::search(line.cbegin(), line.cend(), query.begin(), query.end(), - [](const auto left, const auto right) { - return std::tolower(left) == std::tolower(right); - }) == line.cend()) { - continue; - } - - auto entry{sourcemeta::core::JSON::make_object()}; - const std::string line_string{line}; - auto line_json{sourcemeta::core::parse_json(line_string)}; - entry.assign("path", std::move(line_json.at(0))); - entry.assign("title", std::move(line_json.at(1))); - entry.assign("description", std::move(line_json.at(2))); - result.push_back(std::move(entry)); - - constexpr auto MAXIMUM_SEARCH_COUNT{10}; - if (result.array_size() >= MAXIMUM_SEARCH_COUNT) { - break; - } - } - - return result; -} - -} // namespace sourcemeta::one - -static auto action_schema_search(const std::filesystem::path &base, +static auto action_schema_search(sourcemeta::one::SearchView &search_view, sourcemeta::one::HTTPRequest &request, sourcemeta::one::HTTPResponse &response) -> void { @@ -91,8 +23,7 @@ static auto action_schema_search(const std::filesystem::path &base, "missing-query", "You must provide a query parameter to search for"); } else { - auto result{sourcemeta::one::search( - base / "explorer" / SENTINEL / "search.metapack", query)}; + auto result{search_view.search(query)}; response.write_status(sourcemeta::one::STATUS_OK); response.write_header("Access-Control-Allow-Origin", "*"); response.write_header("Content-Type", "application/json"); diff --git a/src/server/server.cc b/src/server/server.cc index ce51e74a..85d23e13 100644 --- a/src/server/server.cc +++ b/src/server/server.cc @@ -156,7 +156,9 @@ static auto handle_self_v1_api_schemas_search( const std::filesystem::path &base, const std::span, sourcemeta::one::HTTPRequest &request, sourcemeta::one::HTTPResponse &response) -> void { - action_schema_search(base, request, response); + static sourcemeta::one::SearchView search_view{base / "explorer" / SENTINEL / + "search.metapack"}; + action_schema_search(search_view, request, response); } static auto handle_self_api_not_found(const std::filesystem::path &, diff --git a/test/unit/search/CMakeLists.txt b/test/unit/search/CMakeLists.txt new file mode 100644 index 00000000..2cf1db8b --- /dev/null +++ b/test/unit/search/CMakeLists.txt @@ -0,0 +1,5 @@ +sourcemeta_googletest(NAMESPACE sourcemeta PROJECT one NAME search + SOURCES search_test.cc) + +target_link_libraries(sourcemeta_one_search_unit + PRIVATE sourcemeta::one::search) diff --git a/test/unit/search/search_test.cc b/test/unit/search/search_test.cc new file mode 100644 index 00000000..948fac00 --- /dev/null +++ b/test/unit/search/search_test.cc @@ -0,0 +1,194 @@ +#include + +#include + +#include + +#include // std::string +#include // std::move +#include // std::vector + +#define EXPECT_SEARCH_RESULT(result, index, expected_path, expected_title, \ + expected_description) \ + EXPECT_EQ((result).at(index).at("path").to_string(), (expected_path)); \ + EXPECT_EQ((result).at(index).at("title").to_string(), (expected_title)); \ + EXPECT_EQ((result).at(index).at("description").to_string(), \ + (expected_description)); + +TEST(Search, make_search_empty) { + std::vector entries; + const auto payload{sourcemeta::one::make_search(std::move(entries))}; + EXPECT_TRUE(payload.empty()); +} + +TEST(Search, make_search_single_entry) { + std::vector entries{ + {"/foo/bar", "My Title", "A description"}}; + const auto payload{sourcemeta::one::make_search(std::move(entries))}; + EXPECT_FALSE(payload.empty()); + + const std::string payload_string(payload.begin(), payload.end()); + EXPECT_NE(payload_string.find("/foo/bar"), std::string::npos); + EXPECT_NE(payload_string.find("My Title"), std::string::npos); + EXPECT_NE(payload_string.find("A description"), std::string::npos); +} + +TEST(Search, search_empty_payload) { + const auto result{sourcemeta::one::search(nullptr, 0, "anything")}; + EXPECT_TRUE(result.is_array()); + EXPECT_EQ(result.size(), 0); +} + +TEST(Search, search_no_match) { + std::vector entries{ + {"/foo/bar", "Title", "Desc"}}; + const auto payload{sourcemeta::one::make_search(std::move(entries))}; + const auto result{ + sourcemeta::one::search(payload.data(), payload.size(), "zzzzz")}; + EXPECT_TRUE(result.is_array()); + EXPECT_EQ(result.size(), 0); +} + +TEST(Search, search_match_in_path) { + std::vector entries{ + {"/foo/bar", "Title", "Desc"}}; + const auto payload{sourcemeta::one::make_search(std::move(entries))}; + const auto result{ + sourcemeta::one::search(payload.data(), payload.size(), "foo")}; + EXPECT_EQ(result.size(), 1); + EXPECT_SEARCH_RESULT(result, 0, "/foo/bar", "Title", "Desc"); +} + +TEST(Search, search_match_in_title) { + std::vector entries{ + {"/foo/bar", "Special Title", "Desc"}}; + const auto payload{sourcemeta::one::make_search(std::move(entries))}; + const auto result{ + sourcemeta::one::search(payload.data(), payload.size(), "Special")}; + EXPECT_EQ(result.size(), 1); + EXPECT_SEARCH_RESULT(result, 0, "/foo/bar", "Special Title", "Desc"); +} + +TEST(Search, search_match_in_description) { + std::vector entries{ + {"/foo/bar", "Title", "Unique description here"}}; + const auto payload{sourcemeta::one::make_search(std::move(entries))}; + const auto result{ + sourcemeta::one::search(payload.data(), payload.size(), "Unique")}; + EXPECT_EQ(result.size(), 1); + EXPECT_SEARCH_RESULT(result, 0, "/foo/bar", "Title", + "Unique description here"); +} + +TEST(Search, search_case_insensitive) { + std::vector entries_lower{ + {"/foo/bar", "Hello World", "desc"}}; + const auto payload_lower{ + sourcemeta::one::make_search(std::move(entries_lower))}; + const auto result_lower{sourcemeta::one::search( + payload_lower.data(), payload_lower.size(), "hello")}; + EXPECT_EQ(result_lower.size(), 1); + EXPECT_SEARCH_RESULT(result_lower, 0, "/foo/bar", "Hello World", "desc"); + + std::vector entries_upper{ + {"/foo/bar", "Hello World", "desc"}}; + const auto payload_upper{ + sourcemeta::one::make_search(std::move(entries_upper))}; + const auto result_upper{sourcemeta::one::search( + payload_upper.data(), payload_upper.size(), "HELLO")}; + EXPECT_EQ(result_upper.size(), 1); + EXPECT_SEARCH_RESULT(result_upper, 0, "/foo/bar", "Hello World", "desc"); + + std::vector entries_mixed{ + {"/foo/bar", "Hello World", "desc"}}; + const auto payload_mixed{ + sourcemeta::one::make_search(std::move(entries_mixed))}; + const auto result_mixed{sourcemeta::one::search( + payload_mixed.data(), payload_mixed.size(), "hElLo")}; + EXPECT_EQ(result_mixed.size(), 1); + EXPECT_SEARCH_RESULT(result_mixed, 0, "/foo/bar", "Hello World", "desc"); +} + +TEST(Search, search_multiple_matches) { + std::vector entries{ + {"/schemas/address", "Address Schema", "For addresses"}, + {"/schemas/person", "Person Schema", "For people"}, + {"/schemas/email", "Email Schema", "For emails"}}; + const auto payload{sourcemeta::one::make_search(std::move(entries))}; + const auto result{ + sourcemeta::one::search(payload.data(), payload.size(), "schema")}; + EXPECT_EQ(result.size(), 3); + EXPECT_SEARCH_RESULT(result, 0, "/schemas/address", "Address Schema", + "For addresses"); + EXPECT_SEARCH_RESULT(result, 1, "/schemas/email", "Email Schema", + "For emails"); + EXPECT_SEARCH_RESULT(result, 2, "/schemas/person", "Person Schema", + "For people"); +} + +TEST(Search, search_limit_10) { + std::vector entries{ + {"/schemas/test0", "Test 0", ""}, {"/schemas/test1", "Test 1", ""}, + {"/schemas/test2", "Test 2", ""}, {"/schemas/test3", "Test 3", ""}, + {"/schemas/test4", "Test 4", ""}, {"/schemas/test5", "Test 5", ""}, + {"/schemas/test6", "Test 6", ""}, {"/schemas/test7", "Test 7", ""}, + {"/schemas/test8", "Test 8", ""}, {"/schemas/test9", "Test 9", ""}, + {"/schemas/test10", "Test 10", ""}, {"/schemas/test11", "Test 11", ""}, + {"/schemas/test12", "Test 12", ""}, {"/schemas/test13", "Test 13", ""}, + {"/schemas/test14", "Test 14", ""}}; + + const auto payload{sourcemeta::one::make_search(std::move(entries))}; + const auto result{ + sourcemeta::one::search(payload.data(), payload.size(), "test")}; + EXPECT_EQ(result.size(), 10); + EXPECT_SEARCH_RESULT(result, 0, "/schemas/test0", "Test 0", ""); + EXPECT_SEARCH_RESULT(result, 1, "/schemas/test1", "Test 1", ""); + EXPECT_SEARCH_RESULT(result, 2, "/schemas/test10", "Test 10", ""); + EXPECT_SEARCH_RESULT(result, 3, "/schemas/test11", "Test 11", ""); + EXPECT_SEARCH_RESULT(result, 4, "/schemas/test12", "Test 12", ""); + EXPECT_SEARCH_RESULT(result, 5, "/schemas/test13", "Test 13", ""); + EXPECT_SEARCH_RESULT(result, 6, "/schemas/test14", "Test 14", ""); + EXPECT_SEARCH_RESULT(result, 7, "/schemas/test2", "Test 2", ""); + EXPECT_SEARCH_RESULT(result, 8, "/schemas/test3", "Test 3", ""); + EXPECT_SEARCH_RESULT(result, 9, "/schemas/test4", "Test 4", ""); +} + +TEST(Search, search_round_trip_data_fidelity) { + std::vector entries{ + {"/a/b/c", "My Title", "My Description"}, + {"/x/y/z", "", "Only description"}, + {"/p/q", "Only title", ""}}; + const auto payload{sourcemeta::one::make_search(std::move(entries))}; + const auto result{ + sourcemeta::one::search(payload.data(), payload.size(), "/")}; + EXPECT_EQ(result.size(), 3); + EXPECT_SEARCH_RESULT(result, 0, "/a/b/c", "My Title", "My Description"); + EXPECT_SEARCH_RESULT(result, 1, "/p/q", "Only title", ""); + EXPECT_SEARCH_RESULT(result, 2, "/x/y/z", "", "Only description"); +} + +TEST(Search, search_single_entry_match) { + std::vector entries{{"/only", "One", "Entry"}}; + const auto payload{sourcemeta::one::make_search(std::move(entries))}; + const auto result{ + sourcemeta::one::search(payload.data(), payload.size(), "One")}; + EXPECT_EQ(result.size(), 1); + EXPECT_SEARCH_RESULT(result, 0, "/only", "One", "Entry"); +} + +TEST(Search, search_single_entry_no_match) { + std::vector entries{{"/only", "One", "Entry"}}; + const auto payload{sourcemeta::one::make_search(std::move(entries))}; + const auto result{ + sourcemeta::one::search(payload.data(), payload.size(), "nope")}; + EXPECT_EQ(result.size(), 0); +} + +TEST(Search, search_empty_title_and_description) { + std::vector entries{{"/path/only", "", ""}}; + const auto payload{sourcemeta::one::make_search(std::move(entries))}; + const auto result{ + sourcemeta::one::search(payload.data(), payload.size(), "path")}; + EXPECT_EQ(result.size(), 1); + EXPECT_SEARCH_RESULT(result, 0, "/path/only", "", ""); +}