[plasma/union] tools: tools: Add styletool as a helper CLI for working with styles

Arjen Hiemstra <[email protected]> Wed, 5 Aug 2026 10:33:24 +0000 (UTC)
Newsgroups gmane.comp.kde.cvs
Message-ID <[email protected]>
Git commit 97ad2c5aba4b2ff9ba4763f4d4bc98da55561559 by Arjen Hiemstra.
Committed on 05/08/2026 at 10:24.
Pushed by ahiemstra into branch 'master'.

tools: Add styletool as a helper CLI for working with styles

This allows listing, installing and other operations on style packages.

M  +1    -0    tools/CMakeLists.txt
A  +9    -0    tools/styletool/CMakeLists.txt
A  +149  -0    tools/styletool/Common.h     [License: LGPL(3+eV) LGPL(v3.0) LGPL(v2.1)]
A  +164  -0    tools/styletool/styletool.cpp     [License: LGPL(3+eV) LGPL(v3.0) LGPL(v2.1)]

https://invent.kde.org/plasma/union/-/commit/97ad2c5aba4b2ff9ba4763f4d4bc98da55561559

diff --git a/tools/CMakeLists.txt b/tools/CMakeLists.txt
index ad064a8f..b324409a 100644
--- a/tools/CMakeLists.txt
+++ b/tools/CMakeLists.txt
@@ -2,3 +2,4 @@
 # SPDX-FileCopyrightText: 2024 Arjen Hiemstra <[email protected]>
 
 add_subdirectory(ruleinspector)
