diff --git a/mcpp.toml b/mcpp.toml index a1c3522..6577c1a 100644 --- a/mcpp.toml +++ b/mcpp.toml @@ -1,7 +1,7 @@ [package] namespace = "mcpplibs" name = "xpkg" -version = "0.0.45" +version = "0.0.46" description = "C++23 reference implementation of the xpkg V2 spec (multi-arch)" license = "Apache-2.0" repo = "https://github.com/openxlings/libxpkg" diff --git a/src/xpkg-index.cppm b/src/xpkg-index.cppm index 18f1a90..f9db75d 100644 --- a/src/xpkg-index.cppm +++ b/src/xpkg-index.cppm @@ -6,6 +6,94 @@ import std; export namespace mcpplibs::xpkg { +namespace index_detail_ { + +std::string namespace_of(std::string_view entryKey, const IndexEntry& entry) { + if (!entry.identity.namespaceName.empty()) { + return entry.identity.namespaceName; + } + auto colon = entryKey.find(':'); + if (colon == std::string_view::npos) return {}; + return std::string(entryKey.substr(0, colon)); +} + +std::string short_name_of(std::string_view entryKey, const IndexEntry& entry) { + if (!entry.identity.name.empty()) return entry.identity.name; + if (!entry.name.empty()) { + auto at = entry.name.find('@'); + return at == std::string::npos ? entry.name : entry.name.substr(0, at); + } + + auto colon = entryKey.find(':'); + auto base = colon == std::string_view::npos + ? entryKey + : entryKey.substr(colon + 1); + auto at = base.find('@'); + return std::string(at == std::string_view::npos ? base : base.substr(0, at)); +} + +std::string canonical_name_of(std::string_view entryKey, + const IndexEntry& entry) { + if (!entry.canonicalName.empty()) return entry.canonicalName; + auto at = entryKey.find('@'); + return std::string(at == std::string_view::npos + ? entryKey + : entryKey.substr(0, at)); +} + +std::vector +identity_entries(const PackageIndex& index, std::string_view canonicalName) { + if (auto it = index.identityEntries.find(std::string(canonicalName)); + it != index.identityEntries.end()) { + return it->second; + } + + std::vector candidates; + auto prefix = std::string(canonicalName) + "@"; + for (auto& [entryKey, entry] : index.entries) { + if (canonical_name_of(entryKey, entry) == canonicalName + || entryKey.starts_with(prefix)) { + candidates.push_back(entryKey); + } + } + std::ranges::sort(candidates); + return candidates; +} + +} // namespace index_detail_ + +std::vector +find_candidates(const PackageIndex& index, + std::string_view name, + std::optional namespaceName = std::nullopt) { + if (namespaceName) { + auto canonicalName = namespaceName->empty() + ? std::string(name) + : std::string(*namespaceName) + ":" + std::string(name); + auto entries = index_detail_::identity_entries(index, canonicalName); + return entries.empty() + ? std::vector {} + : std::vector { canonicalName }; + } + + if (auto it = index.shortNames.find(std::string(name)); + it != index.shortNames.end()) { + return it->second; + } + + std::vector candidates; + for (auto& [entryKey, entry] : index.entries) { + if (index_detail_::short_name_of(entryKey, entry) == name) { + candidates.push_back( + index_detail_::canonical_name_of(entryKey, entry)); + } + } + std::ranges::sort(candidates); + auto uniqueEnd = std::ranges::unique(candidates).begin(); + candidates.erase(uniqueEnd, candidates.end()); + return candidates; +} + // Fuzzy search: returns names of entries whose name or description contains // query (case-insensitive). Results are sorted. std::vector @@ -15,16 +103,22 @@ search(const PackageIndex& index, const std::string& query) { std::transform(q.begin(), q.end(), q.begin(), [](unsigned char c){ return std::tolower(c); }); - for (auto& [name, entry] : index.entries) { - std::string n = name; + for (auto& [entryKey, entry] : index.entries) { + std::string n = entryKey; std::transform(n.begin(), n.end(), n.begin(), [](unsigned char c){ return std::tolower(c); }); + std::string shortName = index_detail_::short_name_of(entryKey, entry); + std::transform(shortName.begin(), shortName.end(), shortName.begin(), + [](unsigned char c){ return std::tolower(c); }); std::string d = entry.description; std::transform(d.begin(), d.end(), d.begin(), [](unsigned char c){ return std::tolower(c); }); - if (n.find(q) != std::string::npos || d.find(q) != std::string::npos) - results.push_back(name); + if (n.find(q) != std::string::npos + || shortName.find(q) != std::string::npos + || d.find(q) != std::string::npos) { + results.push_back(entryKey); + } } std::sort(results.begin(), results.end()); return results; @@ -37,7 +131,16 @@ resolve(const PackageIndex& index, const std::string& name) { auto it = index.entries.find(name); if (it == index.entries.end()) return name; - return it->second.ref.empty() ? name : it->second.ref; + if (it->second.ref.empty()) return name; + + auto ref = it->second.ref; + auto refAt = ref.find('@'); + auto refHead = refAt == std::string::npos ? ref : ref.substr(0, refAt); + if (refHead.find(':') != std::string::npos) return ref; + + auto namespaceName = index_detail_::namespace_of(name, it->second); + if (namespaceName.empty()) return ref; + return namespaceName + ":" + ref; } // Find the best-matching entry for a base package name. @@ -49,13 +152,7 @@ match_version(const PackageIndex& index, const std::string& name) { if (index.entries.count(name)) return name; - // Collect all versioned entries whose name@ prefix matches - std::string prefix = name + "@"; - std::vector candidates; - for (auto& [key, entry] : index.entries) { - if (key.starts_with(prefix)) - candidates.push_back(key); - } + auto candidates = index_detail_::identity_entries(index, name); if (candidates.empty()) return std::nullopt; @@ -92,16 +189,47 @@ mutex_packages(const PackageIndex& index, const std::string& pkg_name) { PackageIndex merge(PackageIndex base, const PackageIndex& overlay, const std::string& namespace_ = "") { - for (auto& [name, entry] : overlay.entries) { - std::string key = namespace_.empty() ? name : namespace_ + "-x-" + name; + for (auto& [entryKey, entry] : overlay.entries) { auto e = entry; - e.name = key; - base.entries[key] = std::move(e); + auto shortName = index_detail_::short_name_of(entryKey, e); + if (e.identity.name.empty()) e.identity.name = shortName; + if (e.identity.namespaceName.empty()) { + e.identity.namespaceName = namespace_; + } + if (e.version.empty()) { + auto versionSeparator = entryKey.find('@'); + if (versionSeparator != std::string::npos) { + e.version = entryKey.substr(versionSeparator + 1); + } + } + auto canonicalName = e.identity.canonical_name(); + auto suffix = e.version.empty() ? std::string {} : "@" + e.version; + auto key = canonicalName + suffix; + e.canonicalName = canonicalName; + e.entryKey = key; + e.name = shortName; + if (base.entries.contains(key)) { + throw std::invalid_argument( + "duplicate package identity in index merge: '" + key + "'"); + } + base.entries.emplace(key, std::move(e)); + base.identityEntries[canonicalName].push_back(key); + base.shortNames[shortName].push_back(canonicalName); } for (auto& [gkey, gmembers] : overlay.mutex_groups) { auto& dest = base.mutex_groups[gkey]; dest.insert(dest.end(), gmembers.begin(), gmembers.end()); } + for (auto& [_, candidates] : base.identityEntries) { + std::ranges::sort(candidates); + auto uniqueEnd = std::ranges::unique(candidates).begin(); + candidates.erase(uniqueEnd, candidates.end()); + } + for (auto& [_, candidates] : base.shortNames) { + std::ranges::sort(candidates); + auto uniqueEnd = std::ranges::unique(candidates).begin(); + candidates.erase(uniqueEnd, candidates.end()); + } return base; } diff --git a/src/xpkg-loader.cppm b/src/xpkg-loader.cppm index 62e4870..7b93df2 100644 --- a/src/xpkg-loader.cppm +++ b/src/xpkg-loader.cppm @@ -682,7 +682,7 @@ load_package(const fs::path& pkg_path) { } std::expected -build_index(const fs::path& repo_dir, const std::string& namespace_ = "") { +build_index(const fs::path& repo_dir, const std::string& defaultNamespace = "") { PackageIndex index; auto pkgs_dir = repo_dir / "pkgs"; if (!fs::is_directory(pkgs_dir)) @@ -691,23 +691,63 @@ build_index(const fs::path& repo_dir, const std::string& namespace_ = "") { // Run pkgindex-build.lua if present (generates complete package files) loader_detail::run_pkgindex_build(repo_dir); + std::vector packagePaths; for (auto& letter_dir : fs::directory_iterator(pkgs_dir)) { if (!letter_dir.is_directory()) continue; for (auto& entry : fs::directory_iterator(letter_dir)) { if (entry.path().extension() != ".lua") continue; - auto result = load_package(entry.path()); - if (!result) continue; // skip malformed packages - auto& pkg = *result; - std::string key = (namespace_.empty() ? "" : namespace_ + "-x-") - + pkg.name; - IndexEntry ie; - ie.name = key; - ie.path = entry.path(); - ie.type = pkg.type; - ie.description = pkg.description; - index.entries[key] = std::move(ie); + packagePaths.push_back(entry.path().lexically_normal()); } } + std::ranges::sort(packagePaths); + + for (auto& packagePath : packagePaths) { + auto result = load_package(packagePath); + if (!result) continue; // skip malformed packages + auto& pkg = *result; + + PackageIdentity identity { + .namespaceName = pkg.namespace_.empty() + ? defaultNamespace + : pkg.namespace_, + .name = pkg.name, + }; + auto canonicalName = identity.canonical_name(); + + IndexEntry indexEntry; + indexEntry.identity = std::move(identity); + indexEntry.canonicalName = canonicalName; + indexEntry.entryKey = canonicalName; + indexEntry.name = pkg.name; + indexEntry.path = packagePath; + indexEntry.type = pkg.type; + indexEntry.description = pkg.description; + + auto existing = index.entries.find(indexEntry.entryKey); + if (existing != index.entries.end()) { + return std::unexpected(std::format( + "duplicate package identity '{}': '{}' conflicts with '{}'", + canonicalName, + existing->second.path.string(), + packagePath.string())); + } + + index.entries.emplace(indexEntry.entryKey, std::move(indexEntry)); + index.identityEntries[canonicalName].push_back(canonicalName); + index.shortNames[pkg.name].push_back(canonicalName); + } + + for (auto& [_, candidates] : index.identityEntries) { + std::ranges::sort(candidates); + auto uniqueEnd = std::ranges::unique(candidates).begin(); + candidates.erase(uniqueEnd, candidates.end()); + } + for (auto& [_, candidates] : index.shortNames) { + std::ranges::sort(candidates); + auto uniqueEnd = std::ranges::unique(candidates).begin(); + candidates.erase(uniqueEnd, candidates.end()); + } + return index; } diff --git a/src/xpkg.cppm b/src/xpkg.cppm index 848e4d4..57bf95a 100644 --- a/src/xpkg.cppm +++ b/src/xpkg.cppm @@ -131,8 +131,18 @@ struct Package { ~Package(); }; +struct PackageIdentity { + std::string namespaceName; + std::string name; + + std::string canonical_name() const; +}; + struct IndexEntry { - std::string name; // e.g. "vscode@1.85.0" + PackageIdentity identity; + std::string canonicalName; + std::string entryKey; + std::string name; // descriptor package.name std::string version; std::filesystem::path path; PackageType type = PackageType::Package; @@ -143,6 +153,8 @@ struct IndexEntry { struct PackageIndex { std::unordered_map entries; + std::unordered_map> identityEntries; + std::unordered_map> shortNames; std::unordered_map> mutex_groups; ~PackageIndex(); }; @@ -172,6 +184,11 @@ Package::~Package() = default; PackageIndex::~PackageIndex() = default; IndexRepos::~IndexRepos() = default; +std::string PackageIdentity::canonical_name() const { + if (namespaceName.empty()) return name; + return namespaceName + ":" + name; +} + std::string normalize_arch(std::string_view raw) { std::string s; s.reserve(raw.size()); diff --git a/tests/fixtures/pkgindex-duplicate/pkgs/a/implicit.demo.lua b/tests/fixtures/pkgindex-duplicate/pkgs/a/implicit.demo.lua new file mode 100644 index 0000000..1bb3f4e --- /dev/null +++ b/tests/fixtures/pkgindex-duplicate/pkgs/a/implicit.demo.lua @@ -0,0 +1,6 @@ +package = { + spec = "1", + name = "demo", + description = "implicit alpha demo", + type = "package", +} diff --git a/tests/fixtures/pkgindex-duplicate/pkgs/b/explicit.demo.lua b/tests/fixtures/pkgindex-duplicate/pkgs/b/explicit.demo.lua new file mode 100644 index 0000000..11f6023 --- /dev/null +++ b/tests/fixtures/pkgindex-duplicate/pkgs/b/explicit.demo.lua @@ -0,0 +1,7 @@ +package = { + spec = "1", + namespace = "alpha", + name = "demo", + description = "explicit alpha demo", + type = "package", +} diff --git a/tests/fixtures/pkgindex-namespaces/pkgs/a/alpha.demo.lua b/tests/fixtures/pkgindex-namespaces/pkgs/a/alpha.demo.lua new file mode 100644 index 0000000..13ea29b --- /dev/null +++ b/tests/fixtures/pkgindex-namespaces/pkgs/a/alpha.demo.lua @@ -0,0 +1,7 @@ +package = { + spec = "1", + namespace = "alpha", + name = "demo", + description = "alpha demo", + type = "package", +} diff --git a/tests/fixtures/pkgindex-namespaces/pkgs/b/beta.demo.lua b/tests/fixtures/pkgindex-namespaces/pkgs/b/beta.demo.lua new file mode 100644 index 0000000..2a2688c --- /dev/null +++ b/tests/fixtures/pkgindex-namespaces/pkgs/b/beta.demo.lua @@ -0,0 +1,7 @@ +package = { + spec = "1", + namespace = "beta", + name = "demo", + description = "beta demo", + type = "package", +} diff --git a/tests/test_index.cpp b/tests/test_index.cpp index d7c9826..424883e 100644 --- a/tests/test_index.cpp +++ b/tests/test_index.cpp @@ -65,6 +65,72 @@ TEST(IndexTest, Search_NoMatch) { EXPECT_TRUE(results.empty()); } +TEST(IndexTest, FindCandidates_ExplicitNamespaceIsExact) { + PackageIndex idx; + IndexEntry entry; + entry.identity = { + .namespaceName = "alpha", + .name = "demo", + }; + entry.canonicalName = "alpha:demo"; + entry.entryKey = "alpha:demo"; + entry.name = "demo"; + idx.entries[entry.entryKey] = entry; + idx.identityEntries[entry.canonicalName].push_back(entry.entryKey); + idx.shortNames["demo"] = {"alpha:demo", "beta:demo"}; + + auto result = find_candidates(idx, "demo", std::string_view { "alpha" }); + + EXPECT_EQ(result, (std::vector { "alpha:demo" })); +} + +TEST(IndexTest, FindCandidates_BareNameReturnsAllSortedIdentities) { + PackageIndex idx; + idx.shortNames["demo"] = {"alpha:demo", "beta:demo"}; + + auto result = find_candidates(idx, "demo"); + + EXPECT_EQ(result, + (std::vector { "alpha:demo", "beta:demo" })); +} + +TEST(IndexTest, Resolve_BareAliasRefInheritsCandidateNamespace) { + PackageIndex idx; + IndexEntry alias; + alias.identity = { + .namespaceName = "alpha", + .name = "tool", + }; + alias.canonicalName = "alpha:tool"; + alias.entryKey = "alpha:tool"; + alias.name = "tool"; + alias.ref = "compiler@1.0.0"; + idx.entries[alias.entryKey] = alias; + + EXPECT_EQ(resolve(idx, "alpha:tool"), "alpha:compiler@1.0.0"); +} + +TEST(IndexTest, MatchVersion_DoesNotCrossNamespaceIdentity) { + PackageIndex idx; + auto add = [&](std::string entryKey, std::string canonicalName, + std::string version, bool installed) { + IndexEntry entry; + entry.entryKey = entryKey; + entry.canonicalName = canonicalName; + entry.version = version; + entry.installed = installed; + idx.entries[entryKey] = entry; + idx.identityEntries[canonicalName].push_back(entryKey); + }; + add("alpha:demo@1.0.0", "alpha:demo", "1.0.0", false); + add("beta:demo@9.0.0", "beta:demo", "9.0.0", true); + + auto result = match_version(idx, "alpha:demo"); + + ASSERT_TRUE(result.has_value()); + EXPECT_EQ(*result, "alpha:demo@1.0.0"); +} + // ── resolve ─────────────────────────────────────────────────────────────── TEST(IndexTest, Resolve_FollowsAlias) { @@ -150,10 +216,22 @@ TEST(IndexTest, Merge_AppliesNamespace) { overlay.entries["cmake"] = e; auto merged = merge(base, overlay, "extra"); - EXPECT_GT(merged.entries.count("extra-x-cmake"), 0u); + EXPECT_GT(merged.entries.count("extra:cmake"), 0u); EXPECT_EQ(merged.entries.count("cmake"), 0u); } +TEST(IndexTest, Merge_PreservesLegacyVersionedEntryKey) { + PackageIndex base, overlay; + IndexEntry entry; + entry.name = "cmake@3.31.0"; + overlay.entries["cmake@3.31.0"] = entry; + + auto merged = merge(std::move(base), overlay, "extra"); + + EXPECT_TRUE(merged.entries.contains("extra:cmake@3.31.0")); + EXPECT_EQ(merged.entries.at("extra:cmake@3.31.0").version, "3.31.0"); +} + TEST(IndexTest, Merge_PreservesBase) { auto base = make_test_index(); PackageIndex overlay; @@ -161,6 +239,32 @@ TEST(IndexTest, Merge_PreservesBase) { EXPECT_EQ(merged.entries.size(), base.entries.size()); } +TEST(IndexTest, Merge_RejectsDuplicateCanonicalIdentity) { + PackageIndex base, overlay; + IndexEntry baseEntry; + baseEntry.identity = { + .namespaceName = "alpha", + .name = "demo", + }; + baseEntry.name = "demo"; + base.entries["alpha:demo"] = baseEntry; + + IndexEntry overlayEntry; + overlayEntry.identity = { + .namespaceName = "alpha", + .name = "demo", + }; + overlayEntry.name = "demo"; + overlay.entries["alpha:demo"] = overlayEntry; + + EXPECT_THROW( + { + auto ignored = merge(std::move(base), overlay); + static_cast(ignored); + }, + std::invalid_argument); +} + // ── set_installed ───────────────────────────────────────────────────────── TEST(IndexTest, SetInstalled_UpdatesFlag) { diff --git a/tests/test_loader.cpp b/tests/test_loader.cpp index bd6e3cf..856b494 100644 --- a/tests/test_loader.cpp +++ b/tests/test_loader.cpp @@ -35,6 +35,14 @@ static const fs::path PKGINDEX_BUILD{ std::string(normalize_pkgindex_macro(XPKG_STRINGIFY(XPKG_TEST_PKGINDEX_BUILD))) }; +static const fs::path PKGINDEX_NAMESPACES{ + PKGINDEX.parent_path() / "pkgindex-namespaces" +}; + +static const fs::path PKGINDEX_DUPLICATE{ + PKGINDEX.parent_path() / "pkgindex-duplicate" +}; + static fs::path copy_pkgindex_build_fixture(std::string_view test_name) { auto destination = fs::temp_directory_path() / ("libxpkg-loader-" + std::string(test_name)); @@ -82,6 +90,32 @@ TEST(LoaderTest, BuildIndex_ReturnsEntries) { EXPECT_GT(result->entries.count("hello"), 0u); } +TEST(LoaderTest, BuildIndex_PreservesSameNameAcrossNamespaces) { + auto result = build_index(PKGINDEX_NAMESPACES, "repo-default"); + ASSERT_TRUE(result.has_value()) << result.error(); + + ASSERT_EQ(result->entries.size(), 2u); + EXPECT_TRUE(result->entries.contains("alpha:demo")); + EXPECT_TRUE(result->entries.contains("beta:demo")); + EXPECT_EQ(result->entries.at("alpha:demo").identity.namespaceName, "alpha"); + EXPECT_EQ(result->entries.at("alpha:demo").identity.name, "demo"); + EXPECT_EQ(result->entries.at("beta:demo").identity.namespaceName, "beta"); + EXPECT_EQ(result->entries.at("beta:demo").identity.name, "demo"); + + ASSERT_TRUE(result->shortNames.contains("demo")); + EXPECT_EQ(result->shortNames.at("demo"), + (std::vector { "alpha:demo", "beta:demo" })); +} + +TEST(LoaderTest, BuildIndex_RejectsDuplicateEffectiveIdentity) { + auto result = build_index(PKGINDEX_DUPLICATE, "alpha"); + ASSERT_FALSE(result.has_value()); + EXPECT_NE(result.error().find("duplicate package identity 'alpha:demo'"), + std::string::npos); + EXPECT_NE(result.error().find("implicit.demo.lua"), std::string::npos); + EXPECT_NE(result.error().find("explicit.demo.lua"), std::string::npos); +} + TEST(LoaderTest, BuildIndex_PkgindexBuild_OsFiles) { // Tests that build_index works with pkgindex-build.lua that uses os.files() // This validates the C++ std::filesystem implementation works cross-platform