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 }