+add_subdirectory(styletool)
diff --git a/tools/styletool/CMakeLists.txt b/tools/styletool/CMakeLists.txt
new file mode 100644
index 00000000..4ac2c698
--- /dev/null
+++ b/tools/styletool/CMakeLists.txt
@@ -0,0 +1,9 @@
+# SPDX-License-Identifier: BSD-2-Clause
+# SPDX-FileCopyrightText: 2026 Arjen Hiemstra <[email protected]>
+
+add_executable(union-styletool)
+target_sources(union-styletool PRIVATE styletool.cpp)
+
+target_link_libraries(union-styletool PRIVATE Union::Union Qt::Core)
+
+install(TARGETS union-styletool ${KDE_INSTALL_DEFAULT_ARGS})
diff --git a/tools/styletool/Common.h b/tools/styletool/Common.h
new file mode 100644
index 00000000..adc95662
--- /dev/null
+++ b/tools/styletool/Common.h
@@ -0,0 +1,149 @@
+// SPDX-License-Identifier: LGPL-2.1-only OR LGPL-3.0-only OR LicenseRef-KDE-Accepted-LGPL
+// SPDX-FileCopyrightText: 2026 Arjen Hiemstra <[email protected]>
+
+#include <filesystem>
+#include <iostream>
+
+#include <QCommandLineParser>
+#include <QCoreApplication>
+#include <QFile>
+#include <QStandardPaths>
+#include <QTextStream>
+
+#include <PackageHandler.h>
+#include <StylePackage.h>
+
+using namespace Qt::StringLiterals;
+
+namespace fs = std::filesystem;
+
+void showCommandHelp(QCommandLineParser *parser, const QString &command, const QString &errorMessage = {}, int exitCode = 0)
+{
+    auto help = parser->helpText();
+    help.replace(u"union-styletool"_s, u"union-styletool " + command);
+
+    if (!errorMessage.isEmpty()) {
+        help.prepend(errorMessage + u"\n");
+        parser->showMessageAndExit(QCommandLineParser::MessageType::Error, help, exitCode);
+    } else {
+        parser->showMessageAndExit(QCommandLineParser::MessageType::Information, help, exitCode);
+    }
+}
+
+[[nodiscard]] std::unique_ptr<QCommandLineParser>
+parseCommand(const QString &command, const QStringList &arguments, std::function<void(QCommandLineParser *)> customiseFunction)
+{
+    auto commandParser = std::make_unique<QCommandLineParser>();
+    commandParser->addOption({u"help"_s, u"Displays help for the current command."_s});
+
+    customiseFunction(commandParser.get());
+
+    QStringList argumentsWithApplication = {QCoreApplication::applicationName()};
+    argumentsWithApplication.append(arguments);
+
+    if (!commandParser->parse(argumentsWithApplication)) {
+        QString errorMessage;
+        if (!commandParser->unknownOptionNames().isEmpty()) {
+            errorMessage = u"Unknown option for command "_s + command + u": " + commandParser->unknownOptionNames().first();
+        }
+
+        showCommandHelp(commandParser.get(), command, errorMessage, 1);
+    }
+
+    if (commandParser->isSet(u"help"_s)) {
+        showCommandHelp(commandParser.get(), command);
+    }
+
+    return commandParser;
+}
+
+void readInput(QString &output, QStringView message, std::function<bool(QString)> validationFunction, QString defaultValue = {})
+{
+    Q_ASSERT(!message.isEmpty());
+
+    if (output.isEmpty()) {
+        QTextStream inputStream(stdin);
+        QString input;
+        while (input.isEmpty()) {
+            std::cout << message.toUtf8().data();
+            if (!defaultValue.isEmpty()) {
+                std::cout << " [Enter to use: " << qPrintable(defaultValue) << "]" << std::endl;
+            } else {
+                std::cout << std::endl;
+            }
+
+            inputStream.readLineInto(&input, 1024);
+
+            if (input.isEmpty() && !defaultValue.isEmpty()) {
+                input = defaultValue;
+            }
+
+            if (!validationFunction(input)) {
+                input = QString{};
+            }
+        }
+
+        output = input;
+    }
+}
+
+void readInput(QString &output, QStringView message, QString defaultValue = {})
+{
+    readInput(
+        output,
+        message,
+        [](const QString &) {
+            return true;
+        },
+        defaultValue);
+}
+
+int printPackageError(const Union::StylePackage &package)
+{
+    auto path = package.path();
+    switch (package.error()) {
+    case Union::StylePackage::Error::NotFound:
+        std::cerr << path << " does not exist.\n";
+        return 1;
+    case Union::StylePackage::Error::MissingFiles:
+        std::cerr << path << " is an invalid style because it is missing required files.\n";
+        return 2;
+    case Union::StylePackage::Error::InvalidMetaData:
+        std::cerr << path << " is an invalid style because its metadata could not be read.\n";
+        return 3;
+    case Union::StylePackage::Error::UnknownInputType:
+        std::cerr << path << " is an invalid style because it uses an unknown input type.\n";
+        return 4;
+    case Union::StylePackage::Error::None:
+        return 0;
+    }
+
+    return 0;
+}
+
+int printHandlerError(const Union::StylePackage &package, Union::PackageHandler::Error error)
+{
+    auto path = package.path();
+    switch (error) {
+    case Union::PackageHandler::Error::InvalidPackage:
+        std::cerr << path << "is an invalid style:\n";
+        printPackageError(package);
+        return 5;
+    case Union::PackageHandler::Error::AlreadyInstalled:
+        std::cerr << "A style with ID " << qPrintable(package.id()) << " is already installed.\n";
+        return 6;
+    case Union::PackageHandler::Error::FilesystemError:
+        std::cerr << "A filesystem error occurred.\n";
+        return 7;
+    case Union::PackageHandler::Error::NotInstalled:
+        std::cerr << "The style " << qPrintable(package.id()) << " is not installed.\n";
+        return 8;
+    case Union::PackageHandler::Error::PackageExists:
+        std::cerr << "A style already exists at " << package.path() << "\n";
+        return 9;
+    case Union::PackageHandler::Error::None:
+        break;
+    }
+
+    return 0;
+}
diff --git a/tools/styletool/styletool.cpp b/tools/styletool/styletool.cpp
new file mode 100644
index 00000000..027be97e
--- /dev/null
+++ b/tools/styletool/styletool.cpp
@@ -0,0 +1,164 @@
+// SPDX-License-Identifier: LGPL-2.1-only OR LGPL-3.0-only OR LicenseRef-KDE-Accepted-LGPL
+// SPDX-FileCopyrightText: 2026 Arjen Hiemstra <[email protected]>
+
+#include <condition_variable>
+#include <mutex>
+
+#include <QStandardPaths>
+
+#include <StyleRegistry.h>
+
+#include "Common.h"
+
+int handleInstallCommand([[maybe_unused]] const QStringList &arguments)
+{
+    return 0;
+}
+
+int handleCreateCommand([[maybe_unused]] const QStringList &arguments)
+{
+    return 0;
+}
+
+int handleInspectCommand([[maybe_unused]] const QStringList &arguments)
+{
+    auto parser = parseCommand(u"inspect"_s, arguments, [](QCommandLineParser *parser) {
+        parser->setApplicationDescription(u"Inspect details of an installed Union style."_s);
+        parser->addPositionalArgument(u"<path or id>"_s, u"The path of a style or the ID of an installed style to inspect."_s);
+    });
+
+    if (parser->positionalArguments().size() != 1) {
+        showCommandHelp(parser.get(), u"inspect"_s, u"Invalid arguments for command \"inspect\""_s, 1);
+    }
+
+    auto pathOrId = parser->positionalArguments().first();
+
+    auto handler = Union::StyleRegistry::instance()->packageHandler();
+
+    auto package = handler->package(pathOrId);
+    if (!package.isValid()) {
+        package = Union::StylePackage{fs::absolute(fs::path(pathOrId.toStdString()))};
+    }
+
+    if (!package.isValid()) {
+        return printPackageError(package);
+    }
+
+    std::cout << "Path:        " << package.path().string() << "\n";
+    std::cout << "Input Type:  " << qPrintable(package.inputType()) << "\n";
+    std::cout << "Name:        " << qPrintable(package.name()) << "\n";
+    std::cout << "Description: " << qPrintable(package.description()) << "\n";
+    std::cout << "Version:     " << qPrintable(package.version()) << "\n";
+    std::cout << "License:     " << qPrintable(package.license()) << "\n";
+    std::cout << "Authors:     " << qPrintable(package.authors().join(u", ")) << "\n";
+
+    return 0;
+}
+
+int handleListCommand([[maybe_unused]] const QStringList &arguments)
+{
+    auto parser = parseCommand(u"list"_s, arguments, [](QCommandLineParser *parser) {
+        parser->setApplicationDescription(u"List all the installed Union styles."_s);
+        parser->addOption({u"hidden"_s, u"Include hidden packages."_s});
+        parser->addOption({u"details"_s, u"Show details information about each package."_s});
+    });
+
+    auto handler = Union::StyleRegistry::instance()->packageHandler();
+    auto packages = handler->allPackages();
+    if (packages.isEmpty()) {
+        std::cerr << "No styles could be found.\n";
+        return 1;
+    }
+
+    std::ranges::stable_sort(packages, [](const Union::StylePackage &first, const Union::StylePackage &second) {
+        return first.id() < second.name();
+    });
+
+    for (const auto &package : packages) {
+        if (!parser->isSet(u"details"_s)) {
+            std::cout << "- " << qPrintable(package.id()) << ": " << qPrintable(package.name()) << "\n";
+        } else {
+            std::cout << "- Path:        " << package.path().string() << "\n";
+            std::cout << "  Input Type:  " << qPrintable(package.inputType()) << "\n";
+            std::cout << "  Name:        " << qPrintable(package.name()) << "\n";
+            std::cout << "  Description: " << qPrintable(package.description()) << "\n";
+            std::cout << "  Version:     " << qPrintable(package.version()) << "\n";
+            std::cout << "  License:     " << qPrintable(package.license()) << "\n";
+            std::cout << "  Authors:     " << qPrintable(package.authors().join(u", ")) << "\n";
+            std::cout << std::endl;
+        }
+    }
+
+    return 0;
+}
+
+int handleUpdateCommand([[maybe_unused]] const QStringList &arguments)
+{
+    return 0;
+}
+
+int handleUninstallCommand([[maybe_unused]] const QStringList &arguments)
+{
+    return 0;
+}
+
+int main(int argc, char **argv)
+{
+    QCoreApplication application(argc, argv);
+    QCoreApplication::setApplicationVersion(u"1.0"_s);
+
+    QCommandLineParser parser;
+    parser.setOptionsAfterPositionalArgumentsMode(QCommandLineParser::ParseAsPositionalArguments);
+    auto versionOption = parser.addVersionOption();
+    auto helpOption = parser.addHelpOption();
+
+    auto commandHelp = u"The command to execute.\nAvailable commands:\n"_s;
+    commandHelp += u"   create     Create a new style.\n"_s;
+    commandHelp += u"   inspect    Inspect details of an installed style.\n"_s;
+    commandHelp += u"   install    Install a new style.\n"_s;
+    commandHelp += u"   list       List installed styles.\n"_s;
+    commandHelp += u"   update     Update an installed style.\n"_s;
+    commandHelp += u"   uninstall  Uninstall an installed style.\n"_s;
+
+    parser.addPositionalArgument(u"<command>"_s, commandHelp);
+
+    auto result = parser.parse(application.arguments());
+    if (!result && parser.unknownOptionNames().isEmpty()) {
+        parser.showHelp(1);
+    }
+
+    if (parser.isSet(versionOption)) {
+        parser.showVersion();
+    }
+
+    if (parser.isSet(helpOption)) {
+        parser.showHelp(0);
+    }
+
+    if (parser.positionalArguments().isEmpty()) {
+        parser.showHelp(1);
+    }
+
+    auto remaining = parser.positionalArguments();
+    auto command = remaining.takeFirst().toLower();
+
+    int commandResult = 0;
+
+    if (command == u"create") {
+        commandResult = handleCreateCommand(remaining);
+    } else if (command == u"inspect") {
+        commandResult = handleInspectCommand(remaining);
+    } else if (command == u"install") {
+        commandResult = handleInstallCommand(remaining);
+    } else if (command == u"list") {
+        commandResult = handleListCommand(remaining);
+    } else if (command == u"update") {
+        commandResult = handleUpdateCommand(remaining);
+    } else if (command == u"uninstall") {
+        commandResult = handleUninstallCommand(remaining);
+    } else {
+        parser.showMessageAndExit(QCommandLineParser::MessageType::Error, u"Unknown command: "_s + command, 1);
+    }
+
+    return commandResult;
+}