From 2d6504b329f9021e858032f82f7eab6af33203dd Mon Sep 17 00:00:00 2001
From: Maxim Slipenko <no-reply@maxim.slipenko.com>
Date: Sun, 19 Jan 2025 11:49:00 +0300
Subject: [PATCH] fix: removeAlreadyInstalled before FindPkgs

---
 go.mod                           |   3 +
 go.sum                           |   2 +
 pkg/build/build.go               |  52 ++++---
 pkg/build/build_internal_test.go | 225 +++++++++++++++++++++++++++++++
 pkg/manager/apk.go               |  15 +++
 pkg/manager/apt.go               |  15 +++
 pkg/manager/apt_rpm.go           |  34 +----
 pkg/manager/common_rpm.go        |  72 ++++++++++
 pkg/manager/dnf.go               |  36 +----
 pkg/manager/managers.go          |   2 +
 pkg/manager/pacman.go            |  15 +++
 pkg/manager/yum.go               |  36 +----
 pkg/manager/zypper.go            |  35 +----
 13 files changed, 386 insertions(+), 156 deletions(-)
 create mode 100644 pkg/build/build_internal_test.go
 create mode 100644 pkg/manager/common_rpm.go

diff --git a/go.mod b/go.mod
index 69cc341..e132faf 100644
--- a/go.mod
+++ b/go.mod
@@ -56,6 +56,7 @@ require (
 	github.com/containerd/console v1.0.4-0.20230313162750-1ae8d489ac81 // indirect
 	github.com/cpuguy83/go-md2man/v2 v2.0.4 // indirect
 	github.com/cyphar/filepath-securejoin v0.2.4 // indirect
+	github.com/davecgh/go-spew v1.1.1 // indirect
 	github.com/dlclark/regexp2 v1.10.0 // indirect
 	github.com/dsnet/compress v0.0.1 // indirect
 	github.com/dustin/go-humanize v1.0.1 // indirect
@@ -92,6 +93,7 @@ require (
 	github.com/nwaples/rardecode/v2 v2.0.0-beta.2 // indirect
 	github.com/pierrec/lz4/v4 v4.1.15 // indirect
 	github.com/pjbgf/sha1cd v0.3.0 // indirect
+	github.com/pmezard/go-difflib v1.0.0 // indirect
 	github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
 	github.com/rivo/uniseg v0.4.4 // indirect
 	github.com/russross/blackfriday/v2 v2.1.0 // indirect
@@ -99,6 +101,7 @@ require (
 	github.com/shopspring/decimal v1.2.0 // indirect
 	github.com/skeema/knownhosts v1.2.2 // indirect
 	github.com/spf13/cast v1.6.0 // indirect
+	github.com/stretchr/testify v1.10.0 // indirect
 	github.com/therootcompany/xz v1.0.1 // indirect
 	github.com/ulikunitz/xz v0.5.12 // indirect
 	github.com/vmihailenco/tagparser/v2 v2.0.0 // indirect
diff --git a/go.sum b/go.sum
index f7eeec2..eabf92d 100644
--- a/go.sum
+++ b/go.sum
@@ -327,6 +327,8 @@ github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO
 github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
 github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg=
 github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
+github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
+github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
 github.com/therootcompany/xz v1.0.1 h1:CmOtsn1CbtmyYiusbfmhmkpAAETj0wBIH6kCYaX+xzw=
 github.com/therootcompany/xz v1.0.1/go.mod h1:3K3UH1yCKgBneZYhuQUvJ9HPD19UEXEI0BWbMn8qNMY=
 github.com/ulikunitz/xz v0.5.6/go.mod h1:2bypXElzHzzJZwzH67Y6wb67pO62Rzfn7BSiF4ABRW8=
diff --git a/pkg/build/build.go b/pkg/build/build.go
index 73a0c1e..685edb5 100644
--- a/pkg/build/build.go
+++ b/pkg/build/build.go
@@ -65,6 +65,7 @@ import (
 // Один содержит пути к собранным пакетам, другой - имена собранных пакетов.
 func BuildPackage(ctx context.Context, opts types.BuildOpts) ([]string, []string, error) {
 	log := loggerctx.From(ctx)
+	reposInstance := repos.GetInstance(ctx)
 
 	info, err := distro.ParseOSRelease(ctx)
 	if err != nil {
@@ -133,12 +134,12 @@ func BuildPackage(ctx context.Context, opts types.BuildOpts) ([]string, []string
 		return nil, nil, err
 	}
 
-	buildDeps, err := installBuildDeps(ctx, vars, opts, installed) // Устанавливаем зависимости для сборки
+	buildDeps, err := installBuildDeps(ctx, reposInstance, vars, opts) // Устанавливаем зависимости для сборки
 	if err != nil {
 		return nil, nil, err
 	}
 
-	err = installOptDeps(ctx, vars, opts, installed) // Устанавливаем опциональные зависимости
+	err = installOptDeps(ctx, reposInstance, vars, opts) // Устанавливаем опциональные зависимости
 	if err != nil {
 		return nil, nil, err
 	}
@@ -329,18 +330,25 @@ func performChecks(ctx context.Context, vars *types.BuildVars, interactive bool,
 	return true, nil
 }
 
+type PackageFinder interface {
+	FindPkgs(ctx context.Context, pkgs []string) (map[string][]db.Package, []string, error)
+}
+
 // Функция installBuildDeps устанавливает все зависимости сборки, которые еще не установлены, и возвращает
 // срез, содержащий имена всех установленных пакетов.
-func installBuildDeps(ctx context.Context, vars *types.BuildVars, opts types.BuildOpts, installed map[string]string) ([]string, error) {
+func installBuildDeps(ctx context.Context, repos PackageFinder, vars *types.BuildVars, opts types.BuildOpts) ([]string, error) {
 	log := loggerctx.From(ctx)
 	var buildDeps []string
 	if len(vars.BuildDepends) > 0 {
-		found, notFound, err := repos.FindPkgs(ctx, vars.BuildDepends) // Находим пакеты-зависимости
+		deps, err := removeAlreadyInstalled(opts, vars.BuildDepends)
 		if err != nil {
 			return nil, err
 		}
 
-		found = removeAlreadyInstalled(found, installed) // Убираем уже установленные зависимости
+		found, notFound, err := repos.FindPkgs(ctx, deps) // Находим пакеты-зависимости
+		if err != nil {
+			return nil, err
+		}
 
 		log.Info("Installing build dependencies").Send() // Логгируем установку зависимостей
 
@@ -353,9 +361,13 @@ func installBuildDeps(ctx context.Context, vars *types.BuildVars, opts types.Bui
 
 // Функция installOptDeps спрашивает у пользователя, какие, если таковые имеются, опциональные зависимости он хочет установить.
 // Если пользователь решает установить какие-либо опциональные зависимости, выполняется их установка.
-func installOptDeps(ctx context.Context, vars *types.BuildVars, opts types.BuildOpts, installed map[string]string) error {
-	if len(vars.OptDepends) > 0 {
-		optDeps, err := cliutils.ChooseOptDepends(ctx, vars.OptDepends, "install", opts.Interactive) // Пользователя просят выбрать опциональные зависимости
+func installOptDeps(ctx context.Context, repos PackageFinder, vars *types.BuildVars, opts types.BuildOpts) error {
+	optDeps, err := removeAlreadyInstalled(opts, vars.OptDepends)
+	if err != nil {
+		return err
+	}
+	if len(optDeps) > 0 {
+		optDeps, err := cliutils.ChooseOptDepends(ctx, optDeps, "install", opts.Interactive) // Пользователя просят выбрать опциональные зависимости
 		if err != nil {
 			return err
 		}
@@ -369,7 +381,6 @@ func installOptDeps(ctx context.Context, vars *types.BuildVars, opts types.Build
 			return err
 		}
 
-		found = removeAlreadyInstalled(found, installed) // Убираем уже установленные зависимости
 		flattened := cliutils.FlattenPkgs(ctx, found, "install", opts.Interactive)
 		InstallPkgs(ctx, flattened, notFound, opts) // Устанавливаем выбранные пакеты
 	}
@@ -836,21 +847,22 @@ func setVersion(ctx context.Context, r *interp.Runner, to string) error {
 	return r.Run(ctx, fl)
 }
 
-// Функция removeAlreadyInstalled возвращает карту без каких-либо зависимостей, которые уже установлены.
-func removeAlreadyInstalled(found map[string][]db.Package, installed map[string]string) map[string][]db.Package {
-	filteredPackages := make(map[string][]db.Package)
+// Returns not installed dependencies
+func removeAlreadyInstalled(opts types.BuildOpts, dependencies []string) ([]string, error) {
+	filteredPackages := []string{}
 
-	for name, pkgList := range found {
-		filteredPkgList := []db.Package{}
-		for _, pkg := range pkgList {
-			if _, isInstalled := installed[pkg.Name]; !isInstalled {
-				filteredPkgList = append(filteredPkgList, pkg)
-			}
+	for _, dep := range dependencies {
+		installed, err := opts.Manager.IsInstalled(dep)
+		if err != nil {
+			return nil, err
 		}
-		filteredPackages[name] = filteredPkgList
+		if installed {
+			continue
+		}
+		filteredPackages = append(filteredPackages, dep)
 	}
 
-	return filteredPackages
+	return filteredPackages, nil
 }
 
 // Функция packageNames возвращает имена всех предоставленных пакетов.
diff --git a/pkg/build/build_internal_test.go b/pkg/build/build_internal_test.go
new file mode 100644
index 0000000..fd089ae
--- /dev/null
+++ b/pkg/build/build_internal_test.go
@@ -0,0 +1,225 @@
+// ALR - Any Linux Repository
+// Copyright (C) 2025 Евгений Храмов
+//
+// This program is free software: you can redistribute it and/or modify
+// it under the terms of the GNU General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+// GNU General Public License for more details.
+//
+// You should have received a copy of the GNU General Public License
+// along with this program.  If not, see <http://www.gnu.org/licenses/>.
+
+package build
+
+import (
+	"context"
+	"testing"
+
+	"github.com/stretchr/testify/assert"
+
+	"plemya-x.ru/alr/internal/db"
+	"plemya-x.ru/alr/internal/types"
+	"plemya-x.ru/alr/pkg/manager"
+)
+
+type TestPackageFinder struct {
+	FindPkgsFunc func(ctx context.Context, pkgs []string) (map[string][]db.Package, []string, error)
+}
+
+func (pf *TestPackageFinder) FindPkgs(ctx context.Context, pkgs []string) (map[string][]db.Package, []string, error) {
+	if pf.FindPkgsFunc != nil {
+		return pf.FindPkgsFunc(ctx, pkgs)
+	}
+	return map[string][]db.Package{}, []string{}, nil
+}
+
+type TestManager struct {
+	NameFunc          func() string
+	FormatFunc        func() string
+	ExistsFunc        func() bool
+	SetRootCmdFunc    func(cmd string)
+	SyncFunc          func(opts *manager.Opts) error
+	InstallFunc       func(opts *manager.Opts, pkgs ...string) error
+	RemoveFunc        func(opts *manager.Opts, pkgs ...string) error
+	UpgradeFunc       func(opts *manager.Opts, pkgs ...string) error
+	InstallLocalFunc  func(opts *manager.Opts, files ...string) error
+	UpgradeAllFunc    func(opts *manager.Opts) error
+	ListInstalledFunc func(opts *manager.Opts) (map[string]string, error)
+	IsInstalledFunc   func(pkg string) (bool, error)
+}
+
+func (m *TestManager) Name() string {
+	if m.NameFunc != nil {
+		return m.NameFunc()
+	}
+	return "TestManager"
+}
+
+func (m *TestManager) Format() string {
+	if m.FormatFunc != nil {
+		return m.FormatFunc()
+	}
+	return "testpkg"
+}
+
+func (m *TestManager) Exists() bool {
+	if m.ExistsFunc != nil {
+		return m.ExistsFunc()
+	}
+	return true
+}
+
+func (m *TestManager) SetRootCmd(cmd string) {
+	if m.SetRootCmdFunc != nil {
+		m.SetRootCmdFunc(cmd)
+	}
+}
+
+func (m *TestManager) Sync(opts *manager.Opts) error {
+	if m.SyncFunc != nil {
+		return m.SyncFunc(opts)
+	}
+	return nil
+}
+
+func (m *TestManager) Install(opts *manager.Opts, pkgs ...string) error {
+	if m.InstallFunc != nil {
+		return m.InstallFunc(opts, pkgs...)
+	}
+	return nil
+}
+
+func (m *TestManager) Remove(opts *manager.Opts, pkgs ...string) error {
+	if m.RemoveFunc != nil {
+		return m.RemoveFunc(opts, pkgs...)
+	}
+	return nil
+}
+
+func (m *TestManager) Upgrade(opts *manager.Opts, pkgs ...string) error {
+	if m.UpgradeFunc != nil {
+		return m.UpgradeFunc(opts, pkgs...)
+	}
+	return nil
+}
+
+func (m *TestManager) InstallLocal(opts *manager.Opts, files ...string) error {
+	if m.InstallLocalFunc != nil {
+		return m.InstallLocalFunc(opts, files...)
+	}
+	return nil
+}
+
+func (m *TestManager) UpgradeAll(opts *manager.Opts) error {
+	if m.UpgradeAllFunc != nil {
+		return m.UpgradeAllFunc(opts)
+	}
+	return nil
+}
+
+func (m *TestManager) ListInstalled(opts *manager.Opts) (map[string]string, error) {
+	if m.ListInstalledFunc != nil {
+		return m.ListInstalledFunc(opts)
+	}
+	return map[string]string{}, nil
+}
+
+func (m *TestManager) IsInstalled(pkg string) (bool, error) {
+	if m.IsInstalledFunc != nil {
+		return m.IsInstalledFunc(pkg)
+	}
+	return true, nil
+}
+
+func TestInstallBuildDeps(t *testing.T) {
+	type testEnv struct {
+		pf   PackageFinder
+		vars *types.BuildVars
+		opts types.BuildOpts
+
+		// Contains pkgs captured by FindPkgsFunc
+		capturedPkgs []string
+	}
+
+	type testCase struct {
+		Name     string
+		Prepare  func() *testEnv
+		Expected func(t *testing.T, e *testEnv, res []string, err error)
+	}
+
+	for _, tc := range []testCase{
+		{
+			Name: "install only needed deps",
+			Prepare: func() *testEnv {
+				pf := TestPackageFinder{}
+				vars := types.BuildVars{}
+				m := TestManager{}
+				opts := types.BuildOpts{
+					Manager:     &m,
+					Interactive: false,
+				}
+
+				env := &testEnv{
+					pf:           &pf,
+					vars:         &vars,
+					opts:         opts,
+					capturedPkgs: []string{},
+				}
+
+				pf.FindPkgsFunc = func(ctx context.Context, pkgs []string) (map[string][]db.Package, []string, error) {
+					env.capturedPkgs = append(env.capturedPkgs, pkgs...)
+					result := make(map[string][]db.Package)
+					result["bar"] = []db.Package{{
+						Name: "bar-pkg",
+					}}
+					result["buz"] = []db.Package{{
+						Name: "buz-pkg",
+					}}
+
+					return result, []string{}, nil
+				}
+
+				vars.BuildDepends = []string{
+					"foo",
+					"bar",
+					"buz",
+				}
+				m.IsInstalledFunc = func(pkg string) (bool, error) {
+					if pkg == "foo" {
+						return true, nil
+					} else {
+						return false, nil
+					}
+				}
+
+				return env
+			},
+			Expected: func(t *testing.T, e *testEnv, res []string, err error) {
+				assert.NoError(t, err)
+				assert.Len(t, res, 2)
+				assert.ElementsMatch(t, res, []string{"bar-pkg", "buz-pkg"})
+
+				assert.ElementsMatch(t, e.capturedPkgs, []string{"bar", "buz"})
+			},
+		},
+	} {
+		t.Run(tc.Name, func(tt *testing.T) {
+			ctx := context.Background()
+			env := tc.Prepare()
+
+			result, err := installBuildDeps(
+				ctx,
+				env.pf,
+				env.vars,
+				env.opts,
+			)
+
+			tc.Expected(tt, env, result, err)
+		})
+	}
+}
diff --git a/pkg/manager/apk.go b/pkg/manager/apk.go
index 8d71c19..aaf0d11 100644
--- a/pkg/manager/apk.go
+++ b/pkg/manager/apk.go
@@ -149,6 +149,21 @@ func (a *APK) ListInstalled(opts *Opts) (map[string]string, error) {
 	return out, nil
 }
 
+func (a *APK) IsInstalled(pkg string) (bool, error) {
+	cmd := exec.Command("apk", "info", "--installed", pkg)
+	output, err := cmd.CombinedOutput()
+	if err != nil {
+		if exitErr, ok := err.(*exec.ExitError); ok {
+			// Exit code 1 means the package is not installed
+			if exitErr.ExitCode() == 1 {
+				return false, nil
+			}
+		}
+		return false, fmt.Errorf("apk: isinstalled: %w, output: %s", err, output)
+	}
+	return true, nil
+}
+
 func (a *APK) getCmd(opts *Opts, mgrCmd string, args ...string) *exec.Cmd {
 	var cmd *exec.Cmd
 	if opts.AsRoot {
diff --git a/pkg/manager/apt.go b/pkg/manager/apt.go
index 6ba1026..dda9e8d 100644
--- a/pkg/manager/apt.go
+++ b/pkg/manager/apt.go
@@ -135,6 +135,21 @@ func (a *APT) ListInstalled(opts *Opts) (map[string]string, error) {
 	return out, nil
 }
 
+func (a *APT) IsInstalled(pkg string) (bool, error) {
+	cmd := exec.Command("dpkg-query", "-l", pkg)
+	output, err := cmd.CombinedOutput()
+	if err != nil {
+		if exitErr, ok := err.(*exec.ExitError); ok {
+			// Exit code 1 means the package is not installed
+			if exitErr.ExitCode() == 1 {
+				return false, nil
+			}
+		}
+		return false, fmt.Errorf("apt: isinstalled: %w, output: %s", err, output)
+	}
+	return true, nil
+}
+
 func (a *APT) getCmd(opts *Opts, mgrCmd string, args ...string) *exec.Cmd {
 	var cmd *exec.Cmd
 	if opts.AsRoot {
diff --git a/pkg/manager/apt_rpm.go b/pkg/manager/apt_rpm.go
index 2593e89..c3ce1a2 100644
--- a/pkg/manager/apt_rpm.go
+++ b/pkg/manager/apt_rpm.go
@@ -17,7 +17,6 @@
 package manager
 
 import (
-	"bufio"
 	"fmt"
 	"os/exec"
 	"strings"
@@ -25,6 +24,7 @@ import (
 
 // APTRpm represents the APT-RPM package manager
 type APTRpm struct {
+	CommonRPM
 	rootCmd string
 }
 
@@ -106,38 +106,6 @@ func (a *APTRpm) UpgradeAll(opts *Opts) error {
 	return nil
 }
 
-func (y *APTRpm) ListInstalled(opts *Opts) (map[string]string, error) {
-	out := map[string]string{}
-	cmd := exec.Command("rpm", "-qa", "--queryformat", "%{NAME}\u200b%|EPOCH?{%{EPOCH}:}:{}|%{VERSION}-%{RELEASE}\\n")
-
-	stdout, err := cmd.StdoutPipe()
-	if err != nil {
-		return nil, err
-	}
-
-	err = cmd.Start()
-	if err != nil {
-		return nil, err
-	}
-
-	scanner := bufio.NewScanner(stdout)
-	for scanner.Scan() {
-		name, version, ok := strings.Cut(scanner.Text(), "\u200b")
-		if !ok {
-			continue
-		}
-		version = strings.TrimPrefix(version, "0:")
-		out[name] = version
-	}
-
-	err = scanner.Err()
-	if err != nil {
-		return nil, err
-	}
-
-	return out, nil
-}
-
 func (a *APTRpm) getCmd(opts *Opts, mgrCmd string, args ...string) *exec.Cmd {
 	var cmd *exec.Cmd
 	if opts.AsRoot {
diff --git a/pkg/manager/common_rpm.go b/pkg/manager/common_rpm.go
new file mode 100644
index 0000000..a5b1a9c
--- /dev/null
+++ b/pkg/manager/common_rpm.go
@@ -0,0 +1,72 @@
+// ALR - Any Linux Repository
+// Copyright (C) 2025 Евгений Храмов
+//
+// This program is free software: you can redistribute it and/or modify
+// it under the terms of the GNU General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+// GNU General Public License for more details.
+//
+// You should have received a copy of the GNU General Public License
+// along with this program.  If not, see <http://www.gnu.org/licenses/>.
+
+package manager
+
+import (
+	"bufio"
+	"fmt"
+	"os/exec"
+	"strings"
+)
+
+type CommonRPM struct{}
+
+func (c *CommonRPM) ListInstalled(opts *Opts) (map[string]string, error) {
+	out := map[string]string{}
+	cmd := exec.Command("rpm", "-qa", "--queryformat", "%{NAME}\u200b%|EPOCH?{%{EPOCH}:}:{}|%{VERSION}-%{RELEASE}\\n")
+
+	stdout, err := cmd.StdoutPipe()
+	if err != nil {
+		return nil, err
+	}
+
+	err = cmd.Start()
+	if err != nil {
+		return nil, err
+	}
+
+	scanner := bufio.NewScanner(stdout)
+	for scanner.Scan() {
+		name, version, ok := strings.Cut(scanner.Text(), "\u200b")
+		if !ok {
+			continue
+		}
+		version = strings.TrimPrefix(version, "0:")
+		out[name] = version
+	}
+
+	err = scanner.Err()
+	if err != nil {
+		return nil, err
+	}
+
+	return out, nil
+}
+
+func (a *CommonRPM) IsInstalled(pkg string) (bool, error) {
+	cmd := exec.Command("rpm", "-q", "--whatprovides", pkg)
+	output, err := cmd.CombinedOutput()
+	if err != nil {
+		if exitErr, ok := err.(*exec.ExitError); ok {
+			if exitErr.ExitCode() == 1 {
+				return false, nil
+			}
+		}
+		return false, fmt.Errorf("rpm: isinstalled: %w, output: %s", err, output)
+	}
+	return true, nil
+}
diff --git a/pkg/manager/dnf.go b/pkg/manager/dnf.go
index a3b4dc6..079e967 100644
--- a/pkg/manager/dnf.go
+++ b/pkg/manager/dnf.go
@@ -19,14 +19,13 @@
 package manager
 
 import (
-	"bufio"
 	"fmt"
 	"os/exec"
-	"strings"
 )
 
 // DNF представляет менеджер пакетов DNF
 type DNF struct {
+	CommonRPM
 	rootCmd string // rootCmd хранит команду, используемую для выполнения команд с правами root
 }
 
@@ -120,39 +119,6 @@ func (d *DNF) UpgradeAll(opts *Opts) error {
 	return nil
 }
 
-// ListInstalled возвращает список установленных пакетов и их версий
-func (d *DNF) ListInstalled(opts *Opts) (map[string]string, error) {
-	out := map[string]string{}
-	cmd := exec.Command("rpm", "-qa", "--queryformat", "%{NAME}\u200b%|EPOCH?{%{EPOCH}:}:{}|%{VERSION}-%{RELEASE}\\n")
-
-	stdout, err := cmd.StdoutPipe()
-	if err != nil {
-		return nil, err
-	}
-
-	err = cmd.Start()
-	if err != nil {
-		return nil, err
-	}
-
-	scanner := bufio.NewScanner(stdout)
-	for scanner.Scan() {
-		name, version, ok := strings.Cut(scanner.Text(), "\u200b")
-		if !ok {
-			continue
-		}
-		version = strings.TrimPrefix(version, "0:")
-		out[name] = version
-	}
-
-	err = scanner.Err()
-	if err != nil {
-		return nil, err
-	}
-
-	return out, nil
-}
-
 // getCmd создает и возвращает команду exec.Cmd для менеджера пакетов DNF
 func (d *DNF) getCmd(opts *Opts, mgrCmd string, args ...string) *exec.Cmd {
 	var cmd *exec.Cmd
diff --git a/pkg/manager/managers.go b/pkg/manager/managers.go
index 3467d72..0219bd3 100644
--- a/pkg/manager/managers.go
+++ b/pkg/manager/managers.go
@@ -80,6 +80,8 @@ type Manager interface {
 	UpgradeAll(*Opts) error
 	// ListInstalled returns all installed packages mapped to their versions
 	ListInstalled(*Opts) (map[string]string, error)
+	//
+	IsInstalled(string) (bool, error)
 }
 
 // Detect returns the package manager detected on the system
diff --git a/pkg/manager/pacman.go b/pkg/manager/pacman.go
index 3e3422b..825bff4 100644
--- a/pkg/manager/pacman.go
+++ b/pkg/manager/pacman.go
@@ -142,6 +142,21 @@ func (p *Pacman) ListInstalled(opts *Opts) (map[string]string, error) {
 	return out, nil
 }
 
+func (p *Pacman) IsInstalled(pkg string) (bool, error) {
+	cmd := exec.Command("pacman", "-Q", pkg)
+	output, err := cmd.CombinedOutput()
+	if err != nil {
+		// Pacman returns exit code 1 if the package is not found
+		if exitErr, ok := err.(*exec.ExitError); ok {
+			if exitErr.ExitCode() == 1 {
+				return false, nil
+			}
+		}
+		return false, fmt.Errorf("pacman: isinstalled: %w, output: %s", err, output)
+	}
+	return true, nil
+}
+
 func (p *Pacman) getCmd(opts *Opts, mgrCmd string, args ...string) *exec.Cmd {
 	var cmd *exec.Cmd
 	if opts.AsRoot {
diff --git a/pkg/manager/yum.go b/pkg/manager/yum.go
index c9938ed..65b00e5 100644
--- a/pkg/manager/yum.go
+++ b/pkg/manager/yum.go
@@ -20,14 +20,14 @@
 package manager
 
 import (
-	"bufio"
 	"fmt"
 	"os/exec"
-	"strings"
 )
 
 // YUM represents the YUM package manager
 type YUM struct {
+	CommonRPM
+
 	rootCmd string
 }
 
@@ -111,38 +111,6 @@ func (y *YUM) UpgradeAll(opts *Opts) error {
 	return nil
 }
 
-func (y *YUM) ListInstalled(opts *Opts) (map[string]string, error) {
-	out := map[string]string{}
-	cmd := exec.Command("rpm", "-qa", "--queryformat", "%{NAME}\u200b%|EPOCH?{%{EPOCH}:}:{}|%{VERSION}-%{RELEASE}\\n")
-
-	stdout, err := cmd.StdoutPipe()
-	if err != nil {
-		return nil, err
-	}
-
-	err = cmd.Start()
-	if err != nil {
-		return nil, err
-	}
-
-	scanner := bufio.NewScanner(stdout)
-	for scanner.Scan() {
-		name, version, ok := strings.Cut(scanner.Text(), "\u200b")
-		if !ok {
-			continue
-		}
-		version = strings.TrimPrefix(version, "0:")
-		out[name] = version
-	}
-
-	err = scanner.Err()
-	if err != nil {
-		return nil, err
-	}
-
-	return out, nil
-}
-
 func (y *YUM) getCmd(opts *Opts, mgrCmd string, args ...string) *exec.Cmd {
 	var cmd *exec.Cmd
 	if opts.AsRoot {
diff --git a/pkg/manager/zypper.go b/pkg/manager/zypper.go
index 5a7cd5c..bf7a022 100644
--- a/pkg/manager/zypper.go
+++ b/pkg/manager/zypper.go
@@ -20,14 +20,13 @@
 package manager
 
 import (
-	"bufio"
 	"fmt"
 	"os/exec"
-	"strings"
 )
 
 // Zypper represents the Zypper package manager
 type Zypper struct {
+	CommonRPM
 	rootCmd string
 }
 
@@ -111,38 +110,6 @@ func (z *Zypper) UpgradeAll(opts *Opts) error {
 	return nil
 }
 
-func (z *Zypper) ListInstalled(opts *Opts) (map[string]string, error) {
-	out := map[string]string{}
-	cmd := exec.Command("rpm", "-qa", "--queryformat", "%{NAME}\u200b%|EPOCH?{%{EPOCH}:}:{}|%{VERSION}-%{RELEASE}\\n")
-
-	stdout, err := cmd.StdoutPipe()
-	if err != nil {
-		return nil, err
-	}
-
-	err = cmd.Start()
-	if err != nil {
-		return nil, err
-	}
-
-	scanner := bufio.NewScanner(stdout)
-	for scanner.Scan() {
-		name, version, ok := strings.Cut(scanner.Text(), "\u200b")
-		if !ok {
-			continue
-		}
-		version = strings.TrimPrefix(version, "0:")
-		out[name] = version
-	}
-
-	err = scanner.Err()
-	if err != nil {
-		return nil, err
-	}
-
-	return out, nil
-}
-
 func (z *Zypper) getCmd(opts *Opts, mgrCmd string, args ...string) *exec.Cmd {
 	var cmd *exec.Cmd
 	if opts.AsRoot {