diff --git a/commands/convert.go b/commands/convert.go deleted file mode 100644 index e69de29..0000000 diff --git a/commands/fdetect.go b/commands/fdetect.go new file mode 100644 index 0000000..ddf8c02 --- /dev/null +++ b/commands/fdetect.go @@ -0,0 +1,339 @@ +package commands + +import ( + "context" + "fmt" + "os" + "path/filepath" + "sort" + "strings" + "time" + + "github.com/fatih/color" + "github.com/olekukonko/tablewriter" + "github.com/urfave/cli/v3" + "golang.org/x/term" +) + +// COLORS +var ( + colorCyan = color.New(color.FgCyan, color.Bold) + colorYellow = color.New(color.FgYellow) +) + +// TERMINAL WIDTH +func termWidth() int { + width, _, err := term.GetSize(int(os.Stdout.Fd())) + if err != nil || width < 40 { + return 60 + } + if width > 80 { + return 80 + } + return width +} + +// SECTION HEADER +func printSection(icon, title string) { + w := termWidth() + dashes := w - len(icon) - len(title) - 6 + if dashes < 2 { + dashes = 2 + } + fmt.Println() + colorCyan.Printf("┌─ %s %s %s\n\n", icon, title, strings.Repeat("─", dashes)) +} + +// DIVIDER +func printDivider() { + colorCyan.Println("└" + strings.Repeat("─", termWidth()-1)) +} + +// NO RESULT +func noResult(msg string) { + colorYellow.Println(" ⚠ " + msg) +} + +// TABLE HELPER +func newTable(headers []string) *tablewriter.Table { + table := tablewriter.NewWriter(os.Stdout) + table.SetHeader(headers) + table.SetBorder(true) + table.SetCenterSeparator("┼") + table.SetColumnSeparator("│") + table.SetRowSeparator("─") + return table +} + +func FileDetectCommand() *cli.Command { + return &cli.Command{ + Name: "sift", + Usage: "Detect and scan files with various filters", + Flags: []cli.Flag{ + &cli.BoolFlag{Name: "emt", Aliases: []string{"e"}, Usage: "Find empty files"}, + &cli.IntFlag{Name: "rec", Aliases: []string{"r"}, Usage: "Find files modified in last N days"}, + &cli.Float64Flag{Name: "lrg", Aliases: []string{"l"}, Usage: "Find files larger than N GB"}, + &cli.BoolFlag{Name: "ext", Aliases: []string{"x"}, Usage: "Show file extension summary"}, + &cli.IntFlag{Name: "top", Aliases: []string{"t"}, Usage: "Show top N largest files"}, + &cli.BoolFlag{Name: "dup", Aliases: []string{"d"}, Usage: "Find duplicate files by name"}, + &cli.BoolFlag{Name: "log", Aliases: []string{"L"}, Usage: "Find all log files (.log)"}, + &cli.StringFlag{Name: "p", Aliases: []string{"P"}, Value: ".", Usage: "Directory path to scan"}, + }, + Action: runDetect, + } +} + +func runDetect(ctx context.Context, c *cli.Command) error { + + path := c.String("p") + + // PATH VALIDATION + if _, err := os.Stat(path); os.IsNotExist(err) { + colorYellow.Println(" ⚠ Invalid path:", path) + return nil + } + + // no flag check + if !c.Bool("emt") && !c.Bool("ext") && !c.Bool("dup") && !c.Bool("log") && + c.Int("rec") == 0 && c.Float64("lrg") == 0 && c.Int("top") == 0 { + fmt.Println() + colorYellow.Println(" ⚠ No flag provided. Use --help to see all available flags.") + return nil + } + + // EMPTY FILES + if c.Bool("emt") { + printSection("🔍", "Empty Files") + table := newTable([]string{"#", "FILE"}) + count := 0 + + filepath.WalkDir(path, func(p string, d os.DirEntry, err error) error { + if err != nil || d.IsDir() { + return nil + } + info, err := d.Info() + if err != nil { + return nil + } + if info.Size() == 0 { + count++ + table.Append([]string{fmt.Sprintf("%d", count), p}) + } + return nil + }) + + if count == 0 { + noResult("No empty files found.") + } else { + table.Render() + } + printDivider() + } + + // RECENT FILES + if c.Int("rec") > 0 { + days := c.Int("rec") + cutoff := time.Now().AddDate(0, 0, -days) + + printSection("🕐", fmt.Sprintf("Recently Modified — Last %d Day(s)", days)) + table := newTable([]string{"#", "FILE", "MODIFIED"}) + count := 0 + + filepath.WalkDir(path, func(p string, d os.DirEntry, err error) error { + if err != nil || d.IsDir() { + return nil + } + info, err := d.Info() + if err != nil { + return nil + } + if info.ModTime().After(cutoff) { + count++ + table.Append([]string{ + fmt.Sprintf("%d", count), + p, + info.ModTime().Format("2006-01-02 15:04:05"), + }) + } + return nil + }) + + if count == 0 { + noResult(fmt.Sprintf("No files modified in last %d day(s).", days)) + } else { + table.Render() + } + printDivider() + } + + // LARGE FILES + if c.Float64("lrg") > 0 { + sizeGB := c.Float64("lrg") + minBytes := int64(sizeGB * 1024 * 1024 * 1024) + + printSection("💾", "Large Files") + table := newTable([]string{"#", "FILE", "SIZE"}) + count := 0 + + filepath.WalkDir(path, func(p string, d os.DirEntry, err error) error { + if err != nil || d.IsDir() { + return nil + } + info, err := d.Info() + if err != nil { + return nil + } + if info.Size() > minBytes { + count++ + table.Append([]string{ + fmt.Sprintf("%d", count), + p, + fmt.Sprintf("%.2f GB", float64(info.Size())/(1024*1024*1024)), + }) + } + return nil + }) + + if count == 0 { + noResult("No large files found.") + } else { + table.Render() + } + printDivider() + } + + // TOP FILES + if c.Int("top") > 0 { + n := c.Int("top") + + type file struct { + path string + size int64 + } + var files []file + + filepath.WalkDir(path, func(p string, d os.DirEntry, err error) error { + if err != nil || d.IsDir() { + return nil + } + info, err := d.Info() + if err != nil { + return nil + } + files = append(files, file{p, info.Size()}) + return nil + }) + + sort.Slice(files, func(i, j int) bool { + return files[i].size > files[j].size + }) + + if n > len(files) { + n = len(files) + } + + printSection("🏆", fmt.Sprintf("Top %d Files", n)) + table := newTable([]string{"RANK", "FILE", "SIZE"}) + for i := 0; i < n; i++ { + table.Append([]string{ + fmt.Sprintf("#%d", i+1), + files[i].path, + fmt.Sprintf("%.2f MB", float64(files[i].size)/(1024*1024)), + }) + } + + if n == 0 { + noResult("No files found.") + } else { + table.Render() + } + printDivider() + } + + // EXTENSIONS + if c.Bool("ext") { + printSection("📋", "Extensions") + extMap := make(map[string]int) + + filepath.WalkDir(path, func(p string, d os.DirEntry, err error) error { + if err != nil || d.IsDir() { + return nil + } + ext := strings.ToLower(filepath.Ext(p)) + if ext == "" { + ext = "(none)" + } + extMap[ext]++ + return nil + }) + + table := newTable([]string{"EXT", "COUNT"}) + for k, v := range extMap { + table.Append([]string{k, fmt.Sprintf("%d", v)}) + } + + if len(extMap) == 0 { + noResult("No files found.") + } else { + table.Render() + } + printDivider() + } + + // DUPLICATES + if c.Bool("dup") { + printSection("♊", "Duplicates") + nameMap := make(map[string][]string) + + filepath.WalkDir(path, func(p string, d os.DirEntry, err error) error { + if err != nil || d.IsDir() { + return nil + } + nameMap[d.Name()] = append(nameMap[d.Name()], p) + return nil + }) + + table := newTable([]string{"FILE", "PATHS"}) + count := 0 + for name, paths := range nameMap { + if len(paths) > 1 { + count++ + table.Append([]string{name, strings.Join(paths, " | ")}) + } + } + + if count == 0 { + noResult("No duplicates found.") + } else { + table.Render() + } + printDivider() + } + + // LOG FILES + if c.Bool("log") { + printSection("📄", "Log Files") + table := newTable([]string{"FILE"}) + count := 0 + + filepath.WalkDir(path, func(p string, d os.DirEntry, err error) error { + if err != nil || d.IsDir() { + return nil + } + if strings.HasSuffix(strings.ToLower(d.Name()), ".log") { + count++ + table.Append([]string{p}) + } + return nil + }) + + if count == 0 { + noResult("No log files found.") + } else { + table.Render() + } + printDivider() + } + + return nil +} diff --git a/go.mod b/go.mod index 32a96d4..d74c870 100644 --- a/go.mod +++ b/go.mod @@ -2,4 +2,17 @@ module funk go 1.25.7 -require github.com/urfave/cli/v3 v3.7.0 // indirect +require ( + github.com/fatih/color v1.18.0 + github.com/olekukonko/tablewriter v0.0.5 + github.com/urfave/cli/v3 v3.7.0 + golang.org/x/term v0.41.0 +) + +require ( + github.com/clipperhouse/uax29/v2 v2.6.0 // indirect + github.com/mattn/go-colorable v0.1.14 // indirect + github.com/mattn/go-isatty v0.0.20 // indirect + github.com/mattn/go-runewidth v0.0.19 // indirect + golang.org/x/sys v0.42.0 // indirect +) diff --git a/go.sum b/go.sum index 831ab6c..c6fe177 100644 --- a/go.sum +++ b/go.sum @@ -1,2 +1,28 @@ +github.com/clipperhouse/uax29/v2 v2.6.0 h1:z0cDbUV+aPASdFb2/ndFnS9ts/WNXgTNNGFoKXuhpos= +github.com/clipperhouse/uax29/v2 v2.6.0/go.mod h1:Wn1g7MK6OoeDT0vL+Q0SQLDz/KpfsVRgg6W7ihQeh4g= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/fatih/color v1.18.0 h1:S8gINlzdQ840/4pfAwic/ZE0djQEH3wM94VfqLTZcOM= +github.com/fatih/color v1.18.0/go.mod h1:4FelSpRwEGDpQ12mAdzqdOukCy4u8WUtOY6lkT/6HfU= +github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE= +github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8= +github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= +github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/mattn/go-runewidth v0.0.9/go.mod h1:H031xJmbD/WCDINGzjvQ9THkh0rPKHF+m2gUSrubnMI= +github.com/mattn/go-runewidth v0.0.19 h1:v++JhqYnZuu5jSKrk9RbgF5v4CGUjqRfBm05byFGLdw= +github.com/mattn/go-runewidth v0.0.19/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs= +github.com/olekukonko/tablewriter v0.0.5 h1:P2Ga83D34wi1o9J6Wh1mRuqd4mF/x/lgBS7N7AbDhec= +github.com/olekukonko/tablewriter v0.0.5/go.mod h1:hPp6KlRPjbx+hW8ykQs1w3UBbZlj6HuIJcUGPhkA7kY= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= github.com/urfave/cli/v3 v3.7.0 h1:AGSnbUyjtLiM+WJUb4dzXKldl/gL+F8OwmRDtVr6g2U= github.com/urfave/cli/v3 v3.7.0/go.mod h1:ysVLtOEmg2tOy6PknnYVhDoouyC/6N42TMeoMzskhso= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo= +golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/term v0.41.0 h1:QCgPso/Q3RTJx2Th4bDLqML4W6iJiaXFq2/ftQF13YU= +golang.org/x/term v0.41.0/go.mod h1:3pfBgksrReYfZ5lvYM0kSO0LIkAl4Yl2bXOkKP7Ec2A= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/main.go b/main.go index 8a5fcd7..d376a5f 100644 --- a/main.go +++ b/main.go @@ -2,19 +2,22 @@ package main import ( "context" - "fmt" - "github.com/urfave/cli/v3" "log" "os" + + "funk/commands" + + "github.com/urfave/cli/v3" ) func main() { + cmd := &cli.Command{ Name: "funk", - Usage: "make an explosive entrance", - Action: func(context.Context, *cli.Command) error { - fmt.Println("boom! I say!") - return nil + Usage: "Developer CLI tool", + + Commands: []*cli.Command{ + commands.FileDetectCommand(), }, }