From 9c4e12084393591903112a3f932dab6533e4b64d Mon Sep 17 00:00:00 2001 From: wellwei Date: Sun, 23 Aug 2026 01:59:38 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20search/add=20=E6=98=BE=E7=A4=BA?= =?UTF-8?q?=E5=8C=85=E7=9A=84=E5=8F=AF=E7=94=A8=E7=89=88=E6=9C=AC=20(#487)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `mcpp search` 的命中行追加描述符 per-OS 版本表的并集:semver 降序、按键去重, 默认显示最新 3 个并以 ", ..." 标记截断,--all-versions 显示全部;描述符不可读 或未发布版本的包保持原两列输出——富化是尽力而为的展示,不是新的失败路径。 `mcpp add` 未命中时的跨命名空间建议从裸 FQN 升级为带版本: compat.eui-neo (0.5.6, 0.5.5, 0.5.3)。数据是白捡的:did-you-mean 扫描本就要 打开每个候选 .lua 读身份(#278),版本只是同一段文本的再一次遍历。build 失败 路径的同款提示同步升级,两条路径不说两套话。 实现: - manifest 新增 merge_xpkg_versions_desc:per-OS 键列表的并集视图。排序用 version_req 的 SemVer 解析;不可解析的键保留原文排在最后(#363 的教训: 任意的索引键无法从解析形态复原);等值异写("1.0"/"1.0.0")保持首见序。 - #278 的扫描由 scan_fqns_with_short_name 改为 scan_short_name_matches, 携带 fqn + versions;三条约束不变(只在已失败路径调用、结果只进错误文本/ 展示、空结果不是错误),版本列表受同一纪律约束——描述存在性,不做解析输入。 - cross_namespace_matches 更名 cross_namespace_suggestions 返回富记录; prepare.cppm 与 commands.cppm 两个调用点同步渲染。 - Fetcher 新增 versions_for_hit:search 命中的 ns:name → 描述符 → 版本并集; 无 ns 前缀或读不到描述符时返回空。 - search_packages 增加 --all-versions;cli 注册选项。 测试:merge 排序 8 例(含预发布/四段/不可解析键)、扫描携带版本 3 例、 versions_for_hit 3 例;e2e 162 断言 build 与 add 两侧建议都带版本 (acme.widget (1.0.0) / compat.gadget (2.0.0)),且被拒的 add 不写 manifest。 Closes #487 Refs #324 --- CHANGELOG.md | 25 +++++ src/build/prepare.cppm | 12 ++- src/cli.cppm | 2 + src/cli/cmd_registry.cppm | 2 +- src/manifest/xpkg.cppm | 43 ++++++++ src/pm/commands.cppm | 4 +- src/pm/index_management.cppm | 36 ++++++- src/pm/index_route.cppm | 17 ++-- src/pm/package_fetcher.cppm | 108 +++++++++++++++++---- tests/e2e/162_bare_name_namespace_scope.sh | 22 +++++ tests/unit/test_search_versions.cpp | 75 ++++++++++++++ tests/unit/test_short_name_scan.cpp | 98 +++++++++++++++++++ tests/unit/test_xpkg_versions_merge.cpp | 98 +++++++++++++++++++ 13 files changed, 505 insertions(+), 37 deletions(-) create mode 100644 tests/unit/test_search_versions.cpp create mode 100644 tests/unit/test_short_name_scan.cpp create mode 100644 tests/unit/test_xpkg_versions_merge.cpp diff --git a/CHANGELOG.md b/CHANGELOG.md index c15fa6a8..aec3d6b1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,31 @@ > 本文件追踪 `mcpp-community/mcpp` 公开仓的版本演进。 > 格式参考 [Keep a Changelog](https://keepachangelog.com/zh-CN/1.1.0/)。 +## [2026.8.22.1] — 在飞 + +### 新增 + +- **`mcpp search` 显示包的可用版本,`mcpp add` 的建议同样携带。** + + search 的命中行追加该包描述符 per-OS 版本表的并集:semver 降序、按键去重, + 默认显示最新 3 个并以 `, ...` 标记截断,`--all-versions` 显示全部。描述符不可读 + 或未发布任何版本的包保持原两列输出——富化是尽力而为的展示,不是新的失败路径。 + + ``` + $ mcpp search imgui + compat:imgui Dear ImGui immediate-mode GUI library core sources (1.92.8, 1.92.8-docking) + mcpplibs:imgui C++23 module package for Dear ImGui core and GLFW/OpenGL3 backends (0.0.6, 0.0.5, 0.0.4) + ``` + + `mcpp add` 未命中时的跨命名空间建议从裸 FQN 升级为带版本: + `compat.eui-neo (0.5.6, 0.5.5, 0.5.3)`。数据是白捡的——did-you-mean 扫描本就要 + 打开每个候选 `.lua` 读身份(#278),版本只是同一段文本的再一次遍历;排序复用 + SemVer 解析(`version_req`),不可解析的键保留原文排在最后(#363 的教训:任意的 + 索引键无法从解析形态复原)。build 失败路径的同款提示同步升级,两条路径不说两套话。 + + 排序与扫描各有单测钉住;e2e 162 断言 build 与 add 两侧的建议都带版本。 + (#487,#324 的遗留半边) + ## [2026.8.20.2] — 2026-08-20 ### 新增 diff --git a/src/build/prepare.cppm b/src/build/prepare.cppm index c6881e61..136d50ed 100644 --- a/src/build/prepare.cppm +++ b/src/build/prepare.cppm @@ -2646,17 +2646,19 @@ prepare_build(bool print_fingerprint, // T12 — did-you-mean. DIAGNOSTIC ONLY: the scan runs solely on // this already-failed path and its result never leaves the - // error string (see Fetcher::scan_fqns_with_short_name). + // error string (see Fetcher::scan_short_name_matches). std::string hint; if (auto cfg = get_cfg()) { - auto fqns = mcpp::pm::cross_namespace_matches( + auto suggestions = mcpp::pm::cross_namespace_suggestions( index_route(*cfg), candidates.front().shortName); - if (!fqns.empty()) { + if (!suggestions.empty()) { hint += "\n a package with this name exists under " "another namespace:"; - for (auto& fqn : fqns) hint += "\n " + fqn; + for (auto& suggestion : suggestions) + hint += "\n " + suggestion.fqn + + suggestion.versions_label(); if (auto suggested = mcpp::pm::parse_package_selector( - fqns.front()); suggested + suggestions.front().fqn); suggested && suggested->namespace_) { hint += std::format( "\n namespace omission means `{}`. write the " diff --git a/src/cli.cppm b/src/cli.cppm index 5e3818b7..dbd2e2e5 100644 --- a/src/cli.cppm +++ b/src/cli.cppm @@ -414,6 +414,8 @@ int run(int argc, char** argv) { .subcommand(cl::App("search") .description("Search packages in configured registries") .arg(cl::Arg("keyword").help("Search keyword (substring match)").required()) + .option(cl::Option("all-versions") + .help("List every published version instead of the latest few")) .action(wrap_rc(cmd_search))) .subcommand(cl::App("publish") .description("Publish package to default registry") diff --git a/src/cli/cmd_registry.cppm b/src/cli/cmd_registry.cppm index 158ab76e..fa0e1c4a 100644 --- a/src/cli/cmd_registry.cppm +++ b/src/cli/cmd_registry.cppm @@ -20,7 +20,7 @@ export int cmd_search(const mcpplibs::cmdline::ParsedArgs& parsed) { std::println(stderr, "error: `mcpp search` requires a keyword"); return 2; } - return mcpp::pm::search_packages(keyword); + return mcpp::pm::search_packages(keyword, parsed.is_flag_set("all-versions")); } export int cmd_index_list(const mcpplibs::cmdline::ParsedArgs& /*parsed*/) { diff --git a/src/manifest/xpkg.cppm b/src/manifest/xpkg.cppm index 13652b21..b1bcc253 100644 --- a/src/manifest/xpkg.cppm +++ b/src/manifest/xpkg.cppm @@ -9,6 +9,7 @@ import mcpp.pm.dep_spec; import mcpp.pm.dependency_selector; import mcpp.platform; import mcpp.platform.axis; +import mcpp.version_req; export namespace mcpp::manifest { @@ -53,6 +54,12 @@ list_xpkg_version_entries(std::string_view luaContent, std::vector list_xpkg_versions(std::string_view luaContent, const mcpp::platform::PlatformKey& platform); + +// Union of per-OS version-key lists, semver-descending, deduplicated — the +// display shape behind `mcpp add` suggestions and `mcpp search` (#487). See +// the definition for the ordering rules applied to unparsable keys. +std::vector merge_xpkg_versions_desc( + const std::vector>& perPlatform); // Extract the `namespace` field from an xpkg .lua's `package = { ... }` block. // Returns empty string if the field is absent (legacy descriptors). std::string extract_xpkg_namespace(std::string_view luaContent); @@ -1062,6 +1069,42 @@ list_xpkg_versions(std::string_view luaContent, return out; } +// #487 — the union view over a descriptor's per-OS version tables, for +// display in `mcpp add` suggestions and `mcpp search`. Sorted +// semver-descending and deduplicated by exact key. +// +// Ordering parses each key with the SemVer grammar; a key that does not parse +// keeps its original text (#363's lesson: an arbitrary index key cannot be +// reproduced from its parsed form), sorts AFTER every parsable key, and orders +// among itself lexicographically. Equal-Version keys written differently +// (e.g. "1.0" vs "1.0.0") keep their first-seen input order. +std::vector merge_xpkg_versions_desc( + const std::vector>& perPlatform) { + std::vector out; + for (auto& list : perPlatform) + for (auto& key : list) + if (std::find(out.begin(), out.end(), key) == out.end()) + out.push_back(key); + + auto parsed = [](const std::string& s) + -> std::optional { + auto v = mcpp::version_req::parse_version(s); + if (!v) return std::nullopt; + return std::move(*v); + }; + + std::stable_sort(out.begin(), out.end(), + [&](const std::string& a, const std::string& b) { + auto pa = parsed(a); + auto pb = parsed(b); + if (pa && pb) return *pb < *pa; // desc + if (pa) return true; // parsable first + if (pb) return false; + return a < b; // both opaque + }); + return out; +} + // Parses the `{ { glob = "...", cflags/cxxflags/asmflags/defines = {...} }, // ... }` array-of-tables shape shared by `[build]`-level `flags` and #253's // `features..flags` — one entry grammar, two anchoring keys. `ctxLabel` diff --git a/src/pm/commands.cppm b/src/pm/commands.cppm index 9397db7b..45eae600 100644 --- a/src/pm/commands.cppm +++ b/src/pm/commands.cppm @@ -277,9 +277,9 @@ inline int cmd_add(const mcpplibs::cmdline::ParsedArgs& parsed) { if (!found.hit && found.conclusive) { std::string hint; if (!selector.candidates.empty()) { - for (auto& fqn : mcpp::pm::cross_namespace_matches( + for (auto& suggestion : mcpp::pm::cross_namespace_suggestions( route, selector.candidates.front().shortName)) { - hint += "\n " + fqn; + hint += "\n " + suggestion.fqn + suggestion.versions_label(); } } if (!hint.empty()) { diff --git a/src/pm/index_management.cppm b/src/pm/index_management.cppm index 4f03fe51..b3d25350 100644 --- a/src/pm/index_management.cppm +++ b/src/pm/index_management.cppm @@ -21,8 +21,28 @@ import mcpp.platform.xlings; namespace mcpp::pm { -// `mcpp search `. -export int search_packages(const std::string& keyword) { +namespace { +// #487 — "1.0.0, 0.9.0" from an already-sorted-desc list. `keep` bounds the +// default view; --all-versions passes std::numeric_limits::max(). +std::string join_versions(const std::vector& versions, + std::size_t keep) { + std::string joined; + auto n = std::min(versions.size(), keep); + for (std::size_t i = 0; i < n; ++i) { + if (i) joined += ", "; + joined += versions[i]; + } + return joined; +} +} // namespace + +// `mcpp search [--all-versions]`. +// +// Each hit line appends the versions the package publishes, merged across +// its descriptor's per-OS tables (#487): the latest three by default, all of +// them under --all-versions. A hit with no readable descriptor prints exactly +// as before — enrichment is best-effort display, never a failure. +export int search_packages(const std::string& keyword, bool allVersions = false) { auto cfg = mcpp::config::load_or_init(/*quiet=*/false, mcpp::fetcher::make_bootstrap_progress_callback()); if (!cfg) { mcpp::ui::error(cfg.error().message); return 4; } @@ -51,9 +71,19 @@ export int search_packages(const std::string& keyword) { std::println("No packages match `{}`.", keyword); return 0; } + constexpr std::size_t kAllVersions = + std::numeric_limits::max(); std::println(""); for (auto& h : *hits) { - std::println(" {:<20} {}", h.name, h.description); + auto versions = f.versions_for_hit(h); + if (versions.empty()) { + std::println(" {:<20} {}", h.name, h.description); + } else { + std::println(" {:<20} {} ({})", h.name, h.description, + join_versions(versions, allVersions + ? kAllVersions + : 3)); + } } return 0; } diff --git a/src/pm/index_route.cppm b/src/pm/index_route.cppm index f175b2dd..823d0db3 100644 --- a/src/pm/index_route.cppm +++ b/src/pm/index_route.cppm @@ -84,10 +84,12 @@ struct Lookup { Lookup lookup_descriptor(const IndexRoute& route, const std::vector& candidates); -// Diagnostic only: fully-qualified names carrying `shortName` under some other -// namespace, for did-you-mean text on an already-failed lookup. -std::vector cross_namespace_matches(const IndexRoute& route, - std::string_view shortName); +// Diagnostic only: descriptors carrying `shortName` under some other +// namespace, for did-you-mean text on an already-failed lookup. Each record +// keeps the FQN plus the versions that package publishes (#487) — display +// only, never a resolution input (see Fetcher::scan_short_name_matches). +std::vector cross_namespace_suggestions( + const IndexRoute& route, std::string_view shortName); // The `[indices]` a project at `root` effectively sees, including the // workspace-root inheritance a member gets for free (#224). @@ -229,8 +231,9 @@ Lookup lookup_descriptor(const IndexRoute& route, return out; } -std::vector cross_namespace_matches(const IndexRoute& route, - std::string_view shortName) { +std::vector +cross_namespace_suggestions( + const IndexRoute& route, std::string_view shortName) { if (!route.cfg) return {}; mcpp::fetcher::Fetcher fetcher(*route.cfg); auto roots = fetcher.builtin_index_roots(); @@ -242,7 +245,7 @@ std::vector cross_namespace_matches(const IndexRoute& route, } } } - return mcpp::fetcher::Fetcher::scan_fqns_with_short_name(roots, shortName); + return mcpp::fetcher::Fetcher::scan_short_name_matches(roots, shortName); } IndexMap effective_indices(const std::filesystem::path& root) { diff --git a/src/pm/package_fetcher.cppm b/src/pm/package_fetcher.cppm index 3f9c010d..55a98b0a 100644 --- a/src/pm/package_fetcher.cppm +++ b/src/pm/package_fetcher.cppm @@ -16,6 +16,7 @@ import std; import mcpp.config; import mcpp.log; import mcpp.platform; // env::offline_mode +import mcpp.platform.axis; // TargetPlatform::for_lint_of — all-OS reads (#487) import mcpp.manifest; // xpkg_lua_identity_matches — descriptor identity gate import mcpp.pm.compat; import mcpp.pm.dep_spec; @@ -74,6 +75,12 @@ public: std::expected, CallError> search(std::string_view keyword); + // #487 — what the package behind a search hit publishes: its descriptor's + // per-OS version tables merged semver-descending. Empty when the hit + // carries no `ns:name` prefix or has no readable descriptor — display + // degrades to the bare line, the search itself never fails on this. + std::vector versions_for_hit(const SearchHit& hit) const; + struct PackageInstall { std::string name; std::string version; @@ -137,27 +144,53 @@ public: // ─── did-you-mean scan (#278) — DIAGNOSTIC ONLY ──────────────── // - // Enumerate every descriptor under the given index roots and return the - // fully-qualified names of those whose canonical SHORT name equals - // `shortName`. This is the only index-wide scan in mcpp, and it exists so a - // failed bare-name lookup can tell the user the exact line to write instead - // of leaving them to guess a namespace. + // Enumerate every descriptor under the given index roots and return those + // whose canonical SHORT name equals `shortName`, each with its FQN and the + // merged per-OS version list it publishes (#487), so a failed lookup can + // tell the user the exact line to write AND what versions exist behind it, + // instead of leaving them to guess a namespace or re-run xpkg parse. // // THREE CONSTRAINTS — this must never grow into an `IdentityIndex`: // 1. Call it ONLY on an already-failed path. Successful resolution must // not pay for a directory walk. - // 2. Its result feeds ERROR TEXT ONLY. It must never be written back into - // a DependencySpec, the lockfile, or the install layer — bare names - // resolving across arbitrary namespaces is precisely the - // reproducibility hazard this design rejects (design §3.2/§4.2). + // 2. Its result feeds ERROR TEXT / SEARCH DISPLAY ONLY. It must never be + // written back into a DependencySpec, the lockfile, or the install + // layer — bare names resolving across arbitrary namespaces is + // precisely the reproducibility hazard this design rejects (design + // §3.2/§4.2). The version list is under the same rule: it describes + // what exists, it is not a resolution input. // 3. An empty result is not an error. It downgrades the message, never // the exit code. - static std::vector - scan_fqns_with_short_name(const std::vector& indexRoots, - std::string_view shortName); + struct ShortNameMatch { + std::string fqn; // "ns.name" + std::vector versions; // semver-desc, may be empty + + // How many versions a suggestion line shows (#487). The full list + // stays one `xpkg parse` away; the hint only has to prove the + // package is alive. + static constexpr std::size_t kVersionsShown = 3; + + // " (1.0.0, 0.9.0, ...)" — empty when nothing is published/readable. + // The ASCII ellipsis marks truncation so the line cannot be read as + // "these three are all of them". + std::string versions_label() const { + if (versions.empty()) return {}; + std::string joined; + auto keep = std::min(versions.size(), kVersionsShown); + for (std::size_t i = 0; i < keep; ++i) { + if (i) joined += ", "; + joined += versions[i]; + } + if (keep < versions.size()) joined += ", ..."; + return " (" + joined + ")"; + } + }; + static std::vector + scan_short_name_matches(const std::vector& indexRoots, + std::string_view shortName); // The index roots a plain `mcpp build` would consult (global registry data - // dirs). Used to feed `scan_fqns_with_short_name` on the failure path. + // dirs). Used to feed `scan_short_name_matches` on the failure path. std::vector builtin_index_roots() const; // ─── Legacy overloads (COMPAT, remove in 1.0.0) ───────────── @@ -488,6 +521,27 @@ Fetcher::search(std::string_view keyword) { return hits; } +std::vector +Fetcher::versions_for_hit(const SearchHit& hit) const { + // Hit names arrive as `ns:name` from the xlings listing; a bare name has + // no routable identity, so there is nothing to read. + auto colon = hit.name.find(':'); + if (colon == std::string::npos || colon == 0 + || colon + 1 == hit.name.size()) + return {}; + auto ns = std::string_view(hit.name).substr(0, colon); + auto shortName = std::string_view(hit.name).substr(colon + 1); + auto lua = read_xpkg_lua(ns, shortName); + if (!lua) return {}; + + std::vector> perPlatform; + for (auto* platform : {"linux", "macosx", "windows"}) { + perPlatform.push_back(mcpp::manifest::list_xpkg_versions( + *lua, mcpp::platform::TargetPlatform::for_lint_of(platform))); + } + return mcpp::manifest::merge_xpkg_versions_desc(perPlatform); +} + std::expected, CallError> Fetcher::plan_install(const std::vector& targets) { auto args = make_targets_args(targets); @@ -869,12 +923,12 @@ Fetcher::install_path_from_project_data(const std::filesystem::path& projectDir, // resolution path. Deliberately tolerant: unreadable files are skipped, not // reported — a diagnostic helper must never turn into a second failure mode. -std::vector -Fetcher::scan_fqns_with_short_name( +std::vector +Fetcher::scan_short_name_matches( const std::vector& indexRoots, std::string_view shortName) { - std::vector hits; + std::vector hits; if (shortName.empty()) return hits; std::error_code ec; @@ -894,13 +948,29 @@ Fetcher::scan_fqns_with_short_name( auto id = mcpp::manifest::canonical_xpkg_identity_from_lua(content); if (id.name != shortName) continue; if (id.ns.empty()) continue; // bare upstream: already reachable - hits.push_back(id.ns + "." + id.name); + + // #487 — the descriptor is already in memory, so the version + // union costs one more pass over text we had to read anyway. + // Display-only: constraint 2 above applies to it verbatim. + std::vector> perPlatform; + for (auto* platform : {"linux", "macosx", "windows"}) { + perPlatform.push_back(mcpp::manifest::list_xpkg_versions( + content, + mcpp::platform::TargetPlatform::for_lint_of(platform))); + } + hits.push_back({id.ns + "." + id.name, + mcpp::manifest::merge_xpkg_versions_desc(perPlatform)}); } } } - std::sort(hits.begin(), hits.end()); - hits.erase(std::unique(hits.begin(), hits.end()), hits.end()); + std::sort(hits.begin(), hits.end(), + [](auto const& a, auto const& b) { return a.fqn < b.fqn; }); + hits.erase(std::unique(hits.begin(), hits.end(), + [](auto const& a, auto const& b) { + return a.fqn == b.fqn; + }), + hits.end()); return hits; } diff --git a/tests/e2e/162_bare_name_namespace_scope.sh b/tests/e2e/162_bare_name_namespace_scope.sh index da34604e..7bd3126f 100755 --- a/tests/e2e/162_bare_name_namespace_scope.sh +++ b/tests/e2e/162_bare_name_namespace_scope.sh @@ -80,6 +80,10 @@ grep -q "tried:" bare.out || { cat bare.out; exit 1; } # ── 2. did-you-mean names the real package and both spellings ─────── grep -q "acme.widget" bare.out || { echo "FAIL: expected did-you-mean to name acme.widget"; cat bare.out; exit 1; } +# #487: the hint must also carry what the suggested package publishes — +# the fixture's xpm table lists exactly one version per OS. +grep -q "acme.widget (1.0.0)" bare.out || { + echo "FAIL: expected did-you-mean to show acme.widget's version"; cat bare.out; exit 1; } grep -q "\[dependencies.acme\]" bare.out || { echo "FAIL: expected the sub-table spelling in the hint"; cat bare.out; exit 1; } @@ -158,4 +162,22 @@ grep -q "no package found for exact selector" explicit-default.out || { echo "FAIL: expected the explicit not-found error" cat explicit-default.out; exit 1; } +# ── 7. `mcpp add` did-you-mean carries versions too (#487) ────────── +# The same suggestion text serves the build path (sections 1-2) and the add +# path. Requesting a gadget under `acme` — a readable index that does not +# serve it — must fail AND point at compat.gadget with its published version, +# straight from the fixture's xpm table. +write_manifest '' +if MCPP_OFFLINE=1 "$MCPP" add acme.gadget@9.9.9 > add-hint.out 2>&1; then + echo "FAIL: adding a package the acme index does not serve must fail" + cat add-hint.out; exit 1 +fi +grep -q "compat.gadget (2.0.0)" add-hint.out || { + echo "FAIL: add hint must name compat.gadget with its published version" + cat add-hint.out; exit 1; } +# And the manifest must NOT have been mutated by the failed add. +if grep -q "gadget" mcpp.toml; then + echo "FAIL: a rejected add must not write the dependency"; cat mcpp.toml; exit 1 +fi + echo "PASS 162_bare_name_namespace_scope" diff --git a/tests/unit/test_search_versions.cpp b/tests/unit/test_search_versions.cpp new file mode 100644 index 00000000..9852e5aa --- /dev/null +++ b/tests/unit/test_search_versions.cpp @@ -0,0 +1,75 @@ +// versions_for_hit — the per-hit enrichment behind `mcpp search` (#487). +// A hit's `ns:name` must route to its descriptor and yield the merged +// semver-descending version union; anything unreadable degrades to empty +// without failing the search. + +#include + +import std; +import mcpp.config; +import mcpp.pm.package_fetcher; + +namespace { + +struct TempDir { + std::filesystem::path path = std::filesystem::temp_directory_path() + / std::format("mcpp-search-versions-{}", + std::chrono::steady_clock::now() + .time_since_epoch().count()); + + TempDir() { std::filesystem::create_directories(path); } + + ~TempDir() { + std::error_code ec; + std::filesystem::remove_all(path, ec); + } +}; + +mcpp::pm::Fetcher::SearchHit hit(std::string name) { + return {"mcpplibs", std::move(name), "description"}; +} + +TEST(SearchVersions, MergesPerOsTablesOfTheHitsDescriptor) { + TempDir temp; + // Layout rule: the reader derives the letter dir from the candidate + // FILENAME's first char — "compat.gadget.lua" lives under pkgs/c/. + auto pkgs = temp.path / "data" / "mcpplibs" / "pkgs" / "c"; + std::filesystem::create_directories(pkgs); + std::ofstream out(pkgs / "compat.gadget.lua"); + out << R"(package = { + spec = "1", namespace = "compat", name = "gadget", + xpm = { linux = { ["1.0.0"] = { url = "u", sha256 = "h" }, + ["2.0.0"] = { url = "u", sha256 = "h" } }, + windows = { ["2.0.0"] = { url = "u", sha256 = "h" } } }, +} +)"; + out.close(); // the reader runs while this test body is still alive + + mcpp::config::GlobalConfig cfg{}; + cfg.registryDir = temp.path; + + mcpp::pm::Fetcher f(cfg); + auto versions = f.versions_for_hit(hit("compat:gadget")); + ASSERT_EQ(versions.size(), 2u); + EXPECT_EQ(versions[0], "2.0.0"); + EXPECT_EQ(versions[1], "1.0.0"); +} + +TEST(SearchVersions, BareNameWithoutNamespaceCarriesNoVersions) { + mcpp::config::GlobalConfig cfg{}; + cfg.registryDir = std::filesystem::temp_directory_path() + / "mcpp-search-versions-no-registry"; + mcpp::pm::Fetcher f(cfg); + EXPECT_TRUE(f.versions_for_hit(hit("gadget")).empty()); +} + +TEST(SearchVersions, UnreadableDescriptorDegradesToEmpty) { + TempDir temp; + mcpp::config::GlobalConfig cfg{}; + cfg.registryDir = temp.path; // data/ exists nowhere: nothing readable + + mcpp::pm::Fetcher f(cfg); + EXPECT_TRUE(f.versions_for_hit(hit("compat:ghost")).empty()); +} + +} // namespace diff --git a/tests/unit/test_short_name_scan.cpp b/tests/unit/test_short_name_scan.cpp new file mode 100644 index 00000000..11f73d2b --- /dev/null +++ b/tests/unit/test_short_name_scan.cpp @@ -0,0 +1,98 @@ +// scan_short_name_matches — the did-you-mean index walk (#278), now carrying +// each match's published versions (#487) so a failed `mcpp add` can show what +// the suggested packages actually offer. Still DIAGNOSTIC ONLY: nothing here +// feeds resolution, the lockfile, or the install layer. + +#include + +import std; +import mcpp.pm.package_fetcher; + +namespace { + +struct TempDir { + std::filesystem::path path = std::filesystem::temp_directory_path() + / std::format("mcpp-short-name-scan-{}", + std::chrono::steady_clock::now() + .time_since_epoch().count()); + + TempDir() { std::filesystem::create_directories(path); } + + ~TempDir() { + std::error_code ec; + std::filesystem::remove_all(path, ec); + } +}; + +void write_descriptor(const std::filesystem::path& root, + const std::string& letterAndFile, + std::string_view lua) { + auto file = root / "pkgs" / letterAndFile.substr(0, 1) + / (letterAndFile.substr(2) + ".lua"); + std::filesystem::create_directories(file.parent_path()); + std::ofstream out(file); + out << lua; +} + +std::string descriptor_with_versions(std::string_view ns, + std::string_view name, + std::string_view xpmTable) { + return std::format( + "package = {{\n" + " spec = \"1\", namespace = \"{}\", name = \"{}\",\n" + " {}\n" + "}}\n", + ns, name, xpmTable); +} + +TEST(ShortNameScan, CarriesMergedDescendingVersionsPerMatch) { + TempDir temp; + write_descriptor(temp.path, "a.acme.widget.lua", descriptor_with_versions( + "acme", "acme.widget", + "xpm = { linux = { [\"1.0.0\"] = { url = \"u\", sha256 = \"h\" }," + " [\"0.9.0\"] = { url = \"u\", sha256 = \"h\" } }," + " macosx = { [\"1.0.0\"] = { url = \"u\", sha256 = \"h\" } } }")); + write_descriptor(temp.path, "c.compat.widget.lua", descriptor_with_versions( + "compat", "gadget", + "xpm = { linux = { [\"2.0.0\"] = { url = \"u\", sha256 = \"h\" } } }")); + + auto matches = mcpp::pm::Fetcher::scan_short_name_matches( + {temp.path}, "widget"); + + // Sorted by fqn; each record keeps its fqn AND its version union. + ASSERT_EQ(matches.size(), 1u); + EXPECT_EQ(matches[0].fqn, "acme.widget"); + ASSERT_EQ(matches[0].versions.size(), 2u); + EXPECT_EQ(matches[0].versions[0], "1.0.0"); + EXPECT_EQ(matches[0].versions[1], "0.9.0"); + + // The short name is matched on the SHORT component only: compat/gadget + // must not appear for "widget". +} + +TEST(ShortNameScan, DescriptorWithoutXpmYieldsEmptyVersions) { + TempDir temp; + write_descriptor(temp.path, "a.acme.bare.lua", descriptor_with_versions( + "acme", "acme.bare", "mcpp = { schema = \"0.1\" }")); + + auto matches = mcpp::pm::Fetcher::scan_short_name_matches( + {temp.path}, "bare"); + + ASSERT_EQ(matches.size(), 1u); + EXPECT_EQ(matches[0].fqn, "acme.bare"); + EXPECT_TRUE(matches[0].versions.empty()); +} + +TEST(ShortNameScan, EmptyResultOnNoMatchIsNotAnError) { + TempDir temp; + write_descriptor(temp.path, "a.acme.widget.lua", descriptor_with_versions( + "acme", "acme.widget", + "xpm = { linux = { [\"1.0.0\"] = {} } }")); + + auto matches = mcpp::pm::Fetcher::scan_short_name_matches( + {temp.path}, "nosuchthing"); + + EXPECT_TRUE(matches.empty()); +} + +} // namespace diff --git a/tests/unit/test_xpkg_versions_merge.cpp b/tests/unit/test_xpkg_versions_merge.cpp new file mode 100644 index 00000000..e3197c18 --- /dev/null +++ b/tests/unit/test_xpkg_versions_merge.cpp @@ -0,0 +1,98 @@ +// merge_xpkg_versions_desc — the union view over a descriptor's per-OS +// version tables (#487). Ordering must follow SemVer on the PARSED value while +// display keeps the ORIGINAL key byte-for-byte (#363's lesson: an arbitrary +// index key cannot be reproduced from its parsed form). + +#include + +import std; +import mcpp.manifest; + +namespace { + +TEST(XpkgVersionsMerge, OrdersSemverDescendingAcrossPlatforms) { + std::vector> perPlatform = { + {"1.0.0"}, + {"0.9.0", "1.2.0"}, + }; + auto merged = mcpp::manifest::merge_xpkg_versions_desc(perPlatform); + ASSERT_EQ(merged.size(), 3u); + EXPECT_EQ(merged[0], "1.2.0"); + EXPECT_EQ(merged[1], "1.0.0"); + EXPECT_EQ(merged[2], "0.9.0"); +} + +TEST(XpkgVersionsMerge, DeduplicatesKeysSharedAcrossPlatforms) { + std::vector> perPlatform = { + {"1.0.0", "0.9.0"}, + {"1.0.0", "0.8.0"}, + {"1.0.0"}, + }; + auto merged = mcpp::manifest::merge_xpkg_versions_desc(perPlatform); + ASSERT_EQ(merged.size(), 3u); + EXPECT_EQ(merged[0], "1.0.0"); + EXPECT_EQ(merged[1], "0.9.0"); + EXPECT_EQ(merged[2], "0.8.0"); +} + +TEST(XpkgVersionsMerge, ReleaseOutranksPrereleaseOfSameNumbers) { + std::vector> perPlatform = { + {"1.0.0-rc.1", "1.0.0"}, + }; + auto merged = mcpp::manifest::merge_xpkg_versions_desc(perPlatform); + ASSERT_EQ(merged.size(), 2u); + EXPECT_EQ(merged[0], "1.0.0"); + EXPECT_EQ(merged[1], "1.0.0-rc.1"); +} + +TEST(XpkgVersionsMerge, PrereleaseNumericIdentifiersCompareNumerically) { + std::vector> perPlatform = { + {"1.0.0-rc.2", "1.0.0-rc.10"}, + }; + auto merged = mcpp::manifest::merge_xpkg_versions_desc(perPlatform); + ASSERT_EQ(merged.size(), 2u); + EXPECT_EQ(merged[0], "1.0.0-rc.10"); + EXPECT_EQ(merged[1], "1.0.0-rc.2"); +} + +TEST(XpkgVersionsMerge, FourthSegmentComparesNumerically) { + std::vector> perPlatform = { + {"1.2.3.10", "1.2.3.9"}, + }; + auto merged = mcpp::manifest::merge_xpkg_versions_desc(perPlatform); + ASSERT_EQ(merged.size(), 2u); + EXPECT_EQ(merged[0], "1.2.3.10"); + EXPECT_EQ(merged[1], "1.2.3.9"); +} + +TEST(XpkgVersionsMerge, KeepsOriginalKeyForSuffixedBuilds) { + // compat.glad publishes keys like "0.0.0-651a425": the suffix rides along + // as pre-release identifiers for ordering, but what reaches the screen is + // the key as written. + std::vector> perPlatform = { + {"0.0.0-651a425", "0.0.1"}, + }; + auto merged = mcpp::manifest::merge_xpkg_versions_desc(perPlatform); + ASSERT_EQ(merged.size(), 2u); + EXPECT_EQ(merged[0], "0.0.1"); + EXPECT_EQ(merged[1], "0.0.0-651a425"); +} + +TEST(XpkgVersionsMerge, UnparsableKeysSortLastByNameAndKeepTheirText) { + std::vector> perPlatform = { + {"unknown", "1.0.0", "beta-thing"}, + }; + auto merged = mcpp::manifest::merge_xpkg_versions_desc(perPlatform); + ASSERT_EQ(merged.size(), 3u); + EXPECT_EQ(merged[0], "1.0.0"); + EXPECT_EQ(merged[1], "beta-thing"); + EXPECT_EQ(merged[2], "unknown"); +} + +TEST(XpkgVersionsMerge, EmptyInputsYieldEmptyOutput) { + EXPECT_TRUE(mcpp::manifest::merge_xpkg_versions_desc({}).empty()); + EXPECT_TRUE( + mcpp::manifest::merge_xpkg_versions_desc({{}, {}}).empty()); +} + +} // namespace