diff --git a/ctx.go b/ctx.go index dca734d..15cb96c 100644 --- a/ctx.go +++ b/ctx.go @@ -3,6 +3,8 @@ package main import ( "net/http" "time" + + "github.com/charmbracelet/log" ) type TypeOfProject int @@ -17,6 +19,7 @@ type Context struct { ProjectType TypeOfProject ProjectFilePath string HTTPClient *http.Client + Logger *log.Logger // Flags DryRun bool @@ -27,6 +30,7 @@ var Ctx = Context{ ProjectType: NotSupported, ProjectFilePath: "", HTTPClient: &http.Client{Timeout: 5 * time.Second}, + Logger: log.Default(), DryRun: false, Verbose: false, } diff --git a/npm.go b/npm.go index 5503db0..538153e 100644 --- a/npm.go +++ b/npm.go @@ -34,6 +34,12 @@ type PackageJSONDependencies struct { DevDependencies []DependencyJSON } +type DependencyUpdate struct { + Name string + Before DependencyVersion + After DependencyVersion +} + func (v DependencyVersion) String() string { if v.HasSemver { return v.Prefix + v.Semver.String() @@ -116,9 +122,52 @@ func getNPMPackageLatestVersion(packageName string) (string, error) { return result.Version, nil } -func updateDependencies(deps []DependencyJSON) error { +func outputLogger() *log.Logger { + if Ctx.Logger != nil { + return Ctx.Logger + } + + return log.Default() +} + +func formatDependencyDiff(before DependencyVersion, after DependencyVersion) string { + if before.HasSemver && after.HasSemver { + if before.Prefix == "" && after.Prefix == "" { + return before.Semver.Diff(after.Semver) + } + + return fmt.Sprintf("%s -> %s (%s)", before.String(), after.String(), before.Semver.ChangeType(after.Semver)) + } + + return fmt.Sprintf("%s -> %s", before.String(), after.String()) +} + +func (update DependencyUpdate) String() string { + return fmt.Sprintf("%s: %s", update.Name, formatDependencyDiff(update.Before, update.After)) +} + +func printDependencyUpdates(packagePath string, section string, updates []DependencyUpdate) { + if len(updates) == 0 { + return + } + + action := "Updated" + if Ctx.DryRun { + action = "Would update" + } + + logger := outputLogger() + logger.Print(action + " " + section + " in " + packagePath + ":") + for _, update := range updates { + logger.Print("- " + update.String()) + } +} + +func updateDependencies(deps []DependencyJSON) ([]DependencyUpdate, error) { + updates := make([]DependencyUpdate, 0) + for i, dep := range deps { - log.Infof("Dependency : %s, version : %s", dep.Name, dep.Version.String()) + log.Debugf("Dependency : %s, version : %s", dep.Name, dep.Version.String()) if dep.Name == "" { log.Warnf("Dependency name is empty, skipping...") continue @@ -126,11 +175,11 @@ func updateDependencies(deps []DependencyJSON) error { latestVersionString, err := getNPMPackageLatestVersion(dep.Name) if err != nil { - return fmt.Errorf("failed to fetch latest version for %s: %w", dep.Name, err) + return nil, fmt.Errorf("failed to fetch latest version for %s: %w", dep.Name, err) } latestVersion := parseDependencyVersion(latestVersionString) - log.Infof("Latest version of %s : %s", dep.Name, latestVersion.String()) + log.Debugf("Latest version of %s : %s", dep.Name, latestVersion.String()) changeType, shouldUpdate := classifyDependencyUpdate(dep.Version, latestVersion) if changeType == SemverChangeDowngrade { @@ -139,16 +188,16 @@ func updateDependencies(deps []DependencyJSON) error { } if !shouldUpdate { - log.Infof("Dependency %s already up to date (%s)", dep.Name, changeType) + log.Debugf("Dependency %s already up to date (%s)", dep.Name, changeType) continue } updatedVersion := mergeDependencyVersion(dep.Version, latestVersion) - log.Infof("Updating %s with %s change: %s -> %s", dep.Name, changeType, dep.Version.String(), updatedVersion.String()) + updates = append(updates, DependencyUpdate{Name: dep.Name, Before: dep.Version, After: updatedVersion}) dep.Version = updatedVersion deps[i] = dep } - return nil + return updates, nil } func classifyDependencyUpdate(currentVersion DependencyVersion, latestVersion DependencyVersion) (SemverChange, bool) { @@ -214,12 +263,14 @@ func processNPMPackage(packagePath string) error { } log.Infof("Dependencies number : %v", len(packageJSON.Dependencies)) - if err := updateDependencies(packageJSON.Dependencies); err != nil { + dependencyUpdates, err := updateDependencies(packageJSON.Dependencies) + if err != nil { return fmt.Errorf("failed to update dependencies: %w", err) } log.Infof("DevDependencies number : %v", len(packageJSON.DevDependencies)) - if err := updateDependencies(packageJSON.DevDependencies); err != nil { + devDependencyUpdates, err := updateDependencies(packageJSON.DevDependencies) + if err != nil { return fmt.Errorf("failed to update devDependencies: %w", err) } @@ -245,5 +296,8 @@ func processNPMPackage(packagePath string) error { log.Infof("Dry run enabled, not writing changes to %v", packagePath) } + printDependencyUpdates(packagePath, "dependencies", dependencyUpdates) + printDependencyUpdates(packagePath, "devDependencies", devDependencyUpdates) + return nil } diff --git a/npm_test.go b/npm_test.go index 2bb24ab..1a20bf5 100644 --- a/npm_test.go +++ b/npm_test.go @@ -1,6 +1,7 @@ package main import ( + "bytes" "encoding/json" "errors" "io" @@ -14,6 +15,8 @@ import ( "strings" "testing" "time" + + "github.com/charmbracelet/log" ) type roundTripFunc func(*http.Request) (*http.Response, error) @@ -53,6 +56,14 @@ func newRegistryClient(t *testing.T, handler http.HandlerFunc) *http.Client { } } +func newTestLogger(output io.Writer) *log.Logger { + return log.NewWithOptions(output, log.Options{ + Level: log.DebugLevel, + ReportTimestamp: false, + ReportCaller: false, + }) +} + func TestMapToDepsAndDepsToMapRoundTrip(t *testing.T) { depsMap := map[string]string{ "react": "^18.3.1", @@ -151,10 +162,13 @@ func TestUpdateDependencies(t *testing.T) { {Name: "broken", Version: parseDependencyVersion("3.0.0")}, } - err := updateDependencies(deps) + updates, err := updateDependencies(deps) if err == nil { t.Fatalf("expected error on broken package fetch, got nil") } + if len(updates) != 0 { + t.Fatalf("expected no updates to be returned on error, got %v", updates) + } if deps[0].Version.String() != "1.3.0" { t.Fatalf("expected successful dependency to update, got %q", deps[0].Version.String()) } @@ -203,9 +217,13 @@ func TestUpdateDependenciesSkipsNoopAndDowngrade(t *testing.T) { {Name: "prefixed", Version: parseDependencyVersion("^1.2.0")}, } - if err := updateDependencies(deps); err != nil { + updates, err := updateDependencies(deps) + if err != nil { t.Fatalf("expected no error, got %v", err) } + if len(updates) != 2 { + t.Fatalf("expected 2 updates, got %d", len(updates)) + } if deps[0].Version.String() != "1.0.0" { t.Fatalf("expected same-version dependency to stay unchanged, got %q", deps[0].Version.String()) @@ -361,7 +379,8 @@ func TestProcessNPMPackage(t *testing.T) { _, _ = io.WriteString(w, `{"version":"`+version+`"}`) }) - withTestContext(t, Context{HTTPClient: client}) + var output bytes.Buffer + withTestContext(t, Context{HTTPClient: client, Logger: newTestLogger(&output)}) if err := processNPMPackage(packagePath); err != nil { t.Fatalf("expected no error, got %v", err) @@ -402,6 +421,19 @@ func TestProcessNPMPackage(t *testing.T) { if document["private"] != true { t.Fatalf("expected private field to be preserved, got %v", document["private"]) } + + printed := output.String() + for _, expected := range []string{ + "Updated dependencies in " + packagePath + ":", + "- react: ^18.2.0 -> ^18.3.1 (minor)", + "- @scope/pkg: ~1.0.0 -> ~1.2.3 (minor)", + "Updated devDependencies in " + packagePath + ":", + "- vitest: >=1.5.0 -> >=1.6.0 (minor)", + } { + if !strings.Contains(printed, expected) { + t.Fatalf("expected output to contain %q, got %q", expected, printed) + } + } }) t.Run("does not add missing dependency sections", func(t *testing.T) { diff --git a/semver.go b/semver.go index 1cef621..393e31b 100644 --- a/semver.go +++ b/semver.go @@ -255,6 +255,10 @@ func (s Semver) String() string { return builder.String() } +func (s Semver) Diff(other Semver) string { + return fmt.Sprintf("%s -> %s (%s)", s.String(), other.String(), s.ChangeType(other)) +} + func CompareSemver(left string, right string) (int, error) { leftSemver, err := ParseSemver(left) if err != nil { diff --git a/semver_test.go b/semver_test.go index 95d2cdd..026b2b5 100644 --- a/semver_test.go +++ b/semver_test.go @@ -92,6 +92,9 @@ func TestSemverHelpers(t *testing.T) { if !(Semver{Major: 1, Minor: 2, Patch: 3}).IsRevisionUpdate(Semver{Major: 1, Minor: 2, Patch: 3, Revision: 1, HasRevision: true}) { t.Fatal("expected IsRevisionUpdate to report true") } + if diff := left.Diff(right); diff != "1.2.3 -> 1.2.4 (patch)" { + t.Fatalf("Diff() = %q, want %q", diff, "1.2.3 -> 1.2.4 (patch)") + } } func TestCompareSemver(t *testing.T) {