012 Project 12: Build a find Clone

Updated

September 8, 2026

012 Build a find Clone

Search a directory tree by name substring, entry type, and minimum size. Use filepath.WalkDir for efficient streaming walks without loading the whole tree into memory.

walk tree -> filter (name/type/size) -> print matching paths

Problem statement

gfind [root] [-name SUBSTR] [-type f|d] [-min-size BYTES]

Default root is .. Filters compose as AND. Name match is case-insensitive substring on the base name (not full path)—document this; classic find uses globs.

Acceptance criteria

  • Walks recursively from root
  • -name filters by basename contains (case-insensitive)
  • -type f files only; -type d dirs only; empty = both
  • -min-size applies to files only
  • Permission errors: skip entry (or print to stderr) without aborting whole walk
  • Stdlib only; go build clean

Setup

mkdir gfind && cd gfind
go mod init example.com/gfind
# go 1.27

Full main.go

package main

import (
    "flag"
    "fmt"
    "io/fs"
    "os"
    "path/filepath"
    "strings"
)

func match(path string, d fs.DirEntry, name, typeFlag string, minSize int64) (bool, error) {
    base := filepath.Base(path)
    if name != "" && !strings.Contains(strings.ToLower(base), strings.ToLower(name)) {
        return false, nil
    }
    switch typeFlag {
    case "f":
        if d.IsDir() {
            return false, nil
        }
    case "d":
        if !d.IsDir() {
            return false, nil
        }
    case "":
        // ok
    default:
        return false, fmt.Errorf("invalid -type %q (want f, d, or empty)", typeFlag)
    }
    if minSize > 0 && !d.IsDir() {
        info, err := d.Info()
        if err != nil {
            return false, nil // skip unreadable
        }
        if info.Size() < minSize {
            return false, nil
        }
    }
    return true, nil
}

func main() {
    name := flag.String("name", "", "substring to match in filename")
    typeFlag := flag.String("type", "", "f=file d=dir")
    minSize := flag.Int64("min-size", 0, "minimum file size in bytes")
    flag.Parse()

    root := "."
    if flag.NArg() > 0 {
        root = flag.Arg(0)
    }

    // Validate type early
    if *typeFlag != "" && *typeFlag != "f" && *typeFlag != "d" {
        fmt.Fprintf(os.Stderr, "invalid -type %q\n", *typeFlag)
        os.Exit(2)
    }

    err := filepath.WalkDir(root, func(path string, d fs.DirEntry, err error) error {
        if err != nil {
            fmt.Fprintf(os.Stderr, "skip %s: %v\n", path, err)
            return nil // keep walking
        }
        ok, mErr := match(path, d, *name, *typeFlag, *minSize)
        if mErr != nil {
            return mErr
        }
        if ok {
            fmt.Println(path)
        }
        return nil
    })
    if err != nil {
        fmt.Fprintln(os.Stderr, err)
        os.Exit(1)
    }
}

Step-by-step build path

  1. Flags + optional root argument.
  2. WalkDir callback: handle walk errors without failing the whole run.
  3. Centralize filters in match for unit tests.
  4. Print paths as you go (streaming).
  5. Later: add prune (-maxdepth) and glob filepath.Match.

Run and verification

go run . /etc -name conf -type f
go run . . -name go -type f
go run . . -min-size 1048576 -type f   # files ≥ 1MiB

# empty name matches everything (careful on huge trees)
go run . /tmp -type d | head

Tests

package main

import (
    "os"
    "path/filepath"
    "testing"
)

func TestMatchNameAndType(t *testing.T) {
    dir := t.TempDir()
    f := filepath.Join(dir, "App.LOG")
    if err := os.WriteFile(f, []byte("hi"), 0o644); err != nil {
        t.Fatal(err)
    }
    info, err := os.Stat(f)
    if err != nil {
        t.Fatal(err)
    }
    // DirEntry from WalkDir; use type from os.DirEntry via ReadDir
    entries, _ := os.ReadDir(dir)
    var d os.DirEntry
    for _, e := range entries {
        if e.Name() == "App.LOG" {
            d = e
            break
        }
    }
    ok, err := match(f, d, "log", "f", 0)
    if err != nil || !ok {
        t.Fatalf("ok=%v err=%v size=%d", ok, err, info.Size())
    }
}
go test ./...

Stretch goals

  1. Glob -name '*.go' via filepath.Match.
  2. -maxdepth N by counting path separators relative to root.
  3. -exec style: print null-terminated for xargs -0.
  4. Concurrent walk with worker pool (careful with FS limits).
  5. Skip directories (.git, node_modules) with a skip list.

Pitfalls

Pitfall Fix
Returning walk err aborts tree Log and return nil
Info() on every dir for size Only when min-size set and file
Symlink loops WalkDir doesn’t follow symlinks by default—good
Case-sensitive surprise Document case-insensitive policy

Learning goals

  • filepath.WalkDir and fs.DirEntry
  • Filter composition for CLI tools
  • Resilient walks over imperfect trees