diff --git a/.gitverse/workflows/e2e-tests.yaml b/.gitverse/workflows/e2e-tests.yaml
new file mode 100644
index 0000000..246f22f
--- /dev/null
+++ b/.gitverse/workflows/e2e-tests.yaml
@@ -0,0 +1,49 @@
+# ALR - Any Linux Repository
+# Copyright (C) 2025 The ALR Authors
+#
+# 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 .
+
+name: E2E
+
+# on:
+# push:
+# branches: [ main ]
+# pull_request:
+on:
+ workflow_dispatch:
+
+jobs:
+ tests:
+ runs-on: ubuntu-latest
+
+ container:
+ image: altlinux.space/maks1ms/actions-container-runner:latest
+
+ steps:
+ - name: Checkout
+ uses: actions/checkout@v4
+ with:
+ fetch-depth: 0
+
+ - name: Set up Go
+ uses: actions/setup-go@v5
+ with:
+ go-version: '1.24'
+ cache: false
+
+ - name: Run E2E tests
+ env:
+ IGNORE_ROOT_CHECK: 1
+ run: |
+ make e2e-test
diff --git a/.gitverse/workflows/pre-commit.yaml b/.gitverse/workflows/pre-commit.yaml
new file mode 100644
index 0000000..be6fcec
--- /dev/null
+++ b/.gitverse/workflows/pre-commit.yaml
@@ -0,0 +1,49 @@
+# ALR - Any Linux Repository
+# Copyright (C) 2025 The ALR Authors
+#
+# 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 .
+
+
+name: Pre-commit
+
+on:
+ push:
+ branches: [ master ]
+ pull_request:
+
+
+jobs:
+ pre-commit:
+ runs-on: ubuntu-latest
+
+ steps:
+ - name: Checkout
+ uses: actions/checkout@v4
+
+ - name: Set up Go
+ uses: actions/setup-go@v5
+ with:
+ go-version: '1.24'
+
+ - name: Set up Python for pre-commit
+ uses: actions/setup-python@v5
+ with:
+ python-version: '3.12'
+
+ - name: Install deps
+ run: apt-get update && apt-get install -y gettext bc
+
+ - run: pip install pre-commit
+ - run: pre-commit install
+ - run: pre-commit run --all-files
diff --git a/.gitverse/workflows/release.yaml b/.gitverse/workflows/release.yaml
new file mode 100644
index 0000000..7d2c866
--- /dev/null
+++ b/.gitverse/workflows/release.yaml
@@ -0,0 +1,185 @@
+# ALR - Any Linux Repository
+# Copyright (C) 2025 The ALR Authors
+#
+# 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 .
+
+name: Create Release
+
+on:
+ push:
+ tags:
+ - 'v[0-9]+.[0-9]+.[0-9]+'
+
+jobs:
+ changelog:
+ runs-on: ubuntu-latest
+ steps:
+ - name: Checkout this repository
+ uses: actions/checkout@v4
+ with:
+ fetch-depth: 0
+
+ - name: Set up Go
+ uses: actions/setup-go@v5
+ with:
+ go-version: '1.24'
+
+ - name: Get Changes between Tags
+ id: changes
+ run: |
+ # Получаем текущий и предыдущий теги
+ CURRENT_TAG=${GITHUB_REF##*/}
+ PREVIOUS_TAG=$(git describe --tags --abbrev=0 ${CURRENT_TAG}^ 2>/dev/null || echo "")
+
+ if [ -n "$PREVIOUS_TAG" ]; then
+ CHANGES=$(git log ${PREVIOUS_TAG}..${CURRENT_TAG} --pretty=format:"- %s" --no-merges)
+ else
+ CHANGES=$(git log ${CURRENT_TAG} --pretty=format:"- %s" --no-merges)
+ fi
+
+ # Экранируем для использования в GitHub Actions
+ CHANGES="${CHANGES//'%'/'%25'}"
+ CHANGES="${CHANGES//$'\n'/'%0A'}"
+ CHANGES="${CHANGES//$'\r'/'%0D'}"
+
+ echo "changes=$CHANGES" >> $GITHUB_OUTPUT
+
+ - name: Set version
+ run: |
+ version=$(echo "${GITHUB_REF##*/}" | sed 's/^v//')
+ echo "Version - $version"
+ echo "VERSION=$version" >> $GITHUB_ENV
+
+ - name: Prepare for install
+ run: |
+ apt-get update
+
+ - name: Build alr
+ env:
+ IGNORE_ROOT_CHECK: 1
+ run: |
+ make build
+
+ - name: Create tar.gz
+ run: |
+ mkdir -p ./out/completion
+ cp alr ./out
+ cp scripts/completion/bash ./out/completion/alr
+ cp scripts/completion/zsh ./out/completion/_alr
+
+ ( cd out && tar -czvf ../alr-${{ env.VERSION }}-linux-x86_64.tar.gz * )
+
+ - name: Create Release via GitVerse API
+ env:
+ GITVERSE_TOKEN: ${{ secrets.GITVERSE_TOKEN }}
+ run: |
+ TAG_NAME=${GITHUB_REF##*/}
+ RELEASE_NAME="ALR ${{ env.VERSION }}"
+ BODY="${{ steps.changes.outputs.changes }}"
+
+ # Создаём релиз через GitVerse API
+ RELEASE_RESPONSE=$(curl -s -X POST \
+ "https://gitverse.ru/api/v1/repos/${{ gitverse.repository_owner }}/${{ gitverse.repository }}/releases" \
+ -H "Authorization: token ${GITVERSE_TOKEN}" \
+ -H "Content-Type: application/json" \
+ -d "{
+ \"tag_name\": \"${TAG_NAME}\",
+ \"name\": \"${RELEASE_NAME}\",
+ \"body\": \"${BODY}\",
+ \"draft\": false,
+ \"prerelease\": false
+ }")
+
+ RELEASE_ID=$(echo "$RELEASE_RESPONSE" | jq -r '.id')
+ echo "RELEASE_ID=$RELEASE_ID" >> $GITHUB_ENV
+ echo "Created release with ID: $RELEASE_ID"
+
+ - name: Upload tar.gz asset
+ env:
+ GITVERSE_TOKEN: ${{ secrets.GITVERSE_TOKEN }}
+ run: |
+ curl -s -X POST \
+ "https://gitverse.ru/api/v1/repos/${{ gitverse.repository_owner }}/${{ gitverse.repository }}/releases/${{ env.RELEASE_ID }}/assets?name=alr-${{ env.VERSION }}-linux-x86_64.tar.gz" \
+ -H "Authorization: token ${GITVERSE_TOKEN}" \
+ -H "Content-Type: application/octet-stream" \
+ --data-binary "@alr-${{ env.VERSION }}-linux-x86_64.tar.gz"
+
+ - name: Checkout alr-default repository
+ uses: actions/checkout@v4
+ with:
+ repository: ${{ gitverse.repository_owner }}/alr-default
+ token: ${{ secrets.GITVERSE_PUBLIC_TOKEN }}
+ path: alr-default
+
+ - name: Calculate checksum
+ run: |
+ # Вычисляем SHA256 контрольную сумму архива
+ CHECKSUM=$(sha256sum alr-${{ env.VERSION }}-linux-x86_64.tar.gz | awk '{print $1}')
+ echo "Archive checksum: $CHECKSUM"
+ echo "CHECKSUM=$CHECKSUM" >> $GITHUB_ENV
+
+ - name: Update version and checksum in alr-bin
+ run: |
+ # Обновляем версию
+ sed -i "s/version='[0-9]\+\.[0-9]\+\.[0-9]\+'/version='${{ env.VERSION }}'/g" alr-default/alr-bin/alr.sh
+ sed -i "s/release='[0-9]\+'/release='1'/g" alr-default/alr-bin/alr.sh
+
+ # Обновляем контрольную сумму
+ sed -i "s/checksums=('[^']*')/checksums=('${{ env.CHECKSUM }}')/g" alr-default/alr-bin/alr.sh
+
+ - name: Commit and push changes to alr-default
+ run: |
+ cd alr-default
+ git config user.name "gitverse"
+ git config user.email "admin@plemya-x.ru"
+ git add alr-bin/alr.sh
+ git commit -m "Обновление alr-bin до версии ${{ env.VERSION }}"
+ git push
+
+ - name: Install alr
+ env:
+ CREATE_SYSTEM_RESOURCES: 0
+ run: |
+ make install
+
+ - name: Prepare directories for ALR
+ run: |
+ # Создаём необходимые директории для работы alr build
+ mkdir -p /tmp/alr/dl /tmp/alr/pkgs /var/cache/alr
+ chmod -R 777 /tmp/alr
+ chmod -R 755 /var/cache/alr
+
+ - name: Build packages
+ run: |
+ SCRIPT_PATH=alr-default/alr-bin/alr.sh
+ ALR_DISTRO=altlinux ALR_PKG_FORMAT=rpm alr build -s "$SCRIPT_PATH"
+ ALR_PKG_FORMAT=rpm alr build -s "$SCRIPT_PATH"
+ ALR_PKG_FORMAT=deb alr build -s "$SCRIPT_PATH"
+ ALR_PKG_FORMAT=archlinux alr build -s "$SCRIPT_PATH"
+
+ - name: Upload package assets
+ env:
+ GITVERSE_TOKEN: ${{ secrets.GITVERSE_TOKEN }}
+ run: |
+ # Загружаем все собранные пакеты
+ for file in alr-bin*.deb alr-bin*.rpm alr-bin*.pkg.tar.zst; do
+ if [ -f "$file" ]; then
+ echo "Uploading $file..."
+ curl -s -X POST \
+ "https://gitverse.ru/api/v1/repos/${{ gitverse.repository_owner }}/${{ gitverse.repository }}/releases/${{ env.RELEASE_ID }}/assets?name=${file}" \
+ -H "Authorization: token ${GITVERSE_TOKEN}" \
+ -H "Content-Type: application/octet-stream" \
+ --data-binary "@${file}"
+ fi
+ done
diff --git a/AUTHORS b/AUTHORS
index e11401e..ba25ae0 100644
--- a/AUTHORS
+++ b/AUTHORS
@@ -1,2 +1 @@
-Евгений Храмов
-Maxim Slipenko
\ No newline at end of file
+Евгений Храмов
\ No newline at end of file
diff --git a/e2e-tests/common_test.go b/e2e-tests/common_test.go
index 8221663..41ab638 100644
--- a/e2e-tests/common_test.go
+++ b/e2e-tests/common_test.go
@@ -30,19 +30,21 @@ var ALL_SYSTEMS []string = []string{
"ubuntu-24.04",
"alt-sisyphus",
"fedora-41",
- // "archlinux",
- // "alpine",
- // "opensuse-leap",
- // "redos-8",
+ "archlinux",
+ "alpine",
+ "opensuse-leap",
}
var AUTOREQ_AUTOPROV_SYSTEMS []string = []string{
- // "alt-sisyphus",
+ "alt-sisyphus",
"fedora-41",
+ "opensuse-leap",
}
var RPM_SYSTEMS []string = []string{
+ "alt-sisyphus",
"fedora-41",
+ "opensuse-leap",
}
var COMMON_SYSTEMS []string = []string{
diff --git a/internal/build/build.go b/internal/build/build.go
index ea76127..9caa8a0 100644
--- a/internal/build/build.go
+++ b/internal/build/build.go
@@ -596,22 +596,73 @@ func (b *Builder) BuildALRDeps(
return nil, nil, fmt.Errorf("failed to sort dependencies: %w", err)
}
+ // Шаг 2.5: Фильтруем уже установленные пакеты
+ // Собираем пакеты вместе с их ключами (именами поиска)
+ type pkgWithKey struct {
+ key string
+ pkg alrsh.Package
+ }
+ var allPkgsWithKeys []pkgWithKey
+ for key, node := range depTree {
+ if node.Package != nil {
+ allPkgsWithKeys = append(allPkgsWithKeys, pkgWithKey{key: key, pkg: *node.Package})
+ }
+ }
+
+ var allPkgs []alrsh.Package
+ for _, p := range allPkgsWithKeys {
+ allPkgs = append(allPkgs, p.pkg)
+ }
+
+ slog.Info("DEBUG: allPkgs count", "count", len(allPkgs))
+ for _, p := range allPkgsWithKeys {
+ slog.Info("DEBUG: package in depTree", "key", p.key, "name", p.pkg.Name, "repo", p.pkg.Repository)
+ }
+
+ needBuildPkgs, err := b.installerExecutor.FilterPackagesByVersion(ctx, allPkgs, input.OSRelease())
+ if err != nil {
+ return nil, nil, fmt.Errorf("failed to filter packages: %w", err)
+ }
+
+ // Создаём множество имён пакетов, которые нужно собрать
+ needBuildNames := make(map[string]bool)
+ for _, pkg := range needBuildPkgs {
+ needBuildNames[pkg.Name] = true
+ }
+
+ slog.Info("DEBUG: needBuildPkgs count", "count", len(needBuildPkgs))
+ for _, pkg := range needBuildPkgs {
+ slog.Info("DEBUG: package needs build", "name", pkg.Name)
+ }
+
+ // Строим needBuildSet по КЛЮЧАМ depTree, а не по pkg.Name
+ // Это важно, т.к. ключ может быть именем из Provides (python3-pyside6),
+ // а pkg.Name - фактическое имя пакета (python3-shiboken6)
+ needBuildSet := make(map[string]bool)
+ for _, p := range allPkgsWithKeys {
+ if needBuildNames[p.pkg.Name] {
+ needBuildSet[p.key] = true
+ }
+ }
+
// Шаг 3: Группируем подпакеты по basePkgName для оптимизации сборки
// Если несколько подпакетов из одного мультипакета, собираем их вместе
- builtBasePkgs := make(map[string]bool) // Уже собранные базовые пакеты
+ slog.Info("DEBUG: sortedPkgs", "pkgs", sortedPkgs)
// Шаг 4: Собираем пакеты в правильном порядке, проверяя кеш
for _, pkgName := range sortedPkgs {
node := depTree[pkgName]
if node == nil {
+ slog.Info("DEBUG: node is nil", "pkgName", pkgName)
continue
}
pkg := node.Package
basePkgName := node.BasePkgName
- // Если базовый пакет уже собран, пропускаем
- if builtBasePkgs[basePkgName] {
+ // Пропускаем уже установленные пакеты
+ if !needBuildSet[pkgName] {
+ slog.Info("DEBUG: skipping (not in needBuildSet)", "pkgName", pkgName)
continue
}
@@ -636,10 +687,13 @@ func (b *Builder) BuildALRDeps(
if allInCache {
// Подпакет в кеше, используем его
+ slog.Info("DEBUG: using cached package", "pkgName", pkgName)
buildDeps = append(buildDeps, cachedDeps...)
continue
}
+ slog.Info("DEBUG: building package", "pkgName", pkgName)
+
// Собираем только запрошенный подпакет
// SkipDepsBuilding: true предотвращает рекурсивный вызов BuildALRDeps
res, err := b.BuildPackageFromDb(
@@ -660,7 +714,6 @@ func (b *Builder) BuildALRDeps(
}
buildDeps = append(buildDeps, res...)
- builtBasePkgs[basePkgName] = true
}
buildDeps = removeDuplicates(buildDeps)
@@ -846,8 +899,9 @@ func (i *Builder) InstallPkgs(
if err != nil {
return nil, err
}
-
- // Отслеживание установки пакетов из репозитория
+
+ _ = i.installerExecutor.CheckVersionsAfterInstall(ctx, repoDeps)
+
for _, pkg := range repoDeps {
if stats.ShouldTrackPackage(pkg) {
stats.TrackInstallation(ctx, pkg, "install")
diff --git a/internal/build/dependency_tree.go b/internal/build/dependency_tree.go
index c00efaa..9c505a7 100644
--- a/internal/build/dependency_tree.go
+++ b/internal/build/dependency_tree.go
@@ -20,6 +20,7 @@ import (
"context"
"fmt"
+ "gitea.plemya-x.ru/Plemya-x/ALR/internal/overrides"
"gitea.plemya-x.ru/Plemya-x/ALR/pkg/alrsh"
)
@@ -44,7 +45,12 @@ func (b *Builder) ResolveDependencyTree(
) (map[string]*DependencyNode, []string, error) {
resolved := make(map[string]*DependencyNode)
visited := make(map[string]bool)
- systemDeps := make(map[string]bool) // Для дедупликации системных зависимостей
+ systemDeps := make(map[string]bool)
+
+ overrideNames, err := overrides.Resolve(input.OSRelease(), overrides.DefaultOpts)
+ if err != nil {
+ return nil, nil, fmt.Errorf("failed to resolve overrides: %w", err)
+ }
var resolve func(pkgNames []string) error
resolve = func(pkgNames []string) error {
@@ -77,20 +83,17 @@ func (b *Builder) ResolveDependencyTree(
pkg := pkgList[0]
- // Определяем базовое имя пакета
+ alrsh.ResolvePackage(&pkg, overrideNames)
+
baseName := pkg.BasePkgName
if baseName == "" {
baseName = pkg.Name
}
- // Используем имя конкретного подпакета как ключ (не basePkgName)
- // Это позволяет собирать только запрошенный подпакет, а не весь мультипакет
if resolved[pkgName] != nil {
continue
}
- // Получаем зависимости для этого дистрибутива
- // Пакет из БД уже содержит разрешенные значения для текущего дистрибутива
deps := pkg.Depends.Resolved()
buildDeps := pkg.BuildDepends.Resolved()
diff --git a/internal/build/find_deps/find_deps.go b/internal/build/find_deps/find_deps.go
index 3231b1e..5dc10ab 100644
--- a/internal/build/find_deps/find_deps.go
+++ b/internal/build/find_deps/find_deps.go
@@ -18,6 +18,8 @@ package finddeps
import (
"context"
+ "os"
+ "os/exec"
"github.com/goreleaser/nfpm/v2"
@@ -39,10 +41,9 @@ func New(info *distro.OSRelease, pkgFormat string) *ProvReqService {
finder: &EmptyFindProvReq{},
}
if pkgFormat == "rpm" {
- switch info.ID {
- case "altlinux":
+ if _, err := os.Stat("/usr/lib/rpm/find-provides"); err == nil {
s.finder = &ALTLinuxFindProvReq{}
- case "fedora":
+ } else if _, err := exec.LookPath("/usr/lib/rpm/rpmdeps"); err == nil {
s.finder = &FedoraFindProvReq{}
}
}
diff --git a/internal/build/installer.go b/internal/build/installer.go
index 571e586..75037dd 100644
--- a/internal/build/installer.go
+++ b/internal/build/installer.go
@@ -27,6 +27,7 @@ import (
"gitea.plemya-x.ru/Plemya-x/ALR/internal/manager"
"gitea.plemya-x.ru/Plemya-x/ALR/internal/overrides"
"gitea.plemya-x.ru/Plemya-x/ALR/pkg/alrsh"
+ "gitea.plemya-x.ru/Plemya-x/ALR/pkg/depver"
"gitea.plemya-x.ru/Plemya-x/ALR/pkg/distro"
)
@@ -43,7 +44,14 @@ func (i *Installer) InstallLocal(ctx context.Context, paths []string, opts *mana
}
func (i *Installer) Install(ctx context.Context, pkgs []string, opts *manager.Opts) error {
- return i.mgr.Install(opts, pkgs...)
+ // Convert dependencies to manager-specific format
+ converted := make([]string, len(pkgs))
+ for idx, pkg := range pkgs {
+ dep := depver.Parse(pkg)
+ converted[idx] = dep.ForManager(i.mgr.Name())
+ }
+
+ return i.mgr.Install(opts, converted...)
}
func (i *Installer) Remove(ctx context.Context, pkgs []string, opts *manager.Opts) error {
@@ -54,19 +62,71 @@ func (i *Installer) RemoveAlreadyInstalled(ctx context.Context, pkgs []string) (
filteredPackages := []string{}
for _, dep := range pkgs {
- installed, err := i.mgr.IsInstalled(dep)
+ parsed := depver.Parse(dep)
+
+ // Check if package is installed
+ installed, err := i.mgr.IsInstalled(parsed.Name)
if err != nil {
return nil, err
}
- if installed {
+
+ if !installed {
+ filteredPackages = append(filteredPackages, dep)
continue
}
- filteredPackages = append(filteredPackages, dep)
+
+ // If there's a version constraint, check if installed version satisfies it
+ if parsed.HasVersionConstraint() {
+ installedVer, err := i.mgr.GetInstalledVersion(parsed.Name)
+ if err != nil {
+ return nil, err
+ }
+
+ if !parsed.Satisfies(installedVer) {
+ // Installed version doesn't satisfy constraint - need to upgrade
+ slog.Debug("installed version doesn't satisfy constraint",
+ "package", parsed.Name,
+ "required", dep,
+ "installed", installedVer)
+ filteredPackages = append(filteredPackages, dep)
+ }
+ }
}
return filteredPackages, nil
}
+func (i *Installer) CheckVersionsAfterInstall(ctx context.Context, pkgs []string) error {
+ for _, pkg := range pkgs {
+ parsed := depver.Parse(pkg)
+ if !parsed.HasVersionConstraint() {
+ continue
+ }
+
+ installedVer, err := i.mgr.GetInstalledVersion(parsed.Name)
+ if err != nil {
+ slog.Warn(gotext.Get("Failed to get installed version"),
+ "package", parsed.Name,
+ "error", err)
+ continue
+ }
+
+ if installedVer == "" {
+ slog.Warn(gotext.Get("Package was not installed"),
+ "package", parsed.Name)
+ continue
+ }
+
+ if !parsed.Satisfies(installedVer) {
+ slog.Warn(gotext.Get("Installed version doesn't satisfy requirement"),
+ "package", parsed.Name,
+ "required", pkg,
+ "installed", installedVer)
+ }
+ }
+ return nil
+}
+
func (i *Installer) FilterPackagesByVersion(ctx context.Context, packages []alrsh.Package, osRelease *distro.OSRelease) ([]alrsh.Package, error) {
installedPkgs, err := i.mgr.ListInstalled(nil)
if err != nil {
diff --git a/internal/build/plugins_executors.go b/internal/build/plugins_executors.go
index 0ddad49..a9d8ec2 100644
--- a/internal/build/plugins_executors.go
+++ b/internal/build/plugins_executors.go
@@ -36,6 +36,7 @@ type InstallerExecutor interface {
Remove(ctx context.Context, pkgs []string, opts *manager.Opts) error
RemoveAlreadyInstalled(ctx context.Context, pkgs []string) ([]string, error)
FilterPackagesByVersion(ctx context.Context, packages []alrsh.Package, osRelease *distro.OSRelease) ([]alrsh.Package, error)
+ CheckVersionsAfterInstall(ctx context.Context, pkgs []string) error
}
type ScriptExecutor interface {
diff --git a/internal/build/plugins_executors_gen.go b/internal/build/plugins_executors_gen.go
index 49bf3f4..7274e0f 100644
--- a/internal/build/plugins_executors_gen.go
+++ b/internal/build/plugins_executors_gen.go
@@ -238,6 +238,33 @@ func (s *InstallerExecutorRPCServer) FilterPackagesByVersion(args *InstallerExec
return nil
}
+type InstallerExecutorCheckVersionsAfterInstallArgs struct {
+ Pkgs []string
+}
+
+type InstallerExecutorCheckVersionsAfterInstallResp struct {
+}
+
+func (s *InstallerExecutorRPC) CheckVersionsAfterInstall(ctx context.Context, pkgs []string) error {
+ var resp *InstallerExecutorCheckVersionsAfterInstallResp
+ err := s.client.Call("Plugin.CheckVersionsAfterInstall", &InstallerExecutorCheckVersionsAfterInstallArgs{
+ Pkgs: pkgs,
+ }, &resp)
+ if err != nil {
+ return err
+ }
+ return nil
+}
+
+func (s *InstallerExecutorRPCServer) CheckVersionsAfterInstall(args *InstallerExecutorCheckVersionsAfterInstallArgs, resp *InstallerExecutorCheckVersionsAfterInstallResp) error {
+ err := s.Impl.CheckVersionsAfterInstall(context.Background(), args.Pkgs)
+ if err != nil {
+ return err
+ }
+ *resp = InstallerExecutorCheckVersionsAfterInstallResp{}
+ return nil
+}
+
type ScriptExecutorReadScriptArgs struct {
ScriptPath string
}
diff --git a/internal/manager/apk.go b/internal/manager/apk.go
index a562c5c..c809156 100644
--- a/internal/manager/apk.go
+++ b/internal/manager/apk.go
@@ -167,3 +167,33 @@ func (a *APK) IsInstalled(pkg string) (bool, error) {
}
return true, nil
}
+
+func (a *APK) GetInstalledVersion(pkg string) (string, 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 "", nil
+ }
+ }
+ return "", fmt.Errorf("apk: getinstalledversion: %w, output: %s", err, output)
+ }
+
+ // Output format: "package-version" (e.g., "curl-8.5.0-r0")
+ // We need to extract just the version part
+ line := strings.TrimSpace(string(output))
+ if line == "" {
+ return "", nil
+ }
+
+ // Find the last hyphen that separates name from version
+ // Alpine package names can contain hyphens, version starts after last one
+ lastDash := strings.LastIndex(line, "-")
+ if lastDash == -1 {
+ return "", nil
+ }
+
+ return line[lastDash+1:], nil
+}
diff --git a/internal/manager/apt.go b/internal/manager/apt.go
index b3ee7fe..72b1709 100644
--- a/internal/manager/apt.go
+++ b/internal/manager/apt.go
@@ -82,8 +82,15 @@ func (a *APT) InstallLocal(opts *Opts, pkgs ...string) error {
func (a *APT) Remove(opts *Opts, pkgs ...string) error {
opts = ensureOpts(opts)
+
+ resolvedPkgs := make([]string, 0, len(pkgs))
+ for _, pkg := range pkgs {
+ resolved := a.resolvePackageName(pkg)
+ resolvedPkgs = append(resolvedPkgs, resolved)
+ }
+
cmd := a.getCmd(opts, "apt", "remove")
- cmd.Args = append(cmd.Args, pkgs...)
+ cmd.Args = append(cmd.Args, resolvedPkgs...)
setCmdEnv(cmd)
err := cmd.Run()
if err != nil {
@@ -92,6 +99,39 @@ func (a *APT) Remove(opts *Opts, pkgs ...string) error {
return nil
}
+func (a *APT) resolvePackageName(pkg string) string {
+ cmd := exec.Command("dpkg-query", "-f", "${Status}", "-W", pkg)
+ output, err := cmd.Output()
+ if err == nil && strings.Contains(string(output), "install ok installed") {
+ return pkg
+ }
+
+ cmd = exec.Command("dpkg-query", "-W", "-f", "${Package}\t${Provides}\n")
+ output, err = cmd.Output()
+ if err != nil {
+ return pkg
+ }
+
+ for _, line := range strings.Split(string(output), "\n") {
+ parts := strings.SplitN(line, "\t", 2)
+ if len(parts) != 2 {
+ continue
+ }
+ pkgName := parts[0]
+ provides := parts[1]
+
+ for _, p := range strings.Split(provides, ", ") {
+ p = strings.TrimSpace(p)
+ provName := strings.Split(p, " ")[0]
+ if provName == pkg {
+ return pkgName
+ }
+ }
+ }
+
+ return pkg
+}
+
func (a *APT) Upgrade(opts *Opts, pkgs ...string) error {
opts = ensureOpts(opts)
return a.Install(opts, pkgs...)
@@ -140,11 +180,11 @@ func (a *APT) ListInstalled(opts *Opts) (map[string]string, error) {
}
func (a *APT) IsInstalled(pkg string) (bool, error) {
- cmd := exec.Command("dpkg-query", "-f", "${Status}", "-W", pkg)
+ resolved := a.resolvePackageName(pkg)
+ cmd := exec.Command("dpkg-query", "-f", "${Status}", "-W", resolved)
output, err := cmd.CombinedOutput()
if err != nil {
if exitErr, ok := err.(*exec.ExitError); ok {
- // Код выхода 1 означает что пакет не найден
if exitErr.ExitCode() == 1 {
return false, nil
}
@@ -153,6 +193,21 @@ func (a *APT) IsInstalled(pkg string) (bool, error) {
}
status := strings.TrimSpace(string(output))
- // Проверяем что пакет действительно установлен (статус должен содержать "install ok installed")
return strings.Contains(status, "install ok installed"), nil
}
+
+func (a *APT) GetInstalledVersion(pkg string) (string, error) {
+ resolved := a.resolvePackageName(pkg)
+ cmd := exec.Command("dpkg-query", "-f", "${Version}", "-W", resolved)
+ output, err := cmd.CombinedOutput()
+ if err != nil {
+ if exitErr, ok := err.(*exec.ExitError); ok {
+ if exitErr.ExitCode() == 1 {
+ return "", nil
+ }
+ }
+ return "", fmt.Errorf("apt: getinstalledversion: %w, output: %s", err, output)
+ }
+
+ return strings.TrimSpace(string(output)), nil
+}
diff --git a/internal/manager/common_rpm.go b/internal/manager/common_rpm.go
index f2ec654..c3920f2 100644
--- a/internal/manager/common_rpm.go
+++ b/internal/manager/common_rpm.go
@@ -70,3 +70,21 @@ func (a *CommonRPM) IsInstalled(pkg string) (bool, error) {
}
return true, nil
}
+
+func (a *CommonRPM) GetInstalledVersion(pkg string) (string, error) {
+ cmd := exec.Command("rpm", "-q", "--queryformat", "%|EPOCH?{%{EPOCH}:}:{}|%{VERSION}-%{RELEASE}", pkg)
+ output, err := cmd.CombinedOutput()
+ if err != nil {
+ if exitErr, ok := err.(*exec.ExitError); ok {
+ if exitErr.ExitCode() == 1 {
+ return "", nil
+ }
+ }
+ return "", fmt.Errorf("rpm: getinstalledversion: %w, output: %s", err, output)
+ }
+
+ version := strings.TrimSpace(string(output))
+ // Remove epoch 0: prefix if present
+ version = strings.TrimPrefix(version, "0:")
+ return version, nil
+}
diff --git a/internal/manager/manager_test.go b/internal/manager/manager_test.go
new file mode 100644
index 0000000..f8ddefe
--- /dev/null
+++ b/internal/manager/manager_test.go
@@ -0,0 +1,59 @@
+// ALR - Any Linux Repository
+// Copyright (C) 2025 The ALR Authors
+//
+// 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 .
+
+package manager
+
+import (
+ "testing"
+)
+
+func TestNewZypperReturnsCorrectType(t *testing.T) {
+ z := NewZypper()
+ if z == nil {
+ t.Fatal("NewZypper() returned nil")
+ }
+ if z.Name() != "zypper" {
+ t.Errorf("Expected name 'zypper', got '%s'", z.Name())
+ }
+ if z.Format() != "rpm" {
+ t.Errorf("Expected format 'rpm', got '%s'", z.Format())
+ }
+}
+
+func TestManagersOrder(t *testing.T) {
+ // Проверяем, что APT-RPM идёт раньше APT в списке менеджеров
+ aptRpmIndex := -1
+ aptIndex := -1
+
+ for i, m := range managers {
+ switch m.Name() {
+ case "apt-rpm":
+ aptRpmIndex = i
+ case "apt":
+ aptIndex = i
+ }
+ }
+
+ if aptRpmIndex == -1 {
+ t.Fatal("APT-RPM not found in managers list")
+ }
+ if aptIndex == -1 {
+ t.Fatal("APT not found in managers list")
+ }
+ if aptRpmIndex >= aptIndex {
+ t.Errorf("APT-RPM (index %d) should come before APT (index %d)", aptRpmIndex, aptIndex)
+ }
+}
diff --git a/internal/manager/managers.go b/internal/manager/managers.go
index 39bc691..648bae8 100644
--- a/internal/manager/managers.go
+++ b/internal/manager/managers.go
@@ -37,12 +37,12 @@ var DefaultOpts = &Opts{
var managers = []Manager{
NewPacman(),
+ NewAPTRpm(), // APT-RPM должен проверяться раньше APT, т.к. на ALT Linux есть оба
NewAPT(),
NewDNF(),
NewYUM(),
NewAPK(),
NewZypper(),
- NewAPTRpm(),
}
// Register registers a new package manager
@@ -74,8 +74,11 @@ type Manager interface {
UpgradeAll(*Opts) error
// ListInstalled returns all installed packages mapped to their versions
ListInstalled(*Opts) (map[string]string, error)
- //
+ // IsInstalled checks if a package is installed
IsInstalled(string) (bool, error)
+ // GetInstalledVersion returns the version of an installed package.
+ // Returns empty string and no error if package is not installed.
+ GetInstalledVersion(string) (string, error)
}
// Detect returns the package manager detected on the system
diff --git a/internal/manager/pacman.go b/internal/manager/pacman.go
index 3bdc72d..daaef00 100644
--- a/internal/manager/pacman.go
+++ b/internal/manager/pacman.go
@@ -160,3 +160,24 @@ func (p *Pacman) IsInstalled(pkg string) (bool, error) {
}
return true, nil
}
+
+func (p *Pacman) GetInstalledVersion(pkg string) (string, 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 "", nil
+ }
+ }
+ return "", fmt.Errorf("pacman: getinstalledversion: %w, output: %s", err, output)
+ }
+
+ // Output format: "package-name version"
+ _, version, ok := strings.Cut(strings.TrimSpace(string(output)), " ")
+ if !ok {
+ return "", nil
+ }
+ return version, nil
+}
diff --git a/internal/manager/zypper.go b/internal/manager/zypper.go
index a7de9e3..51de062 100644
--- a/internal/manager/zypper.go
+++ b/internal/manager/zypper.go
@@ -30,8 +30,8 @@ type Zypper struct {
CommonRPM
}
-func NewZypper() *YUM {
- return &YUM{
+func NewZypper() *Zypper {
+ return &Zypper{
CommonPackageManager: CommonPackageManager{
noConfirmArg: "-y",
},
diff --git a/internal/repos/find.go b/internal/repos/find.go
index fcbf0e7..56ee943 100644
--- a/internal/repos/find.go
+++ b/internal/repos/find.go
@@ -25,6 +25,7 @@ import (
"strings"
"gitea.plemya-x.ru/Plemya-x/ALR/pkg/alrsh"
+ "gitea.plemya-x.ru/Plemya-x/ALR/pkg/depver"
)
func (rs *Repos) FindPkgs(ctx context.Context, pkgs []string) (map[string][]alrsh.Package, []string, error) {
@@ -36,39 +37,49 @@ func (rs *Repos) FindPkgs(ctx context.Context, pkgs []string) (map[string][]alrs
continue
}
+ // Parse version constraint from package name
+ dep := depver.Parse(pkgName)
+ searchName := dep.Name
+
var result []alrsh.Package
var err error
switch {
- case strings.Contains(pkgName, "/"):
+ case strings.Contains(searchName, "/"):
// repo/pkg
- parts := strings.SplitN(pkgName, "/", 2)
+ parts := strings.SplitN(searchName, "/", 2)
repo := parts[0]
name := parts[1]
result, err = rs.db.GetPkgs(ctx, "name = ? AND repository = ?", name, repo)
- case strings.Contains(pkgName, "+"):
+ case strings.Contains(searchName, "+"):
// pkg+repo
- parts := strings.SplitN(pkgName, "+", 2)
+ parts := strings.SplitN(searchName, "+", 2)
name := parts[0]
repo := parts[1]
result, err = rs.db.GetPkgs(ctx, "name = ? AND repository = ?", name, repo)
default:
- result, err = rs.db.GetPkgs(ctx, "json_array_contains(provides, ?)", pkgName)
+ // Сначала ищем по точному имени пакета
+ result, err = rs.db.GetPkgs(ctx, "name = ?", searchName)
if err != nil {
- return nil, nil, fmt.Errorf("FindPkgs: get by provides: %w", err)
+ return nil, nil, fmt.Errorf("FindPkgs: get by name: %w", err)
}
+ // Затем по provides
if len(result) == 0 {
- result, err = rs.db.GetPkgs(ctx, "basepkg_name = ?", pkgName)
+ result, err = rs.db.GetPkgs(ctx, "json_array_contains(provides, ?)", searchName)
if err != nil {
- return nil, nil, fmt.Errorf("FindPkgs: get by basepkg_name: %w", err)
+ return nil, nil, fmt.Errorf("FindPkgs: get by provides: %w", err)
}
}
+ // В последнюю очередь по basepkg_name (для мультипакетов)
if len(result) == 0 {
- result, err = rs.db.GetPkgs(ctx, "name LIKE ?", pkgName)
+ result, err = rs.db.GetPkgs(ctx, "basepkg_name = ?", searchName)
+ if err != nil {
+ return nil, nil, fmt.Errorf("FindPkgs: get by basepkg_name: %w", err)
+ }
}
}
@@ -76,6 +87,11 @@ func (rs *Repos) FindPkgs(ctx context.Context, pkgs []string) (map[string][]alrs
return nil, nil, fmt.Errorf("FindPkgs: lookup for %q failed: %w", pkgName, err)
}
+ // Filter by version if constraint is specified
+ if dep.HasVersionConstraint() && len(result) > 0 {
+ result = filterByVersion(result, dep)
+ }
+
if len(result) == 0 {
notFound = append(notFound, pkgName)
} else {
@@ -85,3 +101,14 @@ func (rs *Repos) FindPkgs(ctx context.Context, pkgs []string) (map[string][]alrs
return found, notFound, nil
}
+
+// filterByVersion filters packages by version constraint.
+func filterByVersion(pkgs []alrsh.Package, dep depver.Dependency) []alrsh.Package {
+ var filtered []alrsh.Package
+ for _, pkg := range pkgs {
+ if dep.Satisfies(pkg.Version) {
+ filtered = append(filtered, pkg)
+ }
+ }
+ return filtered
+}
diff --git a/internal/shutils/decoder/decoder.go b/internal/shutils/decoder/decoder.go
index d61277b..29855d8 100644
--- a/internal/shutils/decoder/decoder.go
+++ b/internal/shutils/decoder/decoder.go
@@ -71,6 +71,10 @@ func New(info *distro.OSRelease, runner *interp.Runner) *Decoder {
return &Decoder{info, runner, true, len(info.Like) > 0}
}
+func (d *Decoder) Info() *distro.OSRelease {
+ return d.info
+}
+
// DecodeVar decodes a variable to val using reflection.
// Structs should use the "sh" struct tag.
func (d *Decoder) DecodeVar(name string, val any) error {
diff --git a/main.go b/main.go
index ad3ca66..48c15ce 100644
--- a/main.go
+++ b/main.go
@@ -141,7 +141,6 @@ func setLogLevel(newLevel string) {
func main() {
logger.SetupDefault()
- setLogLevel(os.Getenv("ALR_LOG_LEVEL"))
translations.Setup()
ctx := context.Background()
@@ -154,6 +153,10 @@ func main() {
os.Exit(1)
}
setLogLevel(cfg.LogLevel())
+ // Переменная окружения имеет приоритет над конфигом
+ if envLevel := os.Getenv("ALR_LOG_LEVEL"); envLevel != "" {
+ setLogLevel(envLevel)
+ }
ctx, cancel := signal.NotifyContext(ctx, syscall.SIGINT, syscall.SIGTERM)
defer cancel()
diff --git a/pkg/alrsh/alrsh.go b/pkg/alrsh/alrsh.go
index f2237b9..e0dfeb8 100644
--- a/pkg/alrsh/alrsh.go
+++ b/pkg/alrsh/alrsh.go
@@ -20,6 +20,7 @@ import (
"context"
"errors"
"fmt"
+ "log/slog"
"os"
"path/filepath"
"runtime"
@@ -171,7 +172,25 @@ func (s *ScriptFile) createPackageFromMeta(
return nil, err
}
- metaDecoder := decoder.New(&distro.OSRelease{}, metaRunner)
+ // DEBUG: Выводим что в metaRunner.Vars и dec.Runner.Vars для deps_debian
+ if depsDebianMeta, ok := metaRunner.Vars["deps_debian"]; ok {
+ slog.Info("DEBUG createPackageFromMeta: metaRunner.Vars[deps_debian]", "value", depsDebianMeta.String(), "list", depsDebianMeta.List)
+ } else {
+ slog.Info("DEBUG createPackageFromMeta: metaRunner.Vars[deps_debian] NOT FOUND")
+ }
+ if depsDebianParent, ok := dec.Runner.Vars["deps_debian"]; ok {
+ slog.Info("DEBUG createPackageFromMeta: parent Vars[deps_debian]", "value", depsDebianParent.String(), "list", depsDebianParent.List)
+ }
+
+ // Сливаем переменные родительского runner'а с переменными мета-функции.
+ // Переменные мета-функции имеют приоритет (для случаев переопределения).
+ for name, val := range dec.Runner.Vars {
+ if _, exists := metaRunner.Vars[name]; !exists {
+ metaRunner.Vars[name] = val
+ }
+ }
+
+ metaDecoder := decoder.New(dec.Info(), metaRunner)
var vars Package
if err := metaDecoder.DecodeVars(&vars); err != nil {
diff --git a/pkg/depver/depver.go b/pkg/depver/depver.go
new file mode 100644
index 0000000..0d6bcd7
--- /dev/null
+++ b/pkg/depver/depver.go
@@ -0,0 +1,137 @@
+// ALR - Any Linux Repository
+// Copyright (C) 2025 The ALR Authors
+//
+// 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 .
+
+// Package depver provides parsing and comparison of versioned dependencies
+// in PKGBUILD-style format (e.g., "gcc>=5.0", "openssl>=1.1.0").
+package depver
+
+import (
+ "strings"
+
+ "gitea.plemya-x.ru/xpamych/vercmp"
+)
+
+// Operator represents a version comparison operator.
+type Operator string
+
+const (
+ OpNone Operator = "" // No version constraint
+ OpEq Operator = "=" // Equal to
+ OpGt Operator = ">" // Greater than
+ OpGe Operator = ">=" // Greater than or equal to
+ OpLt Operator = "<" // Less than
+ OpLe Operator = "<=" // Less than or equal to
+)
+
+// Dependency represents a package dependency with optional version constraint.
+type Dependency struct {
+ Name string // Package name (e.g., "gcc")
+ Operator Operator // Comparison operator (e.g., OpGe for ">=")
+ Version string // Version string (e.g., "5.0")
+}
+
+// operators lists all supported operators in order of decreasing length
+// (to ensure ">=" is matched before ">").
+var operators = []Operator{OpGe, OpLe, OpGt, OpLt, OpEq}
+
+// Parse parses a dependency string in PKGBUILD format.
+// Examples:
+// - "gcc>=5.0" -> Dependency{Name: "gcc", Operator: OpGe, Version: "5.0"}
+// - "openssl" -> Dependency{Name: "openssl", Operator: OpNone, Version: ""}
+// - "cmake>=3.10" -> Dependency{Name: "cmake", Operator: OpGe, Version: "3.10"}
+func Parse(dep string) Dependency {
+ dep = strings.TrimSpace(dep)
+ if dep == "" {
+ return Dependency{}
+ }
+
+ // Try each operator (longer ones first)
+ for _, op := range operators {
+ if idx := strings.Index(dep, string(op)); idx > 0 {
+ return Dependency{
+ Name: strings.TrimSpace(dep[:idx]),
+ Operator: op,
+ Version: strings.TrimSpace(dep[idx+len(op):]),
+ }
+ }
+ }
+
+ // No operator found - just a package name
+ return Dependency{
+ Name: dep,
+ Operator: OpNone,
+ Version: "",
+ }
+}
+
+// ParseMultiple parses multiple dependency strings.
+func ParseMultiple(deps []string) []Dependency {
+ result := make([]Dependency, 0, len(deps))
+ for _, dep := range deps {
+ if dep != "" {
+ result = append(result, Parse(dep))
+ }
+ }
+ return result
+}
+
+// String returns the dependency in PKGBUILD format.
+func (d Dependency) String() string {
+ if d.Operator == OpNone || d.Version == "" {
+ return d.Name
+ }
+ return d.Name + string(d.Operator) + d.Version
+}
+
+// Satisfies checks if the given version satisfies the dependency constraint.
+// Returns true if:
+// - The dependency has no version constraint (OpNone)
+// - The installed version satisfies the operator/version requirement
+func (d Dependency) Satisfies(installedVersion string) bool {
+ if d.Operator == OpNone || d.Version == "" {
+ return true
+ }
+
+ if installedVersion == "" {
+ return false
+ }
+
+ // vercmp.Compare returns:
+ // -1 if installedVersion < d.Version
+ // 0 if installedVersion == d.Version
+ // 1 if installedVersion > d.Version
+ cmp := vercmp.Compare(installedVersion, d.Version)
+
+ switch d.Operator {
+ case OpEq:
+ return cmp == 0
+ case OpGt:
+ return cmp > 0
+ case OpGe:
+ return cmp >= 0
+ case OpLt:
+ return cmp < 0
+ case OpLe:
+ return cmp <= 0
+ default:
+ return true
+ }
+}
+
+// HasVersionConstraint returns true if the dependency has a version constraint.
+func (d Dependency) HasVersionConstraint() bool {
+ return d.Operator != OpNone && d.Version != ""
+}
diff --git a/pkg/depver/depver_test.go b/pkg/depver/depver_test.go
new file mode 100644
index 0000000..5279059
--- /dev/null
+++ b/pkg/depver/depver_test.go
@@ -0,0 +1,347 @@
+// ALR - Any Linux Repository
+// Copyright (C) 2025 The ALR Authors
+//
+// 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 .
+
+package depver
+
+import (
+ "testing"
+
+ "github.com/stretchr/testify/assert"
+)
+
+func TestParse(t *testing.T) {
+ tests := []struct {
+ name string
+ input string
+ expected Dependency
+ }{
+ {
+ name: "simple package name",
+ input: "gcc",
+ expected: Dependency{
+ Name: "gcc",
+ Operator: OpNone,
+ Version: "",
+ },
+ },
+ {
+ name: "greater or equal",
+ input: "gcc>=5.0",
+ expected: Dependency{
+ Name: "gcc",
+ Operator: OpGe,
+ Version: "5.0",
+ },
+ },
+ {
+ name: "less or equal",
+ input: "openssl<=1.1.0",
+ expected: Dependency{
+ Name: "openssl",
+ Operator: OpLe,
+ Version: "1.1.0",
+ },
+ },
+ {
+ name: "greater than",
+ input: "cmake>3.10",
+ expected: Dependency{
+ Name: "cmake",
+ Operator: OpGt,
+ Version: "3.10",
+ },
+ },
+ {
+ name: "less than",
+ input: "python<4.0",
+ expected: Dependency{
+ Name: "python",
+ Operator: OpLt,
+ Version: "4.0",
+ },
+ },
+ {
+ name: "equal",
+ input: "nodejs=18.0.0",
+ expected: Dependency{
+ Name: "nodejs",
+ Operator: OpEq,
+ Version: "18.0.0",
+ },
+ },
+ {
+ name: "with spaces around",
+ input: " gcc>=5.0 ",
+ expected: Dependency{
+ Name: "gcc",
+ Operator: OpGe,
+ Version: "5.0",
+ },
+ },
+ {
+ name: "complex version",
+ input: "glibc>=2.17-326",
+ expected: Dependency{
+ Name: "glibc",
+ Operator: OpGe,
+ Version: "2.17-326",
+ },
+ },
+ {
+ name: "empty string",
+ input: "",
+ expected: Dependency{},
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ result := Parse(tt.input)
+ assert.Equal(t, tt.expected, result)
+ })
+ }
+}
+
+func TestParseMultiple(t *testing.T) {
+ input := []string{"gcc>=5.0", "openssl", "cmake>=3.10", ""}
+ expected := []Dependency{
+ {Name: "gcc", Operator: OpGe, Version: "5.0"},
+ {Name: "openssl", Operator: OpNone, Version: ""},
+ {Name: "cmake", Operator: OpGe, Version: "3.10"},
+ }
+
+ result := ParseMultiple(input)
+ assert.Equal(t, expected, result)
+}
+
+func TestDependency_String(t *testing.T) {
+ tests := []struct {
+ name string
+ dep Dependency
+ expected string
+ }{
+ {
+ name: "no version",
+ dep: Dependency{Name: "gcc", Operator: OpNone, Version: ""},
+ expected: "gcc",
+ },
+ {
+ name: "with version",
+ dep: Dependency{Name: "gcc", Operator: OpGe, Version: "5.0"},
+ expected: "gcc>=5.0",
+ },
+ {
+ name: "equal operator",
+ dep: Dependency{Name: "python", Operator: OpEq, Version: "3.11"},
+ expected: "python=3.11",
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ result := tt.dep.String()
+ assert.Equal(t, tt.expected, result)
+ })
+ }
+}
+
+func TestDependency_Satisfies(t *testing.T) {
+ tests := []struct {
+ name string
+ dep Dependency
+ installedVersion string
+ expected bool
+ }{
+ {
+ name: "no constraint - always satisfied",
+ dep: Dependency{Name: "gcc", Operator: OpNone, Version: ""},
+ installedVersion: "5.0",
+ expected: true,
+ },
+ {
+ name: "ge - satisfied",
+ dep: Dependency{Name: "gcc", Operator: OpGe, Version: "5.0"},
+ installedVersion: "5.0",
+ expected: true,
+ },
+ {
+ name: "ge - greater version satisfied",
+ dep: Dependency{Name: "gcc", Operator: OpGe, Version: "5.0"},
+ installedVersion: "6.0",
+ expected: true,
+ },
+ {
+ name: "ge - not satisfied",
+ dep: Dependency{Name: "gcc", Operator: OpGe, Version: "5.0"},
+ installedVersion: "4.9",
+ expected: false,
+ },
+ {
+ name: "gt - satisfied",
+ dep: Dependency{Name: "gcc", Operator: OpGt, Version: "5.0"},
+ installedVersion: "5.1",
+ expected: true,
+ },
+ {
+ name: "gt - equal not satisfied",
+ dep: Dependency{Name: "gcc", Operator: OpGt, Version: "5.0"},
+ installedVersion: "5.0",
+ expected: false,
+ },
+ {
+ name: "le - satisfied",
+ dep: Dependency{Name: "gcc", Operator: OpLe, Version: "5.0"},
+ installedVersion: "5.0",
+ expected: true,
+ },
+ {
+ name: "le - lesser satisfied",
+ dep: Dependency{Name: "gcc", Operator: OpLe, Version: "5.0"},
+ installedVersion: "4.9",
+ expected: true,
+ },
+ {
+ name: "le - not satisfied",
+ dep: Dependency{Name: "gcc", Operator: OpLe, Version: "5.0"},
+ installedVersion: "5.1",
+ expected: false,
+ },
+ {
+ name: "lt - satisfied",
+ dep: Dependency{Name: "gcc", Operator: OpLt, Version: "5.0"},
+ installedVersion: "4.9",
+ expected: true,
+ },
+ {
+ name: "lt - equal not satisfied",
+ dep: Dependency{Name: "gcc", Operator: OpLt, Version: "5.0"},
+ installedVersion: "5.0",
+ expected: false,
+ },
+ {
+ name: "eq - satisfied",
+ dep: Dependency{Name: "gcc", Operator: OpEq, Version: "5.0"},
+ installedVersion: "5.0",
+ expected: true,
+ },
+ {
+ name: "eq - not satisfied",
+ dep: Dependency{Name: "gcc", Operator: OpEq, Version: "5.0"},
+ installedVersion: "5.1",
+ expected: false,
+ },
+ {
+ name: "empty installed version",
+ dep: Dependency{Name: "gcc", Operator: OpGe, Version: "5.0"},
+ installedVersion: "",
+ expected: false,
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ result := tt.dep.Satisfies(tt.installedVersion)
+ assert.Equal(t, tt.expected, result)
+ })
+ }
+}
+
+func TestDependency_ForManager(t *testing.T) {
+ dep := Dependency{Name: "gcc", Operator: OpGe, Version: "5.0"}
+
+ tests := []struct {
+ manager string
+ expected string
+ }{
+ {"pacman", "gcc>=5.0"},
+ {"apt", "gcc"},
+ {"dnf", "gcc >= 5.0"},
+ {"yum", "gcc >= 5.0"},
+ {"apk", "gcc>=5.0"},
+ {"zypper", "gcc >= 5.0"},
+ {"apt-rpm", "gcc >= 5.0"},
+ {"unknown", "gcc>=5.0"},
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.manager, func(t *testing.T) {
+ result := dep.ForManager(tt.manager)
+ assert.Equal(t, tt.expected, result)
+ })
+ }
+
+ // Test without version constraint
+ depNoVersion := Dependency{Name: "gcc", Operator: OpNone, Version: ""}
+ for _, tt := range tests {
+ t.Run(tt.manager+"_no_version", func(t *testing.T) {
+ result := depNoVersion.ForManager(tt.manager)
+ assert.Equal(t, "gcc", result)
+ })
+ }
+}
+
+func TestDependency_ForNfpm(t *testing.T) {
+ dep := Dependency{Name: "gcc", Operator: OpGe, Version: "5.0"}
+
+ tests := []struct {
+ format string
+ expected string
+ }{
+ {"deb", "gcc (>= 5.0)"},
+ {"rpm", "gcc >= 5.0"},
+ {"apk", "gcc>=5.0"},
+ {"archlinux", "gcc>=5.0"},
+ {"unknown", "gcc>=5.0"},
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.format, func(t *testing.T) {
+ result := dep.ForNfpm(tt.format)
+ assert.Equal(t, tt.expected, result)
+ })
+ }
+}
+
+func TestHasVersionConstraint(t *testing.T) {
+ tests := []struct {
+ name string
+ dep Dependency
+ expected bool
+ }{
+ {
+ name: "has constraint",
+ dep: Dependency{Name: "gcc", Operator: OpGe, Version: "5.0"},
+ expected: true,
+ },
+ {
+ name: "no operator",
+ dep: Dependency{Name: "gcc", Operator: OpNone, Version: ""},
+ expected: false,
+ },
+ {
+ name: "operator but no version",
+ dep: Dependency{Name: "gcc", Operator: OpGe, Version: ""},
+ expected: false,
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ result := tt.dep.HasVersionConstraint()
+ assert.Equal(t, tt.expected, result)
+ })
+ }
+}
diff --git a/pkg/depver/format.go b/pkg/depver/format.go
new file mode 100644
index 0000000..061fa0c
--- /dev/null
+++ b/pkg/depver/format.go
@@ -0,0 +1,132 @@
+// ALR - Any Linux Repository
+// Copyright (C) 2025 The ALR Authors
+//
+// 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 .
+
+package depver
+
+import "fmt"
+
+// ForManager formats the dependency for a specific package manager.
+// Different package managers have different syntax for version constraints:
+//
+// pacman (Arch): "gcc>=5.0" (no changes)
+// apt (Debian): "gcc" (version ignored for install command)
+// dnf/yum (Fedora): "gcc >= 5.0" (with spaces)
+// apk (Alpine): "gcc>=5.0" (no changes)
+// zypper (openSUSE): "gcc >= 5.0" (with spaces)
+// apt-rpm (ALT): "gcc >= 5.0" (with spaces)
+func (d Dependency) ForManager(managerName string) string {
+ if d.Name == "" {
+ return ""
+ }
+
+ // No version constraint - just return the name
+ if d.Operator == OpNone || d.Version == "" {
+ return d.Name
+ }
+
+ switch managerName {
+ case "apt":
+ // APT doesn't support version constraints in 'apt install' command
+ // Versions are checked after installation
+ return d.Name
+
+ case "pacman":
+ // Pacman uses PKGBUILD-style: package>=version (no spaces)
+ return fmt.Sprintf("%s%s%s", d.Name, d.Operator, d.Version)
+
+ case "apk":
+ // Alpine APK uses similar syntax to pacman
+ return fmt.Sprintf("%s%s%s", d.Name, d.Operator, d.Version)
+
+ case "dnf", "yum":
+ // DNF/YUM use RPM-style: "package >= version" (with spaces)
+ return fmt.Sprintf("%s %s %s", d.Name, d.Operator, d.Version)
+
+ case "zypper":
+ // Zypper uses RPM-style with spaces
+ return fmt.Sprintf("%s %s %s", d.Name, d.Operator, d.Version)
+
+ case "apt-rpm":
+ // ALT Linux apt-rpm uses RPM-style
+ return fmt.Sprintf("%s %s %s", d.Name, d.Operator, d.Version)
+
+ default:
+ // Default: PKGBUILD-style (no spaces)
+ return fmt.Sprintf("%s%s%s", d.Name, d.Operator, d.Version)
+ }
+}
+
+// ForManagerMultiple formats multiple dependencies for a specific package manager.
+func ForManagerMultiple(deps []Dependency, managerName string) []string {
+ result := make([]string, 0, len(deps))
+ for _, dep := range deps {
+ if formatted := dep.ForManager(managerName); formatted != "" {
+ result = append(result, formatted)
+ }
+ }
+ return result
+}
+
+// ForNfpm formats the dependency for nfpm package building.
+// Different package formats have different dependency syntax:
+//
+// deb: "package (>= version)"
+// rpm: "package >= version"
+// apk: "package>=version"
+// archlinux: "package>=version"
+func (d Dependency) ForNfpm(pkgFormat string) string {
+ if d.Name == "" {
+ return ""
+ }
+
+ // No version constraint - just return the name
+ if d.Operator == OpNone || d.Version == "" {
+ return d.Name
+ }
+
+ switch pkgFormat {
+ case "deb":
+ // Debian uses: package (>= version)
+ return fmt.Sprintf("%s (%s %s)", d.Name, d.Operator, d.Version)
+
+ case "rpm":
+ // RPM uses: package >= version
+ return fmt.Sprintf("%s %s %s", d.Name, d.Operator, d.Version)
+
+ case "apk":
+ // Alpine uses: package>=version
+ return fmt.Sprintf("%s%s%s", d.Name, d.Operator, d.Version)
+
+ case "archlinux":
+ // Arch uses: package>=version
+ return fmt.Sprintf("%s%s%s", d.Name, d.Operator, d.Version)
+
+ default:
+ // Default: no spaces
+ return fmt.Sprintf("%s%s%s", d.Name, d.Operator, d.Version)
+ }
+}
+
+// ForNfpmMultiple formats multiple dependencies for nfpm.
+func ForNfpmMultiple(deps []Dependency, pkgFormat string) []string {
+ result := make([]string, 0, len(deps))
+ for _, dep := range deps {
+ if formatted := dep.ForNfpm(pkgFormat); formatted != "" {
+ result = append(result, formatted)
+ }
+ }
+ return result
+}