ficsit-cli-flake/cli/dependency_resolver.go
mircearoata 024b11b1e8
feat: use the pubgrub algorithm for solving versions (#40)
* refactor: separate resolving tests

* feat: use pubgrub to resolve dependencies

* feat: show friendly mod name in error message

* feat: show single version in error message when only one matches

* ci: update go version to match go.mod

* feat: format FactoryGame incompatibility and term

* chore: fetch all necessary data of the version at once

* chore: upgrade pubgrub

* chore: upgrade pubgrub

* ci: update golangci-lint version for go 1.21

* chore: lint

* chore: update go version in readme
2023-12-06 05:01:49 +02:00

187 lines
5.7 KiB
Go

package cli
import (
"context"
"fmt"
"slices"
"github.com/Khan/genqlient/graphql"
"github.com/mircearoata/pubgrub-go/pubgrub"
"github.com/mircearoata/pubgrub-go/pubgrub/helpers"
"github.com/mircearoata/pubgrub-go/pubgrub/semver"
"github.com/pkg/errors"
"github.com/spf13/viper"
"github.com/satisfactorymodding/ficsit-cli/ficsit"
)
const smlDownloadTemplate = `https://github.com/satisfactorymodding/SatisfactoryModLoader/releases/download/v%s/SML.zip`
type DependencyResolver struct {
apiClient graphql.Client
}
func NewDependencyResolver(apiClient graphql.Client) DependencyResolver {
return DependencyResolver{apiClient: apiClient}
}
var (
rootPkg = "$$root$$"
smlPkg = "SML"
factoryGamePkg = "FactoryGame"
)
type ficsitAPISource struct {
apiClient graphql.Client
lockfile *LockFile
toInstall map[string]semver.Constraint
modVersionInfo map[string]ficsit.ModVersionsWithDependenciesResponse
gameVersion semver.Version
smlVersions []ficsit.SMLVersionsSmlVersionsGetSMLVersionsSml_versionsSMLVersion
}
func (f *ficsitAPISource) GetPackageVersions(pkg string) ([]pubgrub.PackageVersion, error) {
if pkg == rootPkg {
return []pubgrub.PackageVersion{{Version: semver.Version{}, Dependencies: f.toInstall}}, nil
}
if pkg == factoryGamePkg {
return []pubgrub.PackageVersion{{Version: f.gameVersion}}, nil
}
if pkg == smlPkg {
versions := make([]pubgrub.PackageVersion, len(f.smlVersions))
for i, smlVersion := range f.smlVersions {
v, err := semver.NewVersion(smlVersion.Version)
if err != nil {
return nil, errors.Wrapf(err, "failed to parse version %s", smlVersion.Version)
}
gameConstraint, err := semver.NewConstraint(fmt.Sprintf(">=%d", smlVersion.Satisfactory_version))
if err != nil {
return nil, errors.Wrapf(err, "failed to parse constraint %s", fmt.Sprintf(">=%d", smlVersion.Satisfactory_version))
}
versions[i] = pubgrub.PackageVersion{
Version: v,
Dependencies: map[string]semver.Constraint{
factoryGamePkg: gameConstraint,
},
}
}
return versions, nil
}
response, err := ficsit.ModVersionsWithDependencies(context.TODO(), f.apiClient, pkg)
if err != nil {
return nil, errors.Wrapf(err, "failed to fetch mod %s", pkg)
}
if response.Mod.Id == "" {
return nil, errors.Errorf("mod %s not found", pkg)
}
f.modVersionInfo[pkg] = *response
versions := make([]pubgrub.PackageVersion, len(response.Mod.Versions))
for i, modVersion := range response.Mod.Versions {
v, err := semver.NewVersion(modVersion.Version)
if err != nil {
return nil, errors.Wrapf(err, "failed to parse version %s", modVersion.Version)
}
dependencies := make(map[string]semver.Constraint)
optionalDependencies := make(map[string]semver.Constraint)
for _, dependency := range modVersion.Dependencies {
c, err := semver.NewConstraint(dependency.Condition)
if err != nil {
return nil, errors.Wrapf(err, "failed to parse constraint %s", dependency.Condition)
}
if dependency.Optional {
optionalDependencies[dependency.Mod_id] = c
} else {
dependencies[dependency.Mod_id] = c
}
}
versions[i] = pubgrub.PackageVersion{
Version: v,
Dependencies: dependencies,
OptionalDependencies: optionalDependencies,
}
}
return versions, nil
}
func (f *ficsitAPISource) PickVersion(pkg string, versions []semver.Version) semver.Version {
if f.lockfile != nil {
if existing, ok := (*f.lockfile)[pkg]; ok {
v, err := semver.NewVersion(existing.Version)
if err == nil {
if slices.ContainsFunc(versions, func(version semver.Version) bool {
return v.Compare(version) == 0
}) {
return v
}
}
}
}
return helpers.StandardVersionPriority(versions)
}
func (d DependencyResolver) ResolveModDependencies(constraints map[string]string, lockFile *LockFile, gameVersion int) (LockFile, error) {
smlVersionsDB, err := ficsit.SMLVersions(context.TODO(), d.apiClient)
if err != nil {
return nil, errors.Wrap(err, "failed fetching SML versions")
}
gameVersionSemver, err := semver.NewVersion(fmt.Sprintf("%d", gameVersion))
if err != nil {
return nil, errors.Wrap(err, "failed parsing game version")
}
toInstall := make(map[string]semver.Constraint, len(constraints))
for k, v := range constraints {
c, err := semver.NewConstraint(v)
if err != nil {
return nil, errors.Wrapf(err, "failed to parse constraint %s", v)
}
toInstall[k] = c
}
ficsitSource := &ficsitAPISource{
apiClient: d.apiClient,
smlVersions: smlVersionsDB.SmlVersions.Sml_versions,
gameVersion: gameVersionSemver,
lockfile: lockFile,
toInstall: toInstall,
modVersionInfo: make(map[string]ficsit.ModVersionsWithDependenciesResponse),
}
result, err := pubgrub.Solve(helpers.NewCachingSource(ficsitSource), rootPkg)
if err != nil {
finalError := err
var solverErr pubgrub.SolvingError
if errors.As(err, &solverErr) {
finalError = DependencyResolverError{SolvingError: solverErr, apiClient: d.apiClient, smlVersions: smlVersionsDB.SmlVersions.Sml_versions, gameVersion: gameVersion}
}
return nil, errors.Wrap(finalError, "failed to solve dependencies")
}
delete(result, rootPkg)
delete(result, factoryGamePkg)
outputLock := make(LockFile, len(result))
for k, v := range result {
if k == smlPkg {
outputLock[k] = LockedMod{
Version: v.String(),
Hash: "",
Link: fmt.Sprintf(smlDownloadTemplate, v.String()),
}
continue
}
versions := ficsitSource.modVersionInfo[k].Mod.Versions
for _, ver := range versions {
if ver.Version == v.RawString() {
outputLock[k] = LockedMod{
Version: v.String(),
Hash: ver.Hash,
Link: viper.GetString("api-base") + ver.Link,
}
break
}
}
}
return outputLock, nil
}