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 }