github.com/josephvusich/fdf@v0.0.0-20230522095411-9326dd32e33f/options.go (about)

     1  package main
     2  
     3  import (
     4  	"errors"
     5  	"flag"
     6  	"fmt"
     7  	"io"
     8  	"os"
     9  	"path/filepath"
    10  	"regexp"
    11  	"strings"
    12  
    13  	"github.com/josephvusich/go-getopt"
    14  	"github.com/josephvusich/go-matchers"
    15  	"github.com/josephvusich/go-matchers/glob"
    16  )
    17  
    18  type verb int
    19  
    20  const (
    21  	VerbNone verb = iota
    22  	VerbClone
    23  	VerbSplitLinks
    24  	VerbMakeLinks
    25  	VerbDelete
    26  )
    27  
    28  func (v verb) PastTense() string {
    29  	switch v {
    30  	case VerbNone:
    31  		return "skipped"
    32  	case VerbClone:
    33  		return "cloned"
    34  	case VerbSplitLinks:
    35  		return "copied"
    36  	case VerbMakeLinks:
    37  		return "hardlinked"
    38  	case VerbDelete:
    39  		return "deleted"
    40  	}
    41  	return fmt.Sprintf("unknown verb value %d", v)
    42  }
    43  
    44  type options struct {
    45  	clone       bool
    46  	splitLinks  bool
    47  	makeLinks   bool
    48  	deleteDupes bool
    49  
    50  	MatchMode matchFlag
    51  
    52  	Comparers []comparer
    53  	Protect   matchers.RuleSet
    54  	Exclude   matchers.RuleSet
    55  	MustKeep  matchers.RuleSet
    56  
    57  	Recursive bool
    58  
    59  	minSize    int64
    60  	SkipHeader int64
    61  	SkipFooter int64
    62  
    63  	IgnoreExistingLinks bool
    64  	Quiet               bool
    65  	Verbose             bool
    66  	DryRun              bool
    67  
    68  	JsonReport string
    69  }
    70  
    71  // OpenFile returns a reader that follows options.SkipHeader
    72  func (o *options) OpenFile(path string) (io.ReadCloser, error) {
    73  	f, err := os.Open(path)
    74  	if err != nil {
    75  		return nil, err
    76  	}
    77  
    78  	if o.SkipHeader > 0 {
    79  		if _, err = f.Seek(o.SkipHeader, io.SeekStart); err != nil {
    80  			f.Close()
    81  			return nil, err
    82  		}
    83  	}
    84  
    85  	if o.SkipFooter == 0 {
    86  		return f, nil
    87  	}
    88  
    89  	st, err := f.Stat()
    90  	if err != nil {
    91  		f.Close()
    92  		return nil, err
    93  	}
    94  
    95  	return newLimitReadCloser(f, st.Size()-o.SkipFooter), nil
    96  }
    97  
    98  type limitReadCloser struct {
    99  	io.Reader
   100  	io.Closer
   101  }
   102  
   103  func newLimitReadCloser(f *os.File, n int64) *limitReadCloser {
   104  	return &limitReadCloser{
   105  		Reader: io.LimitReader(f, n),
   106  		Closer: f,
   107  	}
   108  }
   109  
   110  var matchFunc = regexp.MustCompile(`^([a-z]+)(?:\[([^\]]+)])?$`)
   111  
   112  func (o *options) parseRange(rangePattern string, cmpFlag matchFlag, cmpFunc func(r *fileRecord) string) error {
   113  	// non-indexed fields must use range matchers
   114  	if cmpFlag == matchNothing && rangePattern == "" {
   115  		rangePattern = ":"
   116  	}
   117  
   118  	if rangePattern != "" {
   119  		cmp, err := newComparer(rangePattern, cmpFunc)
   120  		if err != nil {
   121  			return err
   122  		}
   123  		o.Comparers = append(o.Comparers, cmp)
   124  	} else {
   125  		o.MatchMode |= cmpFlag
   126  	}
   127  	return nil
   128  }
   129  
   130  // TODO add mod time
   131  // does not modify options on error
   132  func (o *options) parseMatchSpec(matchSpec string, v verb) (err error) {
   133  	o.MatchMode = matchNothing
   134  	if matchSpec == "" {
   135  		matchSpec = "content"
   136  	}
   137  	modes := strings.Split(strings.ToLower(matchSpec), "+")
   138  	for _, m := range modes {
   139  		r := matchFunc.FindStringSubmatch(m)
   140  		if r == nil {
   141  			return fmt.Errorf("invalid field: %s", m)
   142  		}
   143  
   144  		switch r[1] {
   145  		case "content":
   146  			o.MatchMode |= matchContent
   147  		case "name":
   148  			if err := o.parseRange(r[2], matchName, func(r *fileRecord) string { return r.FoldedName }); err != nil {
   149  				return err
   150  			}
   151  		case "parent":
   152  			if err := o.parseRange(r[2], matchParent, func(r *fileRecord) string { return r.FoldedParent }); err != nil {
   153  				return err
   154  			}
   155  		case "relpath":
   156  			o.MatchMode |= matchPathSuffix
   157  		case "path":
   158  			if err := o.parseRange(r[2], matchNothing, func(r *fileRecord) string { return filepath.Dir(r.FilePath) }); err != nil {
   159  				return err
   160  			}
   161  			// rely on path suffix match to narrow down possible matches pre-comparer
   162  			o.MatchMode |= matchPathSuffix
   163  		case "copyname":
   164  			o.MatchMode |= matchCopyName
   165  		case "namesuffix":
   166  			o.MatchMode |= matchNameSuffix
   167  		case "nameprefix":
   168  			o.MatchMode |= matchNamePrefix
   169  		case "size":
   170  			o.MatchMode |= matchSize
   171  		default:
   172  			return fmt.Errorf("unknown matcher: %s", m)
   173  		}
   174  	}
   175  	if o.MatchMode&matchCopyName != 0 || o.MatchMode&matchNameSuffix != 0 {
   176  		if o.MatchMode&matchCopyName != 0 && o.MatchMode&matchNameSuffix != 0 {
   177  			return errors.New("cannot specify both copyname and namesuffix for --match")
   178  		}
   179  		if o.MatchMode&matchName != 0 {
   180  			return errors.New("cannot specify both name and copyname/namesuffix for --match")
   181  		}
   182  		if o.MatchMode&matchSize == 0 {
   183  			return errors.New("--match copyname/namesuffix also require either size or content")
   184  		}
   185  	}
   186  	if o.MatchMode == matchNothing {
   187  		return errors.New("must specify at least one non-partial matcher")
   188  	}
   189  	if v == VerbSplitLinks {
   190  		o.MatchMode |= matchHardlink
   191  	}
   192  
   193  	return nil
   194  }
   195  
   196  func (o *options) Verb() verb {
   197  	switch true {
   198  	case o.makeLinks:
   199  		return VerbMakeLinks
   200  	case o.clone:
   201  		return VerbClone
   202  	case o.splitLinks:
   203  		return VerbSplitLinks
   204  	case o.deleteDupes:
   205  		return VerbDelete
   206  	}
   207  	return VerbNone
   208  }
   209  
   210  func (o *options) MinSize() int64 {
   211  	if o.SkipHeader > 0 && o.SkipHeader+1 > o.minSize {
   212  		return o.SkipHeader + 1
   213  	}
   214  	return o.minSize
   215  }
   216  
   217  func (o *options) ParseArgs(args []string) (dirs []string) {
   218  	fs := getopt.NewFlagSet(args[0], flag.ContinueOnError)
   219  	fs.Usage = func() {
   220  		fmt.Fprint(os.Stderr,
   221  			"usage: fdf [--clone | --copy | --delete | --link] [-hqrtv]\n"+
   222  				"        [-m FIELDS] [-z BYTES] [-n LENGTH]\n"+
   223  				"        [--protect PATTERN] [--unprotect PATTERN] [directory ...]\n\n")
   224  		fs.PrintDefaults()
   225  	}
   226  	showHelp := false
   227  
   228  	o.Protect.DefaultInclude = false
   229  	protect, unprotect := o.Protect.FlagValues(globMatcher)
   230  	protectDir, unprotectDir := o.Protect.FlagValues(globMatcherFromDir)
   231  
   232  	o.Exclude.DefaultInclude = false
   233  	exclude, include := o.Exclude.FlagValues(globMatcher)
   234  	excludeDir, includeDir := o.Exclude.FlagValues(globMatcherFromDir)
   235  
   236  	o.MustKeep.DefaultInclude = true
   237  	mustKeep, _ := o.MustKeep.FlagValues(globMatcher)
   238  	mustKeepDir, _ := o.MustKeep.FlagValues(globMatcherFromDir)
   239  
   240  	fs.BoolVar(&o.clone, "clone", false, "(verb) create copy-on-write clones instead of hardlinks (not supported on all filesystems)")
   241  	fs.BoolVar(&o.splitLinks, "copy", false, "(verb) split existing hardlinks via copy\nmutually exclusive with --ignore-hardlinks")
   242  	fs.BoolVar(&o.Recursive, "recursive", false, "traverse subdirectories")
   243  	fs.BoolVar(&o.makeLinks, "link", false, "(verb) hardlink duplicate files")
   244  	fs.BoolVar(&o.deleteDupes, "delete", false, "(verb) delete duplicate files")
   245  	fs.BoolVar(&o.DryRun, "dry-run", false, "don't actually do anything, just show what would be done")
   246  	fs.BoolVar(&o.IgnoreExistingLinks, "ignore-hardlinks", false, "ignore existing hardlinks\nmutually exclusive with --copy")
   247  	fs.BoolVar(&o.Quiet, "quiet", false, "don't display current filename during scanning")
   248  	fs.BoolVar(&o.Verbose, "verbose", false, "display additional details regarding protected paths")
   249  	helpFlag := fs.Bool("help", false, "show this help screen and exit")
   250  	fs.Int64Var(&o.minSize, "minimum-size", 1, "skip files smaller than `BYTES`, must be greater than the sum of --skip-header and --skip-footer")
   251  	fs.Int64Var(&o.SkipHeader, "skip-header", 0, "skip `LENGTH` bytes at the beginning of each file when comparing")
   252  	fs.Int64Var(&o.SkipFooter, "skip-footer", 0, "skip `LENGTH` bytes at the end of each file when comparing")
   253  	fs.Var(exclude, "exclude", "exclude files matching `GLOB` from scanning")
   254  	fs.Var(excludeDir, "exclude-dir", "exclude `DIR` from scanning, throws error if DIR does not exist")
   255  	fs.Var(include, "include", "include `GLOB`, opposite of --exclude")
   256  	fs.Var(includeDir, "include-dir", "include `DIR`, throws error if DIR does not exist")
   257  	fs.Var(protect, "protect", "prevent files matching glob `PATTERN` from being modified or deleted\n"+
   258  		"may appear more than once to support multiple patterns\n"+
   259  		"rules are applied in the order specified")
   260  	fs.Var(protect, "preserve", "(deprecated) alias for --protect `PATTERN`")
   261  	fs.Var(protectDir, "protect-dir", "similar to --protect 'DIR/**/*', but throws error if `DIR` does not exist")
   262  	fs.Var(unprotect, "unprotect", "remove files added by --protect\nmay appear more than once\nrules are applied in the order specified")
   263  	fs.Var(unprotectDir, "unprotect-dir", "similar to --unprotect 'DIR/**/*', but throws error if `DIR` does not exist")
   264  	fs.Var(mustKeep, "if-kept", "only remove files if the 'kept' file matches the provided `GLOB`")
   265  	fs.Var(mustKeepDir, "if-kept-dir", "only remove files if the 'kept' file is a descendant of `DIR`")
   266  	matchSpec := fs.String("match", "", "Evaluate `FIELDS` to determine file equality, where valid fields are:\n"+
   267  		"  name (case insensitive)\n"+
   268  		"    range notation supported: name[offset:len,offset:len,...]\n"+
   269  		"      name[0:-1] whole string\n"+
   270  		"      name[0:-2] all except last character\n"+
   271  		"      name[1:2]  second and third characters\n"+
   272  		"      name[-1:1] last character\n"+
   273  		"      name[-3:3] last 3 characters\n"+
   274  		"  copyname (case insensitive)\n"+
   275  		"    'foo.bar' == 'foo (1).bar' == 'Copy of foo.bar', also requires +size or +content\n"+
   276  		"  namesuffix (case insensitive)\n"+
   277  		"    one filename must end with the other, e.g.: 'foo-1.bar' and '1.bar'\n"+
   278  		"  nameprefix (case insensitive)\n"+
   279  		"    one filename must begin with the other, e.g., 'foo-1.bar' and 'foo.bar'\n"+
   280  		"  parent (case insensitive name of immediate parent directory)\n"+
   281  		"    range notation supported: see 'name' for examples\n"+
   282  		"  path\n"+
   283  		"    match parent directory path\n"+
   284  		"  relpath\n"+
   285  		"    match parent directory path relative to input dir(s)\n"+
   286  		"  size\n"+
   287  		"  content (default, also implies size)\n"+
   288  		"specify multiple fields using '+', e.g.: name+content")
   289  	allowNoContent := fs.Bool("ignore-content", false, "allow --match without 'content'")
   290  	fs.StringVar(&o.JsonReport, "json-report", "", "on completion, dump JSON match data to `FILE`")
   291  
   292  	fs.Alias("a", "clone")
   293  	fs.Alias("c", "copy")
   294  	fs.Alias("r", "recursive")
   295  	fs.Alias("l", "link")
   296  	fs.Alias("d", "delete")
   297  	fs.Alias("q", "quiet")
   298  	fs.Alias("v", "verbose")
   299  	fs.Alias("t", "dry-run")
   300  	fs.Alias("h", "ignore-hardlinks")
   301  	fs.Alias("z", "minimum-size")
   302  	fs.Alias("m", "match")
   303  	fs.Alias("n", "skip-header")
   304  	fs.Alias("p", "protect")
   305  
   306  	if err := fs.Parse(args[1:]); err != nil {
   307  		os.Exit(1)
   308  	}
   309  
   310  	var err error
   311  	if o.Quiet && o.Verbose {
   312  		fmt.Println("Invalid flag combination: --quiet and --verbose are mutually exclusive")
   313  		showHelp = true
   314  	}
   315  
   316  	if err = o.parseMatchSpec(*matchSpec, o.Verb()); err != nil {
   317  		fmt.Println("Invalid --match parameter:", err)
   318  		showHelp = true
   319  	}
   320  
   321  	if o.MatchMode&matchContent != matchContent && !*allowNoContent && (o.Verb() != VerbNone && !o.DryRun) {
   322  		fmt.Println("Must specify --ignore-content to use --match without 'content'")
   323  		showHelp = true
   324  	} else if o.MatchMode&matchContent == 1 && *allowNoContent {
   325  		fmt.Println("--ignore-content specified, but --match contains 'content'")
   326  		showHelp = true
   327  	} else if o.DryRun && *allowNoContent {
   328  		fmt.Println("--ignore-content is mutually exclusive with --dry-run")
   329  		showHelp = true
   330  	} else if o.Verb() == VerbNone && *allowNoContent {
   331  		fmt.Println("--ignore-content specified without a verb")
   332  		showHelp = true
   333  	}
   334  
   335  	if o.Verb() == VerbSplitLinks && o.IgnoreExistingLinks {
   336  		fmt.Println("Invalid flag combination: --copy and --ignore-hardlinks are mutually exclusive")
   337  		showHelp = true
   338  	}
   339  
   340  	if showHelp || *helpFlag {
   341  		fs.Usage()
   342  		if !showHelp {
   343  			os.Exit(0)
   344  		}
   345  		os.Exit(1)
   346  	}
   347  
   348  	return fs.Args()
   349  }
   350  
   351  func globMatcher(pattern string) (matchers.Matcher, error) {
   352  	abs, err := filepath.Abs(pattern)
   353  	if err != nil {
   354  		return nil, fmt.Errorf("unable to resolve \"%s\": %w", pattern, err)
   355  	}
   356  	return glob.NewMatcher(abs)
   357  }
   358  
   359  func globMatcherFromDir(dir string) (matchers.Matcher, error) {
   360  	abs, err := filepath.Abs(dir)
   361  	if err != nil {
   362  		return nil, fmt.Errorf("unable to resolve \"%s\": %w", dir, err)
   363  	}
   364  	st, err := os.Stat(abs)
   365  	if err != nil {
   366  		return nil, fmt.Errorf("unable to resolve \"%s\": %w", dir, err)
   367  	}
   368  	if !st.IsDir() {
   369  		return nil, fmt.Errorf("not a directory: %s", dir)
   370  	}
   371  	return glob.NewMatcher(filepath.Join(abs, "**", "*"))
   372  }
   373  
   374  func (o *options) globPattern() string {
   375  	if o.Recursive {
   376  		return "./**/*"
   377  	}
   378  	return "./*"
   379  }