diff --git a/README.md b/README.md index c09e85d..309ade3 100644 --- a/README.md +++ b/README.md @@ -56,6 +56,8 @@ install: deps: - glibc - readline + - "zlib>=1.2.13" + - "lua5.1 | luajit" ``` Debian upstream source mode: @@ -71,10 +73,14 @@ source: ``` Notes: -- `deps` are simple package names (no version solver yet). +- `deps` support full constraints: `name`, `name>=1.2`, `name (<= 2.0)`, and alternatives via `|`. +- Installer resolves dependencies with version checks and transaction ordering (topological sort), including mixed installed + transaction packages. - `{{pkgdir}}` (and `${pkgdir}`) points to staging DESTDIR. - If `source.debian_dsc` (or `.dsc` URL) is used, zsvo downloads only `*.orig*.tar.*` archives from that source package and ignores Debian patch archives (`debian.tar.*` / `diff.gz`). - `zsvo install ` auto-resolves Debian source over HTTP from Debian `Sources` indexes, builds package in `--work-dir` and installs it. +- Auto-build in `install ` is quiet by default and shows a colored live progress bar (spinner + percent + elapsed time) instead of raw compiler output; full command output tail is shown on failure. +- If build fails due to missing tools (`cmake`, `meson`, `pkg-config`, `lua` etc.), `zsvo install` auto-detects them, builds missing dependencies from Debian source with zsvo itself, installs them into `--work-dir/bootstrap-root`, and retries the build (`--auto-build-deps=false` to disable). +- Auto-build can be tuned with env flags: `ZSVO_AUTOGEN_ARGS`, `ZSVO_CONFIGURE_FLAGS`, `ZSVO_CMAKE_FLAGS`, `ZSVO_CMAKE_BUILD_FLAGS`, `ZSVO_MESON_SETUP_ARGS`, `ZSVO_MESON_COMPILE_ARGS`, `ZSVO_MESON_INSTALL_ARGS`, `ZSVO_MAKE_FLAGS`, `ZSVO_MAKE_INSTALL_FLAGS`. - Package metadata is stored in `.zsvo.yml` (not `.PKGINFO`). ## Architecture diff --git a/cmd/install.go b/cmd/install.go index e7a0f26..8919c55 100644 --- a/cmd/install.go +++ b/cmd/install.go @@ -3,8 +3,12 @@ package cmd import ( "fmt" "os" + "os/exec" "path/filepath" + "regexp" + "sort" "strings" + "time" "github.com/spf13/cobra" "zsvo/pkg/builder" @@ -28,13 +32,14 @@ var InstallCmd = &cobra.Command{ workDir = "/tmp/pkg-work" } autoSource, _ := cmd.Flags().GetBool("auto-source") + autoBuildDeps, _ := cmd.Flags().GetBool("auto-build-deps") installTargets := make([]string, 0, len(args)) - var resolver *debian.Resolver - var b *builder.Builder + i := installer.NewInstaller(rootDir) + + var session *autoBuildSession if autoSource { - resolver = debian.NewResolver() - b = builder.NewBuilder(workDir) + session = newAutoBuildSession(workDir, autoBuildDeps) } for _, target := range args { @@ -55,25 +60,13 @@ var InstallCmd = &cobra.Command{ ) } - fmt.Printf("Resolving Debian source for %s...\n", target) - srcInfo, err := resolver.ResolveSource(target) + builtPackage, err := session.buildPackage(target, false, nil) if err != nil { return err } - - rcp := autoRecipeFromDebian(srcInfo) - fmt.Printf("Building %s from %s...\n", rcp.GetPackageName(), srcInfo.DSCURL) - if err := b.Build(rcp); err != nil { - return fmt.Errorf("failed to auto-build %s: %w", target, err) - } - - builtPackage := filepath.Join(rcp.GetPackageDir(workDir), rcp.GetPackageFileName()) - fmt.Printf("Built package: %s\n", builtPackage) installTargets = append(installTargets, builtPackage) } - i := installer.NewInstaller(rootDir) - if len(installTargets) == 1 { fmt.Printf("Installing package from %s...\n", installTargets[0]) } else { @@ -92,6 +85,7 @@ func init() { InstallCmd.Flags().StringP("root", "r", "/", "Root directory for installation") InstallCmd.Flags().StringP("work-dir", "w", "/tmp/pkg-work", "Working directory for source builds") InstallCmd.Flags().Bool("auto-source", true, "Auto-build package names from Debian source") + InstallCmd.Flags().Bool("auto-build-deps", true, "Auto-build missing source build dependencies through zsvo") } func isInstallFileTarget(target string) (bool, error) { @@ -130,6 +124,375 @@ func looksLikeFilePath(target string) bool { strings.HasSuffix(target, ".zov") } +const maxAutoBuildDepth = 16 + +type autoBuildSession struct { + workDir string + toolRoot string + autoBuildDeps bool + resolver *debian.Resolver + builder *builder.Builder + toolInstaller *installer.Installer + builtPackages map[string]string + toolDepsReady map[string]struct{} + buildingPackages map[string]struct{} +} + +func newAutoBuildSession(workDir string, autoBuildDeps bool) *autoBuildSession { + b := builder.NewBuilder(workDir) + b.SetQuiet(true) + + s := &autoBuildSession{ + workDir: workDir, + toolRoot: filepath.Join(workDir, "bootstrap-root"), + autoBuildDeps: autoBuildDeps, + resolver: debian.NewResolver(), + builder: b, + toolInstaller: installer.NewInstaller(filepath.Join(workDir, "bootstrap-root")), + builtPackages: make(map[string]string), + toolDepsReady: make(map[string]struct{}), + buildingPackages: make(map[string]struct{}), + } + s.refreshBuildEnv() + return s +} + +func (s *autoBuildSession) refreshBuildEnv() { + basePath := splitPathList(os.Getenv("PATH")) + binPrefixes := []string{ + filepath.Join(s.toolRoot, "usr", "bin"), + filepath.Join(s.toolRoot, "bin"), + filepath.Join(s.toolRoot, "usr", "sbin"), + filepath.Join(s.toolRoot, "sbin"), + } + mergedPath := joinPathListUnique(append(binPrefixes, basePath...)) + s.builder.SetEnvOverride("PATH", mergedPath) + + pkgConfigPath := splitPathList(os.Getenv("PKG_CONFIG_PATH")) + pkgConfigPrefixes := []string{ + filepath.Join(s.toolRoot, "usr", "lib", "pkgconfig"), + filepath.Join(s.toolRoot, "usr", "lib64", "pkgconfig"), + filepath.Join(s.toolRoot, "usr", "share", "pkgconfig"), + filepath.Join(s.toolRoot, "lib", "pkgconfig"), + filepath.Join(s.toolRoot, "lib64", "pkgconfig"), + } + s.builder.SetEnvOverride("PKG_CONFIG_PATH", joinPathListUnique(append(pkgConfigPrefixes, pkgConfigPath...))) + cmakePrefixes := []string{ + filepath.Join(s.toolRoot, "usr"), + filepath.Join(s.toolRoot), + } + cmakePrefixes = append(cmakePrefixes, splitPathList(os.Getenv("CMAKE_PREFIX_PATH"))...) + s.builder.SetEnvOverride("CMAKE_PREFIX_PATH", joinPathListUnique(cmakePrefixes)) +} + +func (s *autoBuildSession) buildPackage(requestName string, asBuildDep bool, stack []string) (string, error) { + requestName = normalizePackageName(requestName) + if requestName == "" { + return "", fmt.Errorf("invalid package name") + } + + if len(stack) >= maxAutoBuildDepth { + return "", fmt.Errorf("dependency chain is too deep while building %s: %s", requestName, strings.Join(append(stack, requestName), " -> ")) + } + if _, exists := s.buildingPackages[requestName]; exists { + return "", fmt.Errorf("dependency cycle detected: %s", strings.Join(append(stack, requestName), " -> ")) + } + + if builtPath, ok := s.builtPackages[requestName]; ok { + if asBuildDep { + if err := s.installBuildDependency(requestName, builtPath); err != nil { + return "", err + } + } + return builtPath, nil + } + + s.buildingPackages[requestName] = struct{}{} + defer delete(s.buildingPackages, requestName) + + fmt.Printf("Resolving Debian source for %s...\n", requestName) + srcInfo, err := s.resolver.ResolveSource(requestName) + if err != nil { + return "", fmt.Errorf("failed to resolve source for %s: %w", requestName, err) + } + + rcp := autoRecipeFromDebian(srcInfo) + normalizedRecipeName := normalizePackageName(rcp.Name) + if normalizedRecipeName != "" && normalizedRecipeName != requestName { + // Alias the resolved source package name to the requested name. + if _, exists := s.buildingPackages[normalizedRecipeName]; exists { + return "", fmt.Errorf("dependency cycle detected: %s", strings.Join(append(stack, requestName, normalizedRecipeName), " -> ")) + } + } + + fmt.Printf("Building %s from %s...\n", rcp.GetPackageName(), srcInfo.DSCURL) + var buildErr error + for attempt := 0; attempt < 2; attempt++ { + bar := newProgressUI(rcp.Name) + s.builder.SetProgressCallback(func(p builder.BuildProgress) { + bar.update(p.Step, p.Total, fmt.Sprintf("%s: %s", rcp.Name, p.Message)) + }) + + buildErr = s.builder.Build(rcp) + s.builder.SetProgressCallback(nil) + if buildErr == nil { + bar.finish(true, fmt.Sprintf("%s: complete", rcp.Name)) + break + } + bar.finish(false, fmt.Sprintf("%s: failed", rcp.Name)) + + if !s.autoBuildDeps || attempt == 1 { + hint := buildFailureHint(requestName, buildErr) + if hint != "" { + return "", fmt.Errorf("failed to auto-build %s: %w\n%s", requestName, buildErr, hint) + } + return "", fmt.Errorf("failed to auto-build %s: %w", requestName, buildErr) + } + + missingDeps := inferMissingBuildDeps(buildErr) + if len(missingDeps) == 0 { + hint := buildFailureHint(requestName, buildErr) + if hint != "" { + return "", fmt.Errorf("failed to auto-build %s: %w\n%s", requestName, buildErr, hint) + } + return "", fmt.Errorf("failed to auto-build %s: %w", requestName, buildErr) + } + + fmt.Printf("Detected missing build dependencies for %s: %s\n", requestName, strings.Join(missingDeps, ", ")) + for _, dep := range missingDeps { + if dep == requestName || dep == normalizedRecipeName { + continue + } + if _, ready := s.toolDepsReady[dep]; ready { + continue + } + if err := s.ensureBuildDependency(dep, append(stack, requestName)); err != nil { + return "", fmt.Errorf("failed to satisfy build dependency %s for %s: %w", dep, requestName, err) + } + } + + fmt.Printf("Retrying build for %s after auto-installing dependencies...\n", requestName) + } + + if buildErr != nil { + return "", fmt.Errorf("failed to auto-build %s: %w", requestName, buildErr) + } + + builtPackage := filepath.Join(rcp.GetPackageDir(s.workDir), rcp.GetPackageFileName()) + fmt.Printf("Built package: %s\n", builtPackage) + + s.builtPackages[requestName] = builtPackage + if normalizedRecipeName != "" { + s.builtPackages[normalizedRecipeName] = builtPackage + } + + if asBuildDep { + if err := s.installBuildDependency(requestName, builtPackage); err != nil { + return "", err + } + } + + return builtPackage, nil +} + +func (s *autoBuildSession) ensureBuildDependency(dep string, stack []string) error { + dep = normalizePackageName(dep) + if dep == "" { + return fmt.Errorf("invalid build dependency name") + } + + if _, ready := s.toolDepsReady[dep]; ready { + return nil + } + if toolAlreadyAvailable(dep) { + s.toolDepsReady[dep] = struct{}{} + return nil + } + + packagePath, err := s.buildPackage(dep, true, stack) + if err != nil { + return err + } + + return s.installBuildDependency(dep, packagePath) +} + +func (s *autoBuildSession) installBuildDependency(dep, packagePath string) error { + dep = normalizePackageName(dep) + if dep == "" { + return fmt.Errorf("invalid build dependency name") + } + if _, ready := s.toolDepsReady[dep]; ready { + return nil + } + + fmt.Printf("Installing build dependency %s into %s...\n", dep, s.toolRoot) + if err := s.toolInstaller.Install(packagePath); err != nil { + return fmt.Errorf("failed to install build dependency %s: %w", dep, err) + } + s.toolDepsReady[dep] = struct{}{} + s.refreshBuildEnv() + return nil +} + +func splitPathList(value string) []string { + parts := strings.Split(value, string(os.PathListSeparator)) + out := make([]string, 0, len(parts)) + for _, part := range parts { + part = strings.TrimSpace(part) + if part == "" { + continue + } + out = append(out, part) + } + return out +} + +func joinPathListUnique(parts []string) string { + seen := make(map[string]struct{}, len(parts)) + out := make([]string, 0, len(parts)) + for _, part := range parts { + part = strings.TrimSpace(part) + if part == "" { + continue + } + if _, exists := seen[part]; exists { + continue + } + seen[part] = struct{}{} + out = append(out, part) + } + return strings.Join(out, string(os.PathListSeparator)) +} + +var simplePkgNamePattern = regexp.MustCompile(`^[a-z0-9][a-z0-9+.-]*$`) +var missingCommandPatterns = []*regexp.Regexp{ + regexp.MustCompile(`(?m)(?:^|[\s:])(?:/bin/)?sh:\s*(?:\d+:\s*)?([a-zA-Z0-9+_.-]+):\s*(?:command not found|not found)\b`), + regexp.MustCompile(`(?m)\b([a-zA-Z0-9+_.-]+):\s*command not found\b`), + regexp.MustCompile(`(?m)\b([a-zA-Z0-9+_.-]+):\s*not found\b`), +} + +func inferMissingBuildDeps(err error) []string { + if err == nil { + return nil + } + + text := err.Error() + lowerText := strings.ToLower(text) + found := make(map[string]struct{}) + + for _, pattern := range missingCommandPatterns { + matches := pattern.FindAllStringSubmatch(text, -1) + for _, match := range matches { + if len(match) < 2 { + continue + } + pkg := mapToolToSourcePackage(match[1]) + if pkg == "" { + continue + } + found[pkg] = struct{}{} + } + } + + if strings.Contains(lowerText, "failed to find a lua 5.1-compatible interpreter") { + found["lua5.1"] = struct{}{} + } + if strings.Contains(lowerText, "pkg-config") && + (strings.Contains(lowerText, "not found") || strings.Contains(lowerText, "could not find")) { + found["pkgconf"] = struct{}{} + } + if strings.Contains(lowerText, "no acceptable c compiler found in $path") || + strings.Contains(lowerText, "c compiler cannot create executables") { + found["gcc"] = struct{}{} + } + + if len(found) == 0 { + return nil + } + + deps := make([]string, 0, len(found)) + for dep := range found { + deps = append(deps, dep) + } + sort.Strings(deps) + return deps +} + +func mapToolToSourcePackage(tool string) string { + tool = normalizePackageName(tool) + if tool == "" || allDigits(tool) { + return "" + } + switch tool { + case "sh", "bash", "dash", "zsh": + return "" + case "pkg-config": + return "pkgconf" + case "ninja": + return "ninja-build" + case "python": + return "python3" + case "lua": + return "lua5.1" + case "luajit": + return "luajit" + case "cc", "c++", "g++", "gcc": + return "gcc" + case "ld": + return "binutils" + case "xzcat": + return "xz-utils" + } + + if !simplePkgNamePattern.MatchString(tool) { + return "" + } + return tool +} + +func allDigits(s string) bool { + if s == "" { + return false + } + for _, ch := range s { + if ch < '0' || ch > '9' { + return false + } + } + return true +} + +func normalizePackageName(name string) string { + return strings.ToLower(strings.TrimSpace(name)) +} + +var commandHintsByDep = map[string][]string{ + "pkgconf": {"pkg-config"}, + "ninja-build": {"ninja"}, + "python3": {"python3", "python"}, + "lua5.1": {"lua", "lua5.1"}, +} + +func toolAlreadyAvailable(dep string) bool { + dep = normalizePackageName(dep) + if dep == "" { + return false + } + + commands := commandHintsByDep[dep] + if len(commands) == 0 { + commands = []string{dep} + } + + for _, name := range commands { + if _, err := exec.LookPath(name); err == nil { + return true + } + } + return false +} + func autoRecipeFromDebian(src *debian.SourceInfo) *recipe.Recipe { name := src.SourcePackage if name == "" { @@ -142,8 +505,8 @@ func autoRecipeFromDebian(src *debian.SourceInfo) *recipe.Recipe { installCmd := fmt.Sprintf( "if [ -f build/cmake_install.cmake ]; then DESTDIR={{pkgdir}} cmake --install build; "+ - "elif [ -f build/meson-private/coredata.dat ]; then DESTDIR={{pkgdir}} meson install -C build; "+ - "elif [ -f Makefile ] || [ -f makefile ] || [ -f GNUmakefile ]; then make DESTDIR={{pkgdir}} PREFIX=/usr install; "+ + "elif [ -f build/meson-private/coredata.dat ]; then DESTDIR={{pkgdir}} meson install -C build ${ZSVO_MESON_INSTALL_ARGS}; "+ + "elif [ -f Makefile ] || [ -f makefile ] || [ -f GNUmakefile ]; then make DESTDIR={{pkgdir}} PREFIX=/usr ${ZSVO_MAKE_INSTALL_FLAGS} install; "+ "elif [ -f %s ]; then install -Dm755 %s {{pkgdir}}/usr/bin/%s; fi", name, name, @@ -159,11 +522,209 @@ func autoRecipeFromDebian(src *debian.SourceInfo) *recipe.Recipe { Sha256: src.DSCSHA256, }, Build: []string{ - "[ -f configure ] && ./configure --prefix=/usr || true", - "if [ -f CMakeLists.txt ]; then cmake -S . -B build -DCMAKE_BUILD_TYPE=Release -DCMAKE_INSTALL_PREFIX=/usr && cmake --build build -j${jobs}; " + - "elif [ -f meson.build ]; then meson setup build --prefix=/usr && meson compile -C build -j${jobs}; " + - "elif [ -f Makefile ] || [ -f makefile ] || [ -f GNUmakefile ]; then make -j${jobs}; fi", + "if [ ! -f configure ] && [ -f autogen.sh ]; then sh ./autogen.sh --no-check ${ZSVO_AUTOGEN_ARGS}; fi; if [ -f configure ]; then ./configure --prefix=/usr ${ZSVO_CONFIGURE_FLAGS}; fi", + "if [ -f CMakeLists.txt ]; then cmake -S . -B build -DCMAKE_BUILD_TYPE=Release -DCMAKE_INSTALL_PREFIX=/usr ${ZSVO_CMAKE_FLAGS} && cmake --build build -j${jobs} -- ${ZSVO_CMAKE_BUILD_FLAGS}; " + + "elif [ -f meson.build ]; then meson setup build --prefix=/usr ${ZSVO_MESON_SETUP_ARGS} && meson compile -C build -j${jobs} ${ZSVO_MESON_COMPILE_ARGS}; " + + "elif [ -f Makefile ] || [ -f makefile ] || [ -f GNUmakefile ]; then make -j${jobs} ${ZSVO_MAKE_FLAGS}; fi", }, Install: []string{installCmd}, } } + +type progressUI struct { + total int + pkgName string + startedAt time.Time + enabled bool + frameIdx int + lastLineLen int +} + +func newProgressUI(pkgName string) *progressUI { + return &progressUI{ + pkgName: pkgName, + startedAt: time.Now(), + enabled: supportsANSIAndTTY(), + } +} + +func (p *progressUI) update(step, total int, message string) { + if total <= 0 { + total = 1 + } + if step < 0 { + step = 0 + } + if step > total { + step = total + } + p.total = total + + if !p.enabled { + fmt.Printf("[%d/%d] %s\n", step, total, message) + return + } + + const width = 32 + filled := step * width / total + if filled > width { + filled = width + } + + percent := step * 100 / total + spinner := progressFrames[p.frameIdx%len(progressFrames)] + p.frameIdx++ + + bar := renderColoredBar(width, filled) + elapsed := formatElapsed(time.Since(p.startedAt)) + + line := fmt.Sprintf( + "\r%s %s %s %3d%% %s %s", + colorize("36;1", spinner), + colorize("1", p.pkgName), + bar, + percent, + colorize("2", "| "+truncateText(message, 48)), + colorize("2", elapsed), + ) + + if pad := p.lastLineLen - visibleLen(line); pad > 0 { + line += strings.Repeat(" ", pad) + } + p.lastLineLen = visibleLen(line) + fmt.Print(line) +} + +func (p *progressUI) finish(ok bool, message string) { + total := p.total + if total <= 0 { + total = 1 + } + p.update(total, total, message) + + if p.enabled { + status := colorize("31;1", "FAIL") + if ok { + status = colorize("32;1", "DONE") + } + fmt.Printf(" %s %s\n", status, colorize("2", "("+formatElapsed(time.Since(p.startedAt))+")")) + return + } + + fmt.Println() +} + +var progressFrames = []string{"|", "/", "-", "\\"} + +func renderColoredBar(width, filled int) string { + if width <= 0 { + return "[]" + } + + if filled < 0 { + filled = 0 + } + if filled > width { + filled = width + } + + done := strings.Repeat("=", filled) + todo := strings.Repeat(".", width-filled) + return "[" + colorize("32", done) + colorize("2", todo) + "]" +} + +func supportsANSIAndTTY() bool { + if os.Getenv("NO_COLOR") != "" { + return false + } + term := strings.TrimSpace(strings.ToLower(os.Getenv("TERM"))) + if term == "" || term == "dumb" { + return false + } + info, err := os.Stdout.Stat() + if err != nil { + return false + } + return (info.Mode() & os.ModeCharDevice) != 0 +} + +func colorize(code, text string) string { + if text == "" { + return "" + } + return "\x1b[" + code + "m" + text + "\x1b[0m" +} + +func formatElapsed(d time.Duration) string { + if d < 0 { + d = 0 + } + totalSeconds := int(d.Seconds()) + minutes := totalSeconds / 60 + seconds := totalSeconds % 60 + return fmt.Sprintf("%02d:%02d", minutes, seconds) +} + +func truncateText(s string, max int) string { + if max <= 3 || len(s) <= max { + return s + } + return s[:max-3] + "..." +} + +func visibleLen(s string) int { + // This is enough here because we only inject ANSI codes ourselves. + n := 0 + inEsc := false + for i := 0; i < len(s); i++ { + ch := s[i] + if inEsc { + if ch == 'm' { + inEsc = false + } + continue + } + if ch == 0x1b { + inEsc = true + continue + } + n++ + } + return n +} + +func buildFailureHint(pkgName string, err error) string { + if err == nil { + return "" + } + + text := strings.ToLower(err.Error()) + pkgName = strings.TrimSpace(strings.ToLower(pkgName)) + hints := make([]string, 0, 4) + + missingDeps := inferMissingBuildDeps(err) + if len(missingDeps) > 0 { + hints = append( + hints, + fmt.Sprintf("Hint: missing build dependencies detected: %s.", strings.Join(missingDeps, ", ")), + "Hint: добавь рецепты для этих пакетов (или алиасы к Debian source), затем повтори `zsvo install`.", + ) + } + if strings.Contains(text, "on systems using dpkg and apt, try: \"apt-get install package\"") { + hints = append(hints, + "Hint: upstream configure-script ожидает системные build-зависимости; в zsvo это нужно решать рецептами/автосборкой зависимостей.", + ) + } + + if pkgName == "neovim" && strings.Contains(text, "lua") { + hints = append(hints, + "Hint (neovim): после установки Lua можно зафиксировать интерпретатор: `export ZSVO_CMAKE_FLAGS=\"-DLUA_PRG=$(which lua) -DLUA_GEN_PRG=$(which lua)\"`.", + ) + } + + if len(hints) == 0 { + return "" + } + + return strings.Join(hints, "\n") +} diff --git a/cmd/install_test.go b/cmd/install_test.go index 4625542..e341970 100644 --- a/cmd/install_test.go +++ b/cmd/install_test.go @@ -1,8 +1,11 @@ package cmd import ( + "errors" "os" "path/filepath" + "reflect" + "strings" "testing" "zsvo/pkg/debian" @@ -68,4 +71,59 @@ func TestAutoRecipeFromDebian(t *testing.T) { if len(r.Build) == 0 || len(r.Install) == 0 { t.Fatalf("expected auto recipe build/install commands") } + if !strings.Contains(r.Build[0], "autogen.sh --no-check") { + t.Fatalf("expected auto recipe to support autogen fallback, got: %s", r.Build[0]) + } +} + +func TestBuildFailureHintForLua(t *testing.T) { + t.Parallel() + + msg := buildFailureHint("neovim", errors.New("Failed to find a Lua 5.1-compatible interpreter")) + if !strings.Contains(strings.ToLower(msg), "lua") { + t.Fatalf("expected lua hint, got: %q", msg) + } +} + +func TestInferMissingBuildDeps_FromCommandNotFound(t *testing.T) { + t.Parallel() + + err := errors.New("sh: cmake: command not found\n/bin/sh: 1: pkg-config: not found") + got := inferMissingBuildDeps(err) + want := []string{"cmake", "pkgconf"} + if !reflect.DeepEqual(got, want) { + t.Fatalf("inferMissingBuildDeps() mismatch:\nwant: %#v\ngot: %#v", want, got) + } +} + +func TestInferMissingBuildDeps_FromLuaError(t *testing.T) { + t.Parallel() + + err := errors.New("CMake Error: Failed to find a Lua 5.1-compatible interpreter") + got := inferMissingBuildDeps(err) + want := []string{"lua5.1"} + if !reflect.DeepEqual(got, want) { + t.Fatalf("inferMissingBuildDeps() mismatch:\nwant: %#v\ngot: %#v", want, got) + } +} + +func TestMapToolToSourcePackage(t *testing.T) { + t.Parallel() + + cases := map[string]string{ + "pkg-config": "pkgconf", + "ninja": "ninja-build", + "python": "python3", + "lua": "lua5.1", + "cmake": "cmake", + "bash": "", + "1": "", + "": "", + } + + for input, want := range cases { + if got := mapToolToSourcePackage(input); got != want { + t.Fatalf("mapToolToSourcePackage(%q): want %q, got %q", input, want, got) + } + } } diff --git a/main.go b/main.go index 63f1065..c6c4252 100644 --- a/main.go +++ b/main.go @@ -9,8 +9,10 @@ import ( ) var rootCmd = &cobra.Command{ - Use: "zsvo", - Short: "A simple source-based package manager", + Use: "zsvo", + Short: "A simple source-based package manager", + SilenceErrors: true, + SilenceUsage: true, Long: `A minimal package manager for custom Linux distributions based on LFS Available commands: diff --git a/pkg/builder/builder.go b/pkg/builder/builder.go index 6560320..6cf9cfc 100644 --- a/pkg/builder/builder.go +++ b/pkg/builder/builder.go @@ -15,7 +15,17 @@ import ( // Builder handles building packages from recipes type Builder struct { - workDir string + workDir string + quiet bool + progressCallback func(BuildProgress) + envOverrides map[string]string +} + +// BuildProgress represents build progress state. +type BuildProgress struct { + Step int + Total int + Message string } // NewBuilder creates a new builder @@ -31,36 +41,51 @@ func (b *Builder) Build(recipe *recipe.Recipe) error { return fmt.Errorf("recipe cannot be nil") } + totalSteps := 4 + len(recipe.Build) + len(recipe.Install) + step := 0 + nextStep := func(message string) { + step++ + b.reportProgress(step, totalSteps, message) + } + // Create working directories sourceDir := recipe.GetSourceDir(b.workDir) stagingDir := recipe.GetStagingDir(b.workDir) packageDir := recipe.GetPackageDir(b.workDir) + nextStep("Preparing directories") if err := b.prepareDirectories(sourceDir, stagingDir, packageDir); err != nil { return fmt.Errorf("failed to prepare directories: %w", err) } // Download and extract source + nextStep("Downloading and extracting source") if err := b.downloadAndExtract(recipe); err != nil { return fmt.Errorf("failed to download and extract source: %w", err) } // Apply patches + nextStep("Applying recipe patches") if err := b.applyPatches(recipe, sourceDir); err != nil { return fmt.Errorf("failed to apply patches: %w", err) } // Build package - if err := b.executeBuild(recipe, sourceDir, stagingDir); err != nil { + if err := b.executeBuild(recipe, sourceDir, stagingDir, func(i, total int) { + nextStep(fmt.Sprintf("Build step %d/%d", i, total)) + }); err != nil { return fmt.Errorf("failed to build package: %w", err) } // Package files - if err := b.packageFiles(recipe, sourceDir, stagingDir); err != nil { + if err := b.packageFiles(recipe, sourceDir, stagingDir, func(i, total int) { + nextStep(fmt.Sprintf("Install step %d/%d", i, total)) + }); err != nil { return fmt.Errorf("failed to package files: %w", err) } // Create package archive + nextStep("Creating package archive") p := packager.NewPackager(b.workDir) if err := p.Package(recipe); err != nil { return fmt.Errorf("failed to create package archive: %w", err) @@ -133,7 +158,7 @@ func (b *Builder) applyPatches(recipe *recipe.Recipe, sourceDir string) error { } // executeBuild executes build commands -func (b *Builder) executeBuild(recipe *recipe.Recipe, sourceDir, stagingDir string) error { +func (b *Builder) executeBuild(recipe *recipe.Recipe, sourceDir, stagingDir string, progressFn func(step, total int)) error { // Find the source directory (usually the first subdirectory) srcPath, err := b.findSourceDirectory(sourceDir) if err != nil { @@ -145,6 +170,10 @@ func (b *Builder) executeBuild(recipe *recipe.Recipe, sourceDir, stagingDir stri // Execute build commands for i, cmd := range recipe.Build { + if progressFn != nil { + progressFn(i+1, len(recipe.Build)) + } + // Substitute variables in command cmd = b.substituteVariables(cmd, sourceDir, stagingDir) @@ -192,7 +221,7 @@ func (b *Builder) buildEnvironment(stagingDir string) []string { // Add parallel build variable env = append(env, fmt.Sprintf("MAKEFLAGS=-j%d", runtime.NumCPU())) - return env + return applyEnvOverrides(env, b.envOverrides) } // executeCommand executes a single command @@ -204,6 +233,19 @@ func (b *Builder) executeCommand(workDir, command string, env []string) error { cmd := exec.Command("sh", "-c", command) cmd.Dir = workDir cmd.Env = env + + if b.quiet { + output, err := cmd.CombinedOutput() + if err != nil { + details := tailOutput(string(output), 20) + if details != "" { + return fmt.Errorf("%w\n%s", err, details) + } + return err + } + return nil + } + cmd.Stdout = os.Stdout cmd.Stderr = os.Stderr @@ -308,6 +350,53 @@ func (b *Builder) SetWorkDir(workDir string) { b.workDir = workDir } +// SetQuiet enables or disables command output streaming. +func (b *Builder) SetQuiet(quiet bool) { + b.quiet = quiet +} + +// SetProgressCallback sets callback for build progress updates. +func (b *Builder) SetProgressCallback(callback func(BuildProgress)) { + b.progressCallback = callback +} + +// SetEnvOverride sets or removes one environment override used for build commands. +func (b *Builder) SetEnvOverride(key, value string) { + key = strings.TrimSpace(key) + if key == "" { + return + } + if b.envOverrides == nil { + b.envOverrides = make(map[string]string) + } + if value == "" { + delete(b.envOverrides, key) + return + } + b.envOverrides[key] = value +} + +// SetEnvOverrides replaces all environment overrides used for build commands. +func (b *Builder) SetEnvOverrides(overrides map[string]string) { + if len(overrides) == 0 { + b.envOverrides = nil + return + } + cloned := make(map[string]string, len(overrides)) + for key, value := range overrides { + key = strings.TrimSpace(key) + if key == "" || value == "" { + continue + } + cloned[key] = value + } + if len(cloned) == 0 { + b.envOverrides = nil + return + } + b.envOverrides = cloned +} + // substituteVariables substitutes variables in command strings func (b *Builder) substituteVariables(cmdStr, sourceDir, stagingDir string) string { // Get number of CPU cores @@ -325,7 +414,7 @@ func (b *Builder) substituteVariables(cmdStr, sourceDir, stagingDir string) stri } // packageFiles runs the package commands -func (b *Builder) packageFiles(recipe *recipe.Recipe, sourceDir, stagingDir string) error { +func (b *Builder) packageFiles(recipe *recipe.Recipe, sourceDir, stagingDir string, progressFn func(step, total int)) error { // Find the source directory (usually the first subdirectory) srcPath, err := b.findSourceDirectory(sourceDir) if err != nil { @@ -337,6 +426,10 @@ func (b *Builder) packageFiles(recipe *recipe.Recipe, sourceDir, stagingDir stri // Execute package commands for i, cmd := range recipe.Install { + if progressFn != nil { + progressFn(i+1, len(recipe.Install)) + } + // Substitute variables in command cmd = b.substituteVariables(cmd, sourceDir, stagingDir) @@ -347,3 +440,53 @@ func (b *Builder) packageFiles(recipe *recipe.Recipe, sourceDir, stagingDir stri return nil } + +func (b *Builder) reportProgress(step, total int, message string) { + if b.progressCallback == nil { + return + } + b.progressCallback(BuildProgress{ + Step: step, + Total: total, + Message: message, + }) +} + +func tailOutput(output string, maxLines int) string { + output = strings.TrimSpace(output) + if output == "" { + return "" + } + + lines := strings.Split(output, "\n") + if maxLines <= 0 || len(lines) <= maxLines { + return output + } + return strings.Join(lines[len(lines)-maxLines:], "\n") +} + +func applyEnvOverrides(base []string, overrides map[string]string) []string { + if len(overrides) == 0 { + return base + } + + out := append([]string(nil), base...) + indexByKey := make(map[string]int, len(out)) + for i, entry := range out { + if eq := strings.IndexByte(entry, '='); eq > 0 { + indexByKey[entry[:eq]] = i + } + } + + for key, value := range overrides { + item := key + "=" + value + if idx, ok := indexByKey[key]; ok { + out[idx] = item + continue + } + indexByKey[key] = len(out) + out = append(out, item) + } + + return out +} diff --git a/pkg/debian/source.go b/pkg/debian/source.go index 099a7a6..79bcde5 100644 --- a/pkg/debian/source.go +++ b/pkg/debian/source.go @@ -144,6 +144,7 @@ type sourceRecord struct { Directory string DSCName string DSCSHA256 string + Binaries []string } func (r *Resolver) findPackageInIndex(mirror, suite, component, pkg string) (*sourceRecord, error) { @@ -203,7 +204,7 @@ func (r *Resolver) findInSingleIndex(indexURL string, decoder func(io.Reader) (i if err != nil { return nil, false, nil } - if rec.Package == pkg { + if rec.Package == pkg || containsSourceBinary(rec.Binaries, pkg) { return &rec, true, nil } return nil, false, nil @@ -294,9 +295,41 @@ func parseSourcesParagraph(lines []string) (sourceRecord, error) { Directory: dir, DSCName: dscName, DSCSHA256: dscHash, + Binaries: parseCommaSeparatedField(fields["Binary"]), }, nil } +func parseCommaSeparatedField(raw string) []string { + parts := strings.Split(strings.TrimSpace(raw), ",") + out := make([]string, 0, len(parts)) + seen := make(map[string]struct{}, len(parts)) + for _, part := range parts { + part = strings.TrimSpace(strings.ToLower(part)) + if part == "" { + continue + } + if _, exists := seen[part]; exists { + continue + } + seen[part] = struct{}{} + out = append(out, part) + } + return out +} + +func containsSourceBinary(names []string, wanted string) bool { + wanted = strings.TrimSpace(strings.ToLower(wanted)) + if wanted == "" { + return false + } + for _, name := range names { + if strings.TrimSpace(strings.ToLower(name)) == wanted { + return true + } + } + return false +} + func parseChecksumsForDSC(raw string) (string, string) { for _, line := range strings.Split(raw, "\n") { fields := strings.Fields(strings.TrimSpace(line)) diff --git a/pkg/debian/source_test.go b/pkg/debian/source_test.go index 3396243..b77cf2a 100644 --- a/pkg/debian/source_test.go +++ b/pkg/debian/source_test.go @@ -21,6 +21,7 @@ func TestResolveSourceHTTPFallbackToGzip(t *testing.T) { sources := strings.TrimSpace(` Package: neofetch +Binary: neofetch Version: 7.1.0-4 Directory: pool/main/n/neofetch Checksums-Sha256: @@ -77,6 +78,48 @@ Checksums-Sha256: } } +func TestResolveSourceByBinaryName(t *testing.T) { + t.Parallel() + + sources := strings.TrimSpace(` +Package: pkgconf +Binary: pkgconf, pkg-config +Version: 2.0.3-1 +Directory: pool/main/p/pkgconf +Checksums-Sha256: + cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc 1800 pkgconf_2.0.3-1.dsc +`) + "\n" + + client := &http.Client{ + Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) { + if strings.HasSuffix(req.URL.Path, "/Sources") { + return httpResponse(req, http.StatusOK, []byte(sources)), nil + } + return httpResponse(req, http.StatusNotFound, []byte("missing")), nil + }), + } + + r := NewResolver( + WithHTTPClient(client), + WithMirrors([]string{"https://mirror.example/debian"}), + WithSuites([]string{"stable"}), + WithComponents([]string{"main"}), + ) + + info, err := r.ResolveSource("pkg-config") + if err != nil { + t.Fatalf("ResolveSource() error = %v", err) + } + + if info.SourcePackage != "pkgconf" { + t.Fatalf("unexpected source package: %s", info.SourcePackage) + } + wantURL := "https://mirror.example/debian/pool/main/p/pkgconf/pkgconf_2.0.3-1.dsc" + if info.DSCURL != wantURL { + t.Fatalf("unexpected dsc url:\nwant: %s\ngot: %s", wantURL, info.DSCURL) + } +} + func TestResolveSourceHTTPNotFound(t *testing.T) { t.Parallel() diff --git a/pkg/deps/deps.go b/pkg/deps/deps.go new file mode 100644 index 0000000..c9b3914 --- /dev/null +++ b/pkg/deps/deps.go @@ -0,0 +1,411 @@ +package deps + +import ( + "fmt" + "strconv" + "strings" + "unicode" +) + +// VersionOp defines version comparison operator for dependency constraints. +type VersionOp int + +const ( + OpAny VersionOp = iota + OpEqual + OpGreater + OpGreaterOrEqual + OpLess + OpLessOrEqual +) + +// Constraint describes one dependency alternative like "pkg>=1.2". +type Constraint struct { + Name string + Op VersionOp + Version string +} + +// Requirement describes one dependency expression with alternatives: "a | b>=2". +type Requirement struct { + Raw string + Alternatives []Constraint +} + +// ParseRequirements parses dependency expressions. +func ParseRequirements(raw []string) ([]Requirement, error) { + reqs := make([]Requirement, 0, len(raw)) + for _, entry := range raw { + req, err := ParseRequirement(entry) + if err != nil { + return nil, err + } + reqs = append(reqs, req) + } + return reqs, nil +} + +// ParseRequirement parses one dependency expression. +func ParseRequirement(raw string) (Requirement, error) { + raw = strings.TrimSpace(raw) + if raw == "" { + return Requirement{}, fmt.Errorf("dependency cannot be empty") + } + + parts := strings.Split(raw, "|") + alts := make([]Constraint, 0, len(parts)) + for _, part := range parts { + c, err := parseConstraint(part) + if err != nil { + return Requirement{}, fmt.Errorf("invalid dependency %q: %w", raw, err) + } + alts = append(alts, c) + } + if len(alts) == 0 { + return Requirement{}, fmt.Errorf("invalid dependency %q", raw) + } + + return Requirement{ + Raw: raw, + Alternatives: alts, + }, nil +} + +func parseConstraint(raw string) (Constraint, error) { + raw = strings.TrimSpace(raw) + if raw == "" { + return Constraint{}, fmt.Errorf("empty alternative") + } + + // Debian style: "pkg (>= 1.2)". + if open := strings.IndexByte(raw, '('); open >= 0 { + close := strings.LastIndexByte(raw, ')') + if close < open || close != len(raw)-1 { + return Constraint{}, fmt.Errorf("invalid parentheses syntax") + } + + name := strings.TrimSpace(raw[:open]) + if !isValidDepName(name) { + return Constraint{}, fmt.Errorf("invalid package name %q", name) + } + + inner := strings.TrimSpace(raw[open+1 : close]) + op, version, err := splitVersionConstraint(inner) + if err != nil { + return Constraint{}, err + } + return Constraint{Name: name, Op: op, Version: version}, nil + } + + // Generic style: "pkg>=1.2" or "pkg >= 1.2" or plain "pkg". + name, op, version, err := splitNameAndConstraint(raw) + if err != nil { + return Constraint{}, err + } + return Constraint{Name: name, Op: op, Version: version}, nil +} + +func splitNameAndConstraint(raw string) (string, VersionOp, string, error) { + name := strings.TrimSpace(raw) + op := OpAny + version := "" + + idx, opFound := findOp(raw) + if opFound { + name = strings.TrimSpace(raw[:idx]) + right := strings.TrimSpace(raw[idx:]) + var err error + op, version, err = splitVersionConstraint(right) + if err != nil { + return "", OpAny, "", err + } + } + + if !isValidDepName(name) { + return "", OpAny, "", fmt.Errorf("invalid package name %q", name) + } + return name, op, version, nil +} + +func findOp(raw string) (int, bool) { + for i, r := range raw { + switch r { + case '<', '>', '=': + return i, true + } + } + return 0, false +} + +func splitVersionConstraint(raw string) (VersionOp, string, error) { + raw = strings.TrimSpace(raw) + if raw == "" { + return OpAny, "", fmt.Errorf("empty version constraint") + } + + opToken := "" + version := "" + + switch { + case strings.HasPrefix(raw, ">="): + opToken = ">=" + version = strings.TrimSpace(raw[2:]) + case strings.HasPrefix(raw, "<="): + opToken = "<=" + version = strings.TrimSpace(raw[2:]) + case strings.HasPrefix(raw, "="): + opToken = "=" + version = strings.TrimSpace(raw[1:]) + case strings.HasPrefix(raw, ">"): + opToken = ">" + version = strings.TrimSpace(raw[1:]) + case strings.HasPrefix(raw, "<"): + opToken = "<" + version = strings.TrimSpace(raw[1:]) + default: + parts := strings.Fields(raw) + if len(parts) == 2 { + opToken = parts[0] + version = parts[1] + } + } + + if opToken == "" || version == "" { + return OpAny, "", fmt.Errorf("invalid version constraint %q", raw) + } + if strings.HasPrefix(version, "<") || strings.HasPrefix(version, ">") || strings.HasPrefix(version, "=") { + return OpAny, "", fmt.Errorf("invalid version %q", version) + } + if strings.ContainsAny(version, "()|") { + return OpAny, "", fmt.Errorf("invalid version %q", version) + } + + op, err := parseVersionOp(opToken) + if err != nil { + return OpAny, "", err + } + return op, version, nil +} + +func parseVersionOp(raw string) (VersionOp, error) { + switch strings.TrimSpace(raw) { + case "=": + return OpEqual, nil + case ">": + return OpGreater, nil + case ">=": + return OpGreaterOrEqual, nil + case "<": + return OpLess, nil + case "<=": + return OpLessOrEqual, nil + default: + return OpAny, fmt.Errorf("unsupported operator %q", raw) + } +} + +func isValidDepName(name string) bool { + name = strings.TrimSpace(name) + if name == "" { + return false + } + for i, r := range name { + if i == 0 { + if !unicode.IsDigit(r) && !unicode.IsLetter(r) { + return false + } + continue + } + if unicode.IsLetter(r) || unicode.IsDigit(r) { + continue + } + switch r { + case '+', '.', '-', '_': + default: + return false + } + } + return true +} + +// MatchesVersion checks whether package version satisfies this constraint. +func (c Constraint) MatchesVersion(installedVersion string) bool { + switch c.Op { + case OpAny: + return true + case OpEqual: + return CompareVersions(installedVersion, c.Version) == 0 + case OpGreater: + return CompareVersions(installedVersion, c.Version) > 0 + case OpGreaterOrEqual: + return CompareVersions(installedVersion, c.Version) >= 0 + case OpLess: + return CompareVersions(installedVersion, c.Version) < 0 + case OpLessOrEqual: + return CompareVersions(installedVersion, c.Version) <= 0 + default: + return false + } +} + +// CompareVersions compares package versions. +// Returns -1 if a < b, 0 if a == b, and 1 if a > b. +func CompareVersions(a, b string) int { + epochA, restA := splitEpoch(a) + epochB, restB := splitEpoch(b) + if epochA != epochB { + if epochA < epochB { + return -1 + } + return 1 + } + + mainA, relA := splitRelease(restA) + mainB, relB := splitRelease(restB) + + if c := compareVersionPart(mainA, mainB); c != 0 { + return c + } + return compareVersionPart(relA, relB) +} + +func splitEpoch(raw string) (int64, string) { + raw = strings.TrimSpace(raw) + if idx := strings.IndexByte(raw, ':'); idx > 0 { + epochRaw := strings.TrimSpace(raw[:idx]) + if epoch, err := strconv.ParseInt(epochRaw, 10, 64); err == nil { + return epoch, strings.TrimSpace(raw[idx+1:]) + } + } + return 0, raw +} + +func splitRelease(raw string) (string, string) { + raw = strings.TrimSpace(raw) + if raw == "" { + return "0", "0" + } + + if idx := strings.LastIndex(raw, "-"); idx > 0 && idx < len(raw)-1 { + return raw[:idx], raw[idx+1:] + } + return raw, "0" +} + +func compareVersionPart(a, b string) int { + a = strings.TrimSpace(a) + b = strings.TrimSpace(b) + + i, j := 0, 0 + for i < len(a) || j < len(b) { + // Tilde sorts before everything, including end of string. + if i < len(a) && a[i] == '~' || j < len(b) && b[j] == '~' { + switch { + case i < len(a) && a[i] == '~' && j < len(b) && b[j] == '~': + i++ + j++ + continue + case i < len(a) && a[i] == '~': + return -1 + default: + return 1 + } + } + + for i < len(a) && isVersionSeparator(a[i]) { + i++ + } + for j < len(b) && isVersionSeparator(b[j]) { + j++ + } + + if i >= len(a) && j >= len(b) { + return 0 + } + if i >= len(a) { + return -1 + } + if j >= len(b) { + return 1 + } + + aNum := isDigit(a[i]) + bNum := isDigit(b[j]) + + segA, nextI := readVersionSegment(a, i, aNum) + segB, nextJ := readVersionSegment(b, j, bNum) + i, j = nextI, nextJ + + var c int + switch { + case aNum && bNum: + c = compareNumericSegment(segA, segB) + case aNum && !bNum: + c = 1 + case !aNum && bNum: + c = -1 + default: + c = strings.Compare(strings.ToLower(segA), strings.ToLower(segB)) + } + if c < 0 { + return -1 + } + if c > 0 { + return 1 + } + } + + return 0 +} + +func isVersionSeparator(ch byte) bool { + if ch == '~' { + return false + } + return !isDigit(ch) && !isLetter(ch) +} + +func readVersionSegment(s string, start int, numeric bool) (string, int) { + i := start + for i < len(s) { + ch := s[i] + if numeric { + if !isDigit(ch) { + break + } + } else { + if !isLetter(ch) { + break + } + } + i++ + } + return s[start:i], i +} + +func compareNumericSegment(a, b string) int { + a = strings.TrimLeft(a, "0") + b = strings.TrimLeft(b, "0") + if a == "" { + a = "0" + } + if b == "" { + b = "0" + } + + if len(a) < len(b) { + return -1 + } + if len(a) > len(b) { + return 1 + } + return strings.Compare(a, b) +} + +func isDigit(ch byte) bool { + return ch >= '0' && ch <= '9' +} + +func isLetter(ch byte) bool { + return ch >= 'a' && ch <= 'z' || ch >= 'A' && ch <= 'Z' +} diff --git a/pkg/deps/deps_test.go b/pkg/deps/deps_test.go new file mode 100644 index 0000000..788badd --- /dev/null +++ b/pkg/deps/deps_test.go @@ -0,0 +1,134 @@ +package deps + +import ( + "reflect" + "testing" +) + +func TestParseRequirement(t *testing.T) { + t.Parallel() + + req, err := ParseRequirement("lua5.1 (>= 5.1.5) | luajit") + if err != nil { + t.Fatalf("ParseRequirement() error = %v", err) + } + + if len(req.Alternatives) != 2 { + t.Fatalf("expected 2 alternatives, got %d", len(req.Alternatives)) + } + + first := req.Alternatives[0] + if first.Name != "lua5.1" || first.Op != OpGreaterOrEqual || first.Version != "5.1.5" { + t.Fatalf("unexpected first alt: %#v", first) + } + second := req.Alternatives[1] + if second.Name != "luajit" || second.Op != OpAny { + t.Fatalf("unexpected second alt: %#v", second) + } +} + +func TestParseRequirementsInvalid(t *testing.T) { + t.Parallel() + + invalid := []string{ + "", + " ", + "bad dep", + "foo (>> 1)", + "foo >=", + "foo |", + "| foo", + } + + for _, raw := range invalid { + if _, err := ParseRequirement(raw); err == nil { + t.Fatalf("expected parse error for %q", raw) + } + } +} + +func TestCompareVersions(t *testing.T) { + t.Parallel() + + cases := []struct { + a, b string + want int + }{ + {"1.0", "1.0", 0}, + {"1.0", "1.0.1", -1}, + {"2.0", "1.9", 1}, + {"1.02", "1.2", 0}, + {"1.0~rc1", "1.0", -1}, + {"1:1.0-1", "1.9.9", 1}, + {"2:1.0-1", "1:9.0-9", 1}, + {"1.0-2", "1.0-10", -1}, + {"1.0+dfsg1", "1.0+dfsg2", -1}, + {"1.0a", "1.0", 1}, + } + + for _, tc := range cases { + got := CompareVersions(tc.a, tc.b) + if sign(got) != sign(tc.want) { + t.Fatalf("CompareVersions(%q, %q): want sign %d, got %d", tc.a, tc.b, sign(tc.want), sign(got)) + } + } +} + +func TestConstraintMatchesVersion(t *testing.T) { + t.Parallel() + + cases := []struct { + rawDep string + version string + want bool + }{ + {"foo", "1.0", true}, + {"foo=1.0", "1.0", true}, + {"foo=1.0", "1.1", false}, + {"foo>=1.0", "1.2", true}, + {"foo>=1.0", "0.9", false}, + {"foo<2.0", "1.9", true}, + {"foo<2.0", "2.0", false}, + {"foo (<= 2.1)", "2.1", true}, + } + + for _, tc := range cases { + req, err := ParseRequirement(tc.rawDep) + if err != nil { + t.Fatalf("ParseRequirement(%q) error = %v", tc.rawDep, err) + } + got := req.Alternatives[0].MatchesVersion(tc.version) + if got != tc.want { + t.Fatalf("MatchesVersion(%q, %q): want %v, got %v", tc.rawDep, tc.version, tc.want, got) + } + } +} + +func TestParseRequirements(t *testing.T) { + t.Parallel() + + input := []string{"foo", "bar>=1.2", "baz | qux"} + got, err := ParseRequirements(input) + if err != nil { + t.Fatalf("ParseRequirements() error = %v", err) + } + if len(got) != 3 { + t.Fatalf("expected 3 requirements, got %d", len(got)) + } + + names := []string{got[0].Alternatives[0].Name, got[1].Alternatives[0].Name, got[2].Alternatives[0].Name} + if want := []string{"foo", "bar", "baz"}; !reflect.DeepEqual(names, want) { + t.Fatalf("unexpected parsed names:\nwant: %#v\ngot: %#v", want, names) + } +} + +func sign(v int) int { + switch { + case v < 0: + return -1 + case v > 0: + return 1 + default: + return 0 + } +} diff --git a/pkg/installer/installer.go b/pkg/installer/installer.go index 5d0ca61..00f930d 100644 --- a/pkg/installer/installer.go +++ b/pkg/installer/installer.go @@ -1,6 +1,7 @@ package installer import ( + "container/heap" "fmt" "io" "os" @@ -9,6 +10,7 @@ import ( "strings" "syscall" + "zsvo/pkg/deps" "zsvo/pkg/packager" "zsvo/pkg/types" ) @@ -153,48 +155,22 @@ func (i *Installer) installManyNoLock(packagePaths []string) error { defer os.RemoveAll(txDir) applied := make([]installTransactionState, 0, len(candidates)) - remaining := append([]installCandidate(nil), candidates...) + ordered, err := solveInstallOrder(candidates, installedInfos) + if err != nil { + return err + } - for len(remaining) > 0 { - progress := false - for idx, candidate := range remaining { - if !depsSatisfied(candidate.info.Dependencies, installedInfos, candidate.info.Name) { - continue + for _, candidate := range ordered { + state, err := i.installPackageTxNoLock(candidate.path, txDir, installedInfos) + if err != nil { + if rbErr := i.rollbackInstallTransactionNoLock(applied); rbErr != nil { + return fmt.Errorf("install failed for %s: %w (rollback error: %v)", candidate.info.Name, err, rbErr) } - - state, err := i.installPackageTxNoLock(candidate.path, txDir, installedInfos) - if err != nil { - if rbErr := i.rollbackInstallTransactionNoLock(applied); rbErr != nil { - return fmt.Errorf("install failed for %s: %w (rollback error: %v)", candidate.info.Name, err, rbErr) - } - return fmt.Errorf("install failed for %s: %w", candidate.info.Name, err) - } - - applied = append(applied, *state) - installedInfos[candidate.info.Name] = candidate.info - remaining = append(remaining[:idx], remaining[idx+1:]...) - progress = true - break + return fmt.Errorf("install failed for %s: %w", candidate.info.Name, err) } - if progress { - continue - } - - block := remaining[0] - missing := missingDeps(block.info.Dependencies, installedInfos, block.info.Name) - if len(missing) == 0 { - missing = []string{"unknown ordering issue"} - } - if rbErr := i.rollbackInstallTransactionNoLock(applied); rbErr != nil { - return fmt.Errorf( - "cannot resolve install order for %s: missing deps %s (rollback error: %v)", - block.info.Name, - strings.Join(missing, ", "), - rbErr, - ) - } - return fmt.Errorf("cannot resolve install order for %s: missing deps %s", block.info.Name, strings.Join(missing, ", ")) + applied = append(applied, *state) + installedInfos[candidate.info.Name] = candidate.info } return nil @@ -844,47 +820,50 @@ func removePathIfExists(path string) error { return nil } -func validateSimpleDependencies(deps []string) error { - for _, dep := range deps { - if depNameFromConstraint(dep) == "" { - return fmt.Errorf("invalid dependency: %q", dep) - } +func validateSimpleDependencies(rawDeps []string) error { + if _, err := deps.ParseRequirements(rawDeps); err != nil { + return err } return nil } -func depsSatisfied(deps []string, installedInfos map[string]*types.PkgInfo, selfName string) bool { - for _, dep := range deps { - depName := depNameFromConstraint(dep) - if depName == "" { - return false - } - if depName == selfName { - continue - } - if _, ok := installedInfos[depName]; !ok { +func depsSatisfied(rawDeps []string, installedInfos map[string]*types.PkgInfo, selfName string) bool { + reqs, err := deps.ParseRequirements(rawDeps) + if err != nil { + return false + } + + available := packageVersions(installedInfos) + for _, req := range reqs { + if _, ok := resolveRequirementProvider(req, available, nil, selfName); !ok { return false } } return true } -func missingDeps(deps []string, installedInfos map[string]*types.PkgInfo, selfName string) []string { +func missingDeps(rawDeps []string, installedInfos map[string]*types.PkgInfo, selfName string) []string { + reqs, err := deps.ParseRequirements(rawDeps) + if err != nil { + return []string{err.Error()} + } + missing := make([]string, 0) seen := make(map[string]struct{}) - for _, dep := range deps { - depName := depNameFromConstraint(dep) - if depName == "" || depName == selfName { + available := packageVersions(installedInfos) + for _, req := range reqs { + if _, ok := resolveRequirementProvider(req, available, nil, selfName); ok { continue } - if _, ok := installedInfos[depName]; ok { + label := req.Raw + if label == "" { + label = formatRequirement(req) + } + if _, exists := seen[label]; exists { continue } - if _, exists := seen[depName]; exists { - continue - } - seen[depName] = struct{}{} - missing = append(missing, depName) + seen[label] = struct{}{} + missing = append(missing, label) } sort.Strings(missing) return missing @@ -901,27 +880,6 @@ func checkDependenciesAgainstInstalled(deps []string, installedInfos map[string] return fmt.Errorf("dependency not installed: %s", strings.Join(missing, ", ")) } -func depNameFromConstraint(dep string) string { - dep = strings.TrimSpace(dep) - if dep == "" { - return "" - } - - if idx := strings.Index(dep, "|"); idx >= 0 { - dep = strings.TrimSpace(dep[:idx]) - } - - for idx, r := range dep { - switch r { - case '<', '>', '=', ' ', '\t', '\n', '\r': - dep = strings.TrimSpace(dep[:idx]) - return dep - } - } - - return dep -} - func normalizePackageNames(packageNames []string) ([]string, error) { if len(packageNames) == 0 { return nil, fmt.Errorf("no packages provided") @@ -961,17 +919,15 @@ func brokenPackagesAfterRemoval(infos map[string]*types.PkgInfo, removeSet map[s sort.Strings(names) broken := make([]string, 0) + remainingVersions := packageVersions(remaining) for _, pkgName := range names { pkgInfo := remaining[pkgName] - for _, dep := range pkgInfo.Dependencies { - depName := depNameFromConstraint(dep) - if depName == "" { - return nil, fmt.Errorf("invalid dependency in package %s: %q", pkgName, dep) - } - if depName == pkgName { - continue - } - if _, ok := remaining[depName]; !ok { + reqs, err := deps.ParseRequirements(pkgInfo.Dependencies) + if err != nil { + return nil, fmt.Errorf("invalid dependency in package %s: %w", pkgName, err) + } + for _, req := range reqs { + if _, ok := resolveRequirementProvider(req, remainingVersions, nil, pkgName); !ok { broken = append(broken, pkgName) break } @@ -983,15 +939,18 @@ func brokenPackagesAfterRemoval(infos map[string]*types.PkgInfo, removeSet map[s func listOrphansFromInfos(infos map[string]*types.PkgInfo) []string { required := make(map[string]struct{}) + versions := packageVersions(infos) for owner, info := range infos { - for _, dep := range info.Dependencies { - depName := depNameFromConstraint(dep) - if depName == "" || depName == owner { + reqs, err := deps.ParseRequirements(info.Dependencies) + if err != nil { + continue + } + for _, req := range reqs { + provider, ok := resolveRequirementProvider(req, versions, nil, owner) + if !ok || provider == owner { continue } - if _, ok := infos[depName]; ok { - required[depName] = struct{}{} - } + required[provider] = struct{}{} } } @@ -1005,6 +964,191 @@ func listOrphansFromInfos(infos map[string]*types.PkgInfo) []string { return orphans } +func solveInstallOrder(candidates []installCandidate, installedInfos map[string]*types.PkgInfo) ([]installCandidate, error) { + if len(candidates) == 0 { + return nil, nil + } + + candidateByName := make(map[string]installCandidate, len(candidates)) + for _, candidate := range candidates { + if candidate.info == nil { + return nil, fmt.Errorf("invalid install candidate: nil metadata") + } + if prev, exists := candidateByName[candidate.info.Name]; exists { + return nil, fmt.Errorf("duplicate package %s in transaction: %s and %s", candidate.info.Name, prev.path, candidate.path) + } + candidateByName[candidate.info.Name] = candidate + } + + available := packageVersions(installedInfos) + for _, candidate := range candidates { + available[candidate.info.Name] = candidate.info.Version + } + + indegree := make(map[string]int, len(candidates)) + dependents := make(map[string][]string, len(candidates)) + for _, candidate := range candidates { + indegree[candidate.info.Name] = 0 + } + + for _, candidate := range candidates { + reqs, err := deps.ParseRequirements(candidate.info.Dependencies) + if err != nil { + return nil, fmt.Errorf("invalid dependencies in %s: %w", candidate.info.Name, err) + } + + edgeSet := make(map[string]struct{}) + for _, req := range reqs { + provider, ok := resolveRequirementProvider(req, available, candidateByName, candidate.info.Name) + if !ok { + return nil, fmt.Errorf("cannot resolve dependencies for %s: missing %s", candidate.info.Name, req.Raw) + } + if provider == candidate.info.Name { + continue + } + if _, inTx := candidateByName[provider]; !inTx { + continue + } + if _, seen := edgeSet[provider]; seen { + continue + } + edgeSet[provider] = struct{}{} + dependents[provider] = append(dependents[provider], candidate.info.Name) + indegree[candidate.info.Name]++ + } + } + + ready := &stringMinHeap{} + heap.Init(ready) + for name, deg := range indegree { + if deg == 0 { + heap.Push(ready, name) + } + } + + ordered := make([]installCandidate, 0, len(candidates)) + for ready.Len() > 0 { + name := heap.Pop(ready).(string) + ordered = append(ordered, candidateByName[name]) + + next := dependents[name] + sort.Strings(next) + for _, depName := range next { + indegree[depName]-- + if indegree[depName] == 0 { + heap.Push(ready, depName) + } + } + } + + if len(ordered) != len(candidates) { + stuck := make([]string, 0) + for name, deg := range indegree { + if deg > 0 { + stuck = append(stuck, name) + } + } + sort.Strings(stuck) + return nil, fmt.Errorf("cannot resolve install order: dependency cycle among %s", strings.Join(stuck, ", ")) + } + + return ordered, nil +} + +func packageVersions(infos map[string]*types.PkgInfo) map[string]string { + out := make(map[string]string, len(infos)) + for name, info := range infos { + if info == nil { + continue + } + out[name] = info.Version + } + return out +} + +func resolveRequirementProvider( + req deps.Requirement, + availableVersions map[string]string, + candidateByName map[string]installCandidate, + selfName string, +) (string, bool) { + for _, alt := range req.Alternatives { + version, ok := availableVersions[alt.Name] + if !ok { + continue + } + if alt.Name == selfName && candidateByName != nil { + if selfCandidate, exists := candidateByName[selfName]; exists { + version = selfCandidate.info.Version + } + } + if !alt.MatchesVersion(version) { + continue + } + + // If a package with this name is in current transaction, it replaces installed one. + if candidateByName != nil { + if candidate, exists := candidateByName[alt.Name]; exists { + if alt.MatchesVersion(candidate.info.Version) { + return candidate.info.Name, true + } + continue + } + } + return alt.Name, true + } + + return "", false +} + +func formatRequirement(req deps.Requirement) string { + if strings.TrimSpace(req.Raw) != "" { + return strings.TrimSpace(req.Raw) + } + parts := make([]string, 0, len(req.Alternatives)) + for _, alt := range req.Alternatives { + if alt.Op == deps.OpAny { + parts = append(parts, alt.Name) + continue + } + parts = append(parts, fmt.Sprintf("%s%s%s", alt.Name, formatOp(alt.Op), alt.Version)) + } + return strings.Join(parts, " | ") +} + +func formatOp(op deps.VersionOp) string { + switch op { + case deps.OpEqual: + return "=" + case deps.OpGreater: + return ">" + case deps.OpGreaterOrEqual: + return ">=" + case deps.OpLess: + return "<" + case deps.OpLessOrEqual: + return "<=" + default: + return "" + } +} + +type stringMinHeap []string + +func (h stringMinHeap) Len() int { return len(h) } +func (h stringMinHeap) Less(i, j int) bool { return h[i] < h[j] } +func (h stringMinHeap) Swap(i, j int) { h[i], h[j] = h[j], h[i] } +func (h *stringMinHeap) Push(x interface{}) { + *h = append(*h, x.(string)) +} +func (h *stringMinHeap) Pop() interface{} { + old := *h + last := len(old) - 1 + item := old[last] + *h = old[:last] + return item +} + func sanitizePackagePath(path string) (string, error) { if path == "" { return "", fmt.Errorf("file path cannot be empty") diff --git a/pkg/installer/installer_test.go b/pkg/installer/installer_test.go index a67cceb..9a0a93e 100644 --- a/pkg/installer/installer_test.go +++ b/pkg/installer/installer_test.go @@ -142,6 +142,132 @@ func TestInstallManyRollsBackOnFailure(t *testing.T) { } } +func TestInstallManyRespectsVersionConstraints(t *testing.T) { + t.Parallel() + + workDir := t.TempDir() + rootDir := t.TempDir() + ins := NewInstaller(rootDir) + + libPkg := buildTestPackage(t, workDir, &recipe.Recipe{ + Name: "libfoo", + Version: "2.1.0", + Build: []string{"true"}, + Install: []string{"true"}, + }, map[string][]byte{"usr/lib/libfoo.so": []byte("lib2\n")}) + + appPkg := buildTestPackage(t, workDir, &recipe.Recipe{ + Name: "app", + Version: "1.0.0", + Build: []string{"true"}, + Install: []string{"true"}, + Deps: []string{"libfoo>=2.0.0"}, + }, map[string][]byte{"usr/bin/app": []byte("app\n")}) + + if err := ins.InstallMany([]string{appPkg, libPkg}); err != nil { + t.Fatalf("InstallMany() error = %v", err) + } + if !ins.IsInstalled("libfoo") || !ins.IsInstalled("app") { + t.Fatalf("expected libfoo and app to be installed") + } +} + +func TestInstallManyFailsOnUnsatisfiedVersionConstraint(t *testing.T) { + t.Parallel() + + workDir := t.TempDir() + rootDir := t.TempDir() + ins := NewInstaller(rootDir) + + libOld := buildTestPackage(t, workDir, &recipe.Recipe{ + Name: "libfoo", + Version: "1.0.0", + Build: []string{"true"}, + Install: []string{"true"}, + }, map[string][]byte{"usr/lib/libfoo.so": []byte("lib1\n")}) + if err := ins.Install(libOld); err != nil { + t.Fatalf("Install(libOld) error = %v", err) + } + + appPkg := buildTestPackage(t, workDir, &recipe.Recipe{ + Name: "app", + Version: "1.0.0", + Build: []string{"true"}, + Install: []string{"true"}, + Deps: []string{"libfoo>=2.0.0"}, + }, map[string][]byte{"usr/bin/app": []byte("app\n")}) + + err := ins.Install(appPkg) + if err == nil { + t.Fatalf("expected install error for unsatisfied version constraint") + } + if !strings.Contains(err.Error(), "libfoo>=2.0.0") { + t.Fatalf("unexpected error: %v", err) + } +} + +func TestInstallManySupportsAlternativeDependencies(t *testing.T) { + t.Parallel() + + workDir := t.TempDir() + rootDir := t.TempDir() + ins := NewInstaller(rootDir) + + libBarPkg := buildTestPackage(t, workDir, &recipe.Recipe{ + Name: "libbar", + Version: "1.5.0", + Build: []string{"true"}, + Install: []string{"true"}, + }, map[string][]byte{"usr/lib/libbar.so": []byte("bar\n")}) + + appPkg := buildTestPackage(t, workDir, &recipe.Recipe{ + Name: "app", + Version: "1.0.0", + Build: []string{"true"}, + Install: []string{"true"}, + Deps: []string{"libfoo | libbar>=1.2.0"}, + }, map[string][]byte{"usr/bin/app": []byte("app\n")}) + + if err := ins.InstallMany([]string{appPkg, libBarPkg}); err != nil { + t.Fatalf("InstallMany() error = %v", err) + } + if !ins.IsInstalled("libbar") || !ins.IsInstalled("app") { + t.Fatalf("expected libbar and app to be installed") + } +} + +func TestInstallManyDetectsDependencyCycle(t *testing.T) { + t.Parallel() + + workDir := t.TempDir() + rootDir := t.TempDir() + ins := NewInstaller(rootDir) + + pkgA := buildTestPackage(t, workDir, &recipe.Recipe{ + Name: "a", + Version: "1.0.0", + Build: []string{"true"}, + Install: []string{"true"}, + Deps: []string{"b>=1.0"}, + }, map[string][]byte{"usr/bin/a": []byte("a\n")}) + + pkgB := buildTestPackage(t, workDir, &recipe.Recipe{ + Name: "b", + Version: "1.0.0", + Build: []string{"true"}, + Install: []string{"true"}, + Deps: []string{"a>=1.0"}, + }, map[string][]byte{"usr/bin/b": []byte("b\n")}) + + err := ins.InstallMany([]string{pkgA, pkgB}) + if err == nil { + t.Fatalf("expected cycle error") + } + if !strings.Contains(err.Error(), "dependency cycle") { + t.Fatalf("unexpected error: %v", err) + } +} + func TestInstallRespectsRootFlag(t *testing.T) { t.Parallel() @@ -219,6 +345,43 @@ func TestRemoveManyCascade(t *testing.T) { } } +func TestRemoveManyAllowsAlternativeDependencyProvider(t *testing.T) { + t.Parallel() + + rootDir := t.TempDir() + ins := NewInstaller(rootDir) + + libFoo := &types.PkgInfo{Name: "libfoo", Version: "1.0.0", Files: []string{"usr/lib/libfoo.so"}} + libBar := &types.PkgInfo{Name: "libbar", Version: "1.0.0", Files: []string{"usr/lib/libbar.so"}} + app := &types.PkgInfo{ + Name: "app", + Version: "1.0.0", + Dependencies: []string{"libfoo | libbar"}, + Files: []string{"usr/bin/app"}, + } + + if err := ins.registerPackage(libFoo); err != nil { + t.Fatalf("registerPackage(libFoo) error = %v", err) + } + if err := ins.registerPackage(libBar); err != nil { + t.Fatalf("registerPackage(libBar) error = %v", err) + } + if err := ins.registerPackage(app); err != nil { + t.Fatalf("registerPackage(app) error = %v", err) + } + + removed, err := ins.RemoveMany([]string{"libfoo"}, RemoveOptions{}) + if err != nil { + t.Fatalf("RemoveMany() error = %v", err) + } + if len(removed) != 1 || removed[0] != "libfoo" { + t.Fatalf("unexpected removed list: %#v", removed) + } + if !ins.IsInstalled("app") { + t.Fatalf("app should remain installed because libbar satisfies dependency") + } +} + func TestListOrphans(t *testing.T) { t.Parallel() @@ -259,3 +422,44 @@ func TestListOrphans(t *testing.T) { t.Fatalf("did not expect libfoo to be orphan") } } + +func TestListOrphansAlternativeDependencyMarksChosenProvider(t *testing.T) { + t.Parallel() + + rootDir := t.TempDir() + ins := NewInstaller(rootDir) + + libFoo := &types.PkgInfo{Name: "libfoo", Version: "1.0.0"} + libBar := &types.PkgInfo{Name: "libbar", Version: "1.0.0"} + app := &types.PkgInfo{Name: "app", Version: "1.0.0", Dependencies: []string{"libfoo | libbar"}} + + if err := ins.registerPackage(libFoo); err != nil { + t.Fatalf("registerPackage(libFoo) error = %v", err) + } + if err := ins.registerPackage(libBar); err != nil { + t.Fatalf("registerPackage(libBar) error = %v", err) + } + if err := ins.registerPackage(app); err != nil { + t.Fatalf("registerPackage(app) error = %v", err) + } + + orphans, err := ins.ListOrphans() + if err != nil { + t.Fatalf("ListOrphans() error = %v", err) + } + + set := make(map[string]struct{}, len(orphans)) + for _, name := range orphans { + set[name] = struct{}{} + } + + if _, ok := set["app"]; !ok { + t.Fatalf("expected app to be orphan") + } + if _, ok := set["libbar"]; !ok { + t.Fatalf("expected libbar to be orphan when libfoo is first satisfiable provider") + } + if _, ok := set["libfoo"]; ok { + t.Fatalf("did not expect libfoo to be orphan") + } +} diff --git a/pkg/recipe/recipe.go b/pkg/recipe/recipe.go index b4d2702..ac1df16 100644 --- a/pkg/recipe/recipe.go +++ b/pkg/recipe/recipe.go @@ -7,6 +7,8 @@ import ( "os" "path/filepath" "strings" + + "zsvo/pkg/deps" ) // Recipe represents a package recipe. @@ -200,8 +202,8 @@ func ParseRecipeFromReader(r io.Reader) (*Recipe, error) { if dep == "" { return nil, fmt.Errorf("recipe deps cannot contain empty values") } - if strings.ContainsAny(dep, "|<>= \t\n\r") { - return nil, fmt.Errorf("unsupported dependency format %q: use plain package names", dep) + if _, err := deps.ParseRequirement(dep); err != nil { + return nil, fmt.Errorf("invalid recipe dependency %q: %w", dep, err) } } diff --git a/pkg/recipe/recipe_test.go b/pkg/recipe/recipe_test.go index d221504..db38790 100644 --- a/pkg/recipe/recipe_test.go +++ b/pkg/recipe/recipe_test.go @@ -42,7 +42,7 @@ deps: } } -func TestParseRecipeRejectsComplexDependencySyntax(t *testing.T) { +func TestParseRecipeAcceptsComplexDependencySyntax(t *testing.T) { t.Parallel() input := ` @@ -57,11 +57,15 @@ install: - make DESTDIR={{pkgdir}} install deps: - glibc>=2.39 + - liblua5.1-0 | libluajit-5.1-2 ` - _, err := ParseRecipeFromReader(strings.NewReader(input)) - if err == nil { - t.Fatalf("expected parse error for unsupported dependency syntax") + r, err := ParseRecipeFromReader(strings.NewReader(input)) + if err != nil { + t.Fatalf("expected complex dependencies to parse, got error: %v", err) + } + if len(r.Deps) != 2 { + t.Fatalf("expected 2 deps, got %d", len(r.Deps)) } } diff --git a/zsvo b/zsvo index 5778443..f90d1fb 100755 Binary files a/zsvo and b/zsvo differ