diff --git a/main.go b/main.go index f22dbb1..5369c82 100644 --- a/main.go +++ b/main.go @@ -1,23 +1,56 @@ package main import ( + "flag" "fmt" - "log" - "time" + "os" "git.yetaga.in/deltamualpha/libyear/pkg/gomod" "git.yetaga.in/deltamualpha/libyear/pkg/libyear" + "git.yetaga.in/deltamualpha/libyear/pkg/logger" ) func main() { - pairs, err := gomod.LoadAndComputePairs("go.mod") + quiet := flag.Bool("q", false, "quiet; reduces logging and outputs only out-of-date dependencies") + verbose := flag.Bool("v", false, "verbose; outputs diagnostic information") + indirect := flag.Bool("indirect", false, "include indirect dependencies in calculation (off by default)") + + flag.Parse() + + l := logger.NewLogger(*quiet, *verbose, os.Stdout, os.Stderr) + + g := gomod.GoMod{ + IncludeIndirect: *indirect, + ProxyLoader: gomod.Queryer{Logger: l}, + Logger: l, + } + + pairs, err := g.LoadAndComputePairs("go.mod") if err != nil { - log.Fatal(err) + l.Error(err) + os.Exit(255) } for dep := range pairs { - fmt.Printf("%s: %d years (newest version: %s)\n", pairs[dep].Name, libyear.Calc(pairs[dep]).Truncate(time.Hour)/time.Hour/8760, pairs[dep].Latest.Version) + if pairs[dep].Current.Time == pairs[dep].Latest.Time { + if !*quiet { + fmt.Printf( + "%s: %s year(s) (up to date: %s)\n", + pairs[dep].Name, + libyear.DecimalYear(libyear.Calc(pairs[dep])), + pairs[dep].Latest.Version, + ) + } + } else { + fmt.Printf( + "%s: %s year(s) (current: %s, newest: %s)\n", + pairs[dep].Name, + libyear.DecimalYear(libyear.Calc(pairs[dep])), + pairs[dep].Current.Version, + pairs[dep].Latest.Version, + ) + } } - fmt.Printf("total libyear count: %d years\n", libyear.Calc(pairs...).Truncate(time.Hour)/time.Hour/8760) + fmt.Printf("total libyear count: %s year(s)\n", libyear.DecimalYear(libyear.Calc(pairs...))) } diff --git a/pkg/gomod/gomod.go b/pkg/gomod/gomod.go index 1696f04..b4e4c44 100644 --- a/pkg/gomod/gomod.go +++ b/pkg/gomod/gomod.go @@ -7,7 +7,27 @@ import ( "golang.org/x/mod/modfile" ) -func LoadAndComputePairs(filename string) ([]libyear.Pair, error) { +type logger interface { + Logf(f string, s ...interface{}) + Debugf(f string, s ...interface{}) +} + +type GoMod struct { + IncludeIndirect bool + ProxyLoader Queryer + Logger logger +} + +func isReplaced(module string, replaces []*modfile.Replace) bool { + for i := range replaces { + if module == replaces[i].Old.Path { + return true + } + } + return false +} + +func (g *GoMod) LoadAndComputePairs(filename string) ([]libyear.Pair, error) { b, err := os.ReadFile(filename) if err != nil { return nil, err @@ -16,14 +36,20 @@ func LoadAndComputePairs(filename string) ([]libyear.Pair, error) { if err != nil { return nil, err } - q := Queryer{} - pairs := []libyear.Pair{} for v := range f.Require { if f.Require[v].Mod.Path != "" && f.Require[v].Mod.Version != "" { - latest := q.GetLatestVersion(f.Require[v].Mod.Path) - current := q.GetVersion(f.Require[v].Mod.Path, f.Require[v].Mod.Version) + if isReplaced(f.Require[v].Mod.Path, f.Replace) { + g.Logger.Logf("%s is replaced, skipping...\n", f.Require[v].Mod.Path) + continue + } + if !g.IncludeIndirect && f.Require[v].Indirect { + g.Logger.Logf("%s is indirect, skipping...\n", f.Require[v].Mod.Path) + continue + } + latest := g.ProxyLoader.GetLatestVersion(f.Require[v].Mod.Path) + current := g.ProxyLoader.GetVersion(f.Require[v].Mod.Path, f.Require[v].Mod.Version) pairs = append(pairs, libyear.Pair{ Name: f.Require[v].Mod.Path, Current: current, diff --git a/pkg/gomod/modproxy.go b/pkg/gomod/modproxy.go index 0c5cbc3..97aa358 100644 --- a/pkg/gomod/modproxy.go +++ b/pkg/gomod/modproxy.go @@ -4,7 +4,6 @@ import ( "encoding/json" "fmt" "io" - "log" "net/http" "net/url" "strconv" @@ -18,6 +17,7 @@ type Queryer struct { Root string Client http.Client Cache cache.Cache + Logger logger } type majorVersion struct { @@ -59,17 +59,17 @@ func (q *Queryer) findLatestMajorVersion(module string) string { } func (q *Queryer) makeProxyRequest(mod string) libyear.Info { - log.Printf("makeProxyRequest for https://proxy.golang.org/%s", mod) if q.Root == "" { q.Root = "https://proxy.golang.org/" } + q.Logger.Debugf("makeProxyRequest for %s/%s\n", q.Root, mod) u, _ := url.Parse(q.Root) u.Path = mod i := q.Cache.Get(u.String()) if i.Version != "" { - log.Printf("cache hit for https://proxy.golang.org/%s", mod) + q.Logger.Debugf("cache hit for https://proxy.golang.org/%s\n", mod) return i } req, err := http.NewRequest("GET", u.String(), nil) diff --git a/pkg/libyear/libyear.go b/pkg/libyear/libyear.go index aa94fd2..df45587 100644 --- a/pkg/libyear/libyear.go +++ b/pkg/libyear/libyear.go @@ -1,6 +1,9 @@ package libyear -import "time" +import ( + "fmt" + "time" +) type Info struct { Version string // version string @@ -13,6 +16,8 @@ type Pair struct { Current Info } +// TODO: sum can only represent ~290 years before overflowing, but we don't actually need nanosecond precision! +// Probably worth switching to hour-based summing here. func Calc(p ...Pair) time.Duration { sum := time.Duration(0) for i := range p { @@ -20,3 +25,7 @@ func Calc(p ...Pair) time.Duration { } return sum } + +func DecimalYear(d time.Duration) string { + return fmt.Sprintf("%.2f", float64(d.Truncate(time.Hour)/time.Hour)/float64(8760)) +} diff --git a/pkg/libyear/libyear_test.go b/pkg/libyear/libyear_test.go new file mode 100644 index 0000000..887f2ff --- /dev/null +++ b/pkg/libyear/libyear_test.go @@ -0,0 +1,23 @@ +package libyear + +import ( + "testing" + "time" +) + +func TestDecimalYear(t *testing.T) { + values := map[time.Duration]string{ + time.Hour: "0.00", + time.Hour * 8760: "1.00", + time.Hour * 4380: "0.50", + time.Hour * 12264: "1.40", + time.Hour * 27520: "3.14", + time.Hour * 1000000: "114.16", + } + for d, s := range values { + if r := DecimalYear(d); r != s { + t.Logf("Expected %s, got %s for %d", s, r, d) + t.Fail() + } + } +} diff --git a/pkg/logger/logger.go b/pkg/logger/logger.go new file mode 100644 index 0000000..01402ea --- /dev/null +++ b/pkg/logger/logger.go @@ -0,0 +1,61 @@ +package logger + +import ( + "fmt" + "io" + "os" +) + +type Log struct { + quiet bool + debug bool + errOut io.Writer + stdOut io.Writer +} + +func NewLogger(quiet, debug bool, stdOut, errOut io.Writer) *Log { + if stdOut == nil { + stdOut = os.Stdout + } + if errOut == nil { + errOut = os.Stderr + } + return &Log{ + quiet: quiet, + debug: debug, + stdOut: stdOut, + errOut: errOut, + } +} + +func (l *Log) Error(s ...interface{}) { + fmt.Fprint(l.errOut, s...) +} + +func (l *Log) Errorf(f string, s ...interface{}) { + fmt.Fprint(l.errOut, s...) +} + +func (l *Log) Log(s ...interface{}) { + if !l.quiet { + fmt.Fprint(l.stdOut, s...) + } +} + +func (l *Log) Logf(f string, s ...interface{}) { + if !l.quiet { + fmt.Fprintf(l.stdOut, f, s...) + } +} + +func (l *Log) Debug(s ...interface{}) { + if l.debug { + fmt.Fprint(l.errOut, s...) + } +} + +func (l *Log) Debugf(f string, s ...interface{}) { + if l.debug { + fmt.Fprintf(l.errOut, f, s...) + } +} diff --git a/readme.md b/readme.md new file mode 100644 index 0000000..e8d26bc --- /dev/null +++ b/readme.md @@ -0,0 +1,33 @@ +# libyear + +A **simple** measure of software dependency freshness. It is a **single number** telling you how up-to-date your dependencies are. + +[libyear.com](https://libyear.com/) + +## Usage + +`libyear` only supports projects using go modules. It ignores dependencies that are replaced in a go.mod file. + +`-q`: quiet mode; reduce logging and output only dependencies that are not at their latest version + +`-v`: verbose mode; output diagnostic information + +`-indirect`: include dependencies marked `// indirect` in `go.mod` in the calculation (excluded by default) + +## Example output + +When run in a directory that contains a `go.mod` file: + +```bash +$ libyear +github.com/pkg/errors: 1.03 year(s) (current: v0.8.1, newest: v0.9.1) +github.com/stretchr/testify: 1.51 year(s) (current: v1.4.0, newest: v1.7.0) +go.uber.org/atomic: 0.00 year(s) (up to date: v1.7.0) +go.uber.org/multierr: 0.00 year(s) (up to date: v1.6.0) +gopkg.in/yaml.v2: 2.01 year(s) (current: v2.2.2, newest: v2.4.0) +total libyear count: 4.55 year(s) +``` + +## References + +J. Cox, E. Bouwers, M. van Eekelen and J. Visser, Measuring Dependency Freshness in Software Systems. In Proceedings of the 37th International Conference on Software Engineering (ICSE 2015), May 2015