// Copyright (c) 2026 Storm Dragon // SPDX-License-Identifier: GPL-3.0-or-later package tentacle import ( "fmt" "strings" ) // AvailableUpdate describes a newer repository release for an installed tentacle. type AvailableUpdate struct { ID string Name string InstalledVersion string AvailableVersion string RepositoryURL string RepositoryID string LocalID string } // packageVersion accepts dotted numeric releases (including existing short // versions such as 0.1), with optional SemVer prerelease and build identifiers. type packageVersion struct { numbers []string prerelease []string } func numeric_identifier(value string) bool { if value == "" { return false } for _, ch := range value { if ch < '0' || ch > '9' { return false } } return true } func version_identifiers(value string, prerelease bool) ([]string, error) { identifiers := strings.Split(value, ".") for _, identifier := range identifiers { if identifier == "" { return nil, fmt.Errorf("empty version identifier") } for _, ch := range identifier { if !(ch >= '0' && ch <= '9' || ch >= 'a' && ch <= 'z' || ch >= 'A' && ch <= 'Z' || ch == '-') { return nil, fmt.Errorf("invalid version identifier %q", identifier) } } if prerelease && numeric_identifier(identifier) && len(identifier) > 1 && identifier[0] == '0' { return nil, fmt.Errorf("prerelease number has a leading zero") } } return identifiers, nil } func parse_package_version(value string) (packageVersion, error) { var result packageVersion core := strings.TrimPrefix(strings.TrimSpace(value), "v") if before, build, found := strings.Cut(core, "+"); found { if _, err := version_identifiers(build, false); err != nil { return result, err } core = before } if before, prerelease, found := strings.Cut(core, "-"); found { var err error result.prerelease, err = version_identifiers(prerelease, true) if err != nil { return result, err } core = before } result.numbers = strings.Split(core, ".") for _, number := range result.numbers { if !numeric_identifier(number) { return result, fmt.Errorf("invalid numeric version %q", value) } } return result, nil } // Compare decimal strings without an integer-size limit. func compare_version_number(first, second string) int { first, second = strings.TrimLeft(first, "0"), strings.TrimLeft(second, "0") if len(first) < len(second) { return -1 } if len(first) > len(second) { return 1 } return strings.Compare(first, second) } // IsNewerVersion compares release and prerelease precedence. Build metadata // does not change precedence; missing numeric components are treated as zero. func IsNewerVersion(available, installed string) (bool, error) { first, err := parse_package_version(available) if err != nil { return false, err } second, err := parse_package_version(installed) if err != nil { return false, err } for index := 0; index < len(first.numbers) || index < len(second.numbers); index++ { firstNumber, secondNumber := "0", "0" if index < len(first.numbers) { firstNumber = first.numbers[index] } if index < len(second.numbers) { secondNumber = second.numbers[index] } if compared := compare_version_number(firstNumber, secondNumber); compared != 0 { return compared > 0, nil } } if len(first.prerelease) == 0 || len(second.prerelease) == 0 { return len(first.prerelease) == 0 && len(second.prerelease) != 0, nil } for index := 0; index < len(first.prerelease) && index < len(second.prerelease); index++ { firstID, secondID := first.prerelease[index], second.prerelease[index] firstNumeric, secondNumeric := numeric_identifier(firstID), numeric_identifier(secondID) compared := strings.Compare(firstID, secondID) if firstNumeric && secondNumeric { compared = compare_version_number(firstID, secondID) } if firstNumeric != secondNumeric { return !firstNumeric, nil } if compared != 0 { return compared > 0, nil } } return len(first.prerelease) > len(second.prerelease), nil } // AvailableUpdates finds newer versions of installed repository tentacles. func AvailableUpdates(snapshot Snapshot) []AvailableUpdate { var updates []AvailableUpdate for _, status := range snapshot.Entries { entry := status.Entry if entry.Repository == "" || entry.Version == "" { continue } for _, repository := range snapshot.Repositories { if repository.URL != entry.Repository || repository.ID != entry.RepositoryID { continue } for _, manifest := range repository.Tentacles { if manifest.ID != entry.LocalID { continue } newer, err := IsNewerVersion(manifest.Version, entry.Version) if err != nil || !newer { continue } updates = append(updates, AvailableUpdate{ ID: entry.ID, Name: manifest.Name, InstalledVersion: entry.Version, AvailableVersion: manifest.Version, RepositoryURL: repository.URL, RepositoryID: repository.ID, LocalID: manifest.ID, }) } } } return updates }