From 0c1d1b727dda90059d953e94dd59312813380c99 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Mika=C3=ABl=20Capelle?= <capelle.mikael@gmail.com>
Date: Fri, 9 Aug 2024 11:14:24 +0200
Subject: [PATCH] Implement requirements for extension.

---
 include/uibase/exceptions.h                   |   2 +
 include/uibase/extensions/extension.h         |  72 ++++----
 include/uibase/extensions/requirements.h      |  28 ++-
 include/uibase/extensions/theme.h             |   2 +-
 include/uibase/extensions/translation.h       |   2 +-
 .../uibase/extensions/versionconstraints.h    |   7 +-
 include/uibase/versioning.h                   |   3 +-
 src/CMakeLists.txt                            |   1 -
 src/extension.cpp                             | 138 +++++++-------
 src/requirements.cpp                          | 170 ++++++++++++++++--
 src/uibase_en.ts                              |   1 -
 src/versionconstraints.cpp                    |  16 +-
 tests/CMakeLists.txt                          |   1 +
 tests/test_extensions.cpp                     |  54 ++++++
 14 files changed, 372 insertions(+), 125 deletions(-)
 create mode 100644 tests/test_extensions.cpp

diff --git a/include/uibase/exceptions.h b/include/uibase/exceptions.h
index c89a4833..b6425675 100644
--- a/include/uibase/exceptions.h
+++ b/include/uibase/exceptions.h
@@ -20,6 +20,8 @@ namespace MOBase
 class QDLLEXPORT Exception : public std::exception
 {
 public:
+  Exception(const char* text) : m_Message(text) {}
+  Exception(const std::string& text) : m_Message(QByteArray::fromStdString(text)) {}
   Exception(const QString& text) : m_Message(text.toUtf8()) {}
 
   virtual const char* what() const noexcept override { return m_Message.constData(); }
diff --git a/include/uibase/extensions/extension.h b/include/uibase/extensions/extension.h
index 83cb6633..c26f2bad 100644
--- a/include/uibase/extensions/extension.h
+++ b/include/uibase/extensions/extension.h
@@ -8,20 +8,25 @@
 #include <QJsonObject>
 #include <QTranslator>
 
-#include "dllimport.h"
-#include "iplugingame.h"
+#include "../dllimport.h"
+#include "../iplugingame.h"
+#include "../versioning.h"
 #include "requirements.h"
 #include "theme.h"
 #include "translation.h"
-#include "versioninfo.h"
 
 namespace MOBase
 {
 class IExtension;
 
+class InvalidExtensionMetaDataException : public Exception
+{
+public:
+  using Exception::Exception;
+};
+
 enum class ExtensionType
 {
-  INVALID,
   THEME,
   TRANSLATION,
   PLUGIN,
@@ -48,10 +53,6 @@ class QDLLEXPORT ExtensionContributor
 class QDLLEXPORT ExtensionMetaData
 {
 public:
-  // check if that metadata object is valid
-  //
-  bool isValid() const;
-
   // retrieve the identifier of the extension
   //
   const auto& identifier() const { return m_Identifier; }
@@ -72,7 +73,7 @@ class QDLLEXPORT ExtensionMetaData
   //
   auto type() const { return m_Type; }
 
-  // retrieve the description of the extension.
+  // retrieve the description of the extension
   //
   auto description() const { return localized(m_Description); }
 
@@ -80,10 +81,14 @@ class QDLLEXPORT ExtensionMetaData
   //
   const auto& icon() const { return m_Icon; }
 
-  // retrieve the version of the extension.
+  // retrieve the version of the extension
   //
   const auto& version() const { return m_Version; }
 
+  // retrieve the requirements of the extension
+  //
+  const auto& requirements() const { return m_Requirements; }
+
   // retrieve the raw JSON metadata, this is mostly useful for specific extension type
   // to extract custom parts
   //
@@ -92,8 +97,8 @@ class QDLLEXPORT ExtensionMetaData
   // retrieve the content objects of the extension
   QJsonObject content() const;
 
-private:
-  QString localized(QString const& value) const;
+protected:
+  ExtensionMetaData(std::filesystem::path const& path, const QJsonObject& jsonData);
 
 private:
   friend class ExtensionFactory;
@@ -101,9 +106,7 @@ class QDLLEXPORT ExtensionMetaData
   constexpr static const char* DEFAULT_TRANSLATIONS_FOLDER = "translations";
   constexpr static const char* DEFAULT_STYLESHEET_PATH     = "stylesheets";
 
-  ExtensionType parseType(QString const& value) const;
-
-  ExtensionMetaData(std::filesystem::path const& path, const QJsonObject& jsonData);
+  std::optional<ExtensionType> parseType(QString const& value) const;
 
 private:
   QJsonObject m_JsonData;
@@ -116,10 +119,13 @@ class QDLLEXPORT ExtensionMetaData
   ExtensionType m_Type;
   QString m_Description;
   QIcon m_Icon;
-  VersionInfo m_Version;
+  Version m_Version;
+  std::vector<ExtensionRequirement> m_Requirements;
 
   std::filesystem::path m_TranslationFilesPrefix;
   std::filesystem::path m_StyleSheetFilePath;
+
+  QString localized(QString const& value) const;
 };
 
 class QDLLEXPORT IExtension
@@ -133,16 +139,12 @@ class QDLLEXPORT IExtension
   //
   const auto& metadata() const { return m_MetaData; }
 
-  // retrieve the requirements of the extension
-  //
-  const auto& requirements() const { return m_Requirements; }
-
 public:
   virtual ~IExtension() {}
   IExtension& operator=(const IExtension&) = delete;
 
 protected:
-  IExtension(std::filesystem::path path, ExtensionMetaData metadata);
+  IExtension(std::filesystem::path const& path, ExtensionMetaData&& metadata);
 
 public:
   IExtension(const IExtension&) = default;
@@ -150,7 +152,6 @@ class QDLLEXPORT IExtension
 private:
   std::filesystem::path m_Path;
   ExtensionMetaData m_MetaData;
-  std::vector<ExtensionRequirement> m_Requirements;
 };
 
 // factory for extensions
@@ -161,13 +162,14 @@ class QDLLEXPORT ExtensionFactory
   // load an extension from the given directory, return a null-pointer if the extension
   // could not be load
   //
-  static std::unique_ptr<IExtension> loadExtension(std::filesystem::path directory);
+  static std::unique_ptr<IExtension>
+  loadExtension(std::filesystem::path const& directory);
 
 private:
   // load an extension from the given directory
   //
-  static std::unique_ptr<IExtension> loadExtension(std::filesystem::path directory,
-                                                   ExtensionMetaData metadata);
+  static std::unique_ptr<IExtension>
+  loadExtension(std::filesystem::path const& directory, ExtensionMetaData&& metadata);
 };
 
 // theme extension that provides one or more base themes for MO2
@@ -180,12 +182,12 @@ class QDLLEXPORT ThemeExtension : public IExtension
   const auto& themes() const { return m_Themes; }
 
 private:
-  ThemeExtension(std::filesystem::path path, ExtensionMetaData metadata,
+  ThemeExtension(std::filesystem::path const& path, ExtensionMetaData&& metadata,
                  std::vector<std::shared_ptr<const Theme>> themes);
 
   friend class ExtensionFactory;
-  static std::unique_ptr<ThemeExtension> loadExtension(std::filesystem::path path,
-                                                       ExtensionMetaData metadata);
+  static std::unique_ptr<ThemeExtension>
+  loadExtension(std::filesystem::path const& path, ExtensionMetaData&& metadata);
 
   static std::shared_ptr<const Theme>
   parseTheme(std::filesystem::path const& extensionFolder, const QString& identifier,
@@ -205,12 +207,12 @@ class QDLLEXPORT TranslationExtension : public IExtension
   const auto& translations() const { return m_Translations; }
 
 private:
-  TranslationExtension(std::filesystem::path path, ExtensionMetaData metadata,
+  TranslationExtension(std::filesystem::path const& path, ExtensionMetaData&& metadata,
                        std::vector<std::shared_ptr<const Translation>> translations);
 
   friend class ExtensionFactory;
   static std::unique_ptr<TranslationExtension>
-  loadExtension(std::filesystem::path path, ExtensionMetaData metadata);
+  loadExtension(std::filesystem::path const& path, ExtensionMetaData&& metadata);
 
   static std::shared_ptr<const Translation>
   parseTranslation(std::filesystem::path const& extensionFolder,
@@ -241,14 +243,14 @@ class QDLLEXPORT PluginExtension : public IExtension
 
 protected:
   PluginExtension(
-      std::filesystem::path path, ExtensionMetaData metadata, bool autodetect,
+      std::filesystem::path const& path, ExtensionMetaData&& metadata, bool autodetect,
       std::map<std::string, std::filesystem::path> plugins,
       std::vector<std::shared_ptr<const ThemeAddition>> themeAdditions,
       std::vector<std::shared_ptr<const TranslationAddition>> translationAdditions);
 
   friend class ExtensionFactory;
-  static std::unique_ptr<PluginExtension> loadExtension(std::filesystem::path path,
-                                                        ExtensionMetaData metadata);
+  static std::unique_ptr<PluginExtension>
+  loadExtension(std::filesystem::path const& path, ExtensionMetaData&& metadata);
 
 private:
   // auto-detect plugins
@@ -271,8 +273,8 @@ class QDLLEXPORT GameExtension : public PluginExtension
   GameExtension(PluginExtension&& pluginExtension);
 
   friend class ExtensionFactory;
-  static std::unique_ptr<GameExtension> loadExtension(std::filesystem::path path,
-                                                      ExtensionMetaData metadata);
+  static std::unique_ptr<GameExtension> loadExtension(std::filesystem::path const& path,
+                                                      ExtensionMetaData&& metadata);
 };
 
 }  // namespace MOBase
diff --git a/include/uibase/extensions/requirements.h b/include/uibase/extensions/requirements.h
index 1d71d52d..2a10d5de 100644
--- a/include/uibase/extensions/requirements.h
+++ b/include/uibase/extensions/requirements.h
@@ -6,13 +6,26 @@
 #include <QJsonValue>
 #include <QString>
 
-#include "dllimport.h"
+#include "../dllimport.h"
+#include "../exceptions.h"
 
 namespace MOBase
 {
 class IOrganizer;
 class ExtensionMetaData;
 
+class InvalidRequirementException : public Exception
+{
+public:
+  using Exception::Exception;
+};
+
+class InvalidRequirementsException : public Exception
+{
+public:
+  using Exception::Exception;
+};
+
 class ExtensionRequirementImpl;
 
 // extension requirements
@@ -44,13 +57,22 @@ class QDLLEXPORT ExtensionRequirement
   //
   bool check(IOrganizer* organizer) const;
 
+  // retrieve the type of this extension
+  //
+  Type type() const;
+
+  // retrieve a textual representation of this requirement, e.g. "ModOrganizer 2.5.4"
+  // for a requirement that requires MO2 2.5.4
+  //
+  QString string() const;
+
+public:
   ~ExtensionRequirement();
 
 private:
   friend class ExtensionRequirementFactory;
 
   ExtensionRequirement(std::shared_ptr<ExtensionRequirementImpl> impl);
-
   std::shared_ptr<ExtensionRequirementImpl> m_Impl;
 };
 
@@ -62,7 +84,7 @@ class QDLLEXPORT ExtensionRequirementFactory
   // extract requirements from the given metadata
   //
   static std::vector<ExtensionRequirement>
-  parseRequirements(const ExtensionMetaData& metadata);
+  parseRequirements(const QJsonValue& json_requirements);
 
 private:
 };
diff --git a/include/uibase/extensions/theme.h b/include/uibase/extensions/theme.h
index 624f1a01..1802ca14 100644
--- a/include/uibase/extensions/theme.h
+++ b/include/uibase/extensions/theme.h
@@ -6,7 +6,7 @@
 
 #include <QRegularExpression>
 
-#include "dllimport.h"
+#include "../dllimport.h"
 
 namespace MOBase
 {
diff --git a/include/uibase/extensions/translation.h b/include/uibase/extensions/translation.h
index b1dd66f9..dafb3371 100644
--- a/include/uibase/extensions/translation.h
+++ b/include/uibase/extensions/translation.h
@@ -4,7 +4,7 @@
 #include <filesystem>
 #include <string>
 
-#include "dllimport.h"
+#include "../dllimport.h"
 
 namespace MOBase
 {
diff --git a/include/uibase/extensions/versionconstraints.h b/include/uibase/extensions/versionconstraints.h
index a66cbb59..61791fcb 100644
--- a/include/uibase/extensions/versionconstraints.h
+++ b/include/uibase/extensions/versionconstraints.h
@@ -57,13 +57,18 @@ class QDLLEXPORT VersionConstraints
 public:
   // construct a set of constraints
   //
-  VersionConstraints(std::vector<VersionConstraint> constraints);
+  VersionConstraints(QString const& repr, std::vector<VersionConstraint> constraints);
 
   // check if the given version matches the set of constraints
   //
   bool matches(Version const& version) const;
 
+  // retrieve a string representation of this set of constraints
+  //
+  auto string() const { return m_Repr; }
+
 private:
+  QString m_Repr;
   std::vector<VersionConstraint> m_Constraints;
 };
 
diff --git a/include/uibase/versioning.h b/include/uibase/versioning.h
index c4f95419..ab3d7b14 100644
--- a/include/uibase/versioning.h
+++ b/include/uibase/versioning.h
@@ -76,7 +76,8 @@ class QDLLEXPORT Version
 
     // do not add metadata even if present
     //
-    NoMetadata = 0b1000
+    NoMetadata = 0b1000,
+
   };
   Q_DECLARE_FLAGS(FormatModes, FormatMode);
 
diff --git a/src/CMakeLists.txt b/src/CMakeLists.txt
index 8e3379ec..ebd77919 100644
--- a/src/CMakeLists.txt
+++ b/src/CMakeLists.txt
@@ -117,7 +117,6 @@ mo2_target_sources(uibase
 	errorcodes.cpp
 	eventfilter.cpp
 	executableinfo.cpp
-	extension.cpp
 	filesystemutilities.cpp
 	guessedvalue.cpp
 	json.cpp
diff --git a/src/extension.cpp b/src/extension.cpp
index 0b2f83eb..761db63a 100644
--- a/src/extension.cpp
+++ b/src/extension.cpp
@@ -65,15 +65,39 @@ ExtensionContributor::ExtensionContributor(QString name) : m_Name{name} {}
 
 ExtensionMetaData::ExtensionMetaData(std::filesystem::path const& path,
                                      QJsonObject const& jsonData)
-    : m_JsonData{jsonData}
+    : m_JsonData{jsonData}, m_Version{0, 0, 0}, m_Requirements{}
 {
   // read basic fields
-  m_Identifier  = jsonData["id"].toString();
-  m_Type        = parseType(jsonData["type"].toString());
-  m_Name        = jsonData["name"].toString();
+  m_Identifier = jsonData["id"].toString();
+  if (m_Identifier.isEmpty()) {
+    throw InvalidExtensionMetaDataException("missing identifier");
+  }
+
+  {
+    const auto maybeType = parseType(jsonData["type"].toString());
+    if (!maybeType.has_value()) {
+      throw InvalidExtensionMetaDataException(
+          std::format("invalid or missing type '{}'", jsonData["type"].toString()));
+    }
+
+    m_Type = *maybeType;
+  }
+
+  m_Name = jsonData["name"].toString();
+  if (m_Name.isEmpty()) {
+    throw InvalidExtensionMetaDataException("missing name");
+  }
+
   m_Author      = parseContributor(jsonData["author"]);
   m_Description = jsonData["description"].toString();
-  m_Version.parse(jsonData["version"].toString("0.0.0"));
+
+  try {
+    m_Version = Version::parse(jsonData["version"].toString("0.0.0"),
+                               Version::ParseMode::SemVer);
+  } catch (InvalidVersionException const& ex) {
+    throw InvalidExtensionMetaDataException(
+        std::format("invalid or missing version '{}'", jsonData["version"].toString()));
+  }
 
   // TODO: name of the key
   // translation context
@@ -86,38 +110,25 @@ ExtensionMetaData::ExtensionMetaData(std::filesystem::path const& path,
     }
   }
 
-  // TODO: move code in a better place or use a custom icon
-  if (m_Icon.isNull()) {
-    const QImage baseIcon(":/MO/gui/app_icon");
-    QImage grayIcon = baseIcon.convertToFormat(QImage::Format_ARGB32);
-    {
-      for (int y = 0; y < grayIcon.height(); ++y) {
-        QRgb* scanLine = (QRgb*)grayIcon.scanLine(y);
-        for (int x = 0; x < grayIcon.width(); ++x) {
-          QRgb pixel = *scanLine;
-          uint ci    = uint(qGray(pixel));
-          *scanLine  = qRgba(ci, ci, ci, qAlpha(pixel) / 3);
-          ++scanLine;
-        }
-      }
-    }
-    m_Icon = QIcon(QPixmap::fromImage(grayIcon));
-  }
-
   if (jsonData.contains("contributors")) {
     for (const auto& jsonContributor : jsonData["contributors"].toArray()) {
       m_Contributors.push_back(parseContributor(jsonContributor));
     }
   }
-}
 
-bool ExtensionMetaData::isValid() const
-{
-  return !m_Identifier.isEmpty() && !m_Name.isEmpty() && m_Version.isValid() &&
-         m_Type != ExtensionType::INVALID;
+  if (jsonData.contains("requirements")) {
+    try {
+      m_Requirements =
+          ExtensionRequirementFactory::parseRequirements(jsonData["requirements"]);
+    } catch (InvalidRequirementException const& ex) {
+      throw InvalidExtensionMetaDataException(ex.what());
+    } catch (InvalidRequirementsException const& ex) {
+      throw InvalidExtensionMetaDataException(ex.what());
+    }
+  }
 }
 
-ExtensionType ExtensionMetaData::parseType(QString const& value) const
+std::optional<ExtensionType> ExtensionMetaData::parseType(QString const& value) const
 {
   std::map<QString, ExtensionType> stringToTypes{
       {"theme", ExtensionType::THEME},
@@ -125,7 +136,7 @@ ExtensionType ExtensionMetaData::parseType(QString const& value) const
       {"plugin", ExtensionType::PLUGIN},
       {"game", ExtensionType::GAME}};
 
-  auto type = ExtensionType::INVALID;
+  std::optional<ExtensionType> type;
   for (auto& [k, v] : stringToTypes) {
     if (k.compare(value, Qt::CaseInsensitive) == 0) {
       type = v;
@@ -163,13 +174,12 @@ QJsonObject ExtensionMetaData::content() const
   return value.toObject();
 }
 
-IExtension::IExtension(std::filesystem::path path, ExtensionMetaData metadata)
-    : m_Path{std::move(path)}, m_MetaData{std::move(metadata)},
-      m_Requirements{ExtensionRequirementFactory::parseRequirements(m_MetaData)}
+IExtension::IExtension(std::filesystem::path const& path, ExtensionMetaData&& metadata)
+    : m_Path{path}, m_MetaData{std::move(metadata)}
 {}
 
 std::unique_ptr<IExtension>
-ExtensionFactory::loadExtension(std::filesystem::path directory)
+ExtensionFactory::loadExtension(std::filesystem::path const& directory)
 {
   const auto metadataPath = directory / METADATA_FILENAME;
 
@@ -197,44 +207,44 @@ ExtensionFactory::loadExtension(std::filesystem::path directory)
     return nullptr;
   }
 
-  return loadExtension(std::move(directory),
-                       ExtensionMetaData(directory, jsonMetaData.object()));
+  try {
+    return loadExtension(directory,
+                         ExtensionMetaData(directory, jsonMetaData.object()));
+  } catch (InvalidExtensionMetaDataException const& ex) {
+    log::warn("failed to load extension from '{}': invalid metadata ({})",
+              directory.native(), ex.what());
+    return nullptr;
+  }
 }
 
 std::unique_ptr<IExtension>
-ExtensionFactory::loadExtension(std::filesystem::path directory,
-                                ExtensionMetaData metadata)
+ExtensionFactory::loadExtension(std::filesystem::path const& directory,
+                                ExtensionMetaData&& metadata)
 {
-  if (!metadata.isValid()) {
-    log::warn("failed to load extension from '{}': invalid metadata",
-              directory.native());
-    return nullptr;
-  }
-
   switch (metadata.type()) {
   case ExtensionType::THEME:
-    return ThemeExtension::loadExtension(std::move(directory), std::move(metadata));
+    return ThemeExtension::loadExtension(directory, std::move(metadata));
   case ExtensionType::TRANSLATION:
-    return TranslationExtension::loadExtension(std::move(directory),
-                                               std::move(metadata));
+    return TranslationExtension::loadExtension(directory, std::move(metadata));
   case ExtensionType::PLUGIN:
-    return PluginExtension::loadExtension(std::move(directory), std::move(metadata));
+    return PluginExtension::loadExtension(directory, std::move(metadata));
   case ExtensionType::GAME:
-    return GameExtension::loadExtension(std::move(directory), std::move(metadata));
-  case ExtensionType::INVALID:
+    return GameExtension::loadExtension(directory, std::move(metadata));
   default:
     log::warn("failed to load extension from '{}': invalid type", directory.native());
     return nullptr;
   }
 }
 
-ThemeExtension::ThemeExtension(std::filesystem::path path, ExtensionMetaData metadata,
+ThemeExtension::ThemeExtension(std::filesystem::path const& path,
+                               ExtensionMetaData&& metadata,
                                std::vector<std::shared_ptr<const Theme>> themes)
-    : IExtension{std::move(path), std::move(metadata)}, m_Themes{std::move(themes)}
+    : IExtension{path, std::move(metadata)}, m_Themes{std::move(themes)}
 {}
 
 std::unique_ptr<ThemeExtension>
-ThemeExtension::loadExtension(std::filesystem::path path, ExtensionMetaData metadata)
+ThemeExtension::loadExtension(std::filesystem::path const& path,
+                              ExtensionMetaData&& metadata)
 {
   std::vector<std::shared_ptr<const Theme>> themes;
   const auto& jsonThemes = metadata.content()["themes"].toObject();
@@ -253,7 +263,7 @@ ThemeExtension::loadExtension(std::filesystem::path path, ExtensionMetaData meta
   }
 
   return std::unique_ptr<ThemeExtension>{
-      new ThemeExtension(path, metadata, std::move(themes))};
+      new ThemeExtension(path, std::move(metadata), std::move(themes))};
 }
 
 std::shared_ptr<const Theme>
@@ -273,15 +283,15 @@ ThemeExtension::parseTheme(std::filesystem::path const& extensionFolder,
 }
 
 TranslationExtension::TranslationExtension(
-    std::filesystem::path path, ExtensionMetaData metadata,
+    std::filesystem::path const& path, ExtensionMetaData&& metadata,
     std::vector<std::shared_ptr<const Translation>> translations)
     : IExtension{std::move(path), std::move(metadata)},
       m_Translations(std::move(translations))
 {}
 
 std::unique_ptr<TranslationExtension>
-TranslationExtension::loadExtension(std::filesystem::path path,
-                                    ExtensionMetaData metadata)
+TranslationExtension::loadExtension(std::filesystem::path const& path,
+                                    ExtensionMetaData&& metadata)
 {
   std::vector<std::shared_ptr<const Translation>> translations;
   const auto& jsonTranslations = metadata.content()["translations"].toObject();
@@ -300,7 +310,7 @@ TranslationExtension::loadExtension(std::filesystem::path path,
   }
 
   return std::unique_ptr<TranslationExtension>{
-      new TranslationExtension(path, metadata, std::move(translations))};
+      new TranslationExtension(path, std::move(metadata), std::move(translations))};
 }
 
 std::shared_ptr<const Translation>
@@ -333,17 +343,18 @@ TranslationExtension::parseTranslation(std::filesystem::path const& extensionFol
 }
 
 PluginExtension::PluginExtension(
-    std::filesystem::path path, ExtensionMetaData metadata, bool autodetect,
+    std::filesystem::path const& path, ExtensionMetaData&& metadata, bool autodetect,
     std::map<std::string, std::filesystem::path> plugins,
     std::vector<std::shared_ptr<const ThemeAddition>> themeAdditions,
     std::vector<std::shared_ptr<const TranslationAddition>> translationAdditions)
-    : IExtension(std::move(path), std::move(metadata)), m_AutoDetect{autodetect},
+    : IExtension(path, std::move(metadata)), m_AutoDetect{autodetect},
       m_Plugins{std::move(plugins)}, m_ThemeAdditions{std::move(themeAdditions)},
       m_TranslationAdditions{std::move(translationAdditions)}
 {}
 
 std::unique_ptr<PluginExtension>
-PluginExtension::loadExtension(std::filesystem::path path, ExtensionMetaData metadata)
+PluginExtension::loadExtension(std::filesystem::path const& path,
+                               ExtensionMetaData&& metadata)
 {
   namespace fs = std::filesystem;
 
@@ -450,8 +461,9 @@ GameExtension::GameExtension(PluginExtension&& pluginExtension)
     : PluginExtension(std::move(pluginExtension))
 {}
 
-std::unique_ptr<GameExtension> GameExtension::loadExtension(std::filesystem::path path,
-                                                            ExtensionMetaData metadata)
+std::unique_ptr<GameExtension>
+GameExtension::loadExtension(std::filesystem::path const& path,
+                             ExtensionMetaData&& metadata)
 {
   auto extension = PluginExtension::loadExtension(std::move(path), std::move(metadata));
   return extension
diff --git a/src/requirements.cpp b/src/requirements.cpp
index 894f56e7..11bd81c5 100644
--- a/src/requirements.cpp
+++ b/src/requirements.cpp
@@ -3,6 +3,8 @@
 #include <QJsonArray>
 
 #include "extensions/extension.h"
+#include "extensions/iextensionlist.h"
+#include "extensions/versionconstraints.h"
 #include "imoinfo.h"
 #include "log.h"
 
@@ -10,7 +12,91 @@ namespace MOBase
 {
 
 class ExtensionRequirementImpl
-{};
+{
+public:
+  using Type = ExtensionRequirement::Type;
+
+public:
+  virtual bool check(IOrganizer* organizer) const = 0;
+  virtual Type type() const                       = 0;
+  virtual QString string() const                  = 0;
+  virtual ~ExtensionRequirementImpl()             = default;
+};
+
+// requirement for the version of MO2 itself
+//
+class CoreVersionExtensionRequirement : public ExtensionRequirementImpl
+{
+public:
+  CoreVersionExtensionRequirement(VersionConstraints const& constraints)
+      : m_Constraints{constraints}
+  {}
+
+  bool check(IOrganizer* organizer) const override
+  {
+    return m_Constraints.matches(organizer->version());
+  }
+
+  Type type() const override { return Type::VERSION; }
+
+  QString string() const override
+  {
+    return QString("ModOrganizer2 %1").arg(m_Constraints.string());
+  }
+
+private:
+  VersionConstraints m_Constraints;
+};
+
+// requirement for another extension
+//
+class DependencyExtensionRequirement : public ExtensionRequirementImpl
+{
+public:
+  DependencyExtensionRequirement(QString const& extension,
+                                 VersionConstraints const& constraints)
+      : m_Extension{extension}, m_Constraints{constraints}
+  {}
+
+  bool check(IOrganizer* organizer) const override
+  {
+    return organizer->extensionList().enabled(m_Extension) &&
+           m_Constraints.matches(
+               organizer->extensionList().get(m_Extension).metadata().version());
+  }
+
+  Type type() const override { return Type::DEPENDENCY; }
+
+  QString string() const override
+  {
+    return QString("%1 %2").arg(m_Extension, m_Constraints.string());
+  }
+
+private:
+  QString m_Extension;
+  VersionConstraints m_Constraints;
+};
+
+// requirement for games
+//
+class GameExtensionRequirement : public ExtensionRequirementImpl
+{
+public:
+  GameExtensionRequirement(QStringList const& games) : m_Games{games} {}
+
+  bool check(IOrganizer* organizer) const override
+  {
+    return organizer->managedGame() &&
+           m_Games.contains(organizer->managedGame()->gameName());
+  }
+
+  Type type() const override { return Type::GAME; }
+
+  QString string() const override { return m_Games.join(", "); }
+
+private:
+  QStringList m_Games;
+};
 
 }  // namespace MOBase
 
@@ -23,29 +109,89 @@ ExtensionRequirement::ExtensionRequirement(
 
 ExtensionRequirement::~ExtensionRequirement() = default;
 
-bool ExtensionRequirement::check([[maybe_unused]] IOrganizer* organizer) const
+bool ExtensionRequirement::check(IOrganizer* organizer) const
 {
-  return true;
+  return m_Impl->check(organizer);
 }
 
-std::vector<ExtensionRequirement>
-ExtensionRequirementFactory::parseRequirements(const ExtensionMetaData& metadata)
+ExtensionRequirement::Type ExtensionRequirement::type() const
+{
+  return m_Impl->type();
+}
+
+QString ExtensionRequirement::string() const
+{
+  return m_Impl->string();
+}
+
+namespace
+{
+std::optional<ExtensionRequirement::Type> parseType(QString const& value)
 {
-  if (!metadata.json().contains("requirements")) {
-    return {};
+  std::map<QString, ExtensionRequirement::Type> stringToTypes{
+      {"game", ExtensionRequirement::Type::GAME},
+      {"extension", ExtensionRequirement::Type::DEPENDENCY},
+      {"version", ExtensionRequirement::Type::VERSION}};
+
+  std::optional<ExtensionRequirement::Type> type;
+  for (auto& [k, v] : stringToTypes) {
+    if (k.compare(value, Qt::CaseInsensitive) == 0) {
+      type = v;
+      break;
+    }
   }
 
-  const auto json_requirements = metadata.json()["requirements"];
+  return type;
+}
+}  // namespace
 
+std::vector<ExtensionRequirement>
+ExtensionRequirementFactory::parseRequirements(const QJsonValue& json_requirements)
+{
   if (!json_requirements.isArray()) {
-    log::warn("expected array of requirements for extension '{}', found '{}'",
-              metadata.identifier(), json_requirements.type());
-    return {};
+    throw InvalidRequirementsException("expected an array of requirements");
   }
 
   std::vector<ExtensionRequirement> requirements;
   for (const auto& json_requirement : json_requirements.toArray()) {
-    // TODO
+    if (!json_requirement.isObject()) {
+      throw InvalidRequirementException("invalid requirement");
+    }
+
+    auto json_object = json_requirement.toObject();
+
+    const auto type = parseType(json_object["type"].toString());
+    if (!type.has_value()) {
+      throw InvalidRequirementException("missing requirement type");
+    }
+
+    try {
+      switch (*type) {
+      case ExtensionRequirement::Type::GAME:
+        if (!json_object.contains("games") || !json_object["games"].isArray()) {
+          throw InvalidRequirementException("invalid requirement");
+        }
+        requirements.push_back(
+            ExtensionRequirement(std::make_shared<GameExtensionRequirement>(
+                json_object["games"].toVariant().toStringList())));
+        break;
+      case ExtensionRequirement::Type::DEPENDENCY:
+        requirements.push_back(
+            ExtensionRequirement(std::make_shared<DependencyExtensionRequirement>(
+                json_object["extension"].toString(),
+                VersionConstraints::parse(json_object["version"].toString(),
+                                          Version::ParseMode::SemVer))));
+        break;
+      case ExtensionRequirement::Type::VERSION:
+        requirements.push_back(ExtensionRequirement(
+            std::make_shared<CoreVersionExtensionRequirement>(VersionConstraints::parse(
+                json_object["version"].toString(), Version::ParseMode::MO2))));
+        break;
+      }
+    } catch (InvalidConstraintException const& ex) {
+      throw InvalidRequirementException(
+          std::format("invalid requirement constraints: {}", ex.what()));
+    }
   }
 
   return requirements;
diff --git a/src/uibase_en.ts b/src/uibase_en.ts
index d5dd17a5..1156e86c 100644
--- a/src/uibase_en.ts
+++ b/src/uibase_en.ts
@@ -181,7 +181,6 @@
     <message>
         <location filename="safewritefile.cpp" line="42"/>
         <source>Failed to save &apos;%1&apos;, could not create a temporary file: %2 (error %3)</source>
-        <oldsource>Failed to save &apos;{}&apos;, could not create a temporary file: {} (error {})</oldsource>
         <translation type="unfinished"></translation>
     </message>
     <message>
diff --git a/src/versionconstraints.cpp b/src/versionconstraints.cpp
index 69f09be4..1cfc9dd7 100644
--- a/src/versionconstraints.cpp
+++ b/src/versionconstraints.cpp
@@ -169,7 +169,6 @@ VersionConstraint VersionConstraint::parse(QString const& value,
     }
   }
 
-  constexpr auto min_int = std::numeric_limits<int>::min();
   constexpr auto max_int = std::numeric_limits<int>::max();
 
   std::shared_ptr<VersionConstraintImpl> impl;
@@ -284,10 +283,14 @@ VersionConstraints VersionConstraints::parse(QString const& value,
                                              Version::ParseMode mode)
 {
   std::vector<VersionConstraint> constraints;
-  for (const auto& part : value.split(",")) {
-    constraints.push_back(VersionConstraint::parse(part.trimmed(), mode));
+  auto parts = value.split(",");
+  for (auto& part : parts) {
+    // replace the part in-place to create a proper representation
+    part = part.simplified().replace(" ", "");
+
+    constraints.push_back(VersionConstraint::parse(part, mode));
   }
-  return VersionConstraints(std::move(constraints));
+  return VersionConstraints(parts.join(", "), std::move(constraints));
 }
 
 bool VersionConstraints::matches(Version const& version) const
@@ -298,8 +301,9 @@ bool VersionConstraints::matches(Version const& version) const
                      });
 }
 
-VersionConstraints::VersionConstraints(std::vector<VersionConstraint> checkers)
-    : m_Constraints{std::move(checkers)}
+VersionConstraints::VersionConstraints(QString const& repr,
+                                       std::vector<VersionConstraint> checkers)
+    : m_Repr{repr}, m_Constraints{std::move(checkers)}
 {}
 
 }  // namespace MOBase
diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt
index ac17234a..9301f5be 100644
--- a/tests/CMakeLists.txt
+++ b/tests/CMakeLists.txt
@@ -7,6 +7,7 @@ target_sources(uibase-tests
 		test_ifiletree.cpp
 		test_strings.cpp
 		test_versioning.cpp
+		test_extensions.cpp
 )
 mo2_configure_tests(uibase-tests NO_SOURCES WARNINGS 4)
 target_link_libraries(uibase-tests PRIVATE uibase)
diff --git a/tests/test_extensions.cpp b/tests/test_extensions.cpp
new file mode 100644
index 00000000..4e767282
--- /dev/null
+++ b/tests/test_extensions.cpp
@@ -0,0 +1,54 @@
+#pragma warning(push)
+#pragma warning(disable : 4668)
+#include <gtest/gtest.h>
+#pragma warning(pop)
+
+#include <QJsonDocument>
+
+#include <uibase/extensions/extension.h>
+
+#include <format>
+
+using namespace MOBase;
+
+class TestMetaData : public ExtensionMetaData
+{
+public:
+  TestMetaData(std::filesystem::path const& path, QByteArray const& metadata)
+      : ExtensionMetaData(path, QJsonDocument::fromJson(metadata).object())
+  {}
+};
+
+TEST(ExtensionsTest, MetaData)
+{
+  const auto metadata = TestMetaData({}, R"({
+  "id": "mo2-game-bethesda",
+  "name": "Elder Scrolls & Fallout Games",
+  "version": "1.0.0",
+  "description": "ModOrganizer2 support for The Elder Scrolls & Fallout games.",
+  "author": {
+    "name": "Mod Organizer 2",
+    "homepage": "https://www.modorganizer.org/"
+  },
+  "icon": "./tests/icon.png",
+  "contributors": [
+    "AL",
+    "AnyOldName3",
+    "Holt59",
+    "Silarn"
+  ],
+  "type": "game",
+  "content": {
+    "plugins": {
+      "autodetect": true
+    },
+    "translations": {
+      "autodetect": "translations"
+    }
+  }
+})");
+
+  EXPECT_EQ("mo2-game-bethesda", metadata.identifier());
+  EXPECT_EQ("Elder Scrolls & Fallout Games", metadata.name());
+  EXPECT_FALSE(metadata.icon().isNull());
+}