package main
import (
"bytes"
"errors"
"flag"
"fmt"
"io"
"io/fs"
"os"
"path/filepath"
"strconv"
"strings"
"time"
"git.awl.red/~neallred/touched/sortedset"
)
const (
usage = `USAGE:
touched
--after time-ish (inclusive)
--before time-ish (inclusive)
--depth (-d) int
--max (-m) max results
--top bool (print the top most results, exclusive with bottom)
--bottom bool (print the bottom most results, exclusive with top)
--hidden (if passed, also show hidden files)
--attrs [modified,created]
<dir>
respects .git project .gitignore files
`
)
func dieUsage() {
fmt.Printf(usage)
os.Exit(1)
}
func parseTimish(in string, since bool) (time.Time, bool) {
if unixTimestamp, err := strconv.ParseInt(in, 10, 64); err == nil {
switch len(in) {
case 10:
return time.Unix(unixTimestamp, 0), true
case 13:
return time.UnixMilli(unixTimestamp), true
case 16:
return time.UnixMicro(unixTimestamp), true
default:
var tm time.Time
return tm, false
}
}
if dur, err := time.ParseDuration(in); err == nil {
if since {
return time.Now().Add(dur * -1), true
}
return time.Now().Add(dur), true
}
if tm, err := time.Parse(time.RFC3339Nano, in); err == nil {
return tm, true
}
if tm, err := time.Parse(time.RFC3339, in); err == nil {
return tm, true
}
if tm, err := time.Parse(time.DateTime, in); err == nil {
return tm, true
}
if tm, err := time.Parse(time.DateOnly, in); err == nil {
return tm, true
}
var zero time.Time
return zero, false
}
var dur24fps = time.Second / 24
func main() {
config := getConfig()
initialSlashCount := 0
ss := sortedset.New[string, int64, struct{}]()
fileCount := 0
var countInterrupted bool
showHidden := config.showHidden
maxDepth := config.depth
maxResults := int(config.maxResults)
userRequestedBefore := !config.before.IsZero()
userRequestedAfter := !config.after.IsZero()
userRequestedBottom := config.bottom
lastUpdate := time.Now()
err := filepath.WalkDir(config.searchDir, func(path string, d fs.DirEntry, err error) error {
fileCount++
if (fileCount%1000 == 0 && time.Since(lastUpdate) > dur24fps) || fileCount == 1 {
lastUpdate = time.Now()
fmt.Fprintf(os.Stderr, "\rSearching file %d of", fileCount)
}
if initialSlashCount == 0 {
initialSlashCount = strings.Count(path, "/")
}
// crude ignoring of build dirs
if path == "node_modules" || strings.HasSuffix(path, "/node_modules") {
return fs.SkipDir
}
// crude ignoring of git repos
if path == "HEAD" || strings.HasSuffix(path, "/HEAD") {
head, err := os.Open(path)
if err == nil {
buf := make([]byte, 4)
_, err := io.ReadFull(head, buf)
if err == nil {
if bytes.Equal(buf, []byte("ref:")) {
head.Close()
return fs.SkipDir
}
}
head.Close()
}
}
if !showHidden && isHidden(path) {
if d.IsDir() {
return fs.SkipDir
}
return nil
}
if maxDepth > 0 && ((strings.Count(path, "/") - initialSlashCount) > int(maxDepth)-1) {
return fs.SkipDir
}
info, err := d.Info()
if err != nil {
if errors.Is(err, fs.ErrNotExist) {
if d.IsDir() {
return fs.SkipDir
}
return nil
}
if errors.Is(err, fs.ErrPermission) {
if d.IsDir() {
return fs.SkipDir
}
return nil
}
// unrecognized error, print file
countInterrupted = true
fmt.Fprintf(os.Stderr, "\nError gathering info on file %q\n", path)
return nil
}
scoreTime := info.ModTime()
score := scoreTime.UnixMilli()
if userRequestedBefore && scoreTime.After(config.before) {
return nil
}
if userRequestedAfter && scoreTime.Before(config.after) {
return nil
}
saturated := ss.GetCount() == maxResults
if saturated {
// checking if value to add is going to be evicted anyways.
// if it is, skip adding to avoid the shuffle
if userRequestedBottom {
if max := ss.PeekMax(); max != nil && score > max.Score() {
return nil
}
} else {
if min := ss.PeekMin(); min != nil && score < min.Score() {
return nil
}
}
}
ss.AddOrUpdate(path, score, struct{}{})
if saturated {
if userRequestedBottom {
ss.PopMax()
} else {
ss.PopMin()
}
}
return nil
})
if countInterrupted {
fmt.Fprintf(os.Stderr, "\nSearched %d files\n", fileCount)
} else {
fmt.Fprintf(os.Stderr, "\rSearching file %d of %d\n", fileCount, fileCount)
}
if err != nil {
fmt.Fprintf(os.Stderr, err.Error())
os.Exit(1)
}
ss.IterFuncRangeByRank(1, ss.GetCount(), func(key string, score int64, _ struct{}) bool {
fmt.Println(time.UnixMilli(score).Format(time.DateTime) + " " + key)
return true
})
}
type config struct {
showHidden bool
top bool
bottom bool
dirs bool
maxResults uint64
searchDir string
depth uint64
before time.Time
after time.Time
}
const nearPresumedTerminalHeight = 40
func getConfig() config {
top := flag.Bool("top", true, "print the top most results, exclusive with bottom")
bottom := flag.Bool("bottom", false, "print the bottom most results, exclusive with bottom")
hidden := flag.Bool("hidden", false, "if passed, also search/show hidden files")
dirs := flag.Bool("dirs", false, "if passed, also show directories as entries")
maxResults := flag.Uint64("max", nearPresumedTerminalHeight, "maximum number of results to return")
maxResultsShort := flag.Uint64("m", nearPresumedTerminalHeight, "maximum number of results to return")
depth := flag.Uint64("depth", 0, "max depth to traverse")
depthShort := flag.Uint64("d", 0, "max depth to traverse")
afterStr := flag.String("after", "", "only include files after this time")
beforeStr := flag.String("before", "", "only include files before this time")
flag.Parse()
cfg := config{}
if top != nil {
cfg.top = *top
}
if afterStr != nil {
if after, ok := parseTimish(*afterStr, true); ok {
cfg.after = after
}
}
if beforeStr != nil {
if before, ok := parseTimish(*beforeStr, true); ok {
cfg.before = before
}
}
if bottom != nil {
cfg.bottom = *bottom
if cfg.bottom {
cfg.top = false
}
}
if cfg.top && cfg.bottom {
dieUsage()
}
if !cfg.top && !cfg.bottom {
cfg.top = true
}
if hidden != nil {
cfg.showHidden = *hidden
}
if dirs != nil {
cfg.dirs = *dirs
}
if maxResults != nil {
cfg.maxResults = *maxResults
}
if maxResultsShort != nil {
cfg.maxResults = *maxResultsShort
}
if depth != nil {
cfg.depth = *depth
}
if depthShort != nil {
cfg.depth = *depthShort
}
nonCommandArgs := flag.Args()
searchDir := ""
var err error
if len(nonCommandArgs) > 0 {
searchDir, err = filepath.Abs(nonCommandArgs[0])
if err != nil {
fmt.Println(err)
dieUsage()
}
} else {
searchDir, err = os.Getwd()
if err != nil {
fmt.Println(err)
dieUsage()
}
}
cfg.searchDir = searchDir
return cfg
}