diff options
Diffstat (limited to 'internal/scan/scan.go')
| -rw-r--r-- | internal/scan/scan.go | 233 |
1 files changed, 233 insertions, 0 deletions
diff --git a/internal/scan/scan.go b/internal/scan/scan.go new file mode 100644 index 0000000..e87b741 --- /dev/null +++ b/internal/scan/scan.go @@ -0,0 +1,233 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +// Package scan walks a directory tree and reports the files krino will +// consider sorting, and why the rest were skipped. +package scan + +import ( + "errors" + "fmt" + "io/fs" + "os" + "path/filepath" + "sort" + "time" + + "krino/internal/ignore" +) + +// File is a regular file found by Walk. +type File struct { + Path string // absolute, cleaned + Rel string // slash-separated, relative to the root + Name string // base name + Size int64 + ModTime time.Time + Mode fs.FileMode +} + +// Reason is why an entry was not returned as a File. +type Reason int + +const ( + Ignored Reason = iota // matched an ignore pattern + Busy // a sibling NAME<busy-suffix> exists + TooNew // modified less than MinAge ago + Symlink // a symbolic link (never followed) + NotRegular // fifo, socket, device + Unreadable // a directory that could not be read +) + +// String names a Reason the way it should read in a report. +func (r Reason) String() string { + switch r { + case Ignored: + return "ignored" + case Busy: + return "busy" + case TooNew: + return "too new" + case Symlink: + return "symlink" + case NotRegular: + return "not a regular file" + case Unreadable: + return "unreadable" + default: + return fmt.Sprintf("Reason(%d)", int(r)) + } +} + +// Skipped is one entry Walk did not return as a File, and why. +type Skipped struct { + Rel string + Reason Reason +} + +// Options controls how Walk traverses a directory. +type Options struct { + Recursive bool + MaxDepth int // 0: unlimited; 1: the root's own entries only + Ignore *ignore.Matcher // nil: nothing ignored + Exclude []string // absolute directories never entered + Busy []string // suffixes, e.g. ".part" + MinAge time.Duration + Now time.Time +} + +// Result is everything Walk found under a root. +type Result struct { + Files []File // sorted by Rel + Skipped []Skipped // sorted by Rel +} + +// Walk lists the files under root that krino will consider, and reports why +// the rest were skipped. root must exist, be a directory, and be readable; +// an unreadable subdirectory found during the walk is reported as +// Unreadable and does not abort the scan. +func Walk(root string, opt Options) (*Result, error) { + root, err := filepath.Abs(root) + if err != nil { + return nil, err + } + info, err := os.Stat(root) + if err != nil { + return nil, err + } + if !info.IsDir() { + return nil, fmt.Errorf("%s is not a directory", root) + } + entries, err := os.ReadDir(root) + if err != nil { + return nil, err + } + + exclude := make(map[string]bool, len(opt.Exclude)) + for _, e := range opt.Exclude { + exclude[filepath.Clean(e)] = true + } + + w := &walker{root: root, opt: opt, exclude: exclude} + if err := w.walk(root, "", 1, entries); err != nil { + return nil, err + } + + sort.Slice(w.result.Files, func(i, j int) bool { return w.result.Files[i].Rel < w.result.Files[j].Rel }) + sort.Slice(w.result.Skipped, func(i, j int) bool { return w.result.Skipped[i].Rel < w.result.Skipped[j].Rel }) + return &w.result, nil +} + +// walker accumulates the Result across recursive calls. +type walker struct { + opt Options + root string + exclude map[string]bool + result Result +} + +// walk applies the skip checks to entries, the already-read contents of dir +// (at relDir relative to the root, dir's own entries at depth). A +// subdirectory it recurses into is read here, right before recursing, so a +// ReadDir failure on it can be reported as Unreadable and skipped without +// aborting the rest of the walk; only a failure reading dir itself (passed +// in by the caller) would need to propagate, and only Walk's own read of +// the root works that way. +func (w *walker) walk(dir, relDir string, depth int, entries []os.DirEntry) error { + // The set of names in this directory, built once, for the busy check. + names := make(map[string]bool, len(entries)) + for _, e := range entries { + names[e.Name()] = true + } + + for _, e := range entries { + name := e.Name() + rel := name + if relDir != "" { + rel = relDir + "/" + name + } + path := filepath.Join(dir, name) + + // A symlink is never followed, whatever it points to; DirEntry's + // Type is Lstat-like and does not resolve it. + if e.Type()&fs.ModeSymlink != 0 { + w.result.Skipped = append(w.result.Skipped, Skipped{Rel: rel, Reason: Symlink}) + continue + } + + if e.IsDir() { + // Directories themselves are not reported, with two + // exceptions: the symlink case above, and an ignored + // directory (C2) — reported once for the directory itself, + // not for each file inside it, since pruning it without + // descending is the whole point; without this, its contents + // would appear in no count and in no -v listing at all. + if !w.opt.Recursive { + continue + } + if w.opt.Ignore != nil && w.opt.Ignore.Match(rel, true) { + w.result.Skipped = append(w.result.Skipped, Skipped{Rel: rel, Reason: Ignored}) + continue + } + if w.exclude[filepath.Clean(path)] { + continue + } + if w.opt.MaxDepth > 0 && depth+1 > w.opt.MaxDepth { + continue + } + subEntries, err := os.ReadDir(path) + if err != nil { + w.result.Skipped = append(w.result.Skipped, Skipped{Rel: rel, Reason: Unreadable}) + continue + } + if err := w.walk(path, rel, depth+1, subEntries); err != nil { + return err + } + continue + } + + info, err := e.Info() + if err != nil { + if errors.Is(err, fs.ErrNotExist) { + continue // vanished mid-walk (a download finishing, say): silently skipped + } + w.result.Skipped = append(w.result.Skipped, Skipped{Rel: rel, Reason: Unreadable}) + continue + } + if !info.Mode().IsRegular() { + w.result.Skipped = append(w.result.Skipped, Skipped{Rel: rel, Reason: NotRegular}) + continue + } + if w.opt.Ignore != nil && w.opt.Ignore.Match(rel, false) { + w.result.Skipped = append(w.result.Skipped, Skipped{Rel: rel, Reason: Ignored}) + continue + } + if busy := w.isBusy(name, names); busy { + w.result.Skipped = append(w.result.Skipped, Skipped{Rel: rel, Reason: Busy}) + continue + } + if w.opt.Now.Sub(info.ModTime()) < w.opt.MinAge { + w.result.Skipped = append(w.result.Skipped, Skipped{Rel: rel, Reason: TooNew}) + continue + } + w.result.Files = append(w.result.Files, File{ + Path: path, + Rel: rel, + Name: name, + Size: info.Size(), + ModTime: info.ModTime(), + Mode: info.Mode(), + }) + } + return nil +} + +// isBusy reports whether name+suffix, for any configured Busy suffix, is +// among names — the sibling of an in-progress download. +func (w *walker) isBusy(name string, names map[string]bool) bool { + for _, suffix := range w.opt.Busy { + if names[name+suffix] { + return true + } + } + return false +} |
