chkbit/cmd/chkbit/main.go
2024-08-22 16:02:58 +02:00

352 lines
9.7 KiB
Go

package main
import (
"fmt"
"io"
"log"
"os"
"strings"
"sync"
"time"
"github.com/alecthomas/kong"
"github.com/laktak/chkbit/v5"
"github.com/laktak/chkbit/v5/cmd/chkbit/util"
"github.com/laktak/lterm"
)
type Progress int
const (
Quiet Progress = iota
Summary
Plain
Fancy
)
const (
updateInterval = time.Millisecond * 700
sizeMB int64 = 1024 * 1024
)
var appVersion = "vdev"
var (
termBG = lterm.Bg8(240)
termSep = "|"
termSepFG = lterm.Fg8(235)
termFG1 = lterm.Fg8(255)
termFG2 = lterm.Fg8(228)
termFG3 = lterm.Fg8(202)
termOKFG = lterm.Fg4(2)
termAlertFG = lterm.Fg4(1)
)
var cli struct {
Paths []string `arg:"" optional:"" name:"paths" help:"directories to check"`
Tips bool `short:"H" help:"Show tips."`
Check bool `short:"c" help:"check mode: chkbit will verify files in readonly mode (default mode)"`
Update bool `short:"u" help:"update mode: add and update indices"`
AddOnly bool `short:"a" help:"add mode: only add new files, do not check existing (quicker)"`
ShowIgnoredOnly bool `short:"i" help:"show-ignored mode: only show ignored files"`
ShowMissing bool `short:"m" help:"show missing files/directories"`
Force bool `help:"force update of damaged items (advanced usage only)"`
SkipSymlinks bool `short:"S" help:"do not follow symlinks"`
NoRecurse bool `short:"R" help:"do not recurse into subdirectories"`
NoDirInIndex bool `short:"D" help:"do not track directories in the index"`
LogFile string `short:"l" help:"write to a logfile if specified"`
LogVerbose bool `help:"verbose logging"`
Algo string `default:"blake3" help:"hash algorithm: md5, sha512, blake3 (default: blake3)"`
IndexName string `default:".chkbit" help:"filename where chkbit stores its hashes, needs to start with '.' (default: .chkbit)"`
IgnoreName string `default:".chkbitignore" help:"filename that chkbit reads its ignore list from, needs to start with '.' (default: .chkbitignore)"`
Workers int `short:"w" default:"5" help:"number of workers to use (default: 5)"`
Plain bool `help:"show plain status instead of being fancy"`
Quiet bool `short:"q" help:"quiet, don't show progress/information"`
Verbose bool `short:"v" help:"verbose output"`
Version bool `short:"V" help:"show version information"`
}
type Main struct {
context *chkbit.Context
dmgList []string
errList []string
verbose bool
logger *log.Logger
logVerbose bool
progress Progress
termWidth int
fps *util.RateCalc
bps *util.RateCalc
}
func (m *Main) log(text string) {
m.logger.Println(time.Now().UTC().Format("2006-01-02 15:04:05"), text)
}
func (m *Main) logStatus(stat chkbit.Status, message string) bool {
if stat == chkbit.STATUS_UPDATE_INDEX {
return false
}
if stat == chkbit.STATUS_ERR_DMG {
m.dmgList = append(m.dmgList, message)
} else if stat == chkbit.STATUS_PANIC {
m.errList = append(m.errList, message)
}
if m.logVerbose || !stat.IsVerbose() {
m.log(stat.String() + " " + message)
}
if m.verbose || !stat.IsVerbose() {
col := ""
if stat.IsErrorOrWarning() {
col = termAlertFG
}
lterm.Printline(col, stat.String(), " ", message, lterm.Reset)
return true
}
return false
}
func (m *Main) showStatus() {
last := time.Now().Add(-updateInterval)
stat := ""
for {
select {
case item := <-m.context.LogQueue:
if item == nil {
if m.progress == Fancy {
lterm.Printline("")
}
return
}
if m.logStatus(item.Stat, item.Message) {
if m.progress == Fancy {
lterm.Write(termBG, termFG1, stat, lterm.ClearLine(0), lterm.Reset, "\r")
} else {
fmt.Print(m.context.NumTotal, "\r")
}
}
case perf := <-m.context.PerfQueue:
now := time.Now()
m.fps.Push(now, perf.NumFiles)
m.bps.Push(now, perf.NumBytes)
if last.Add(updateInterval).Before(now) {
last = now
if m.progress == Fancy {
statF := fmt.Sprintf("%d files/s", m.fps.Last())
statB := fmt.Sprintf("%d MB/s", m.bps.Last()/sizeMB)
stat = "RW"
if !m.context.UpdateIndex {
stat = "RO"
}
stat = fmt.Sprintf("[%s:%d] %5d files $ %s %-13s $ %s %-13s",
stat, m.context.NumWorkers, m.context.NumTotal,
util.Sparkline(m.fps.Stats), statF,
util.Sparkline(m.bps.Stats), statB)
stat = util.LeftTruncate(stat, m.termWidth-1)
stat = strings.Replace(stat, "$", termSepFG+termSep+termFG2, 1)
stat = strings.Replace(stat, "$", termSepFG+termSep+termFG3, 1)
lterm.Write(termBG, termFG1, stat, lterm.ClearLine(0), lterm.Reset, "\r")
} else if m.progress == Plain {
fmt.Print(m.context.NumTotal, "\r")
}
}
}
}
}
func (m *Main) process() bool {
// verify mode
var b01 = map[bool]int8{false: 0, true: 1}
if b01[cli.Check]+b01[cli.Update]+b01[cli.AddOnly]+b01[cli.ShowIgnoredOnly] > 1 {
fmt.Println("Error: can only run one mode at a time!")
os.Exit(1)
}
var err error
m.context, err = chkbit.NewContext(cli.Workers, cli.Algo, cli.IndexName, cli.IgnoreName)
if err != nil {
fmt.Println(err)
return false
}
m.context.ForceUpdateDmg = cli.Force
m.context.UpdateIndex = cli.Update || cli.AddOnly
m.context.AddOnly = cli.AddOnly
m.context.ShowIgnoredOnly = cli.ShowIgnoredOnly
m.context.ShowMissing = cli.ShowMissing
m.context.SkipSymlinks = cli.SkipSymlinks
m.context.SkipSubdirectories = cli.NoRecurse
m.context.TrackDirectories = !cli.NoDirInIndex
var wg sync.WaitGroup
wg.Add(1)
go func() {
defer wg.Done()
m.showStatus()
}()
m.context.Start(cli.Paths)
wg.Wait()
return true
}
func (m *Main) printResult() {
cprint := func(col, text string) {
if m.progress != Quiet {
if m.progress == Fancy {
lterm.Printline(col, text, lterm.Reset)
} else {
fmt.Println(text)
}
}
}
eprint := func(col, text string) {
if m.progress == Fancy {
lterm.Write(col)
fmt.Fprintln(os.Stderr, text)
lterm.Write(lterm.Reset)
} else {
fmt.Fprintln(os.Stderr, text)
}
}
if m.progress != Quiet {
mode := ""
if !m.context.UpdateIndex {
mode = " in readonly mode"
}
status := fmt.Sprintf("Processed %s%s.", util.LangNum1MutateSuffix(m.context.NumTotal, "file"), mode)
cprint(termOKFG, status)
m.log(status)
if m.progress == Fancy && m.context.NumTotal > 0 {
elapsed := time.Since(m.fps.Start)
elapsedS := elapsed.Seconds()
fmt.Println("-", elapsed.Truncate(time.Second), "elapsed")
fmt.Printf("- %.2f files/second\n", (float64(m.fps.Total)+float64(m.fps.Current))/elapsedS)
fmt.Printf("- %.2f MB/second\n", (float64(m.bps.Total)+float64(m.bps.Current))/float64(sizeMB)/elapsedS)
}
del := ""
if m.context.UpdateIndex {
if m.context.NumIdxUpd > 0 {
if m.context.NumDel > 0 {
del = fmt.Sprintf("\n- %s been removed", util.LangNum1Choice(m.context.NumDel, "file/directory has", "files/directories have"))
}
cprint(termOKFG, fmt.Sprintf("- %s updated\n- %s added\n- %s updated%s",
util.LangNum1Choice(m.context.NumIdxUpd, "directory was", "directories were"),
util.LangNum1Choice(m.context.NumNew, "file hash was", "file hashes were"),
util.LangNum1Choice(m.context.NumUpd, "file hash was", "file hashes were"),
del))
}
} else if m.context.NumNew+m.context.NumUpd+m.context.NumDel > 0 {
if m.context.NumDel > 0 {
del = fmt.Sprintf("\n- %s would have been removed", util.LangNum1Choice(m.context.NumDel, "file/directory", "files/directories"))
}
cprint(termAlertFG, fmt.Sprintf("No changes were made (specify -u to update):\n- %s would have been added\n- %s would have been updated%s",
util.LangNum1MutateSuffix(m.context.NumNew, "file"),
util.LangNum1MutateSuffix(m.context.NumUpd, "file"),
del))
}
}
if len(m.dmgList) > 0 {
eprint(termAlertFG, "chkbit detected damage in these files:")
for _, err := range m.dmgList {
fmt.Fprintln(os.Stderr, err)
}
n := len(m.dmgList)
status := fmt.Sprintf("error: detected %s with damage!", util.LangNum1MutateSuffix(n, "file"))
m.log(status)
eprint(termAlertFG, status)
}
if len(m.errList) > 0 {
status := "chkbit ran into errors"
m.log(status + "!")
eprint(termAlertFG, status+":")
for _, err := range m.errList {
fmt.Fprintln(os.Stderr, err)
}
}
if len(m.dmgList) > 0 || len(m.errList) > 0 {
os.Exit(1)
}
}
func (m *Main) run() {
if len(os.Args) < 2 {
os.Args = append(os.Args, "--help")
}
kong.Parse(&cli,
kong.Name("chkbit"),
kong.Description(""),
kong.UsageOnError(),
)
if cli.Tips {
fmt.Println(helpTips)
os.Exit(0)
}
if cli.Version {
fmt.Println("github.com/laktak/chkbit")
fmt.Println(appVersion)
return
}
m.verbose = cli.Verbose || cli.ShowIgnoredOnly
if cli.LogFile != "" {
m.logVerbose = cli.LogVerbose
f, err := os.OpenFile(cli.LogFile, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0666)
if err != nil {
fmt.Println(err)
return
}
defer f.Close()
m.logger = log.New(f, "", 0)
}
if cli.Quiet {
m.progress = Quiet
} else if fileInfo, _ := os.Stdout.Stat(); (fileInfo.Mode() & os.ModeCharDevice) == 0 {
m.progress = Summary
} else if cli.Plain {
m.progress = Plain
} else {
m.progress = Fancy
}
if len(cli.Paths) > 0 {
m.log("chkbit " + strings.Join(cli.Paths, ", "))
if m.process() && !m.context.ShowIgnoredOnly {
m.printResult()
}
} else {
fmt.Println("specify a path to check, see -h")
}
}
func main() {
defer func() {
if r := recover(); r != nil {
fmt.Println(r)
os.Exit(1)
}
}()
termWidth := lterm.GetWidth()
m := &Main{
logger: log.New(io.Discard, "", 0),
termWidth: termWidth,
fps: util.NewRateCalc(time.Second, (termWidth-70)/2),
bps: util.NewRateCalc(time.Second, (termWidth-70)/2),
}
m.run()
}