github.com/atc0005/elbow@v0.8.8/internal/paths/paths.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 paths provides various functions and types related to processing
    18  // paths in the filesystem, often for the purpose of removing older/unwanted
    19  // files.
    20  package paths
    21  
    22  import (
    23  	"fmt"
    24  	"os"
    25  	"path/filepath"
    26  	"strings"
    27  
    28  	"github.com/atc0005/elbow/internal/config"
    29  	"github.com/atc0005/elbow/internal/matches"
    30  	"github.com/sirupsen/logrus"
    31  )
    32  
    33  // ProcessingResults is used to collect execution results for use in logging
    34  // and output summary presentation to the user
    35  type ProcessingResults struct {
    36  
    37  	// Number of files eligible for removal. This is before files are excluded
    38  	// per user request.
    39  	EligibleRemove int
    40  
    41  	// Number of files successfully removed.
    42  	SuccessRemoved int
    43  
    44  	// Number of files failed to remove.
    45  	FailedRemoved int
    46  
    47  	// Size of all files eligible for removal.
    48  	EligibleFileSize int64
    49  
    50  	// Size of all files successfully removed.
    51  	SuccessTotalFileSize int64
    52  
    53  	// Size of all files failed to remove.
    54  	FailedTotalFileSize int64
    55  
    56  	// Size of all files successfully and unsuccessfully removed. This is
    57  	// essentially the size of eligible files to be removed minus any files
    58  	// that are excluded by user request.
    59  	TotalProcessedFileSize int64
    60  }
    61  
    62  // PathPruningResults represents the number of files that were successfully
    63  // removed and those that were not. This is used in various calculations and
    64  // to provide a brief summary of results to the user at program completion.
    65  type PathPruningResults struct {
    66  	SuccessfulRemovals matches.FileMatches
    67  	FailedRemovals     matches.FileMatches
    68  }
    69  
    70  // CleanPath receives a slice of FileMatch objects and removes each file. Any
    71  // errors encountered while removing files may optionally be ignored via
    72  // command-line flag(default is to return immediately upon first error). The
    73  // total number of files successfully removed is returned along with an error
    74  // code (nil if no errors were encountered).
    75  func CleanPath(files matches.FileMatches, config *config.Config) (PathPruningResults, error) {
    76  
    77  	log := config.GetLogger()
    78  
    79  	for _, file := range files {
    80  		log.WithFields(logrus.Fields{
    81  			"fullpath":        strings.TrimSpace(file.Path),
    82  			"shortpath":       file.Name(),
    83  			"size":            file.Size(),
    84  			"modified":        file.ModTime().Format("2006-01-02 15:04:05"),
    85  			"removal_enabled": config.GetRemove(),
    86  		}).Debug("Matching file")
    87  	}
    88  
    89  	var removalResults PathPruningResults
    90  
    91  	if !config.GetRemove() {
    92  
    93  		log.Info("File removal not enabled, not removing files")
    94  
    95  		// Nothing to show for this yet, but since the initial state reflects
    96  		// that we can return it as-is
    97  		return removalResults, nil
    98  	}
    99  
   100  	for _, file := range files {
   101  
   102  		log.WithFields(logrus.Fields{
   103  			"removal_enabled": config.GetRemove(),
   104  
   105  			// fully-qualified path to the file
   106  			"file": file.Path,
   107  		}).Debug("Removing file")
   108  
   109  		// We need to reference the full path here, not the short name since
   110  		// the current working directory may not be the same directory
   111  		// where the file is located
   112  		err := os.Remove(file.Path)
   113  		if err != nil {
   114  			log.WithFields(logrus.Fields{
   115  
   116  				// Include full details for troubleshooting purposes
   117  				"file": file,
   118  			}).Errorf("Error encountered while removing file: %s", err)
   119  
   120  			// Record failed removal, proceed to the next file
   121  			removalResults.FailedRemovals = append(removalResults.FailedRemovals, file)
   122  
   123  			// Confirm that we should ignore errors (likely enabled)
   124  			if !config.GetIgnoreErrors() {
   125  				remainingFiles := len(files) - len(removalResults.FailedRemovals) - len(removalResults.SuccessfulRemovals)
   126  				log.Debugf("Abandoning removal of %d remaining files", remainingFiles)
   127  				break
   128  			}
   129  
   130  			log.Debug("Ignoring error as requested")
   131  			continue
   132  		}
   133  
   134  		// Record successful removal
   135  		removalResults.SuccessfulRemovals = append(removalResults.SuccessfulRemovals, file)
   136  	}
   137  
   138  	return removalResults, nil
   139  
   140  }
   141  
   142  // PathExists confirms that the specified path exists
   143  func PathExists(path string) (bool, error) {
   144  
   145  	// Make sure path isn't empty
   146  	if strings.TrimSpace(path) == "" {
   147  		return false, fmt.Errorf("specified path is empty string")
   148  	}
   149  
   150  	_, statErr := os.Stat(path)
   151  	if statErr != nil {
   152  		if !os.IsNotExist(statErr) {
   153  			// ERROR: another error occurred aside from file not found
   154  			return false, fmt.Errorf(
   155  				"error checking path %s: %w",
   156  				path,
   157  				statErr,
   158  			)
   159  		}
   160  		// file not found
   161  		return false, nil
   162  	}
   163  
   164  	// file found
   165  	return true, nil
   166  
   167  }
   168  
   169  // ProcessPath accepts a configuration object and a path to process and
   170  // returns a slice of FileMatch objects
   171  func ProcessPath(config *config.Config, path string) (matches.FileMatches, error) {
   172  
   173  	log := config.GetLogger()
   174  
   175  	var fileMatches matches.FileMatches
   176  	var err error
   177  
   178  	log.WithFields(logrus.Fields{
   179  		"recursive_search": config.GetRecursiveSearch(),
   180  	}).Debugf("Recursive search: %t", config.GetRecursiveSearch())
   181  
   182  	if config.GetRecursiveSearch() {
   183  
   184  		// Walk walks the file tree rooted at root, calling the anonymous function
   185  		// for each file or directory in the tree, including root. All errors that
   186  		// arise visiting files and directories are filtered by the anonymous
   187  		// function. The files are walked in lexical order, which makes the output
   188  		// deterministic but means that for very large directories Walk can be
   189  		// inefficient. Walk does not follow symbolic links.
   190  		err = filepath.Walk(path, func(path string, info os.FileInfo, err error) error {
   191  
   192  			// If an error is received, check to see whether we should ignore
   193  			// it or return it. If we return a non-nil error, this will stop
   194  			// the filepath.Walk() function from continuing to walk the path,
   195  			// and your main function will immediately move to the next line.
   196  			// If the option to ignore errors is set, processing of the current
   197  			// path will continue until complete
   198  			if err != nil {
   199  				if !config.GetIgnoreErrors() {
   200  					return err
   201  				}
   202  
   203  				log.WithFields(logrus.Fields{
   204  					"ignore_errors": config.GetIgnoreErrors(),
   205  				}).Warn("Error encountered:", err)
   206  
   207  				log.WithFields(logrus.Fields{
   208  					"ignore_errors": config.GetIgnoreErrors(),
   209  				}).Warn("Ignoring error as requested")
   210  
   211  			}
   212  
   213  			// make sure we're not working with the root directory itself
   214  			if path != "." {
   215  
   216  				// ignore directories
   217  				if info.IsDir() {
   218  					return nil
   219  				}
   220  
   221  				// ignore non-matching extension (only applies if user chose
   222  				// one or more extensions to match against)
   223  				if !matches.HasMatchingExtension(path, config) {
   224  					return nil
   225  				}
   226  
   227  				// ignore non-matching filename pattern (only applies if user
   228  				// specified a filename pattern)
   229  				if !matches.HasMatchingFilenamePattern(path, config) {
   230  					return nil
   231  				}
   232  
   233  				// ignore non-matching modification age
   234  				if !matches.HasMatchingAge(info, config) {
   235  					return nil
   236  				}
   237  
   238  				// If we made it to this point, then we must assume that the file
   239  				// has met all criteria to be removed by this application.
   240  				fileMatch := matches.FileMatch{FileInfo: info, Path: path}
   241  				fileMatches = append(fileMatches, fileMatch)
   242  
   243  			}
   244  
   245  			return err
   246  		})
   247  
   248  	} else {
   249  
   250  		// If RecursiveSearch is not enabled, process just the provided StartPath
   251  		// NOTE: The same cleanPath() function is used in either case, the
   252  		// difference is in how the FileMatches slice is populated.
   253  
   254  		files, err := os.ReadDir(path)
   255  		if err != nil {
   256  			// TODO: Do we really want to exit early at this point if there are
   257  			// failures evaluating some of the files?
   258  			// Is it possible to partially evaluate some of the files?
   259  			//
   260  			// return nil, fmt.Errorf(
   261  			// 	"error reading directory %s: %w",
   262  			// 	path,
   263  			// 	err,
   264  			// )
   265  			log.Errorf("Error reading directory %s: %s", path, err)
   266  		}
   267  
   268  		// Build collection of FileMatch objects for later evaluation.
   269  		for _, file := range files {
   270  
   271  			// ignore directories
   272  			if file.IsDir() {
   273  				continue
   274  			}
   275  
   276  			fileInfo, err := file.Info()
   277  			if err != nil {
   278  				return nil, fmt.Errorf(
   279  					"file %s renamed or removed since directory read: %w",
   280  					fileInfo.Name(),
   281  					err,
   282  				)
   283  			}
   284  
   285  			// Apply validity checks against filename. If validity fails,
   286  			// go to the next file in the list.
   287  
   288  			// ignore invalid extensions (only applies if user chose one
   289  			// or more extensions to match against)
   290  			if !matches.HasMatchingExtension(fileInfo.Name(), config) {
   291  				continue
   292  			}
   293  
   294  			// ignore invalid filename patterns (only applies if user
   295  			// specified a filename pattern)
   296  			if !matches.HasMatchingFilenamePattern(fileInfo.Name(), config) {
   297  				continue
   298  			}
   299  
   300  			// ignore non-matching modification age
   301  			if !matches.HasMatchingAge(fileInfo, config) {
   302  				continue
   303  			}
   304  
   305  			// If we made it to this point, then we must assume that the file
   306  			// has met all criteria to be removed by this application.
   307  			fileMatch := matches.FileMatch{
   308  				FileInfo: fileInfo,
   309  				Path:     filepath.Join(path, file.Name()),
   310  			}
   311  
   312  			fileMatches = append(fileMatches, fileMatch)
   313  		}
   314  	}
   315  
   316  	return fileMatches, err
   317  }