github.com/atc0005/elbow@v0.8.8/internal/matches/matches.go (about)

     1  // Copyright 2020 Adam Chalkley
     2  //
     3  // https://github.com/atc0005/elbow
     4  //
     5  // Licensed under the Apache License, Version 2.0 (the "License");
     6  // you may not use this file except in compliance with the License.
     7  // You may obtain a copy of the License at
     8  //
     9  //     https://www.apache.org/licenses/LICENSE-2.0
    10  //
    11  // Unless required by applicable law or agreed to in writing, software
    12  // distributed under the License is distributed on an "AS IS" BASIS,
    13  // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
    14  // See the License for the specific language governing permissions and
    15  // limitations under the License.
    16  
    17  // Package matches provides types and functions intended to help with
    18  // collecting and validating file search results against required criteria.
    19  package matches
    20  
    21  import (
    22  	"os"
    23  	"path/filepath"
    24  	"sort"
    25  	"strings"
    26  	"time"
    27  
    28  	"github.com/atc0005/elbow/internal/config"
    29  	"github.com/atc0005/elbow/internal/units"
    30  	"github.com/sirupsen/logrus"
    31  )
    32  
    33  // FileAgeThreshold represents the threshold where a file is eligible for
    34  // removal.
    35  type FileAgeThreshold struct {
    36  	daysBack int
    37  	time     time.Time
    38  }
    39  
    40  // String implements the Stringer interface for display purposes.
    41  func (ft FileAgeThreshold) String() string {
    42  	return ft.FormatDisplay()
    43  }
    44  
    45  // FormatDisplay returns the file age threshold in a human friendly time
    46  // format for display purposes.
    47  func (ft FileAgeThreshold) FormatDisplay() string {
    48  	return ft.time.Format(time.RFC1123)
    49  }
    50  
    51  // FormatLog returns the file age threshold in a format intended for use in
    52  // log messages, often for the purposes of debugging.
    53  func (ft FileAgeThreshold) FormatLog() string {
    54  	return ft.time.Format(time.RFC3339)
    55  }
    56  
    57  // DaysBack returns the number of days prior to the current date used as the
    58  // file age threshold.
    59  func (ft FileAgeThreshold) DaysBack() int {
    60  	return ft.daysBack
    61  }
    62  
    63  // Time returns the file age threshold as a time.Time value.
    64  func (ft FileAgeThreshold) Time() time.Time {
    65  	return ft.time
    66  }
    67  
    68  // NewFileAgeThreshold is used to create a new instance of FileAgeThreshold.
    69  func NewFileAgeThreshold(daysOld int) FileAgeThreshold {
    70  
    71  	// Flip user specified number of days negative so that we can wind
    72  	// back that many days from the file modification time. This gives
    73  	// us our threshold to compare file modification times against.
    74  	daysBack := -(daysOld)
    75  	fileAgeThreshold := time.Now().AddDate(0, 0, daysBack)
    76  
    77  	return FileAgeThreshold{
    78  		daysBack: daysBack,
    79  		time:     fileAgeThreshold,
    80  	}
    81  }
    82  
    83  // FileMatch represents a superset of statistics (including os.FileInfo) for a
    84  // file matched by provided search criteria. This allows us to record the
    85  // original full path while also recording file metadata used in later
    86  // calculations.
    87  type FileMatch struct {
    88  	os.FileInfo
    89  	Path string
    90  }
    91  
    92  // FileMatches is a slice of FileMatch objects that represents the search
    93  // results based on user-specified criteria.
    94  type FileMatches []FileMatch
    95  
    96  // TotalFileSize returns the cumulative size of all files in the slice in bytes
    97  func (fm FileMatches) TotalFileSize() int64 {
    98  
    99  	var totalSize int64
   100  
   101  	for _, file := range fm {
   102  
   103  		totalSize += file.Size()
   104  	}
   105  
   106  	return totalSize
   107  
   108  }
   109  
   110  // TotalFileSizeHR returns a human-readable string of the cumulative size of
   111  // all files in the slice of bytes
   112  func (fm FileMatches) TotalFileSizeHR() string {
   113  	return units.ByteCountIEC(fm.TotalFileSize())
   114  }
   115  
   116  // SizeHR returns a human-readable string of the size of a FileMatch object.
   117  func (fm FileMatch) SizeHR() string {
   118  	return units.ByteCountIEC(fm.Size())
   119  }
   120  
   121  // HasMatchingExtension validates whether a file has the desired extension. If
   122  // no extensions are specified, the file being evaluated is considered
   123  // eligible for removal.
   124  func HasMatchingExtension(filename string, config *config.Config) bool {
   125  
   126  	log := config.GetLogger()
   127  
   128  	ext := filepath.Ext(filename)
   129  	ext = strings.TrimPrefix(ext, ".")
   130  
   131  	// handle empty extensions list scenario
   132  	if len(config.GetFileExtensions()) == 0 {
   133  		log.Debug("No extension limits have been set!")
   134  		log.Debugf("Considering %s safe for removal", filename)
   135  		return true
   136  	}
   137  
   138  	log.Debug("Removing leading dot from specified file extensions for comparison")
   139  	fileExtensions := make([]string, 0, len(config.GetFileExtensions()))
   140  	for _, fileExt := range config.GetFileExtensions() {
   141  		fileExtensions = append(fileExtensions, strings.TrimPrefix(fileExt, "."))
   142  	}
   143  
   144  	log.Debug("Comparing extensions case-insensitively")
   145  	if InList(ext, fileExtensions, true) {
   146  		log.Debugf("%s has a valid extension for removal", filename)
   147  		return true
   148  	}
   149  
   150  	log.Debug("HasMatchingExtension: returning false for:", filename)
   151  	log.Debugf("HasMatchingExtension: returning false (%q not in %q)",
   152  		ext, fileExtensions)
   153  	return false
   154  }
   155  
   156  // HasMatchingFilenamePattern validates whether a filename matches the desired
   157  // pattern. If no filename pattern is specified, the file being evaluated is
   158  // considered eligible for removal.
   159  func HasMatchingFilenamePattern(filename string, config *config.Config) bool {
   160  
   161  	log := config.GetLogger()
   162  
   163  	if strings.TrimSpace(config.GetFilePattern()) == "" {
   164  		log.Debug("No FilePattern has been specified!")
   165  		log.Debugf("Considering %s safe for removal", filename)
   166  		return true
   167  	}
   168  
   169  	// Search for substring
   170  	if strings.Contains(filename, config.GetFilePattern()) {
   171  		log.Debug("HasMatchingFilenamePattern: returning true for:", filename)
   172  		log.Debugf("HasMatchingFilenamePattern: returning true (%q contains %q)",
   173  			filename, config.GetFilePattern())
   174  		return true
   175  	}
   176  
   177  	log.Debug("HasMatchingFilenamePattern: returning false for:", filename)
   178  	log.Debugf("HasMatchingFilenamePattern: returning false (%q does not contain %q)",
   179  		filename, config.GetFilePattern())
   180  	return false
   181  }
   182  
   183  // HasMatchingAge validates whether a file matches the desired age threshold
   184  func HasMatchingAge(file os.FileInfo, config *config.Config) bool {
   185  
   186  	log := config.GetLogger()
   187  
   188  	// used by this function's context logger and for return code
   189  	var ageCheckResults bool
   190  
   191  	now := time.Now()
   192  	fileModTime := file.ModTime()
   193  
   194  	// common fields that we can apply to all messages in this function
   195  	contextLogger := log.WithFields(logrus.Fields{
   196  		"file_mod_time": fileModTime.Format(time.RFC3339),
   197  		"current_time":  now.Format(time.RFC3339),
   198  		"file_age_flag": config.GetFileAge(),
   199  		"filename":      file.Name(),
   200  	})
   201  
   202  	// The default for this flag is 0, so only a positive, non-zero number
   203  	// is considered for use with age matching.
   204  	if config.GetFileAge() > 0 {
   205  
   206  		fileAgeThreshold := NewFileAgeThreshold(config.GetFileAge())
   207  
   208  		// Bundle more fields now that we have access to the data
   209  		contextLogger = contextLogger.WithFields(logrus.Fields{
   210  			"file_age_threshold": fileAgeThreshold.FormatLog(),
   211  			"days_back":          fileAgeThreshold.DaysBack(),
   212  		})
   213  
   214  		contextLogger.Debug("Before age check")
   215  
   216  		switch {
   217  		case fileModTime.Equal(fileAgeThreshold.Time()):
   218  			ageCheckResults = true
   219  			contextLogger.WithFields(logrus.Fields{
   220  				"safe_for_removal": ageCheckResults,
   221  			}).Debug("HasMatchingAge: file mod time is equal to threshold")
   222  
   223  		case fileModTime.Before(fileAgeThreshold.Time()):
   224  			ageCheckResults = true
   225  			contextLogger.WithFields(logrus.Fields{
   226  				"safe_for_removal": ageCheckResults,
   227  			}).Debug("HasMatchingAge: file mod time is before threshold")
   228  
   229  		case fileModTime.After(fileAgeThreshold.Time()):
   230  			ageCheckResults = false
   231  			contextLogger.WithFields(logrus.Fields{
   232  				"safe_for_removal": ageCheckResults,
   233  			}).Debug("HasMatchingAge: file mod time is after threshold")
   234  
   235  		}
   236  
   237  		return ageCheckResults
   238  
   239  	}
   240  
   241  	contextLogger.WithFields(logrus.Fields{
   242  		"safe_for_removal": ageCheckResults,
   243  	}).Debugf("HasMatchingAge: age flag was not set")
   244  
   245  	return true
   246  
   247  }
   248  
   249  // InList is a helper function to emulate Python's `if "x" in list:`
   250  // functionality. The caller can optionally ignore case of compared items.
   251  func InList(needle string, haystack []string, ignoreCase bool) bool {
   252  	for _, item := range haystack {
   253  
   254  		if ignoreCase {
   255  			if strings.EqualFold(item, needle) {
   256  				return true
   257  			}
   258  		}
   259  
   260  		if item == needle {
   261  			return true
   262  		}
   263  	}
   264  	return false
   265  }
   266  
   267  // SortByModTimeAsc sorts slice of FileMatch objects in ascending order with
   268  // older values listed first.
   269  func (fm FileMatches) SortByModTimeAsc() {
   270  	sort.Slice(fm, func(i, j int) bool {
   271  		return fm[i].ModTime().Before(fm[j].ModTime())
   272  	})
   273  }
   274  
   275  // SortByModTimeDesc sorts slice of FileMatch objects in descending order with
   276  // newer values listed first.
   277  func (fm FileMatches) SortByModTimeDesc() {
   278  	sort.Slice(fm, func(i, j int) bool {
   279  		return fm[i].ModTime().After(fm[j].ModTime())
   280  	})
   281  }
   282  
   283  // FilesToPrune receives a slice of FileMatch objects and a config object.
   284  // Returns a slice of FileMatch objects selected based on the current config
   285  // object settings.
   286  func (fm FileMatches) FilesToPrune(c *config.Config) FileMatches {
   287  
   288  	log := c.GetLogger()
   289  
   290  	var pruneStartRange int
   291  	var pruneEndRange int
   292  
   293  	switch {
   294  	case c.GetNumFilesToKeep() > len(fm):
   295  		log.Debug("Specified number to keep is larger than total matches; will process all matches")
   296  		pruneStartRange = 0
   297  		pruneEndRange = len(fm)
   298  	case c.GetKeepOldest():
   299  		fm.SortByModTimeAsc()
   300  		log.Debug("Keeping older files by sorting in ascending order")
   301  		pruneStartRange = 0
   302  		pruneEndRange = (len(fm) - c.GetNumFilesToKeep())
   303  	case !c.GetKeepOldest():
   304  		fm.SortByModTimeDesc()
   305  		log.Debug("Keeping newer files by sorting in descending order")
   306  		pruneStartRange = 0
   307  		pruneEndRange = (len(fm) - c.GetNumFilesToKeep())
   308  	}
   309  
   310  	log.WithFields(logrus.Fields{
   311  		"start_range": pruneStartRange,
   312  		"end_range":   pruneEndRange,
   313  		"num_to_keep": c.GetNumFilesToKeep(),
   314  	}).Debug("Building list of files to prune by skipping forward specified number of files to keep")
   315  
   316  	return fm[pruneStartRange:pruneEndRange]
   317  }