File Scanner

A complete CLI tool that scans a directory, reports file statistics by extension, and finds duplicate files using MD5 hashing. Uses goroutines for concurrent file processing.

package main

import (
	"crypto/md5"
	"fmt"
	"io"
	"os"
	"path/filepath"
	"sort"
	"sync"
)

type FileInfo struct {
	Path string
	Size int64
	Ext  string
	Hash string
}

type ExtStats struct {
	Count int
	Size  int64
}

func collectFiles(root string) ([]string, error) {
	var paths []string
	err := filepath.Walk(root, func(path string, info os.FileInfo, err error) error {
		if err != nil {
			return nil
		}
		if !info.IsDir() {
			paths = append(paths, path)
		}
		return nil
	})
	return paths, err
}

func hashFile(path string) (string, error) {
	f, err := os.Open(path)
	if err != nil {
		return "", err
	}
	defer f.Close()

	h := md5.New()
	if _, err := io.Copy(h, f); err != nil {
		return "", err
	}
	return fmt.Sprintf("%x", h.Sum(nil)), nil
}

func processFiles(paths []string) []FileInfo {
	results := make(chan FileInfo, len(paths))
	var wg sync.WaitGroup

	for _, p := range paths {
		wg.Add(1)
		go func(path string) {
			defer wg.Done()

			info, err := os.Stat(path)
			if err != nil {
				return
			}

			hash, _ := hashFile(path)

			results <- FileInfo{
				Path: path,
				Size: info.Size(),
				Ext:  filepath.Ext(path),
				Hash: hash,
			}
		}(p)
	}

	go func() {
		wg.Wait()
		close(results)
	}()

	var files []FileInfo
	for f := range results {
		files = append(files, f)
	}
	return files
}

func formatSize(bytes int64) string {
	const (
		KB = 1024
		MB = KB * 1024
	)
	switch {
	case bytes >= MB:
		return fmt.Sprintf("%.1f MB", float64(bytes)/float64(MB))
	case bytes >= KB:
		return fmt.Sprintf("%.1f KB", float64(bytes)/float64(KB))
	default:
		return fmt.Sprintf("%d B", bytes)
	}
}

func analyze(files []FileInfo) {
	var totalSize int64
	extMap := make(map[string]*ExtStats)
	hashMap := make(map[string][]FileInfo)

	for _, f := range files {
		totalSize += f.Size

		if _, ok := extMap[f.Ext]; !ok {
			extMap[f.Ext] = &ExtStats{}
		}
		extMap[f.Ext].Count++
		extMap[f.Ext].Size += f.Size

		if f.Hash != "" {
			hashMap[f.Hash] = append(hashMap[f.Hash], f)
		}
	}

	fmt.Printf("Found %d files (%s)\n\n", len(files), formatSize(totalSize))

	type extEntry struct {
		Ext   string
		Stats *ExtStats
	}
	var exts []extEntry
	for ext, stats := range extMap {
		name := ext
		if name == "" {
			name = "(none)"
		}
		exts = append(exts, extEntry{name, stats})
	}
	sort.Slice(exts, func(i, j int) bool {
		return exts[i].Stats.Count > exts[j].Stats.Count
	})

	fmt.Println("By extension:")
	for _, e := range exts {
		fmt.Printf("  %-8s %4d files   %s\n", e.Ext, e.Stats.Count, formatSize(e.Stats.Size))
	}

	fmt.Println("\nDuplicates:")
	found := false
	for _, group := range hashMap {
		if len(group) < 2 {
			continue
		}
		found = true
		name := filepath.Base(group[0].Path)
		fmt.Printf("  %s (%d copies, %s each)\n", name, len(group), formatSize(group[0].Size))
	}
	if !found {
		fmt.Println("  No duplicates found.")
	}
}

func main() {
	if len(os.Args) < 2 {
		fmt.Fprintln(os.Stderr, "Usage: filescan <directory>")
		os.Exit(1)
	}

	root := os.Args[1]
	fmt.Printf("Scanning: %s\n", root)

	paths, err := collectFiles(root)
	if err != nil {
		fmt.Fprintf(os.Stderr, "Error: %v\n", err)
		os.Exit(1)
	}

	if len(paths) == 0 {
		fmt.Println("No files found.")
		return
	}

	files := processFiles(paths)
	analyze(files)
}

💻 Run locally

Copy the code above and run it on your machine

© 2026 ByteLearn.dev. Free courses for developers. · Privacy