├── .gitignore ├── internal ├── path │ └── path.go ├── tui │ ├── interfaces │ │ ├── clean_model.go │ │ ├── rules.go │ │ └── clean.go │ ├── menu │ │ ├── menu.go │ │ └── constants.go │ ├── help │ │ └── text.go │ ├── tabs │ │ ├── rules │ │ │ ├── factory.go │ │ │ ├── manager.go │ │ │ ├── options_tab.go │ │ │ ├── filters_tab.go │ │ │ └── main_tab.go │ │ ├── base │ │ │ ├── tab.go │ │ │ └── manager.go │ │ └── clean │ │ │ ├── clean_factory.go │ │ │ ├── options_tab.go │ │ │ ├── clean_manager.go │ │ │ ├── help_tab.go │ │ │ ├── filters_tab.go │ │ │ ├── log_tab.go │ │ │ └── main_tab.go │ ├── errors │ │ ├── error.go │ │ └── styles.go │ ├── options │ │ ├── utils.go │ │ ├── constants.go │ │ └── utils_test.go │ ├── views │ │ ├── menu.go │ │ └── cache.go │ ├── app.go │ └── styles │ │ └── styles.go ├── models │ └── clean_item.go ├── logging │ ├── utils.go │ ├── operations.go │ ├── storage │ │ └── file_storage.go │ └── logger.go ├── cache │ ├── common.go │ ├── locations.go │ ├── types.go │ ├── unix.go │ ├── windows.go │ └── manager.go ├── runner │ ├── tui.go │ └── cli.go ├── filemanager │ ├── interface.go │ ├── utils.go │ ├── filters.go │ ├── scanners.go │ └── operations.go ├── tests │ ├── unit │ │ ├── tabmanager │ │ │ └── manager_test.go │ │ ├── cache │ │ │ ├── location_test.go │ │ │ ├── windows_test.go │ │ │ ├── unix_internal_test.go │ │ │ └── manager_test.go │ │ ├── utils │ │ │ └── utils_test.go │ │ ├── filemanager │ │ │ └── filters_test.go │ │ ├── validation │ │ │ └── validator_test.go │ │ └── logging │ │ │ ├── storage_test.go │ │ │ └── logger_test.go │ ├── tui │ │ └── views │ │ │ └── menu_test.go │ └── integration │ │ └── runner │ │ ├── cli_trash_test.go │ │ └── cli_test.go ├── rules │ ├── interface.go │ ├── options.go │ └── manager.go ├── validation │ └── validator.go └── cli │ ├── config │ ├── config.go │ ├── flags.go │ └── config_test.go │ └── output │ └── printer.go ├── main.go ├── .github ├── ISSUE_TEMPLATE │ └── bug_report.md └── workflows │ └── ci-test.yml ├── .golangci.json ├── LICENSE ├── CONTRIBUTING.md ├── go.mod ├── CODE_OF_CONDUCT.md ├── README.md └── go.sum /.gitignore: -------------------------------------------------------------------------------- 1 | *.log 2 | .env 3 | .idea 4 | .qodo 5 | build/ 6 | vendor/ 7 | deletor.json 8 | -------------------------------------------------------------------------------- /internal/path/path.go: -------------------------------------------------------------------------------- 1 | package path 2 | 3 | var ( 4 | AppDirName = "deletor" 5 | RuleFileName = "rule.json" 6 | LogFileName = "deletor.log" 7 | ) 8 | -------------------------------------------------------------------------------- /internal/tui/interfaces/clean_model.go: -------------------------------------------------------------------------------- 1 | package interfaces 2 | 3 | type CleanOptions struct { 4 | MoveToTrash bool 5 | LogOperations bool 6 | LogToFile bool 7 | ShowStatistics bool 8 | } 9 | -------------------------------------------------------------------------------- /internal/tui/menu/menu.go: -------------------------------------------------------------------------------- 1 | package menu 2 | 3 | type Item struct { 4 | title string 5 | } 6 | 7 | func (i Item) Title() string { return i.title } 8 | func (i Item) Description() string { return "" } 9 | func (i Item) FilterValue() string { return i.title } 10 | -------------------------------------------------------------------------------- /internal/models/clean_item.go: -------------------------------------------------------------------------------- 1 | package models 2 | 3 | type CleanItem struct { 4 | Path string 5 | Size int64 6 | IsDir bool 7 | } 8 | 9 | // For list.Item bubble tea 10 | func (i CleanItem) Title() string { 11 | return "" 12 | } 13 | func (i CleanItem) Description() string { return i.Path } 14 | func (i CleanItem) FilterValue() string { return i.Path } 15 | -------------------------------------------------------------------------------- /internal/tui/menu/constants.go: -------------------------------------------------------------------------------- 1 | package menu 2 | 3 | var ( 4 | CleanFIlesTitle = "🧹 Clean files" 5 | CleanCacheTitle = "♻️ Clean cache" 6 | ManageRulesTitle = "⚙️ Manage rules" 7 | StatisticsTitle = "📊 Statistics" 8 | ExitTitle = "🚪 Exit" 9 | ) 10 | 11 | var MenuItems = []string{ 12 | CleanFIlesTitle, 13 | CleanCacheTitle, 14 | ManageRulesTitle, 15 | ExitTitle, 16 | } 17 | -------------------------------------------------------------------------------- /internal/tui/help/text.go: -------------------------------------------------------------------------------- 1 | package help 2 | 3 | var ( 4 | CleanHelpText = "Ctrl+R: refresh • Ctrl+D: delete files • Ctrl+S: toogle show dirs/files • Ctrl+O: open in explorer" 5 | NavigateHelpText = "Tab: cycle focus • Shift+Tab: focus back • Enter: select/confirm/update • Esc: back to menu\n" 6 | ListHelpText = "⬇/⬆: navigate in files • Shift+↑/↓: select file • Alt+↑/↓: deselect file • Space: toggle selection • Ctrl+A: select all files" 7 | ) 8 | -------------------------------------------------------------------------------- /internal/logging/utils.go: -------------------------------------------------------------------------------- 1 | package logging 2 | 3 | import ( 4 | "os" 5 | "path/filepath" 6 | 7 | "github.com/pashkov256/deletor/internal/path" 8 | ) 9 | 10 | // GetLogFilePath returns the path to the application's log file 11 | // The log file is stored in the user's config directory under the app's directory 12 | func GetLogFilePath() string { 13 | userConfigDir, _ := os.UserConfigDir() 14 | fileLogPath := filepath.Join(userConfigDir, path.AppDirName, path.LogFileName) 15 | 16 | return fileLogPath 17 | } 18 | -------------------------------------------------------------------------------- /internal/tui/tabs/rules/factory.go: -------------------------------------------------------------------------------- 1 | package rules 2 | 3 | import ( 4 | "github.com/pashkov256/deletor/internal/tui/interfaces" 5 | "github.com/pashkov256/deletor/internal/tui/tabs/base" 6 | ) 7 | 8 | type RulesTabFactory struct{} 9 | 10 | func NewRulesTabFactory() *RulesTabFactory { 11 | return &RulesTabFactory{} 12 | } 13 | 14 | func (f *RulesTabFactory) CreateTabs(model interfaces.RulesModel) []base.Tab { 15 | return []base.Tab{ 16 | &MainTab{model: model}, 17 | &FiltersTab{model: model}, 18 | &OptionsTab{model: model}, 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /internal/tui/tabs/base/tab.go: -------------------------------------------------------------------------------- 1 | package base 2 | 3 | import ( 4 | tea "github.com/charmbracelet/bubbletea" 5 | "github.com/charmbracelet/lipgloss" 6 | ) 7 | 8 | // Tab - interface for all tabs 9 | type Tab interface { 10 | View() string 11 | Update(msg tea.Msg) tea.Cmd 12 | Init() tea.Cmd 13 | } 14 | 15 | // TabStyles - base tab styles 16 | type TabStyles struct { 17 | TabStyle lipgloss.Style 18 | ActiveTabStyle lipgloss.Style 19 | } 20 | 21 | // TabFactory - interface for creating tabs 22 | type TabFactory interface { 23 | CreateTabs(model interface{}) []Tab 24 | } 25 | -------------------------------------------------------------------------------- /internal/cache/common.go: -------------------------------------------------------------------------------- 1 | //go:build !windows && !linux && !darwin 2 | // +build !windows,!linux,!darwin 3 | 4 | package cache 5 | 6 | // DeleteFileWithWindowsAPI is a stub implementation for non-Windows platforms. 7 | // Returns nil as this function is not implemented on non-Windows systems. 8 | func DeleteFileWithWindowsAPI(path string) error { 9 | return nil 10 | } 11 | 12 | // DeleteFileWithUnixAPI is a stub implementation for non-Unix platforms. 13 | // Returns nil as this function is not implemented on non-Unix systems. 14 | func DeleteFileWithUnixAPI(path string) error { 15 | return nil 16 | } 17 | -------------------------------------------------------------------------------- /internal/tui/tabs/base/manager.go: -------------------------------------------------------------------------------- 1 | package base 2 | 3 | // TabManager - base tab manager 4 | type TabManager[m any] struct { 5 | tabs []Tab 6 | activeTab int 7 | model *m 8 | } 9 | 10 | func (t *TabManager[m]) GetActiveTab() Tab { 11 | return t.tabs[t.activeTab] 12 | } 13 | 14 | func (t *TabManager[m]) GetActiveTabIndex() int { 15 | return t.activeTab 16 | } 17 | 18 | func (t *TabManager[m]) SetActiveTabIndex(index int) { 19 | t.activeTab = index 20 | } 21 | 22 | func NewTabManager[m any](tabs []Tab, model *m) *TabManager[m] { 23 | return &TabManager[m]{ 24 | model: model, 25 | activeTab: 0, 26 | tabs: tabs, 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /internal/runner/tui.go: -------------------------------------------------------------------------------- 1 | package runner 2 | 3 | import ( 4 | tea "github.com/charmbracelet/bubbletea" 5 | zone "github.com/lrstanley/bubblezone" 6 | "github.com/pashkov256/deletor/internal/filemanager" 7 | "github.com/pashkov256/deletor/internal/rules" 8 | "github.com/pashkov256/deletor/internal/tui" 9 | "github.com/pashkov256/deletor/internal/validation" 10 | ) 11 | 12 | func RunTUI( 13 | filemanager filemanager.FileManager, 14 | rules rules.Rules, validator *validation.Validator, 15 | ) error { 16 | zone.NewGlobal() 17 | app := tui.NewApp(filemanager, rules, validator) 18 | p := tea.NewProgram(app, tea.WithAltScreen(), tea.WithMouseAllMotion()) 19 | _, err := p.Run() 20 | return err 21 | } 22 | -------------------------------------------------------------------------------- /internal/tui/interfaces/rules.go: -------------------------------------------------------------------------------- 1 | package interfaces 2 | 3 | import ( 4 | "github.com/charmbracelet/bubbles/textinput" 5 | tea "github.com/charmbracelet/bubbletea" 6 | ) 7 | 8 | type RulesModel interface { 9 | // Getters 10 | GetPathInput() textinput.Model 11 | GetExtInput() textinput.Model 12 | GetMinSizeInput() textinput.Model 13 | GetMaxSizeInput() textinput.Model 14 | GetExcludeInput() textinput.Model 15 | GetOlderInput() textinput.Model 16 | GetNewerInput() textinput.Model 17 | GetFocusedElement() string 18 | GetOptionState() map[string]bool 19 | GetRulesPath() string 20 | // Setters 21 | SetFocusedElement(element string) 22 | SetOptionState(option string, state bool) 23 | 24 | // Other 25 | Update(msg tea.Msg) (tea.Model, tea.Cmd) 26 | } 27 | -------------------------------------------------------------------------------- /main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "fmt" 5 | "os" 6 | 7 | "github.com/pashkov256/deletor/internal/cli/config" 8 | "github.com/pashkov256/deletor/internal/filemanager" 9 | "github.com/pashkov256/deletor/internal/rules" 10 | "github.com/pashkov256/deletor/internal/runner" 11 | "github.com/pashkov256/deletor/internal/validation" 12 | ) 13 | 14 | func main() { 15 | var rules = rules.NewRules() 16 | rules.SetupRulesConfig() 17 | config := config.GetFlags() 18 | validator := validation.NewValidator() 19 | fm := filemanager.NewFileManager() 20 | 21 | if config.IsCLIMode { 22 | runner.RunCLI(fm, rules, config) 23 | } else { 24 | if err := runner.RunTUI(fm, rules, validator); err != nil { 25 | fmt.Printf("Error: %v\n", err) 26 | os.Exit(1) 27 | } 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: Create a report to help us improve 4 | title: '' 5 | labels: '' 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Describe the bug** 11 | A clear and concise description of what the bug is. 12 | 13 | **To Reproduce** 14 | Steps to reproduce the behavior: 15 | 1. Go to '...' 16 | 2. Click on '....' 17 | 3. Scroll down to '....' 18 | 4. See error 19 | 20 | **Expected behavior** 21 | A clear and concise description of what you expected to happen. 22 | 23 | **Screenshots** 24 | If applicable, add screenshots to help explain your problem. 25 | 26 | **Desktop (please complete the following information):** 27 | - OS: [e.g. Linux] 28 | - Version [e.g. 22] 29 | 30 | 31 | **Additional context** 32 | Add any other context about the problem here. 33 | -------------------------------------------------------------------------------- /internal/tui/tabs/clean/clean_factory.go: -------------------------------------------------------------------------------- 1 | package clean 2 | 3 | import ( 4 | "fmt" 5 | 6 | "github.com/pashkov256/deletor/internal/tui/interfaces" 7 | "github.com/pashkov256/deletor/internal/tui/tabs/base" 8 | ) 9 | 10 | type CleanTabFactory struct{} 11 | 12 | func NewCleanTabFactory() *CleanTabFactory { 13 | return &CleanTabFactory{} 14 | } 15 | 16 | func (f *CleanTabFactory) CreateTabs(model interfaces.CleanModel) []base.Tab { 17 | // Create tabs 18 | tabs := []base.Tab{ 19 | &MainTab{model: model}, 20 | &FiltersTab{model: model}, 21 | &OptionsTab{model: model}, 22 | &LogTab{model: model}, 23 | &HelpTab{model: model}, 24 | } 25 | 26 | // Initialize each tab 27 | for _, tab := range tabs { 28 | if err := tab.Init(); err != nil { 29 | fmt.Printf("Error initializing tab: %v\n", err) 30 | } 31 | } 32 | 33 | return tabs 34 | } 35 | -------------------------------------------------------------------------------- /internal/cache/locations.go: -------------------------------------------------------------------------------- 1 | package cache 2 | 3 | import ( 4 | "os" 5 | "path/filepath" 6 | ) 7 | 8 | // getLocationsForOS returns a list of cache locations specific to the given operating system. 9 | // Returns nil for unsupported operating systems. 10 | func getLocationsForOS(osName OS) []CacheLocation { 11 | switch osName { 12 | case Windows: 13 | return []CacheLocation{ 14 | { 15 | Path: filepath.Join(os.Getenv("LOCALAPPDATA"), "Temp"), 16 | }, 17 | 18 | { 19 | Path: filepath.Join(os.Getenv("LOCALAPPDATA"), "Microsoft", "Windows", "Explorer"), 20 | }, 21 | } 22 | case Linux: 23 | home, _ := os.UserHomeDir() 24 | return []CacheLocation{ 25 | { 26 | Path: "/tmp", 27 | }, 28 | { 29 | Path: "/var/tmp", 30 | }, 31 | { 32 | Path: filepath.Join(home, ".cache"), 33 | }, 34 | } 35 | default: 36 | return nil 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /internal/cache/types.go: -------------------------------------------------------------------------------- 1 | package cache 2 | 3 | // CacheType represents the type of cache (system or application) 4 | type CacheType string 5 | 6 | // OS represents supported operating systems 7 | type OS string 8 | 9 | const ( 10 | Windows OS = "windows" 11 | Linux OS = "linux" 12 | ) 13 | 14 | const ( 15 | SystemCache CacheType = "system" // System-wide cache 16 | AppCache CacheType = "app" // Application-specific cache 17 | ) 18 | 19 | // CacheLocation represents a cache directory location with its path and type 20 | type CacheLocation struct { 21 | Path string 22 | Type string 23 | } 24 | 25 | // ScanResult contains information about a cache scan operation 26 | type ScanResult struct { 27 | FileCount int64 // Number of files found 28 | Path string // Path that was scanned 29 | Size int64 // Total size of cache in bytes 30 | Error error // Any error that occurred during scanning 31 | } 32 | -------------------------------------------------------------------------------- /internal/cache/unix.go: -------------------------------------------------------------------------------- 1 | //go:build linux || darwin 2 | // +build linux darwin 3 | 4 | package cache 5 | 6 | import ( 7 | "golang.org/x/sys/unix" 8 | ) 9 | 10 | // DeleteFileWithUnixAPI deletes a file using Unix system calls. 11 | // Ensures file has proper permissions (0744) before deletion. 12 | // Returns error if file cannot be deleted or if stat/chmod operations fail. 13 | func DeleteFileWithUnixAPI(path string) error { 14 | var stat unix.Stat_t 15 | err := unix.Stat(path, &stat) 16 | if err != nil { 17 | return err 18 | } 19 | 20 | if stat.Mode&0744 != 0 { 21 | err := unix.Chmod(path, 0744) 22 | if err != nil { 23 | return err 24 | } 25 | } 26 | 27 | return unix.Unlink(path) 28 | } 29 | 30 | // DeleteFileWithWindowsAPI is a stub implementation for Unix platforms. 31 | // Returns nil as this function is not implemented on Unix systems. 32 | func DeleteFileWithWindowsAPI(path string) error { 33 | return nil 34 | } 35 | -------------------------------------------------------------------------------- /.github/workflows/ci-test.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: 4 | push: 5 | branches: [ main ] 6 | pull_request: 7 | branches: [ main ] 8 | 9 | jobs: 10 | test-and-lint: 11 | name: Test and Lint 12 | runs-on: ubuntu-latest 13 | timeout-minutes: 10 14 | steps: 15 | - name: Set up Go 16 | uses: actions/setup-go@v4 17 | with: 18 | go-version: '1.21' 19 | 20 | - name: Check out code 21 | uses: actions/checkout@v3 22 | 23 | - name: Get dependencies 24 | run: go mod download 25 | 26 | - name: golangci-lint 27 | uses: golangci/golangci-lint-action@v8 28 | with: 29 | version: v2.1.6 30 | 31 | - name: Run tests with coverage 32 | run: go test -v -coverprofile=coverage.txt ./... 33 | 34 | - name: Upload coverage reports to Codecov 35 | uses: codecov/codecov-action@v5 36 | with: 37 | token: ${{ secrets.CODECOV_TOKEN }} 38 | slug: pashkov256/deletor 39 | -------------------------------------------------------------------------------- /.golangci.json: -------------------------------------------------------------------------------- 1 | { 2 | "formatters": { 3 | "enable": [ 4 | "gofmt" 5 | ], 6 | "exclusions": { 7 | "generated": "lax", 8 | "paths": [ 9 | "third_party$", 10 | "builtin$", 11 | "examples$" 12 | ] 13 | } 14 | }, 15 | "linters": { 16 | "enable": [ 17 | "staticcheck","govet" 18 | ], 19 | "disable": [ 20 | "errcheck" 21 | ], 22 | "exclusions": { 23 | "generated": "lax", 24 | "paths": [ 25 | "third_party$", 26 | "builtin$", 27 | "examples$" 28 | ], 29 | "presets": [ 30 | "comments", 31 | "common-false-positives", 32 | "legacy", 33 | "std-error-handling" 34 | ], 35 | "rules": [ 36 | { 37 | "linters": [ 38 | "errcheck" 39 | ], 40 | "path": "_test\\.go" 41 | } 42 | ] 43 | } 44 | }, 45 | "run": { 46 | "tests": true 47 | }, 48 | "version": "2" 49 | } 50 | -------------------------------------------------------------------------------- /internal/tui/tabs/rules/manager.go: -------------------------------------------------------------------------------- 1 | package rules 2 | 3 | import ( 4 | "github.com/pashkov256/deletor/internal/tui/interfaces" 5 | "github.com/pashkov256/deletor/internal/tui/tabs/base" 6 | ) 7 | 8 | type RulesTabManager struct { 9 | model interfaces.RulesModel 10 | tabs []base.Tab 11 | activeTab int 12 | } 13 | 14 | func NewRulesTabManager(model interfaces.RulesModel, factory *RulesTabFactory) *RulesTabManager { 15 | tabs := factory.CreateTabs(model) 16 | return &RulesTabManager{ 17 | model: model, 18 | tabs: tabs, 19 | activeTab: 0, 20 | } 21 | } 22 | 23 | func (m *RulesTabManager) GetActiveTab() base.Tab { 24 | return m.tabs[m.activeTab] 25 | } 26 | 27 | func (m *RulesTabManager) GetActiveTabIndex() int { 28 | return m.activeTab 29 | } 30 | 31 | func (m *RulesTabManager) SetActiveTabIndex(index int) { 32 | if index >= 0 && index < len(m.tabs) { 33 | m.activeTab = index 34 | } 35 | } 36 | 37 | func (m *RulesTabManager) GetAllTabs() []base.Tab { 38 | return m.tabs 39 | } 40 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | Copyright © 2025 Artem Pashkov 3 | 4 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the “Software”), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: 5 | 6 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 7 | 8 | THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. -------------------------------------------------------------------------------- /internal/filemanager/interface.go: -------------------------------------------------------------------------------- 1 | package filemanager 2 | 3 | import ( 4 | "os" 5 | "time" 6 | ) 7 | 8 | // FileManager defines the interface for file system operations 9 | type FileManager interface { 10 | NewFileFilter(minSize, maxSize int64, extensions map[string]struct{}, exclude []string, olderThan, newerThan time.Time) *FileFilter 11 | WalkFilesWithFilter(callback func(fi os.FileInfo, path string), dir string, filter *FileFilter) 12 | MoveFilesToTrash(dir string, extensions []string, exclude []string, minSize, maxSize int64, olderThan, newerThan time.Time) 13 | DeleteFiles(dir string, extensions []string, exclude []string, minSize, maxSize int64, olderThan, newerThan time.Time) 14 | DeleteEmptySubfolders(dir string) 15 | IsEmptyDir(dir string) bool 16 | ExpandTilde(path string) string 17 | CalculateDirSize(path string) int64 18 | DeleteFile(filePath string) 19 | MoveFileToTrash(filePath string) 20 | } 21 | 22 | // defaultFileManager implements the FileManager interface 23 | type defaultFileManager struct { 24 | } 25 | 26 | // NewFileManager creates a new instance of the default file manager 27 | func NewFileManager() FileManager { 28 | return &defaultFileManager{} 29 | } 30 | -------------------------------------------------------------------------------- /internal/cache/windows.go: -------------------------------------------------------------------------------- 1 | //go:build windows 2 | // +build windows 3 | 4 | package cache 5 | 6 | import ( 7 | "golang.org/x/sys/windows" 8 | ) 9 | 10 | // DeleteFileWithWindowsAPI deletes a file using Windows API calls. 11 | // Handles read-only files by removing the read-only attribute before deletion. 12 | // Returns error if file cannot be deleted or if path conversion fails. 13 | func DeleteFileWithWindowsAPI(path string) error { 14 | // Convert path to Windows path format 15 | pathPtr, err := windows.UTF16PtrFromString(path) 16 | if err != nil { 17 | return err 18 | } 19 | 20 | // Try to get file attributes 21 | attrs, err := windows.GetFileAttributes(pathPtr) 22 | if err != nil { 23 | return err 24 | } 25 | 26 | // Remove read-only attribute if present 27 | if attrs&windows.FILE_ATTRIBUTE_READONLY != 0 { 28 | err := windows.SetFileAttributes(pathPtr, attrs&^windows.FILE_ATTRIBUTE_READONLY) 29 | if err != nil { 30 | return err 31 | } 32 | } 33 | 34 | // Try to delete with Windows API 35 | return windows.DeleteFile(pathPtr) 36 | } 37 | 38 | // DeleteFileWithUnixAPI is a stub implementation for Windows platforms. 39 | // Returns nil as this function is not implemented on Windows. 40 | func DeleteFileWithUnixAPI(path string) error { 41 | return nil 42 | } 43 | -------------------------------------------------------------------------------- /internal/tui/errors/error.go: -------------------------------------------------------------------------------- 1 | package errors 2 | 3 | import ( 4 | "time" 5 | ) 6 | 7 | // ErrorType represents different types of errors that can occur 8 | type ErrorType int 9 | 10 | const ( 11 | ErrorTypeValidation ErrorType = iota 12 | ErrorTypeFileSystem 13 | ErrorTypePermission 14 | ErrorTypeConfiguration 15 | ) 16 | 17 | // Error represents an application error with additional context 18 | type Error struct { 19 | Type ErrorType 20 | Message string 21 | visible bool 22 | Timestamp time.Time 23 | } 24 | 25 | // New creates a new error with the given type and message 26 | func New(errType ErrorType, message string) *Error { 27 | return &Error{ 28 | Type: errType, 29 | Message: message, 30 | visible: true, 31 | Timestamp: time.Now(), 32 | } 33 | } 34 | 35 | // Hide makes the error invisible 36 | func (e *Error) Hide() { 37 | e.visible = false 38 | } 39 | 40 | // Show makes the error visible 41 | func (e *Error) Show() { 42 | e.visible = true 43 | } 44 | 45 | // IsVisible returns whether the error is currently visible 46 | func (e *Error) IsVisible() bool { 47 | return e.visible 48 | } 49 | 50 | // GetMessage returns the error message 51 | func (e *Error) GetMessage() string { 52 | return e.Message 53 | } 54 | 55 | // GetType returns the error type 56 | func (e *Error) GetType() ErrorType { 57 | return e.Type 58 | } 59 | -------------------------------------------------------------------------------- /internal/filemanager/utils.go: -------------------------------------------------------------------------------- 1 | package filemanager 2 | 3 | import ( 4 | "os" 5 | "path/filepath" 6 | "strings" 7 | ) 8 | 9 | // IsEmptyDir checks if a directory is empty, including recursive check of subdirectories. 10 | // Returns true if the directory is empty or contains only empty subdirectories. 11 | func (f *defaultFileManager) IsEmptyDir(dirPath string) bool { 12 | dir, err := os.Open(dirPath) 13 | if err != nil { 14 | return false 15 | } 16 | defer dir.Close() 17 | 18 | entries, err := dir.Readdir(0) 19 | 20 | if err != nil { 21 | return false 22 | } 23 | if len(entries) == 0 { 24 | return true 25 | } 26 | 27 | for _, entry := range entries { 28 | if entry.IsDir() { 29 | // If this is a directory, we check recursively 30 | if !f.IsEmptyDir(filepath.Join(dirPath, entry.Name())) { 31 | return false 32 | } 33 | } else { 34 | return false 35 | } 36 | } 37 | return true 38 | } 39 | 40 | // ExpandTilde expands the tilde (~) in a path to the user's home directory. 41 | // Returns the original path if it doesn't start with tilde or if home directory cannot be determined. 42 | func (f *defaultFileManager) ExpandTilde(path string) string { 43 | if !strings.HasPrefix(path, "~") { 44 | return path 45 | } 46 | 47 | home, err := os.UserHomeDir() 48 | if err != nil { 49 | return path 50 | } 51 | 52 | return filepath.Join(home, path[1:]) 53 | } 54 | -------------------------------------------------------------------------------- /internal/tui/tabs/clean/options_tab.go: -------------------------------------------------------------------------------- 1 | package clean 2 | 3 | import ( 4 | "fmt" 5 | "strings" 6 | 7 | tea "github.com/charmbracelet/bubbletea" 8 | zone "github.com/lrstanley/bubblezone" 9 | "github.com/pashkov256/deletor/internal/tui/interfaces" 10 | "github.com/pashkov256/deletor/internal/tui/options" 11 | "github.com/pashkov256/deletor/internal/tui/styles" 12 | ) 13 | 14 | // Define options in fixed order 15 | 16 | type OptionsTab struct { 17 | model interfaces.CleanModel 18 | } 19 | 20 | func (t *OptionsTab) View() string { 21 | var content strings.Builder 22 | 23 | for i, name := range options.DefaultCleanOption { 24 | optionStyle := styles.OptionStyle 25 | if t.model.GetFocusedElement() == fmt.Sprintf("clean_option_%d", i+1) { 26 | optionStyle = styles.OptionFocusedStyle 27 | } else { 28 | if t.model.GetOptionState()[name] { 29 | optionStyle = styles.SelectedOptionStyle 30 | } 31 | } 32 | 33 | emoji := options.GetEmojiByCleanOption(name) 34 | 35 | content.WriteString(zone.Mark(fmt.Sprintf("clean_option_%d", i+1), optionStyle.Render(fmt.Sprintf("[%s] %s %-20s", map[bool]string{true: "✓", false: "○"}[t.model.GetOptionState()[name]], emoji, name)))) 36 | 37 | content.WriteString("\n") 38 | } 39 | 40 | return content.String() 41 | } 42 | 43 | func (t *OptionsTab) Init() tea.Cmd { 44 | return nil 45 | } 46 | 47 | func (t *OptionsTab) Update(msg tea.Msg) tea.Cmd { 48 | return nil 49 | } 50 | -------------------------------------------------------------------------------- /internal/tui/tabs/rules/options_tab.go: -------------------------------------------------------------------------------- 1 | package rules 2 | 3 | import ( 4 | "fmt" 5 | "strings" 6 | 7 | tea "github.com/charmbracelet/bubbletea" 8 | zone "github.com/lrstanley/bubblezone" 9 | "github.com/pashkov256/deletor/internal/tui/interfaces" 10 | "github.com/pashkov256/deletor/internal/tui/options" 11 | "github.com/pashkov256/deletor/internal/tui/styles" 12 | ) 13 | 14 | type OptionsTab struct { 15 | model interfaces.RulesModel 16 | } 17 | 18 | func (t *OptionsTab) Init() tea.Cmd { return nil } 19 | func (t *OptionsTab) Update(msg tea.Msg) tea.Cmd { return nil } 20 | 21 | func (t *OptionsTab) View() string { 22 | var content strings.Builder 23 | 24 | for i, name := range options.DefaultCleanOption { 25 | style := styles.OptionStyle 26 | if t.model.GetFocusedElement() == fmt.Sprintf("rules_option_%d", i+1) { 27 | style = styles.OptionFocusedStyle 28 | } else { 29 | if t.model.GetOptionState()[name] { 30 | style = styles.SelectedOptionStyle 31 | } 32 | } 33 | 34 | emoji := options.GetEmojiByCleanOption(name) 35 | 36 | content.WriteString(fmt.Sprintf("%-4s", fmt.Sprintf("%d.", i+1))) 37 | content.WriteString(zone.Mark(fmt.Sprintf("rules_option_%d", i+1), style.Render(fmt.Sprintf("[%s] %s %-20s", 38 | map[bool]string{true: "✓", false: "○"}[t.model.GetOptionState()[name]], 39 | emoji, name)))) 40 | content.WriteString("\n") 41 | } 42 | 43 | return content.String() 44 | } 45 | -------------------------------------------------------------------------------- /internal/tui/options/utils.go: -------------------------------------------------------------------------------- 1 | package options 2 | 3 | import ( 4 | "fmt" 5 | "strconv" 6 | "strings" 7 | ) 8 | 9 | func GetEmojiByCleanOption(optionName string) string { 10 | emoji := "" 11 | 12 | switch optionName { 13 | case ShowHiddenFiles: 14 | emoji = "👁️" 15 | case ConfirmDeletion: 16 | emoji = "⚠️" 17 | case IncludeSubfolders: 18 | emoji = "📁" 19 | case DeleteEmptySubfolders: 20 | emoji = "🗑️" 21 | case SendFilesToTrash: 22 | emoji = "♻️" 23 | case LogOperations: 24 | emoji = "📝" 25 | case LogToFile: 26 | emoji = "📄" 27 | case ShowStatistics: 28 | emoji = "📊" 29 | case ExitAfterDeletion: 30 | emoji = "🚪" 31 | } 32 | 33 | return emoji 34 | } 35 | 36 | // GetNextOption returns the next or previous option in a circular manner 37 | func GetNextOption(currentOption, optionPrefix string, maxOptions int, forward bool) string { 38 | currentNum := 1 39 | if strings.HasPrefix(currentOption, optionPrefix) { 40 | numStr := strings.TrimPrefix(currentOption, optionPrefix) 41 | if num, err := strconv.Atoi(numStr); err == nil { 42 | currentNum = num 43 | } 44 | } 45 | 46 | var nextNum int 47 | if forward { 48 | nextNum = currentNum + 1 49 | if nextNum > maxOptions { 50 | nextNum = 1 51 | } 52 | } else { 53 | nextNum = currentNum - 1 54 | if nextNum < 1 { 55 | nextNum = maxOptions 56 | } 57 | } 58 | 59 | return fmt.Sprintf(optionPrefix + fmt.Sprintf("%d", nextNum)) 60 | } 61 | -------------------------------------------------------------------------------- /internal/logging/operations.go: -------------------------------------------------------------------------------- 1 | package logging 2 | 3 | import ( 4 | "time" 5 | ) 6 | 7 | // OperationType represents the type of file operation performed 8 | type OperationType string 9 | 10 | const ( 11 | OperationDeleted OperationType = "deleted" // File was permanently deleted 12 | OperationIgnored OperationType = "ignored" // File was skipped 13 | OperationTrashed OperationType = "trashed" // File was moved to trash 14 | ) 15 | 16 | // FileOperation records details about a single file operation 17 | type FileOperation struct { 18 | Timestamp time.Time `json:"timestamp"` // When the operation occurred 19 | FilePath string `json:"file_path"` // Path to the affected file 20 | FileSize int64 `json:"file_size"` // Size of the file in bytes 21 | OperationType OperationType `json:"operation_type"` // Type of operation performed 22 | Reason string `json:"reason"` // Why the operation was performed 23 | RuleApplied string `json:"rule_applied"` // Which rule triggered the operation 24 | } 25 | 26 | // NewFileOperation creates a new file operation record 27 | func NewFileOperation(filePath string, size int64, opType OperationType, reason, rule string) *FileOperation { 28 | return &FileOperation{ 29 | Timestamp: time.Now(), 30 | FilePath: filePath, 31 | FileSize: size, 32 | OperationType: opType, 33 | Reason: reason, 34 | RuleApplied: rule, 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /internal/tui/errors/styles.go: -------------------------------------------------------------------------------- 1 | package errors 2 | 3 | import ( 4 | "github.com/charmbracelet/lipgloss" 5 | ) 6 | 7 | var ( 8 | // Base error style 9 | baseErrorStyle = lipgloss.NewStyle(). 10 | Padding(0, 1). 11 | Margin(1, 0). 12 | Border(lipgloss.RoundedBorder()). 13 | BorderForeground(lipgloss.Color("#FF0000")) 14 | 15 | // nolint:staticcheck 16 | validationErrorStyle = baseErrorStyle.Copy(). 17 | BorderForeground(lipgloss.Color("#FFA500")). 18 | Foreground(lipgloss.Color("#FFA500")) 19 | 20 | // nolint:staticcheck 21 | fileSystemErrorStyle = baseErrorStyle.Copy(). 22 | BorderForeground(lipgloss.Color("#FF0000")). 23 | Foreground(lipgloss.Color("#FF0000")) 24 | 25 | // nolint:staticcheck 26 | permissionErrorStyle = baseErrorStyle.Copy(). 27 | BorderForeground(lipgloss.Color("#FF00FF")). 28 | Foreground(lipgloss.Color("#FF00FF")) 29 | // nolint:staticcheck 30 | configurationErrorStyle = baseErrorStyle.Copy(). 31 | BorderForeground(lipgloss.Color("#00FFFF")). 32 | Foreground(lipgloss.Color("#00FFFF")) 33 | ) 34 | 35 | // GetStyle returns the appropriate style for the given error type 36 | func GetStyle(errType ErrorType) lipgloss.Style { 37 | switch errType { 38 | case ErrorTypeValidation: 39 | return validationErrorStyle 40 | case ErrorTypeFileSystem: 41 | return fileSystemErrorStyle 42 | case ErrorTypePermission: 43 | return permissionErrorStyle 44 | case ErrorTypeConfiguration: 45 | return configurationErrorStyle 46 | default: 47 | return baseErrorStyle 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /internal/tui/tabs/rules/filters_tab.go: -------------------------------------------------------------------------------- 1 | package rules 2 | 3 | import ( 4 | "fmt" 5 | "strings" 6 | 7 | "github.com/charmbracelet/bubbles/textinput" 8 | tea "github.com/charmbracelet/bubbletea" 9 | zone "github.com/lrstanley/bubblezone" 10 | "github.com/pashkov256/deletor/internal/tui/interfaces" 11 | "github.com/pashkov256/deletor/internal/tui/styles" 12 | ) 13 | 14 | type FiltersTab struct { 15 | model interfaces.RulesModel 16 | } 17 | 18 | func (t *FiltersTab) Init() tea.Cmd { return nil } 19 | func (t *FiltersTab) Update(msg tea.Msg) tea.Cmd { return nil } 20 | 21 | func (t *FiltersTab) View() string { 22 | var content strings.Builder 23 | 24 | inputs := []struct { 25 | name string 26 | input textinput.Model 27 | key string 28 | }{ 29 | {"Extensions", t.model.GetExtInput(), "extensionsInput"}, 30 | {"Min Size", t.model.GetMinSizeInput(), "minSizeInput"}, 31 | {"Max Size", t.model.GetMaxSizeInput(), "maxSizeInput"}, 32 | {"Exclude", t.model.GetExcludeInput(), "excludeInput"}, 33 | {"Older Than", t.model.GetOlderInput(), "olderInput"}, 34 | {"Newer Than", t.model.GetNewerInput(), "newerInput"}, 35 | } 36 | 37 | for _, input := range inputs { 38 | style := styles.StandardInputStyle 39 | if t.model.GetFocusedElement() == input.key { 40 | style = styles.StandardInputFocusedStyle 41 | } 42 | content.WriteString(zone.Mark(fmt.Sprintf("rules_%s", input.key), style.Render(input.name+": "+input.input.View()))) 43 | content.WriteString("\n") 44 | } 45 | 46 | return content.String() 47 | } 48 | -------------------------------------------------------------------------------- /internal/tui/tabs/clean/clean_manager.go: -------------------------------------------------------------------------------- 1 | package clean 2 | 3 | import ( 4 | "fmt" 5 | 6 | "github.com/pashkov256/deletor/internal/tui/interfaces" 7 | "github.com/pashkov256/deletor/internal/tui/tabs/base" 8 | ) 9 | 10 | // CleanTabManager manages the tabs for the clean view 11 | type CleanTabManager struct { 12 | model interfaces.CleanModel 13 | tabs []base.Tab 14 | activeTab int 15 | } 16 | 17 | // NewCleanTabManager creates a new CleanTabManager 18 | func NewCleanTabManager(model interfaces.CleanModel, factory *CleanTabFactory) *CleanTabManager { 19 | // Create tabs 20 | tabs := factory.CreateTabs(model) 21 | 22 | // Initialize each tab 23 | for _, tab := range tabs { 24 | if err := tab.Init(); err != nil { 25 | fmt.Printf("Error initializing tab: %v\n", err) 26 | } 27 | } 28 | 29 | return &CleanTabManager{ 30 | model: model, 31 | tabs: tabs, 32 | activeTab: 0, 33 | } 34 | } 35 | 36 | // GetActiveTab returns the currently active tab 37 | func (m *CleanTabManager) GetActiveTab() base.Tab { 38 | return m.tabs[m.activeTab] 39 | } 40 | 41 | // GetActiveTabIndex returns the index of the currently active tab 42 | func (m *CleanTabManager) GetActiveTabIndex() int { 43 | return m.activeTab 44 | } 45 | 46 | // SetActiveTabIndex sets the active tab index 47 | func (m *CleanTabManager) SetActiveTabIndex(index int) { 48 | if index >= 0 && index < len(m.tabs) { 49 | m.activeTab = index 50 | } 51 | } 52 | 53 | // GetAllTabs returns all tabs 54 | func (m *CleanTabManager) GetAllTabs() []base.Tab { 55 | return m.tabs 56 | } 57 | -------------------------------------------------------------------------------- /internal/tui/options/constants.go: -------------------------------------------------------------------------------- 1 | package options 2 | 3 | // Option names as constants to avoid string literals 4 | const ( 5 | //options for clean view 6 | ShowHiddenFiles = "Show hidden files" 7 | ConfirmDeletion = "Confirm deletion" 8 | IncludeSubfolders = "Include subfolders" 9 | DeleteEmptySubfolders = "Delete empty subfolders" 10 | SendFilesToTrash = "Send files to trash" 11 | LogOperations = "Log operations" 12 | LogToFile = "Log to file" 13 | ShowStatistics = "Show statistics" 14 | ExitAfterDeletion = "Exit after deletion" 15 | //options for cache view 16 | SystemCache = "System cache" 17 | ) 18 | 19 | // If you change the bool in these options, you must also change the values in the default rules json (rules/manager.go). 20 | var DefaultCleanOptionState = map[string]bool{ 21 | ShowHiddenFiles: false, 22 | ConfirmDeletion: false, 23 | IncludeSubfolders: false, 24 | DeleteEmptySubfolders: false, 25 | SendFilesToTrash: false, 26 | LogOperations: false, 27 | LogToFile: false, 28 | ShowStatistics: true, 29 | ExitAfterDeletion: false, 30 | } 31 | 32 | var DefaultCleanOption = []string{ 33 | ShowHiddenFiles, 34 | ConfirmDeletion, 35 | IncludeSubfolders, 36 | DeleteEmptySubfolders, 37 | SendFilesToTrash, 38 | LogOperations, 39 | LogToFile, 40 | ShowStatistics, 41 | ExitAfterDeletion, 42 | } 43 | 44 | var DefaultCacheOptionState = map[string]bool{ 45 | SystemCache: true, 46 | } 47 | 48 | var DefaultCacheOption = []string{ 49 | SystemCache, 50 | } 51 | -------------------------------------------------------------------------------- /internal/tui/tabs/rules/main_tab.go: -------------------------------------------------------------------------------- 1 | package rules 2 | 3 | import ( 4 | "fmt" 5 | "strings" 6 | 7 | tea "github.com/charmbracelet/bubbletea" 8 | zone "github.com/lrstanley/bubblezone" 9 | "github.com/pashkov256/deletor/internal/tui/help" 10 | "github.com/pashkov256/deletor/internal/tui/interfaces" 11 | "github.com/pashkov256/deletor/internal/tui/styles" 12 | ) 13 | 14 | type MainTab struct { 15 | model interfaces.RulesModel 16 | } 17 | 18 | func (t *MainTab) Init() tea.Cmd { return nil } 19 | func (t *MainTab) Update(msg tea.Msg) tea.Cmd { return nil } 20 | 21 | func (t *MainTab) View() string { 22 | var content strings.Builder 23 | 24 | // Location input with label 25 | pathStyle := styles.StandardInputStyle 26 | if t.model.GetFocusedElement() == "locationInput" { 27 | pathStyle = styles.StandardInputFocusedStyle 28 | } 29 | inputContent := pathStyle.Render("Path: " + t.model.GetPathInput().View()) 30 | content.WriteString(zone.Mark("rules_location_input", inputContent)) 31 | content.WriteString("\n\n") 32 | 33 | // Save button 34 | saveButtonStyle := styles.StandardButtonStyle 35 | if t.model.GetFocusedElement() == "saveButton" { 36 | saveButtonStyle = styles.StandardButtonFocusedStyle 37 | } 38 | buttonContent := saveButtonStyle.Render("💾 Save rules") 39 | content.WriteString(zone.Mark("rules_save_button", buttonContent)) 40 | content.WriteString("\n\n\n") 41 | 42 | content.WriteString(styles.PathStyle.Render(fmt.Sprintf("Rules are stored in: %s", t.model.GetRulesPath()))) 43 | content.WriteString("\n\n" + help.NavigateHelpText) 44 | 45 | return content.String() 46 | } 47 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | 2 | # 🛠 Contributing to Deletor 3 | 4 | Thank you for considering contributing to **Deletor**! 5 | Here’s how you can help improve this project. 6 | 7 | --- 8 | 9 | ## 🚀 First-Time Contributors 10 | If you’re new to open source, check out these **`good first issues`**: 11 | *(Look for the `good first issue` label in [Issues](https://github.com/pashkov256/deletor/issues)).* 12 | 13 | --- 14 | 15 | ## 🧑💻 How to Contribute 16 | 17 | ### 1. **Report a Bug** 18 | - Check if the bug is already reported in [Issues](https://github.com/yourusername/deletor/issues). 19 | - Provide details: 20 | ``` 21 | - OS version 22 | - Steps to reproduce 23 | - Expected vs actual behavior 24 | - Logs if possible 25 | ``` 26 | 27 | ### 2. **Suggest a Feature** 28 | - Open an Issue with the `enhancement` label. 29 | - Describe the use case and proposed solution. 30 | 31 | ### 3. **Submit Code** 32 | 1. **Fork** the repository. 33 | 2. **Branch** from `main`: 34 | ```bash 35 | git checkout -b fix/2-parallel-processing 36 | ``` 37 | 3. **Commit** changes: 38 | ```bash 39 | git commit -m "feat: #2 parallel file processing" 40 | ``` 41 | 4. **Push** and open a **Pull Request** with: 42 | - Screenshots/logs if applicable. 43 | 44 | --- 45 | 46 | 47 | ## 🔧 Development Setup 48 | 49 | **Prerequisites** 50 | - Go 1.20+ 51 | 52 | **Steps** 53 | 1. Clone the repository 54 | 2. Install dependencies 55 | 3. Run tests or build 56 | ### Steps 57 | 58 | Clone the repo 59 | ```bash 60 | git clone https://github.com/pashkov256/deletor.git 61 | ``` 62 | 63 | 64 | 65 | 66 | ## 📜 Code Guidelines 67 | - **Formatting**: Use `gofmt`. 68 | - **Comments**: Document public functions with GoDoc. 69 | 70 | --- 71 | 72 | ## ❓ Need Help? 73 | Feel free to reach out on [Telegram](https://t.me/pashkov256) 74 | 75 | 76 | 77 | 🙌 **Thank you for contributing!** 78 | 79 | -------------------------------------------------------------------------------- /internal/tui/tabs/clean/help_tab.go: -------------------------------------------------------------------------------- 1 | package clean 2 | 3 | import ( 4 | "strings" 5 | 6 | tea "github.com/charmbracelet/bubbletea" 7 | 8 | "github.com/pashkov256/deletor/internal/tui/interfaces" 9 | "github.com/pashkov256/deletor/internal/tui/styles" 10 | ) 11 | 12 | type HelpTab struct { 13 | model interfaces.CleanModel 14 | } 15 | 16 | func (t *HelpTab) View() string { 17 | var content strings.Builder 18 | // Navigation 19 | content.WriteString(styles.OptionStyle.Render("Navigation:")) 20 | content.WriteString("\n") 21 | content.WriteString(" F1-F4 - Switch between tabs\n") 22 | content.WriteString(" Esc - Return to main menu\n") 23 | content.WriteString(" Tab - Next field\n") 24 | content.WriteString(" Shift+Tab - Previous field\n") 25 | content.WriteString(" Ctrl+C - Exit application\n\n") 26 | 27 | // File Operations 28 | content.WriteString(styles.OptionStyle.Render("File Operations:")) 29 | content.WriteString("\n") 30 | content.WriteString(" Ctrl+R - Refresh file list\n") 31 | content.WriteString(" Ctrl+S - Show dirs in file list\n") 32 | content.WriteString(" Crtl+O - Open in explorer\n") 33 | content.WriteString(" Ctrl+D - Delete files\n\n") 34 | 35 | // Filter Operations 36 | content.WriteString(styles.OptionStyle.Render("Filter Operations:")) 37 | content.WriteString("\n") 38 | content.WriteString(" Alt+C - Clear filters\n\n") 39 | 40 | // Options 41 | content.WriteString(styles.OptionStyle.Render("Options:")) 42 | content.WriteString("\n") 43 | content.WriteString(" Alt+1 - Toggle hidden files\n") 44 | content.WriteString(" Alt+2 - Toggle confirm deletion\n") 45 | content.WriteString(" Alt+3 - Toggle include subfolders\n") 46 | content.WriteString(" Alt+4 - Toggle delete empty subfolders\n") 47 | 48 | return content.String() 49 | } 50 | 51 | func (t *HelpTab) Init() tea.Cmd { 52 | return nil 53 | } 54 | 55 | func (t *HelpTab) Update(msg tea.Msg) tea.Cmd { 56 | return nil 57 | } 58 | -------------------------------------------------------------------------------- /internal/tests/unit/tabmanager/manager_test.go: -------------------------------------------------------------------------------- 1 | package tabmanager 2 | 3 | import ( 4 | "testing" 5 | 6 | tea "github.com/charmbracelet/bubbletea" 7 | "github.com/pashkov256/deletor/internal/tui/tabs/base" 8 | "github.com/stretchr/testify/assert" 9 | ) 10 | 11 | // mockTab реализует интерфейс base.Tab 12 | type mockTab struct { 13 | id string 14 | } 15 | 16 | func (m mockTab) View() string { 17 | return "view of " + m.id 18 | } 19 | 20 | func (m mockTab) Update(msg tea.Msg) tea.Cmd { 21 | return nil 22 | } 23 | 24 | func (m mockTab) Init() tea.Cmd { 25 | return nil 26 | } 27 | 28 | func TestTabManagerInitialization(t *testing.T) { 29 | tabs := []base.Tab{ 30 | mockTab{id: "tab1"}, 31 | mockTab{id: "tab2"}, 32 | } 33 | model := "mockModel" 34 | 35 | manager := base.NewTabManager(tabs, &model) 36 | 37 | assert.NotNil(t, manager) 38 | assert.Equal(t, 0, manager.GetActiveTabIndex()) 39 | assert.Equal(t, tabs[0], manager.GetActiveTab()) 40 | } 41 | 42 | func TestGetActiveTab(t *testing.T) { 43 | tabs := []base.Tab{ 44 | mockTab{id: "A"}, 45 | mockTab{id: "B"}, 46 | } 47 | manager := base.NewTabManager(tabs, new(string)) 48 | 49 | assert.Equal(t, tabs[0], manager.GetActiveTab()) 50 | 51 | manager.SetActiveTabIndex(1) 52 | assert.Equal(t, tabs[1], manager.GetActiveTab()) 53 | } 54 | 55 | func TestGetActiveTabIndex(t *testing.T) { 56 | tabs := []base.Tab{ 57 | mockTab{id: "One"}, 58 | mockTab{id: "Two"}, 59 | } 60 | manager := base.NewTabManager(tabs, new(string)) 61 | 62 | assert.Equal(t, 0, manager.GetActiveTabIndex()) 63 | 64 | manager.SetActiveTabIndex(1) 65 | assert.Equal(t, 1, manager.GetActiveTabIndex()) 66 | } 67 | 68 | func TestSetActiveTabIndex(t *testing.T) { 69 | tabs := []base.Tab{ 70 | mockTab{id: "X"}, 71 | mockTab{id: "Y"}, 72 | mockTab{id: "Z"}, 73 | } 74 | manager := base.NewTabManager(tabs, new(string)) 75 | 76 | manager.SetActiveTabIndex(2) 77 | assert.Equal(t, 2, manager.GetActiveTabIndex()) 78 | assert.Equal(t, tabs[2], manager.GetActiveTab()) 79 | } 80 | -------------------------------------------------------------------------------- /internal/tui/interfaces/clean.go: -------------------------------------------------------------------------------- 1 | package interfaces 2 | 3 | import ( 4 | "github.com/charmbracelet/bubbles/list" 5 | "github.com/charmbracelet/bubbles/textinput" 6 | tea "github.com/charmbracelet/bubbletea" 7 | "github.com/pashkov256/deletor/internal/filemanager" 8 | "github.com/pashkov256/deletor/internal/models" 9 | "github.com/pashkov256/deletor/internal/rules" 10 | ) 11 | 12 | // CleanModel defines the interface that models must implement to work with clean tabs 13 | type CleanModel interface { 14 | // Getters 15 | GetCurrentPath() string 16 | GetExtensions() []string 17 | GetMinSize() int64 18 | GetExclude() []string 19 | GetOptions() []string 20 | GetOptionState() map[string]bool 21 | GetFocusedElement() string 22 | GetShowDirs() bool 23 | GetDirSize() int64 24 | GetCalculatingSize() bool 25 | GetFilteredSize() int64 26 | GetFilteredCount() int 27 | GetList() list.Model 28 | GetDirList() list.Model 29 | GetRules() rules.Rules 30 | GetFilemanager() filemanager.FileManager 31 | GetFileToDelete() *models.CleanItem 32 | GetPathInput() textinput.Model 33 | GetExtInput() textinput.Model 34 | GetMinSizeInput() textinput.Model 35 | GetMaxSizeInput() textinput.Model 36 | GetExcludeInput() textinput.Model 37 | GetOlderInput() textinput.Model 38 | GetNewerInput() textinput.Model 39 | GetSelectedFiles() map[string]bool 40 | GetSelectedCount() int 41 | GetSelectedSize() int64 42 | 43 | // Setters and state updates 44 | SetFocusedElement(element string) 45 | SetShowDirs(show bool) 46 | SetOptionState(option string, state bool) 47 | SetMinSize(size int64) 48 | SetMaxSize(size int64) 49 | SetExclude(exclude []string) 50 | SetExtensions(extensions []string) 51 | SetCurrentPath(path string) 52 | SetPathInput(input textinput.Model) 53 | SetExtInput(input textinput.Model) 54 | SetExcludeInput(input textinput.Model) 55 | SetSizeInput(input textinput.Model) 56 | Update(msg tea.Msg) (tea.Model, tea.Cmd) 57 | CalculateDirSizeAsync() tea.Cmd 58 | LoadFiles() tea.Cmd 59 | LoadDirs() tea.Cmd 60 | } 61 | -------------------------------------------------------------------------------- /internal/rules/interface.go: -------------------------------------------------------------------------------- 1 | package rules 2 | 3 | // Rules defines the interface for managing file operation rules 4 | type Rules interface { 5 | UpdateRules(options ...RuleOption) error // Updates rules with provided options 6 | GetRules() (*defaultRules, error) // Returns current file rules configuration 7 | SetupRulesConfig() error // Initializes rules configuration 8 | GetRulesPath() string // Returns path to rules configuration file 9 | } 10 | 11 | // defaultRules holds the configuration for file operations 12 | type defaultRules struct { 13 | Path string `json:",omitempty"` // Target directory path 14 | Extensions []string `json:",omitempty"` // File extensions to process 15 | Exclude []string `json:",omitempty"` // Patterns to exclude 16 | MinSize string `json:",omitempty"` // Minimum file size 17 | MaxSize string `json:",omitempty"` // Maximum file size 18 | OlderThan string `json:",omitempty"` // Only process files older than 19 | NewerThan string `json:",omitempty"` // Only process files newer than 20 | ShowHiddenFiles bool `json:",omitempty"` // Whether to show hidden files 21 | ConfirmDeletion bool `json:",omitempty"` // Whether to confirm deletions 22 | IncludeSubfolders bool `json:",omitempty"` // Whether to process subfolders 23 | DeleteEmptySubfolders bool `json:",omitempty"` // Whether to remove empty folders 24 | SendFilesToTrash bool `json:",omitempty"` // Whether to use trash instead of delete 25 | LogOperations bool `json:",omitempty"` // Whether to log operations 26 | LogToFile bool `json:",omitempty"` // Whether to write logs to file 27 | ShowStatistics bool `json:",omitempty"` // Whether to display statistics 28 | ExitAfterDeletion bool `json:",omitempty"` // Whether to exit after deletion 29 | } 30 | 31 | // NewRules creates a new instance of the default rules 32 | func NewRules() Rules { 33 | return &defaultRules{} 34 | } 35 | -------------------------------------------------------------------------------- /internal/tests/unit/cache/location_test.go: -------------------------------------------------------------------------------- 1 | package cache_test 2 | 3 | import ( 4 | "os" 5 | "path/filepath" 6 | "runtime" 7 | "sort" 8 | "testing" 9 | 10 | "github.com/pashkov256/deletor/internal/cache" 11 | "github.com/pashkov256/deletor/internal/filemanager" 12 | ) 13 | 14 | func TestNewCacheManager_InitializesCorrectLocations(t *testing.T) { 15 | fm := filemanager.NewFileManager() 16 | cm := cache.NewCacheManager(fm) 17 | expectedOS := cache.OS(runtime.GOOS) 18 | if expectedOS != cm.GetOS() { 19 | t.Errorf("expectedOS %s: functionResult match = %s", expectedOS, cm.GetOS()) 20 | } 21 | // Get expected locations 22 | var expectedLocations []cache.CacheLocation 23 | switch expectedOS { 24 | case cache.Windows: 25 | localAppData := os.Getenv("LOCALAPPDATA") 26 | if len(localAppData) == 0 { 27 | t.Error("LOCALAPPDATA environment variable should be set on Windows") 28 | } 29 | expectedLocations = []cache.CacheLocation{ 30 | {Path: filepath.Join(localAppData, "Temp")}, 31 | {Path: filepath.Join(localAppData, "Microsoft", "Windows", "Explorer")}, 32 | } 33 | case cache.Linux: 34 | homeDir, err := os.UserHomeDir() 35 | if err != nil { 36 | t.Error(err) 37 | } 38 | expectedLocations = []cache.CacheLocation{ 39 | {Path: "/tmp"}, 40 | {Path: "/var/tmp"}, 41 | {Path: filepath.Join(homeDir, ".cache")}, 42 | } 43 | default: 44 | expectedLocations = nil 45 | } 46 | 47 | actualLocations := cm.ScanAllLocations() 48 | if len(expectedLocations) != len(actualLocations) { 49 | t.Error("Wrong number of locations initialized") 50 | } 51 | 52 | // Sort both slices by path to ensure consistent comparison 53 | sort.Slice(expectedLocations, func(i, j int) bool { 54 | return expectedLocations[i].Path < expectedLocations[j].Path 55 | }) 56 | sort.Slice(actualLocations, func(i, j int) bool { 57 | return actualLocations[i].Path < actualLocations[j].Path 58 | }) 59 | 60 | for i, expected := range expectedLocations { 61 | if expected.Path != actualLocations[i].Path { 62 | t.Errorf("Path mismatch for location %d", i) 63 | } 64 | } 65 | } 66 | -------------------------------------------------------------------------------- /internal/rules/options.go: -------------------------------------------------------------------------------- 1 | package rules 2 | 3 | // RuleOption is a function type that modifies rule settings 4 | type RuleOption func(*defaultRules) 5 | 6 | // WithPath sets the target directory path 7 | func WithPath(path string) RuleOption { 8 | return func(r *defaultRules) { 9 | r.Path = path 10 | } 11 | } 12 | 13 | // WithMinSize sets the minimum file size filter 14 | func WithMinSize(size string) RuleOption { 15 | return func(r *defaultRules) { 16 | r.MinSize = size 17 | } 18 | } 19 | 20 | // WithMaxSize sets the maximum file size filter 21 | func WithMaxSize(size string) RuleOption { 22 | return func(r *defaultRules) { 23 | r.MaxSize = size 24 | } 25 | } 26 | 27 | // WithExtensions sets the file extensions to process 28 | func WithExtensions(extensions []string) RuleOption { 29 | return func(r *defaultRules) { 30 | r.Extensions = extensions 31 | } 32 | } 33 | 34 | // WithExclude sets the patterns to exclude from processing 35 | func WithExclude(exclude []string) RuleOption { 36 | return func(r *defaultRules) { 37 | r.Exclude = exclude 38 | } 39 | } 40 | 41 | // WithOlderThan sets the time filter for older files 42 | func WithOlderThan(time string) RuleOption { 43 | return func(r *defaultRules) { 44 | r.OlderThan = time 45 | } 46 | } 47 | 48 | // WithNewerThan sets the time filter for newer files 49 | func WithNewerThan(time string) RuleOption { 50 | return func(r *defaultRules) { 51 | r.NewerThan = time 52 | } 53 | } 54 | 55 | // WithOptions sets multiple boolean options at once 56 | func WithOptions(showHidden, confirmDeletion, includeSubfolders, deleteEmptySubfolders, sendToTrash, logOps, logToFile, showStats, exitAfterDeletion bool) RuleOption { 57 | return func(r *defaultRules) { 58 | r.ShowHiddenFiles = showHidden 59 | r.ConfirmDeletion = confirmDeletion 60 | r.IncludeSubfolders = includeSubfolders 61 | r.DeleteEmptySubfolders = deleteEmptySubfolders 62 | r.SendFilesToTrash = sendToTrash 63 | r.LogOperations = logOps 64 | r.LogToFile = logToFile 65 | r.ShowStatistics = showStats 66 | r.ExitAfterDeletion = exitAfterDeletion 67 | } 68 | } 69 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/pashkov256/deletor 2 | 3 | go 1.23.4 4 | 5 | toolchain go1.23.5 6 | 7 | require ( 8 | github.com/charmbracelet/bubbles v0.18.0 9 | github.com/charmbracelet/bubbletea v1.3.5 10 | github.com/charmbracelet/lipgloss v1.1.0 11 | github.com/fatih/color v1.16.0 12 | github.com/lrstanley/bubblezone v1.0.0 13 | github.com/schollz/progressbar/v3 v3.14.2 14 | github.com/stretchr/testify v1.10.0 15 | 16 | ) 17 | 18 | require ( 19 | github.com/charmbracelet/colorprofile v0.3.1 // indirect 20 | github.com/charmbracelet/x/ansi v0.9.2 // indirect 21 | github.com/charmbracelet/x/cellbuf v0.0.13 // indirect 22 | github.com/charmbracelet/x/term v0.2.1 // indirect 23 | github.com/davecgh/go-spew v1.1.1 // indirect 24 | github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect 25 | github.com/pmezard/go-difflib v1.0.0 // indirect 26 | github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect 27 | gopkg.in/yaml.v3 v3.0.1 // indirect 28 | ) 29 | 30 | require ( 31 | github.com/Bios-Marcel/wastebasket/v2 v2.0.3 32 | github.com/atotto/clipboard v0.1.4 // indirect 33 | github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect 34 | github.com/gobwas/glob v0.2.3 // indirect 35 | github.com/google/uuid v1.6.0 36 | github.com/lucasb-eyer/go-colorful v1.2.0 // indirect 37 | github.com/mattn/go-colorable v0.1.13 // indirect 38 | github.com/mattn/go-isatty v0.0.20 // indirect 39 | github.com/mattn/go-localereader v0.0.1 // indirect 40 | github.com/mattn/go-runewidth v0.0.16 // indirect 41 | github.com/mitchellh/colorstring v0.0.0-20190213212951-d06e56a500db // indirect 42 | github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect 43 | github.com/muesli/cancelreader v0.2.2 // indirect 44 | github.com/muesli/reflow v0.3.0 // indirect 45 | github.com/muesli/termenv v0.16.0 // indirect 46 | github.com/rivo/uniseg v0.4.7 // indirect 47 | github.com/sahilm/fuzzy v0.1.1-0.20230530133925-c48e322e2a8f // indirect 48 | golang.org/x/sync v0.15.0 // indirect 49 | golang.org/x/sys v0.33.0 50 | golang.org/x/term v0.28.0 // indirect 51 | golang.org/x/text v0.26.0 // indirect 52 | ) 53 | -------------------------------------------------------------------------------- /internal/validation/validator.go: -------------------------------------------------------------------------------- 1 | package validation 2 | 3 | import ( 4 | "errors" 5 | "os" 6 | "regexp" 7 | "strings" 8 | ) 9 | 10 | // Validator provides methods for validating various input parameters 11 | type Validator struct{} 12 | 13 | // NewValidator creates a new instance of the Validator 14 | func NewValidator() *Validator { 15 | return &Validator{} 16 | } 17 | 18 | // ValidatePath checks if a path exists and is valid 19 | // If optional is true, empty paths are allowed 20 | func (v *Validator) ValidatePath(path string, optional bool) error { 21 | if path == "" { 22 | if optional { 23 | return nil 24 | } 25 | return errors.New("path cannot be empty") 26 | } 27 | 28 | if _, err := os.Stat(path); os.IsNotExist(err) { 29 | return errors.New("path does not exist") 30 | } 31 | 32 | return nil 33 | } 34 | 35 | // ValidateExtension checks if a file extension is valid 36 | // Extensions must contain only alphanumeric characters 37 | func (v *Validator) ValidateExtension(ext string) error { 38 | if ext == "" { 39 | return errors.New("extension cannot be empty") 40 | } 41 | 42 | // Check for invalid characters 43 | re := regexp.MustCompile(`^[a-zA-Z0-9]+$`) 44 | if !re.MatchString(ext) { 45 | return errors.New("extension contains invalid characters") 46 | } 47 | 48 | return nil 49 | } 50 | 51 | // ValidateSize checks if a size string is in a valid format 52 | // Valid format: number followed by unit (e.g., "1.5MB", "2GB") 53 | func (v *Validator) ValidateSize(size string) error { 54 | re := regexp.MustCompile(`^\d+(\.\d+)?\s*(mb|kb|b|gb)$`) 55 | if !re.MatchString(size) { 56 | return errors.New("invalid size format") 57 | } 58 | return nil 59 | } 60 | 61 | // ValidateTimeDuration checks if a time duration string is in a valid format 62 | // Valid format: number (optional space) followed by time unit (sec, min, hour, day, week, month, year) 63 | // Examples: "7days", "24 hours", "1min", "2 weeks" 64 | func (v *Validator) ValidateTimeDuration(timeStr string) error { 65 | re := regexp.MustCompile(`^\d+\s*(sec|min|hour|day|week|month|year)s?$`) 66 | if !re.MatchString(strings.ToLower(timeStr)) { 67 | return errors.New("expected format: number followed by time unit (sec, min, hour, day, week, month, year)") 68 | } 69 | 70 | return nil 71 | } 72 | -------------------------------------------------------------------------------- /internal/tui/tabs/clean/filters_tab.go: -------------------------------------------------------------------------------- 1 | package clean 2 | 3 | import ( 4 | "strings" 5 | 6 | tea "github.com/charmbracelet/bubbletea" 7 | zone "github.com/lrstanley/bubblezone" 8 | "github.com/pashkov256/deletor/internal/tui/interfaces" 9 | "github.com/pashkov256/deletor/internal/tui/styles" 10 | ) 11 | 12 | type FiltersTab struct { 13 | model interfaces.CleanModel 14 | } 15 | 16 | func (t *FiltersTab) Init() tea.Cmd { return nil } 17 | func (t *FiltersTab) Update(msg tea.Msg) tea.Cmd { return nil } 18 | 19 | func (t *FiltersTab) View() string { 20 | var content strings.Builder 21 | 22 | // Exclude patterns 23 | excludeStyle := styles.StandardInputStyle 24 | if t.model.GetFocusedElement() == "excludeInput" { 25 | excludeStyle = styles.StandardInputFocusedStyle 26 | } 27 | content.WriteString(zone.Mark("filters_exclude_input", excludeStyle.Render("Exclude: "+t.model.GetExcludeInput().View()))) 28 | content.WriteString("\n") 29 | 30 | // Size filters 31 | minSizeStyle := styles.StandardInputStyle 32 | if t.model.GetFocusedElement() == "minSizeInput" { 33 | minSizeStyle = styles.StandardInputFocusedStyle 34 | } 35 | content.WriteString(zone.Mark("filters_min_size_input", minSizeStyle.Render("Min size: "+t.model.GetMinSizeInput().View()))) 36 | content.WriteString("\n") 37 | 38 | maxSizeStyle := styles.StandardInputStyle 39 | if t.model.GetFocusedElement() == "maxSizeInput" { 40 | maxSizeStyle = styles.StandardInputFocusedStyle 41 | } 42 | content.WriteString(zone.Mark("filters_max_size_input", maxSizeStyle.Render("Max size: "+t.model.GetMaxSizeInput().View()))) 43 | content.WriteString("\n") 44 | 45 | // Date filters 46 | olderStyle := styles.StandardInputStyle 47 | if t.model.GetFocusedElement() == "olderInput" { 48 | olderStyle = styles.StandardInputFocusedStyle 49 | } 50 | content.WriteString(zone.Mark("filters_older_input", olderStyle.Render("Older than: "+t.model.GetOlderInput().View()))) 51 | content.WriteString("\n") 52 | 53 | newerStyle := styles.StandardInputStyle 54 | if t.model.GetFocusedElement() == "newerInput" { 55 | newerStyle = styles.StandardInputFocusedStyle 56 | } 57 | content.WriteString(zone.Mark("filters_newer_input", newerStyle.Render("Newer than: "+t.model.GetNewerInput().View()))) 58 | 59 | return content.String() 60 | } 61 | -------------------------------------------------------------------------------- /internal/tests/tui/views/menu_test.go: -------------------------------------------------------------------------------- 1 | package views_test 2 | 3 | import ( 4 | "testing" 5 | 6 | tea "github.com/charmbracelet/bubbletea" 7 | "github.com/pashkov256/deletor/internal/tui/views" 8 | ) 9 | 10 | func setupMenuTestModel() *views.MainMenu { 11 | return views.NewMainMenu() 12 | } 13 | 14 | func TestMainMenu_Init(t *testing.T) { 15 | model := setupMenuTestModel() 16 | cmd := model.Init() 17 | 18 | if cmd != nil { 19 | t.Error("Init() should return nil command") 20 | } 21 | } 22 | 23 | func TestMainMenu_Update(t *testing.T) { 24 | tests := []struct { 25 | name string 26 | key string 27 | initialIndex int 28 | expectedIndex int 29 | }{ 30 | { 31 | name: "Tab key navigation", 32 | key: "tab", 33 | initialIndex: 0, 34 | expectedIndex: 1, 35 | }, 36 | { 37 | name: "Shift+Tab key navigation", 38 | key: "shift+tab", 39 | initialIndex: 1, 40 | expectedIndex: 0, 41 | }, 42 | } 43 | 44 | for _, tt := range tests { 45 | t.Run(tt.name, func(t *testing.T) { 46 | model := views.NewMainMenu() 47 | model.SelectedIndex = tt.initialIndex 48 | 49 | msg := tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune(tt.key)} 50 | newModel, _ := model.Update(msg) 51 | if m, ok := newModel.(*views.MainMenu); ok { 52 | if m.SelectedIndex != tt.expectedIndex { 53 | t.Errorf("Model state after update does not match expected state for test case: %s\nGot: %d, Expected: %d", 54 | tt.name, m.SelectedIndex, tt.expectedIndex) 55 | } 56 | } else { 57 | t.Errorf("Failed to convert model to MainMenu") 58 | } 59 | }) 60 | } 61 | } 62 | 63 | func TestMainMenu_ListNavigation(t *testing.T) { 64 | model := setupMenuTestModel() 65 | 66 | if model.SelectedIndex != 0 { 67 | t.Error("Initial list index should be 0") 68 | } 69 | 70 | updatedModel, _ := model.Update(tea.KeyMsg{Type: tea.KeyTab}) 71 | updatedMenu := updatedModel.(*views.MainMenu) 72 | if model.SelectedIndex != 1 { 73 | t.Error("Tab key should move cursor down") 74 | } 75 | 76 | updatedModel, _ = updatedMenu.Update(tea.KeyMsg{Type: tea.KeyShiftTab}) 77 | // nolint:staticcheck 78 | updatedMenu = updatedModel.(*views.MainMenu) 79 | if model.SelectedIndex != 0 { 80 | t.Error("Shift+Tab key should move cursor up") 81 | } 82 | } 83 | -------------------------------------------------------------------------------- /internal/tui/options/utils_test.go: -------------------------------------------------------------------------------- 1 | package options 2 | 3 | import "testing" 4 | 5 | func TestGetNextOption(t *testing.T) { 6 | tests := []struct { 7 | name string 8 | current string 9 | prefix string 10 | max int 11 | forward bool 12 | expected string 13 | }{ 14 | // 1. Basic Forward Navigation 15 | { 16 | name: "Forward from 1 to 2", 17 | current: "Option1", 18 | prefix: "Option", 19 | max: 3, 20 | forward: true, 21 | expected: "Option2", 22 | }, 23 | { 24 | name: "Forward from 2 to 3", 25 | current: "Test2", 26 | prefix: "Test", 27 | max: 3, 28 | forward: true, 29 | expected: "Test3", 30 | }, 31 | 32 | // 2. Forward Wrapping (End -> Start) 33 | { 34 | name: "Forward wrap around (Max to 1)", 35 | current: "Page3", 36 | prefix: "Page", 37 | max: 3, 38 | forward: true, 39 | expected: "Page1", 40 | }, 41 | 42 | // 3. Basic Backward Navigation 43 | { 44 | name: "Backward from 2 to 1", 45 | current: "Option2", 46 | prefix: "Option", 47 | max: 3, 48 | forward: false, // Backward 49 | expected: "Option1", 50 | }, 51 | 52 | // 4. Backward Wrapping (Start -> End) 53 | { 54 | name: "Backward wrap around (1 to Max)", 55 | current: "Option1", 56 | prefix: "Option", 57 | max: 5, 58 | forward: false, // Backward 59 | expected: "Option5", 60 | }, 61 | 62 | // 5. Edge Case: Invalid or Empty Current Option 63 | // The code defaults 'currentNum' to 1 if parsing fails. 64 | // So if we go forward, 1 + 1 = 2. 65 | { 66 | name: "Invalid current string (defaults to 1, goes forward to 2)", 67 | current: "InvalidString", 68 | prefix: "Option", 69 | max: 5, 70 | forward: true, 71 | expected: "Option2", 72 | }, 73 | // If parsing fails (defaults to 1) and we go backward: 74 | // 1 - 1 = 0 -> wraps to Max. 75 | { 76 | name: "Invalid current string (defaults to 1, goes backward to Max)", 77 | current: "InvalidString", 78 | prefix: "Option", 79 | max: 5, 80 | forward: false, 81 | expected: "Option5", 82 | }, 83 | } 84 | 85 | for _, tt := range tests { 86 | t.Run(tt.name, func(t *testing.T) { 87 | got := GetNextOption(tt.current, tt.prefix, tt.max, tt.forward) 88 | if got != tt.expected { 89 | t.Errorf("GetNextOption(%q, %q, %d, %v) = %q; want %q", 90 | tt.current, tt.prefix, tt.max, tt.forward, got, tt.expected) 91 | } 92 | }) 93 | } 94 | } 95 | -------------------------------------------------------------------------------- /internal/tests/unit/cache/windows_test.go: -------------------------------------------------------------------------------- 1 | //go:build windows 2 | // +build windows 3 | 4 | package cache 5 | 6 | import ( 7 | "os" 8 | "path/filepath" 9 | "syscall" 10 | "testing" 11 | 12 | "github.com/pashkov256/deletor/internal/cache" 13 | ) 14 | 15 | func TestDeleteFileWithWindowsAPI(t *testing.T) { 16 | tempDir := t.TempDir() 17 | testFileContent := []byte("lorem ipsum") 18 | 19 | t.Run("successful file deletion", func(t *testing.T) { 20 | testFilePath := filepath.Join(tempDir, "test") 21 | err := os.WriteFile(testFilePath, testFileContent, os.ModePerm) 22 | if err != nil { 23 | t.Fatalf("Failed to create test file: %v", err) 24 | } 25 | 26 | err = cache.DeleteFileWithWindowsAPI(testFilePath) 27 | if err != nil { 28 | t.Errorf("DeleteFileWithWindowsAPI failed: %v", err) 29 | } 30 | 31 | if _, err := os.Stat(testFilePath); !os.IsNotExist(err) { 32 | t.Errorf("File still exists after deletion") 33 | } 34 | }) 35 | 36 | t.Run("deletion of read-only file", func(t *testing.T) { 37 | testFilePath := filepath.Join(tempDir, "test-read-only") 38 | err := os.WriteFile(testFilePath, testFileContent, os.FileMode(os.O_RDONLY)) 39 | if err != nil { 40 | t.Fatalf("Failed to create test file: %v", err) 41 | } 42 | 43 | filenameW, err := syscall.UTF16PtrFromString(testFilePath) 44 | if err != nil { 45 | t.Fatalf("Failed to convert test file pathname to UTF16 ptr: %v", err) 46 | } 47 | 48 | if err := syscall.SetFileAttributes(filenameW, syscall.FILE_ATTRIBUTE_READONLY); err != nil { 49 | t.Fatalf("Failed to set readonly attribute to test file: %v", err) 50 | } 51 | 52 | err = cache.DeleteFileWithWindowsAPI(testFilePath) 53 | if err != nil { 54 | t.Errorf("DeleteFileWithWindowsAPI failed: %v", err) 55 | } 56 | 57 | if _, err := os.Stat(testFilePath); !os.IsNotExist(err) { 58 | t.Errorf("File still exists after deletion") 59 | } 60 | }) 61 | 62 | t.Run("deletion of hidden file", func(t *testing.T) { 63 | testFilePath := filepath.Join(tempDir, "test-hidden-file") 64 | err := os.WriteFile(testFilePath, testFileContent, os.ModePerm) 65 | if err != nil { 66 | t.Fatalf("Failed to create test file: %v", err) 67 | } 68 | 69 | filenameW, err := syscall.UTF16PtrFromString(testFilePath) 70 | if err != nil { 71 | t.Fatalf("Failed to convert test file pathname to UTF16 ptr: %v", err) 72 | } 73 | 74 | if err := syscall.SetFileAttributes(filenameW, syscall.FILE_ATTRIBUTE_HIDDEN); err != nil { 75 | t.Fatalf("Failed to set hidden attribute to test file: %v", err) 76 | } 77 | 78 | err = cache.DeleteFileWithWindowsAPI(testFilePath) 79 | if err != nil { 80 | t.Errorf("DeleteFileWithWindowsAPI failed: %v", err) 81 | } 82 | 83 | if _, err := os.Stat(testFilePath); !os.IsNotExist(err) { 84 | t.Errorf("File still exists after deletion") 85 | } 86 | }) 87 | } 88 | -------------------------------------------------------------------------------- /internal/tui/views/menu.go: -------------------------------------------------------------------------------- 1 | package views 2 | 3 | import ( 4 | "fmt" 5 | "strings" 6 | 7 | tea "github.com/charmbracelet/bubbletea" 8 | zone "github.com/lrstanley/bubblezone" 9 | "github.com/pashkov256/deletor/internal/tui/help" 10 | "github.com/pashkov256/deletor/internal/tui/menu" 11 | "github.com/pashkov256/deletor/internal/tui/styles" 12 | ) 13 | 14 | type MainMenu struct { 15 | SelectedIndex int 16 | } 17 | 18 | func NewMainMenu() *MainMenu { 19 | return &MainMenu{ 20 | SelectedIndex: 0, 21 | } 22 | } 23 | 24 | func (m *MainMenu) Init() tea.Cmd { 25 | return nil 26 | } 27 | 28 | func (m *MainMenu) Update(msg tea.Msg) (tea.Model, tea.Cmd) { 29 | switch msg := msg.(type) { 30 | case tea.KeyMsg: 31 | switch msg.String() { 32 | case "tab", "down": 33 | return m.HandleFocusBottom() 34 | case "shift+tab", "up": 35 | return m.HandleFocusTop() 36 | case "enter": 37 | return m, func() tea.Msg { 38 | return tea.KeyMsg{ 39 | Type: tea.KeyEnter, 40 | } 41 | } 42 | } 43 | 44 | case tea.MouseMsg: 45 | if msg.Action == tea.MouseActionRelease && msg.Button == tea.MouseButtonLeft { 46 | // Check each menu item for click 47 | for i := 0; i < 5; i++ { 48 | if zone.Get(fmt.Sprintf("menu_button_%d", i)).InBounds(msg) { 49 | m.SelectedIndex = i 50 | // Emulate Enter key press 51 | return m, func() tea.Msg { 52 | return tea.KeyMsg{ 53 | Type: tea.KeyEnter, 54 | } 55 | } 56 | } 57 | } 58 | } 59 | } 60 | 61 | return m, nil 62 | } 63 | 64 | func (m *MainMenu) View() string { 65 | var content strings.Builder 66 | 67 | // Title 68 | content.WriteString(styles.TitleStyle.Render("🗑️ Deletor v1.5.0")) 69 | content.WriteString("\n\n") 70 | 71 | // Menu items from constants 72 | 73 | // Render buttons 74 | for i, item := range menu.MenuItems { 75 | style := styles.MenuItem 76 | if i == m.SelectedIndex { 77 | style = styles.SelectedMenuItemStyle 78 | } 79 | 80 | button := style.Render(item) 81 | content.WriteString(zone.Mark(fmt.Sprintf("menu_button_%d", i), button)) 82 | content.WriteString("\n") 83 | } 84 | 85 | content.WriteString("\n") 86 | content.WriteString(help.NavigateHelpText) 87 | 88 | return zone.Scan(styles.AppStyle.Render(styles.DocStyle.Render(content.String()))) 89 | } 90 | 91 | // HandleFocusBottom moves focus down 92 | func (m *MainMenu) HandleFocusBottom() (tea.Model, tea.Cmd) { 93 | if m.SelectedIndex < len(menu.MenuItems)-1 { 94 | m.SelectedIndex++ 95 | } else { 96 | m.SelectedIndex = 0 97 | } 98 | return m, nil 99 | } 100 | 101 | // HandleFocusTop moves focus up 102 | func (m *MainMenu) HandleFocusTop() (tea.Model, tea.Cmd) { 103 | if m.SelectedIndex > 0 { 104 | m.SelectedIndex-- 105 | } else { 106 | m.SelectedIndex = len(menu.MenuItems) - 1 107 | } 108 | return m, nil 109 | } 110 | -------------------------------------------------------------------------------- /internal/cache/manager.go: -------------------------------------------------------------------------------- 1 | package cache 2 | 3 | import ( 4 | "os" 5 | "path/filepath" 6 | "runtime" 7 | "sync" 8 | 9 | "github.com/pashkov256/deletor/internal/filemanager" 10 | ) 11 | 12 | // Manager handles cache operations for different operating systems 13 | type Manager struct { 14 | Os OS //made exportable for testing 15 | Locations []CacheLocation //made exportable for testing 16 | Filemanager filemanager.FileManager //made exportable for testing 17 | } 18 | 19 | // NewCacheManager creates a new cache manager instance for the current OS 20 | func NewCacheManager(fm filemanager.FileManager) *Manager { 21 | return &Manager{ 22 | Os: OS(runtime.GOOS), 23 | Locations: getLocationsForOS(OS(runtime.GOOS)), 24 | Filemanager: fm, 25 | } 26 | } 27 | 28 | // ScanAllLocations concurrently scans all cache locations and returns their statistics 29 | func (m *Manager) ScanAllLocations() []ScanResult { 30 | var resultsScan []ScanResult 31 | 32 | var wg sync.WaitGroup 33 | var mu sync.Mutex 34 | 35 | for _, location := range m.Locations { 36 | wg.Add(1) 37 | go func() { 38 | defer wg.Done() 39 | 40 | result := m.scan(location.Path) 41 | 42 | mu.Lock() 43 | resultsScan = append(resultsScan, result) 44 | mu.Unlock() 45 | }() 46 | } 47 | 48 | wg.Wait() 49 | 50 | return resultsScan 51 | } 52 | 53 | // scan analyzes a single cache location and returns its statistics 54 | func (m *Manager) scan(path string) ScanResult { 55 | result := ScanResult{Path: path, FileCount: 0, Size: 0} 56 | filepath.Walk(path, func(path string, info os.FileInfo, err error) error { 57 | if info == nil { 58 | return nil 59 | } 60 | 61 | if err != nil { 62 | return nil 63 | } 64 | 65 | result.Size += info.Size() 66 | result.FileCount++ 67 | 68 | return nil 69 | }) 70 | return result 71 | } 72 | 73 | // ClearCache removes all files from cache locations using OS-specific deletion methods 74 | func (m *Manager) ClearCache() (deleteError error) { 75 | for _, location := range m.Locations { 76 | filepath.Walk(location.Path, func(path string, info os.FileInfo, err error) error { 77 | if info == nil { 78 | return nil 79 | } 80 | 81 | if err != nil { 82 | return nil 83 | } 84 | 85 | if !info.IsDir() { 86 | // Try normal deletion first 87 | err := os.Remove(path) 88 | if err != nil { 89 | deleteError = err 90 | 91 | if runtime.GOOS == "windows" { 92 | err := DeleteFileWithWindowsAPI(path) 93 | if err != nil { 94 | return err 95 | } 96 | } 97 | 98 | if runtime.GOOS == "linux" || runtime.GOOS == "darwin" { 99 | err := DeleteFileWithUnixAPI(path) 100 | if err != nil { 101 | return err 102 | } 103 | } 104 | } 105 | return nil 106 | } 107 | return nil 108 | }) 109 | } 110 | 111 | return deleteError 112 | } 113 | func (m *Manager) GetOS() OS { 114 | return m.Os 115 | } 116 | -------------------------------------------------------------------------------- /internal/filemanager/filters.go: -------------------------------------------------------------------------------- 1 | package filemanager 2 | 3 | import ( 4 | "os" 5 | "path/filepath" 6 | "strings" 7 | "time" 8 | ) 9 | 10 | // FileFilter defines criteria for filtering files 11 | type FileFilter struct { 12 | MinSize int64 // Minimum file size in bytes 13 | MaxSize int64 // Maximum file size in bytes 14 | Extensions map[string]struct{} // Set of allowed file extensions 15 | Exclude []string // Patterns to exclude from results 16 | OlderThan time.Time // Only include files older than this time 17 | NewerThan time.Time // Only include files newer than this time 18 | } 19 | 20 | func (d *defaultFileManager) NewFileFilter(minSize, maxSize int64, extensions map[string]struct{}, exclude []string, olderThan, newerThan time.Time) *FileFilter { 21 | return &FileFilter{ 22 | MinSize: minSize, 23 | MaxSize: maxSize, 24 | Exclude: exclude, 25 | Extensions: extensions, 26 | OlderThan: olderThan, 27 | NewerThan: newerThan, 28 | } 29 | } 30 | 31 | // MatchesFilters checks if a file matches all filter criteria 32 | func (f *FileFilter) MatchesFilters(info os.FileInfo, path string) bool { 33 | if !f.ExcludeFilter(info, path) { 34 | return false 35 | } 36 | 37 | if len(f.Extensions) > 0 { 38 | _, existExt := f.Extensions[filepath.Ext(info.Name())] 39 | if !existExt { 40 | return false 41 | } 42 | } 43 | if f.MaxSize > 0 { 44 | if info.Size() > f.MaxSize { 45 | return false 46 | } 47 | } 48 | if f.MinSize > 0 { 49 | if info.Size() < f.MinSize { 50 | return false 51 | } 52 | } 53 | 54 | modTime := info.ModTime() 55 | if !f.OlderThan.IsZero() && !f.NewerThan.IsZero() { 56 | // Support 'between' range regardless of which is earlier 57 | start := f.OlderThan 58 | end := f.NewerThan 59 | if end.Before(start) { 60 | start, end = end, start 61 | } 62 | if modTime.Before(start) || modTime.After(end) { 63 | return false 64 | } 65 | } else { 66 | if !f.OlderThan.IsZero() { 67 | if !f.OlderThanFilter(info) { 68 | return false 69 | } 70 | } 71 | if !f.NewerThan.IsZero() { 72 | if !f.NewerThanFilter(info) { 73 | return false 74 | } 75 | } 76 | } 77 | 78 | return true 79 | } 80 | 81 | // ExcludeFilter checks if a file should be excluded based on path patterns 82 | func (f *FileFilter) ExcludeFilter(info os.FileInfo, path string) bool { 83 | if len(f.Exclude) != 0 { 84 | for _, excludePattern := range f.Exclude { 85 | if strings.Contains(filepath.ToSlash(path), excludePattern+"/") { 86 | return false 87 | } 88 | if strings.HasPrefix(info.Name(), excludePattern) { 89 | return false 90 | } 91 | } 92 | } 93 | return true 94 | } 95 | 96 | // OlderThanFilter checks if a file is older than the specified time 97 | func (f *FileFilter) OlderThanFilter(info os.FileInfo) bool { 98 | return info.ModTime().Before(f.OlderThan) 99 | } 100 | 101 | // NewerThanFilter checks if a file is newer than the specified time 102 | func (f *FileFilter) NewerThanFilter(info os.FileInfo) bool { 103 | return info.ModTime().After(f.NewerThan) 104 | } 105 | -------------------------------------------------------------------------------- /internal/logging/storage/file_storage.go: -------------------------------------------------------------------------------- 1 | package storage 2 | 3 | import ( 4 | "encoding/json" 5 | "fmt" 6 | "io" 7 | "os" 8 | "path/filepath" 9 | "sync" 10 | 11 | "github.com/pashkov256/deletor/internal/logging" 12 | ) 13 | 14 | type FileStorage struct { 15 | basePath string 16 | mu sync.RWMutex 17 | } 18 | 19 | func NewFileStorage(basePath string) *FileStorage { 20 | return &FileStorage{ 21 | basePath: basePath, 22 | } 23 | } 24 | 25 | func (fs *FileStorage) SaveStatistics(stats *logging.ScanStatistics) error { 26 | fs.mu.Lock() 27 | defer fs.mu.Unlock() 28 | 29 | // Create statistics directory if it doesn't exist 30 | if err := os.MkdirAll(fs.basePath, 0755); err != nil { 31 | return fmt.Errorf("failed to create statistics directory: %w", err) 32 | } 33 | 34 | path := filepath.Join(fs.basePath, "statistics.json") 35 | return fs.saveToFile(path, stats) 36 | } 37 | 38 | func (fs *FileStorage) SaveOperation(operation *logging.FileOperation) error { 39 | fs.mu.Lock() 40 | defer fs.mu.Unlock() 41 | 42 | path := filepath.Join(fs.basePath, "operations.json") 43 | return fs.appendToFile(path, operation) 44 | } 45 | 46 | func (fs *FileStorage) GetStatistics(scanID string) (*logging.ScanStatistics, error) { 47 | fs.mu.RLock() 48 | defer fs.mu.RUnlock() 49 | 50 | path := filepath.Join(fs.basePath, "statistics.json") 51 | file, err := os.Open(path) 52 | if err != nil { 53 | return nil, err 54 | } 55 | defer file.Close() 56 | 57 | var stats logging.ScanStatistics 58 | if err := json.NewDecoder(file).Decode(&stats); err != nil { 59 | return nil, err 60 | } 61 | 62 | return &stats, nil 63 | } 64 | 65 | func (fs *FileStorage) GetOperations(scanID string) ([]logging.FileOperation, error) { 66 | fs.mu.RLock() 67 | defer fs.mu.RUnlock() 68 | 69 | path := filepath.Join(fs.basePath, "operations.json") 70 | file, err := os.Open(path) 71 | if err != nil { 72 | if os.IsNotExist(err) { 73 | return []logging.FileOperation{}, nil 74 | } 75 | return nil, err 76 | } 77 | defer file.Close() 78 | 79 | var operations []logging.FileOperation 80 | if err := json.NewDecoder(file).Decode(&operations); err != nil { 81 | return nil, err 82 | } 83 | 84 | return operations, nil 85 | } 86 | 87 | // Вспомогательные методы 88 | func (fs *FileStorage) saveToFile(path string, data interface{}) error { 89 | file, err := os.Create(path) 90 | if err != nil { 91 | return err 92 | } 93 | defer file.Close() 94 | 95 | return json.NewEncoder(file).Encode(data) 96 | } 97 | 98 | func (fs *FileStorage) appendToFile(path string, data interface{}) error { 99 | // Read existing operations 100 | var operations []interface{} 101 | if file, err := os.Open(path); err == nil { 102 | defer file.Close() 103 | if err := json.NewDecoder(file).Decode(&operations); err != nil && err != io.EOF { 104 | return err 105 | } 106 | } 107 | 108 | // Append new operation 109 | operations = append(operations, data) 110 | 111 | // Write back to file 112 | file, err := os.Create(path) 113 | if err != nil { 114 | return err 115 | } 116 | defer file.Close() 117 | 118 | return json.NewEncoder(file).Encode(operations) 119 | } 120 | -------------------------------------------------------------------------------- /internal/rules/manager.go: -------------------------------------------------------------------------------- 1 | package rules 2 | 3 | import ( 4 | "encoding/json" 5 | "os" 6 | "path/filepath" 7 | 8 | "github.com/pashkov256/deletor/internal/path" 9 | "github.com/pashkov256/deletor/internal/tui/options" 10 | ) 11 | 12 | // UpdateRules applies the provided options and saves the updated rules to file 13 | func (d *defaultRules) UpdateRules(options ...RuleOption) error { 14 | // Update the struct fields 15 | for _, option := range options { 16 | option(d) 17 | } 18 | 19 | // Marshal and save to file 20 | rulesJSON, err := json.Marshal(d) 21 | if err != nil { 22 | return err 23 | } 24 | 25 | err = os.WriteFile(d.GetRulesPath(), rulesJSON, 0644) 26 | if err != nil { 27 | return err 28 | } 29 | 30 | return nil 31 | } 32 | 33 | // GetRules loads and returns the current rules from the configuration file 34 | func (d *defaultRules) GetRules() (*defaultRules, error) { 35 | jsonRules, err := os.ReadFile(d.GetRulesPath()) 36 | if err != nil { 37 | return nil, err 38 | } 39 | 40 | // Create a new instance to avoid modifying the receiver 41 | rules := &defaultRules{} 42 | err = json.Unmarshal(jsonRules, rules) 43 | if err != nil { 44 | return nil, err 45 | } 46 | 47 | return rules, nil 48 | } 49 | 50 | // SetupRulesConfig initializes the rules configuration file with default values 51 | func (d *defaultRules) SetupRulesConfig() error { 52 | filePathRuleConfig := d.GetRulesPath() 53 | 54 | err := os.MkdirAll(filepath.Dir(filePathRuleConfig), 0755) 55 | if err != nil { 56 | return err 57 | } 58 | 59 | _, err = os.Stat(filePathRuleConfig) 60 | 61 | if err != nil { 62 | // Create a new defaultRules instance with values from DefaultCleanOptionState 63 | rules := &defaultRules{ 64 | Path: "", 65 | Extensions: []string{}, 66 | Exclude: []string{}, 67 | MinSize: "", 68 | MaxSize: "", 69 | OlderThan: "", 70 | NewerThan: "", 71 | ShowHiddenFiles: options.DefaultCleanOptionState[options.ShowHiddenFiles], 72 | ConfirmDeletion: options.DefaultCleanOptionState[options.ConfirmDeletion], 73 | IncludeSubfolders: options.DefaultCleanOptionState[options.IncludeSubfolders], 74 | DeleteEmptySubfolders: options.DefaultCleanOptionState[options.DeleteEmptySubfolders], 75 | SendFilesToTrash: options.DefaultCleanOptionState[options.SendFilesToTrash], 76 | LogOperations: options.DefaultCleanOptionState[options.LogOperations], 77 | LogToFile: options.DefaultCleanOptionState[options.LogToFile], 78 | ShowStatistics: options.DefaultCleanOptionState[options.ShowStatistics], 79 | ExitAfterDeletion: options.DefaultCleanOptionState[options.ExitAfterDeletion], 80 | } 81 | 82 | rulesJSON, err := json.Marshal(rules) 83 | if err != nil { 84 | return err 85 | } 86 | 87 | err = os.WriteFile(filePathRuleConfig, rulesJSON, 0644) 88 | if err != nil { 89 | return err 90 | } 91 | } 92 | 93 | return nil 94 | } 95 | 96 | // GetRulesPath returns the path to the rules configuration file 97 | func (d *defaultRules) GetRulesPath() string { 98 | userConfigDir, _ := os.UserConfigDir() 99 | filePathRuleConfig := filepath.Join(userConfigDir, path.AppDirName, path.RuleFileName) 100 | 101 | return filePathRuleConfig 102 | } 103 | -------------------------------------------------------------------------------- /internal/cli/config/config.go: -------------------------------------------------------------------------------- 1 | package config 2 | 3 | import ( 4 | "time" 5 | 6 | "github.com/pashkov256/deletor/internal/rules" 7 | "github.com/pashkov256/deletor/internal/utils" 8 | ) 9 | 10 | // Config holds all command-line configuration options for the application 11 | type Config struct { 12 | Directory string // Target directory to process 13 | Extensions []string // File extensions to include 14 | MinSize int64 // Minimum file size in bytes 15 | MaxSize int64 // Maximum file size in bytes 16 | Exclude []string // Patterns to exclude 17 | IncludeSubdirs bool // Whether to process subdirectories 18 | ShowProgress bool // Whether to display progress 19 | IsCLIMode bool // Whether running in CLI mode 20 | HaveProgress bool // Whether progress tracking is available 21 | SkipConfirm bool // Whether to skip confirmation prompts 22 | DeleteEmptyFolders bool // Whether to remove empty directories 23 | OlderThan time.Time // Only process files older than this time 24 | NewerThan time.Time // Only process files newer than this time 25 | MoveFileToTrash bool // If true, files will be moved to trash instead of being permanently deleted 26 | UseRules bool // Whether to use rules from configuration file 27 | JsonLogsEnabled bool // Whether to generates JSON-formatted logs 28 | JsonLogsPath string // Path to append JSON-formatted logs 29 | } 30 | 31 | // LoadConfig initializes and returns a new Config instance with values from command-line flags 32 | func LoadConfig() *Config { 33 | return GetFlags() 34 | } 35 | 36 | // GetConfig returns the current configuration instance 37 | func (c *Config) GetConfig() *Config { 38 | return c 39 | } 40 | 41 | // GetWithRules returns a value from config if it's set, otherwise returns value from rules 42 | func (c *Config) GetWithRules(rules rules.Rules) *Config { 43 | if c == nil { 44 | c = &Config{} 45 | } 46 | 47 | // Get all rules at once 48 | defaultRules, err := rules.GetRules() 49 | if err != nil { 50 | return c 51 | } 52 | 53 | // Get values from rules if not set in config 54 | if len(c.Extensions) == 0 { 55 | c.Extensions = defaultRules.Extensions 56 | } 57 | if c.Directory == "" && defaultRules.Path != "" { 58 | c.Directory = utils.ExpandTilde(defaultRules.Path) 59 | } else if c.Directory != "" && defaultRules.Path != "" { 60 | c.Directory = utils.ExpandTilde(c.Directory) 61 | } 62 | if c.MinSize == 0 && defaultRules.MinSize != "" { 63 | c.MinSize = utils.ToBytesOrDefault(defaultRules.MinSize) 64 | } 65 | if c.MaxSize == 0 && defaultRules.MaxSize != "" { 66 | c.MaxSize = utils.ToBytesOrDefault(defaultRules.MaxSize) 67 | } 68 | if len(c.Exclude) == 0 { 69 | c.Exclude = defaultRules.Exclude 70 | } 71 | if c.OlderThan.IsZero() && defaultRules.OlderThan != "" { 72 | c.OlderThan, _ = utils.ParseTimeDuration(defaultRules.OlderThan) 73 | } 74 | if c.NewerThan.IsZero() && defaultRules.NewerThan != "" { 75 | c.NewerThan, _ = utils.ParseTimeDuration(defaultRules.NewerThan) 76 | } 77 | if !c.IncludeSubdirs { 78 | c.IncludeSubdirs = defaultRules.IncludeSubfolders 79 | } 80 | if !c.DeleteEmptyFolders { 81 | 82 | c.DeleteEmptyFolders = defaultRules.DeleteEmptySubfolders 83 | } 84 | if !c.MoveFileToTrash { 85 | c.MoveFileToTrash = defaultRules.SendFilesToTrash 86 | } 87 | 88 | return c 89 | } 90 | -------------------------------------------------------------------------------- /internal/cli/output/printer.go: -------------------------------------------------------------------------------- 1 | package output 2 | 3 | import ( 4 | "bufio" 5 | "fmt" 6 | "log" 7 | "os" 8 | "strings" 9 | 10 | "github.com/fatih/color" 11 | ) 12 | 13 | // Printer handles formatted output with color coding for different message types 14 | type Printer struct { 15 | successColor *color.Color // Green color for success messages 16 | errorColor *color.Color // Red color for error messages 17 | warningColor *color.Color // Yellow color for warning messages 18 | infoColor *color.Color // Cyan color for info messages 19 | progress chan int64 // Channel for progress updates 20 | } 21 | 22 | // NewPrinter creates a new Printer instance with default color settings 23 | func NewPrinter() *Printer { 24 | return &Printer{ 25 | successColor: color.New(color.FgGreen), 26 | errorColor: color.New(color.FgRed), 27 | warningColor: color.New(color.FgYellow), 28 | infoColor: color.New(color.FgCyan), 29 | progress: make(chan int64), 30 | } 31 | } 32 | 33 | // PrintSuccess prints a success message with a green checkmark 34 | func (p *Printer) PrintSuccess(format string, args ...interface{}) { 35 | p.successColor.Printf("✓ %s\n", fmt.Sprintf(format, args...)) 36 | } 37 | 38 | // PrintError prints an error message with a red X mark 39 | func (p *Printer) PrintError(format string, args ...interface{}) { 40 | p.errorColor.Printf("✗ %s\n", fmt.Sprintf(format, args...)) 41 | } 42 | 43 | // PrintWarning prints a warning message with a yellow warning symbol 44 | func (p *Printer) PrintWarning(format string, args ...interface{}) { 45 | p.warningColor.Printf("⚠ %s\n", fmt.Sprintf(format, args...)) 46 | } 47 | 48 | // PrintInfo prints an info message with a blue info symbol 49 | func (p *Printer) PrintInfo(format string, args ...interface{}) { 50 | p.infoColor.Printf("ℹ %s\n", fmt.Sprintf(format, args...)) 51 | } 52 | 53 | // PrintFilesTable prints a formatted table of files with their sizes 54 | func (p *Printer) PrintFilesTable(files map[string]string) { 55 | yellow := color.New(color.FgYellow).SprintFunc() 56 | white := color.New(color.FgWhite).SprintFunc() 57 | 58 | maxSizeLen := 0 59 | for _, size := range files { 60 | if len(size) > maxSizeLen { 61 | maxSizeLen = len(size) 62 | } 63 | } 64 | 65 | for path, size := range files { 66 | fmt.Printf("%s %s\n", yellow(fmt.Sprintf("%-*s", maxSizeLen, size)), white(path)) 67 | } 68 | } 69 | 70 | // PrintEmptyDirs prints a list of empty directories 71 | func (p *Printer) PrintEmptyDirs(files []string) { 72 | yellow := color.New(color.FgYellow).SprintFunc() 73 | white := color.New(color.FgWhite).SprintFunc() 74 | 75 | for _, path := range files { 76 | fmt.Printf("%s %s\n", yellow("DIR"), white(path)) 77 | } 78 | } 79 | 80 | // AskForConfirmation prompts the user for confirmation with a yes/no question 81 | func (p *Printer) AskForConfirmation(s string) bool { 82 | bold := color.New(color.Bold).SprintFunc() 83 | green := color.New(color.FgGreen).SprintFunc() 84 | 85 | reader := bufio.NewReader(os.Stdin) 86 | fmt.Printf("%s %s ", bold(s), green("[y/n]:")) 87 | 88 | for { 89 | response, err := reader.ReadString('\n') 90 | if err != nil { 91 | log.Fatal(err) 92 | } 93 | 94 | response = strings.ToLower(strings.TrimSpace(response)) 95 | 96 | fmt.Print("\n") 97 | 98 | switch response { 99 | case "y", "yes": 100 | return true 101 | case "n", "no": 102 | return false 103 | } 104 | } 105 | } 106 | -------------------------------------------------------------------------------- /internal/runner/cli.go: -------------------------------------------------------------------------------- 1 | package runner 2 | 3 | import ( 4 | "fmt" 5 | "os" 6 | 7 | "github.com/pashkov256/deletor/internal/cli/config" 8 | "github.com/pashkov256/deletor/internal/cli/output" 9 | "github.com/pashkov256/deletor/internal/filemanager" 10 | "github.com/pashkov256/deletor/internal/rules" 11 | "github.com/pashkov256/deletor/internal/utils" 12 | ) 13 | 14 | const ( 15 | confirmMsgDlt string = "Delete these files?" 16 | confirmMsgTrash string = "Move files to trash?" 17 | ) 18 | 19 | func RunCLI( 20 | fm filemanager.FileManager, 21 | rules rules.Rules, 22 | config *config.Config, 23 | ) { 24 | // Get values from rules if --rules flag is set 25 | if config.UseRules { 26 | config = config.GetWithRules(rules) 27 | } 28 | 29 | extMap := utils.ParseExtToMap(config.Extensions) 30 | 31 | filter := fm.NewFileFilter( 32 | config.MinSize, 33 | config.MaxSize, 34 | extMap, 35 | config.Exclude, 36 | config.OlderThan, 37 | config.NewerThan, 38 | ) 39 | 40 | fileScanner := filemanager.NewFileScanner(fm, filter, config.ShowProgress) 41 | printer := output.NewPrinter() 42 | 43 | if config.ShowProgress { 44 | fileScanner.ProgressBarScanner(config.Directory) 45 | } 46 | 47 | var toDeleteMap map[string]string 48 | var totalClearSize int64 49 | 50 | if config.IncludeSubdirs { 51 | toDeleteMap, totalClearSize = fileScanner.ScanFilesRecursively(config.Directory) 52 | } else { 53 | toDeleteMap, totalClearSize = fileScanner.ScanFilesCurrentLevel(config.Directory) 54 | } 55 | if len(toDeleteMap) != 0 { 56 | printer.PrintFilesTable(toDeleteMap) 57 | 58 | actionIsDelete := true 59 | 60 | fmt.Println() // This is required for formatting 61 | if !config.SkipConfirm { 62 | fmt.Println(utils.FormatSize(totalClearSize), "will be cleared.") 63 | var msg string 64 | if config.MoveFileToTrash { 65 | msg = confirmMsgTrash 66 | } else { 67 | msg = confirmMsgDlt 68 | } 69 | actionIsDelete = printer.AskForConfirmation(msg) 70 | } 71 | 72 | if actionIsDelete { 73 | if config.MoveFileToTrash { 74 | for path := range toDeleteMap { 75 | fm.MoveFileToTrash(path) 76 | } 77 | printer.PrintSuccess("Moved to trash: %s", utils.FormatSize(totalClearSize)) 78 | } else { 79 | for path := range toDeleteMap { 80 | fm.DeleteFile(path) 81 | } 82 | printer.PrintSuccess("Deleted: %s", utils.FormatSize(totalClearSize)) 83 | } 84 | 85 | if config.JsonLogsEnabled { 86 | utils.LogDeletionToFileAsJson(toDeleteMap, config.JsonLogsPath) 87 | } else { 88 | utils.LogDeletionToFile(toDeleteMap) 89 | } 90 | } 91 | 92 | } else { 93 | printer.PrintWarning("File not found") 94 | } 95 | if config.DeleteEmptyFolders { 96 | printer.PrintInfo("Scan empty subfolders") 97 | toDeleteEmptyFolders := fileScanner.ScanEmptySubFolders(config.Directory) 98 | if len(toDeleteEmptyFolders) != 0 { 99 | printer.PrintEmptyDirs(toDeleteEmptyFolders) 100 | 101 | actionIsEmptyDeleteFolders := true 102 | 103 | if !config.SkipConfirm { 104 | actionIsEmptyDeleteFolders = printer.AskForConfirmation("Delete these empty folders?") 105 | } 106 | 107 | if actionIsEmptyDeleteFolders { 108 | for i := len(toDeleteEmptyFolders) - 1; i >= 0; i-- { 109 | os.Remove(toDeleteEmptyFolders[i]) 110 | } 111 | fmt.Println() 112 | printer.PrintSuccess("Number of deleted empty folders: %d", len(toDeleteEmptyFolders)) 113 | } 114 | } else { 115 | printer.PrintWarning("Empty folders not found") 116 | } 117 | } 118 | } 119 | -------------------------------------------------------------------------------- /internal/cli/config/flags.go: -------------------------------------------------------------------------------- 1 | package config 2 | 3 | import ( 4 | "flag" 5 | "fmt" 6 | "os" 7 | 8 | "github.com/pashkov256/deletor/internal/utils" 9 | ) 10 | 11 | // GetFlags parses command-line flags and returns a Config instance with the parsed values. 12 | func GetFlags() *Config { 13 | config := &Config{} 14 | 15 | extensions := flag.String("e", "", "File extensions to delete (comma-separated)") 16 | excludeFlag := flag.String("exclude", "", "Exclude specific files/paths (e.g. data,backup)") 17 | minSize := flag.String("min-size", "", "Minimum file size to delete (e.g. 10kb, 10mb, 10b)") 18 | maxSize := flag.String("max-size", "", "Maximum file size to delete (e.g. 10kb, 10mb, 10b)") 19 | dir := flag.String("d", ".", "Directory to scan") 20 | includeSubdirsScan := flag.Bool("subdirs", false, "Include subdirectories in scan") 21 | isCLIMode := flag.Bool("cli", false, "CLI mode (default is TUI)") 22 | progress := flag.Bool("progress", false, "Display a progress bar during file scanning") 23 | deleteEmptyFolders := flag.Bool("prune-empty", false, "Delete empty folders after scan") 24 | skipConfirm := flag.Bool("skip-confirm", false, "Skip the confirmation of deletion?") 25 | older := flag.String("older", "", "Modification time older than (e.g. 1sec, 2min, 3hour, 4day, 5week, 6month, 7year)") 26 | newer := flag.String("newer", "", "Modification time newer than (e.g. 1sec, 2min, 3hour, 4day, 5week, 6month, 7year)") 27 | moveToTrash := flag.Bool("trash", false, "Move files to trash?") 28 | useRules := flag.Bool("rules", false, "Use rules from configuration file") 29 | jsonLogsEnabled := flag.Bool("log-json", false, "Enable JSON-formatted logging. Use --log-json or --log-json \"/path/to/file\" to specify a path to write logs.") 30 | 31 | flag.Parse() 32 | 33 | *dir = utils.ExpandTilde(*dir) 34 | 35 | // Parse exclude patterns 36 | if *excludeFlag != "" { 37 | config.Exclude = utils.ParseExcludeToSlice(*excludeFlag) 38 | } 39 | 40 | // Convert extensions to slice 41 | if *extensions != "" { 42 | config.Extensions = utils.ParseExtToSlice(*extensions) 43 | } 44 | 45 | // Convert size to bytes 46 | if *minSize != "" { 47 | sizeBytes, err := utils.ToBytes(*minSize) 48 | if err != nil { 49 | fmt.Printf("Error parsing size: %v\n", err) 50 | os.Exit(1) 51 | } 52 | config.MinSize = sizeBytes 53 | } 54 | 55 | // Convert size to bytes 56 | if *maxSize != "" { 57 | sizeBytes, err := utils.ToBytes(*maxSize) 58 | if err != nil { 59 | fmt.Printf("Error parsing size: %v\n", err) 60 | os.Exit(1) 61 | } 62 | config.MaxSize = sizeBytes 63 | } 64 | 65 | // Convert older to time.Time 66 | if *older != "" { 67 | olderThan, err := utils.ParseTimeDuration(*older) 68 | if err != nil { 69 | fmt.Printf("Error parsing older: %v\n", err) 70 | os.Exit(1) 71 | } 72 | config.OlderThan = olderThan 73 | } 74 | 75 | // Convert newer to time.Time 76 | if *newer != "" { 77 | newerThan, err := utils.ParseTimeDuration(*newer) 78 | if err != nil { 79 | fmt.Printf("Error parsing newer: %v\n", err) 80 | os.Exit(1) 81 | } 82 | config.NewerThan = newerThan 83 | } 84 | 85 | // Get file path for outputting Json logs 86 | if *jsonLogsEnabled { 87 | config.JsonLogsEnabled = true 88 | config.JsonLogsPath = utils.ParseJsonLogsPath(os.Args[1:], "--log-json") 89 | } 90 | 91 | config.IsCLIMode = *isCLIMode 92 | config.HaveProgress = *progress 93 | config.IncludeSubdirs = *includeSubdirsScan 94 | config.Directory = *dir 95 | config.SkipConfirm = *skipConfirm 96 | config.DeleteEmptyFolders = *deleteEmptyFolders 97 | config.MoveFileToTrash = *moveToTrash 98 | config.UseRules = *useRules 99 | 100 | return config 101 | } 102 | -------------------------------------------------------------------------------- /internal/logging/logger.go: -------------------------------------------------------------------------------- 1 | package logging 2 | 3 | import ( 4 | "encoding/json" 5 | "fmt" 6 | "os" 7 | "path/filepath" 8 | "sync" 9 | "time" 10 | ) 11 | 12 | // LogLevel represents the severity level of a log message 13 | type LogLevel string 14 | 15 | const ( 16 | INFO LogLevel = "INFO" // Informational messages 17 | DEBUG LogLevel = "DEBUG" // Debug messages 18 | ERROR LogLevel = "ERROR" // Error messages 19 | ) 20 | 21 | // ScanStatistics tracks metrics for file scanning operations 22 | type ScanStatistics struct { 23 | TotalFiles int64 // Total number of files processed 24 | TotalSize int64 // Total size of all files 25 | DeletedFiles int64 // Number of files deleted 26 | DeletedSize int64 // Size of deleted files 27 | TrashedFiles int64 // Number of files moved to trash 28 | TrashedSize int64 // Size of trashed files 29 | IgnoredFiles int64 // Number of ignored files 30 | IgnoredSize int64 // Size of ignored files 31 | StartTime time.Time // Operation start time 32 | EndTime time.Time // Operation end time 33 | Directory string // Target directory 34 | OperationType string // Type of operation performed 35 | } 36 | 37 | // LogEntry represents a single log entry with metadata 38 | type LogEntry struct { 39 | Timestamp time.Time `json:"timestamp"` // When the entry was created 40 | Level LogLevel `json:"level"` // Log level 41 | Message string `json:"message"` // Log message 42 | Stats *ScanStatistics `json:"stats,omitempty"` // Optional scan statistics 43 | } 44 | 45 | // Logger handles writing log entries to a file with thread safety 46 | type Logger struct { 47 | mu sync.Mutex 48 | logFile *os.File 49 | ConfigPath string 50 | currentScan *ScanStatistics 51 | StatsCallback func(*ScanStatistics) // Callback for stats updates 52 | } 53 | 54 | // NewLogger creates a new logger instance with the specified configuration 55 | func NewLogger(ConfigPath string, statsCallback func(*ScanStatistics)) (*Logger, error) { 56 | // Ensure config directory exists 57 | if err := os.MkdirAll(filepath.Dir(ConfigPath), 0755); err != nil { 58 | return nil, fmt.Errorf("failed to create config directory: %w", err) 59 | } 60 | 61 | // Open or create log file 62 | logFile, err := os.OpenFile(ConfigPath, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644) 63 | if err != nil { 64 | return nil, fmt.Errorf("failed to open log file: %w", err) 65 | } 66 | 67 | return &Logger{ 68 | logFile: logFile, 69 | ConfigPath: ConfigPath, 70 | StatsCallback: statsCallback, 71 | }, nil 72 | } 73 | 74 | // Log writes a log entry with the specified level and message 75 | func (l *Logger) Log(level LogLevel, message string) error { 76 | l.mu.Lock() 77 | defer l.mu.Unlock() 78 | 79 | entry := LogEntry{ 80 | Timestamp: time.Now(), 81 | Level: level, 82 | Message: message, 83 | Stats: l.currentScan, 84 | } 85 | 86 | data, err := json.Marshal(entry) 87 | if err != nil { 88 | return fmt.Errorf("failed to marshal log entry: %w", err) 89 | } 90 | 91 | if _, err := l.logFile.Write(append(data, '\n')); err != nil { 92 | return fmt.Errorf("failed to write to log file: %w", err) 93 | } 94 | 95 | return nil 96 | } 97 | 98 | // UpdateStats updates the current scan statistics and triggers callback if set 99 | func (l *Logger) UpdateStats(stats *ScanStatistics) { 100 | l.mu.Lock() 101 | l.currentScan = stats 102 | l.mu.Unlock() 103 | 104 | if l.StatsCallback != nil { 105 | l.StatsCallback(stats) 106 | } 107 | } 108 | 109 | // Close closes the log file 110 | func (l *Logger) Close() error { 111 | l.mu.Lock() 112 | defer l.mu.Unlock() 113 | 114 | if l.logFile == nil { 115 | return nil 116 | } 117 | 118 | err := l.logFile.Close() 119 | l.logFile = nil // Set to nil after closing to prevent double-close 120 | return err 121 | } 122 | -------------------------------------------------------------------------------- /internal/tests/unit/utils/utils_test.go: -------------------------------------------------------------------------------- 1 | package utils 2 | 3 | import ( 4 | "bytes" 5 | "io" 6 | "os" 7 | "strings" 8 | "testing" 9 | 10 | "github.com/pashkov256/deletor/internal/cli/output" 11 | "github.com/pashkov256/deletor/internal/utils" 12 | ) 13 | 14 | var printer = output.NewPrinter() 15 | 16 | func TestPrintFilesTable(t *testing.T) { 17 | type args struct { 18 | files map[string]string 19 | } 20 | tests := []struct { 21 | name string 22 | args args 23 | want string 24 | }{ 25 | { 26 | name: "BytePrint", 27 | args: args{map[string]string{ 28 | "/Users/test/Documents/deletor/main.go": "8.04 KB", 29 | }}, 30 | want: "8.04 KB /Users/test/Documents/deletor/main.go\n", 31 | }, 32 | } 33 | 34 | for _, tt := range tests { 35 | t.Run(tt.name, func(t *testing.T) { 36 | old := os.Stdout 37 | r, w, _ := os.Pipe() 38 | os.Stdout = w 39 | 40 | printer.PrintFilesTable(tt.args.files) 41 | 42 | w.Close() 43 | os.Stdout = old 44 | 45 | var buf bytes.Buffer 46 | io.Copy(&buf, r) 47 | got := buf.String() 48 | 49 | // Remove color codes before comparison 50 | got = strings.ReplaceAll(got, "\x1b[33m", "") 51 | got = strings.ReplaceAll(got, "\x1b[0m", "") 52 | got = strings.ReplaceAll(got, "\x1b[37m", "") 53 | 54 | if got != tt.want { 55 | t.Errorf("\ngot:\n%q\nwant:\n%q", got, tt.want) 56 | } 57 | }) 58 | } 59 | } 60 | 61 | func TestAskForConfirmation(t *testing.T) { 62 | type args struct { 63 | userInput string 64 | } 65 | tests := []struct { 66 | name string 67 | args args 68 | want bool 69 | }{ 70 | {"yLowerCase", args{"y\n"}, true}, 71 | {"yUpperCase", args{"Y\n"}, true}, 72 | {"yesLowerCase", args{"YES\n"}, true}, 73 | {"yesUpperCase", args{"yes\n"}, true}, 74 | {"nLowerCase", args{"n\n"}, false}, 75 | {"nUpperCase", args{"N\n"}, false}, 76 | {"noLowerCase", args{"no\n"}, false}, 77 | {"noUpperCase", args{"NO\n"}, false}, 78 | } 79 | for _, tt := range tests { 80 | t.Run(tt.name, func(t *testing.T) { 81 | originalStdin := os.Stdin 82 | defer func() { os.Stdin = originalStdin }() 83 | 84 | r, w, _ := os.Pipe() 85 | os.Stdin = r 86 | go func() { 87 | w.Write([]byte(tt.args.userInput)) 88 | w.Close() 89 | }() 90 | got := printer.AskForConfirmation("Delete these files?") 91 | if got != tt.want { 92 | t.Errorf("gotAskForConfirmation = %v\n wantAskForConfirmation = %v", got, tt.want) 93 | } 94 | }) 95 | } 96 | } 97 | 98 | func TestToBytes(t *testing.T) { 99 | type args struct { 100 | sizeStr string 101 | } 102 | 103 | tests := []struct { 104 | name string 105 | args args 106 | wantToByte int64 107 | }{ 108 | {"minB", args{"0b"}, 0}, 109 | {"100B", args{"100b"}, 100}, 110 | {"minKB", args{"0k"}, 0}, 111 | {"100KB", args{"100kb"}, 102400}, 112 | {"minMB", args{"0mb"}, 0}, 113 | {"100MB", args{"100mb"}, 104857600}, 114 | } 115 | 116 | for _, tt := range tests { 117 | t.Run(tt.name, func(t *testing.T) { 118 | if got, _ := utils.ToBytes(tt.args.sizeStr); got != tt.wantToByte { 119 | t.Errorf("gotToBytes = %v\n wantToBytes = %v", got, tt.wantToByte) 120 | } 121 | }) 122 | } 123 | } 124 | 125 | func TestFormatSize(t *testing.T) { 126 | type args struct { 127 | bytes int64 128 | } 129 | 130 | tests := []struct { 131 | name string 132 | args args 133 | wantFormatSize string 134 | }{ 135 | {"MinB", args{0}, "0 B"}, 136 | {"MaxB", args{1023}, "1023 B"}, 137 | {"MinKB", args{1024}, "1.00 KB"}, 138 | {"MaxKB", args{1048575}, "1024.00 KB"}, 139 | {"MinMB", args{1048576}, "1.00 MB"}, 140 | {"MaxMB", args{1073741823}, "1024.00 MB"}, 141 | } 142 | 143 | for _, tt := range tests { 144 | t.Run(tt.name, func(t *testing.T) { 145 | if gotFormatSize := utils.FormatSize(tt.args.bytes); gotFormatSize != tt.wantFormatSize { 146 | t.Errorf("gotFormatSize = %v\n wantFormatSize = %v", gotFormatSize, tt.wantFormatSize) 147 | } 148 | }) 149 | } 150 | } 151 | -------------------------------------------------------------------------------- /internal/tui/app.go: -------------------------------------------------------------------------------- 1 | package tui 2 | 3 | import ( 4 | tea "github.com/charmbracelet/bubbletea" 5 | "github.com/pashkov256/deletor/internal/filemanager" 6 | "github.com/pashkov256/deletor/internal/rules" 7 | "github.com/pashkov256/deletor/internal/validation" 8 | 9 | "github.com/pashkov256/deletor/internal/tui/menu" 10 | "github.com/pashkov256/deletor/internal/tui/styles" 11 | "github.com/pashkov256/deletor/internal/tui/views" 12 | ) 13 | 14 | type page int 15 | 16 | const ( 17 | menuPage page = iota 18 | cleanPage 19 | cachePage 20 | rulesPage 21 | statsPage 22 | ) 23 | 24 | type App struct { 25 | page page 26 | menu *views.MainMenu 27 | cleanFilesModel *views.CleanFilesModel 28 | rulesModel *views.RulesModel 29 | cacheModel *views.CacheModel 30 | filemanager filemanager.FileManager 31 | rules rules.Rules 32 | validator *validation.Validator 33 | } 34 | 35 | func NewApp( 36 | filemanager filemanager.FileManager, 37 | rules rules.Rules, 38 | validator *validation.Validator, 39 | ) *App { 40 | return &App{ 41 | menu: views.NewMainMenu(), 42 | rulesModel: views.NewRulesModel(rules, validator), 43 | page: menuPage, 44 | filemanager: filemanager, 45 | rules: rules, 46 | validator: validator, 47 | } 48 | } 49 | 50 | func (a *App) Init() tea.Cmd { 51 | a.cleanFilesModel = views.InitialCleanModel(a.rules, a.filemanager, a.validator) 52 | a.cacheModel = views.InitialCacheModel(a.filemanager) 53 | return tea.Batch(a.menu.Init(), a.cleanFilesModel.Init(), a.rulesModel.Init()) 54 | } 55 | 56 | func (a *App) Update(msg tea.Msg) (tea.Model, tea.Cmd) { 57 | var cmd tea.Cmd 58 | var cmds []tea.Cmd 59 | 60 | switch msg := msg.(type) { 61 | case tea.KeyMsg: 62 | switch msg.String() { 63 | case "ctrl+c", "q": 64 | return a, tea.Quit 65 | case "esc": 66 | if a.page != menuPage { 67 | if a.page == rulesPage { 68 | a.cleanFilesModel = views.InitialCleanModel(a.rules, a.filemanager, a.validator) 69 | cmds = append(cmds, a.cleanFilesModel.Init()) 70 | } 71 | a.page = menuPage 72 | return a, tea.Batch(cmds...) 73 | } 74 | case "enter": 75 | if a.page == menuPage { 76 | switch menu.MenuItems[a.menu.SelectedIndex] { 77 | case menu.CleanFIlesTitle: 78 | a.cleanFilesModel = views.InitialCleanModel(a.rules, a.filemanager, a.validator) 79 | cmds = append(cmds, a.cleanFilesModel.Init(), a.cleanFilesModel.LoadFiles()) 80 | a.page = cleanPage 81 | case menu.CleanCacheTitle: 82 | a.page = cachePage 83 | case menu.ManageRulesTitle: 84 | a.page = rulesPage 85 | 86 | case menu.ExitTitle: 87 | return a, tea.Quit 88 | } 89 | return a, tea.Batch(cmds...) 90 | } 91 | } 92 | } 93 | 94 | switch a.page { 95 | case menuPage: 96 | menuModel, menuCmd := a.menu.Update(msg) 97 | menu := menuModel.(*views.MainMenu) 98 | a.menu = menu 99 | cmd = menuCmd 100 | case cleanPage: 101 | cleanModel, cleanCmd := a.cleanFilesModel.Update(msg) 102 | if m, ok := cleanModel.(*views.CleanFilesModel); ok { 103 | a.cleanFilesModel = m 104 | } 105 | cmd = cleanCmd 106 | case cachePage: 107 | cacheModel, cacheCmd := a.cacheModel.Update(msg) 108 | if m, ok := cacheModel.(*views.CacheModel); ok { 109 | a.cacheModel = m 110 | } 111 | cmd = cacheCmd 112 | case rulesPage: 113 | rulesModel, rulesCmd := a.rulesModel.Update(msg) 114 | if r, ok := rulesModel.(*views.RulesModel); ok { 115 | a.rulesModel = r 116 | } 117 | cmd = rulesCmd 118 | } 119 | 120 | return a, tea.Batch(cmd, tea.Batch(cmds...)) 121 | } 122 | 123 | func (a *App) View() string { 124 | var content string 125 | switch a.page { 126 | case menuPage: 127 | content = a.menu.View() 128 | case cleanPage: 129 | content = a.cleanFilesModel.View() 130 | case cachePage: 131 | content = a.cacheModel.View() 132 | case rulesPage: 133 | content = a.rulesModel.View() 134 | } 135 | 136 | return styles.AppStyle.Render(content) 137 | } 138 | -------------------------------------------------------------------------------- /internal/tests/unit/filemanager/filters_test.go: -------------------------------------------------------------------------------- 1 | package filemanager_test 2 | 3 | import ( 4 | "os" 5 | "path/filepath" 6 | "testing" 7 | "time" 8 | 9 | "github.com/pashkov256/deletor/internal/filemanager" 10 | ) 11 | 12 | func createTestFilesWithTimes(t *testing.T, root string, files map[string]struct { 13 | size int64 14 | modTime time.Time 15 | }) { 16 | for name, data := range files { 17 | fullPath := filepath.Join(root, name) 18 | os.MkdirAll(filepath.Dir(fullPath), 0755) 19 | f, err := os.Create(fullPath) 20 | if err != nil { 21 | t.Fatalf("failed to create file %s: %v", name, err) 22 | } 23 | if data.size > 0 { 24 | if err := f.Truncate(data.size); err != nil { 25 | t.Fatalf("failed to set size for %s: %v", name, err) 26 | } 27 | } 28 | f.Close() 29 | os.Chtimes(fullPath, data.modTime, data.modTime) 30 | } 31 | } 32 | 33 | func TestFileFilter_FullRequirements(t *testing.T) { 34 | now := time.Now() 35 | dayAgo := now.Add(-24 * time.Hour) 36 | weekAgo := now.Add(-7 * 24 * time.Hour) 37 | monthAgo := now.Add(-30 * 24 * time.Hour) 38 | 39 | tests := []struct { 40 | name string 41 | files map[string]struct { 42 | size int64 43 | modTime time.Time 44 | } 45 | exclude []string 46 | extensions map[string]struct{} 47 | minSize int64 48 | maxSize int64 49 | olderThan time.Time 50 | newerThan time.Time 51 | expectMatch map[string]bool 52 | }{ 53 | { 54 | name: "SizeFilters", 55 | files: map[string]struct { 56 | size int64 57 | modTime time.Time 58 | }{ 59 | "small.txt": {100, now}, 60 | "large.txt": {1024 * 1024 * 5, now}, 61 | }, 62 | minSize: 1000, 63 | maxSize: 1024 * 1024 * 10, 64 | expectMatch: map[string]bool{ 65 | "small.txt": false, 66 | "large.txt": true, 67 | }, 68 | }, 69 | { 70 | name: "ExtensionFilters", 71 | files: map[string]struct { 72 | size int64 73 | modTime time.Time 74 | }{ 75 | "doc.txt": {500, now}, 76 | "report.pdf": {600, now}, 77 | "image.JPG": {700, now}, 78 | }, 79 | extensions: map[string]struct{}{".txt": {}, ".pdf": {}, ".jpg": {}}, 80 | expectMatch: map[string]bool{ 81 | "doc.txt": true, 82 | "report.pdf": true, 83 | "image.JPG": false, 84 | }, 85 | }, 86 | { 87 | name: "DateFilters", 88 | files: map[string]struct { 89 | size int64 90 | modTime time.Time 91 | }{ 92 | "new.log": {200, dayAgo}, 93 | "old.log": {200, monthAgo}, 94 | "now.log": {200, now}, 95 | }, 96 | olderThan: now.Add(-5 * 24 * time.Hour), 97 | newerThan: now.Add(-31 * 24 * time.Hour), 98 | expectMatch: map[string]bool{ 99 | "new.log": false, 100 | "old.log": true, 101 | "now.log": false, 102 | }, 103 | }, 104 | { 105 | name: "CombinedFilters", 106 | files: map[string]struct { 107 | size int64 108 | modTime time.Time 109 | }{ 110 | "target.txt": {2048, weekAgo}, 111 | "skip.txt": {50, now}, 112 | }, 113 | extensions: map[string]struct{}{".txt": {}}, 114 | minSize: 1000, 115 | olderThan: now.Add(-2 * 24 * time.Hour), 116 | expectMatch: map[string]bool{ 117 | "target.txt": true, 118 | "skip.txt": false, 119 | }, 120 | }, 121 | } 122 | 123 | for _, tt := range tests { 124 | t.Run(tt.name, func(t *testing.T) { 125 | root := t.TempDir() 126 | createTestFilesWithTimes(t, root, tt.files) 127 | 128 | filter := &filemanager.FileFilter{ 129 | MinSize: tt.minSize, 130 | MaxSize: tt.maxSize, 131 | Extensions: tt.extensions, 132 | Exclude: tt.exclude, 133 | OlderThan: tt.olderThan, 134 | NewerThan: tt.newerThan, 135 | } 136 | 137 | for name := range tt.files { 138 | path := filepath.Join(root, name) 139 | info, err := os.Stat(path) 140 | if err != nil { 141 | t.Fatalf("failed to stat file: %v", err) 142 | } 143 | matched := filter.MatchesFilters(info, path) 144 | expected := tt.expectMatch[name] 145 | if matched != expected { 146 | t.Errorf("file %s: expected match = %v, got %v", name, expected, matched) 147 | } 148 | } 149 | }) 150 | } 151 | } 152 | -------------------------------------------------------------------------------- /internal/filemanager/scanners.go: -------------------------------------------------------------------------------- 1 | package filemanager 2 | 3 | import ( 4 | "fmt" 5 | "os" 6 | "path/filepath" 7 | "runtime" 8 | "sync" 9 | "time" 10 | 11 | "github.com/pashkov256/deletor/internal/utils" 12 | "github.com/schollz/progressbar/v3" 13 | ) 14 | 15 | // FileScanner handles file system scanning operations with progress tracking 16 | type FileScanner struct { 17 | fileManager FileManager // File manager instance for operations 18 | filter *FileFilter // Filter criteria for files 19 | ProgressChan chan int64 // Channel for progress updates 20 | haveProgress bool // Whether progress tracking is enabled 21 | mutex *sync.Mutex 22 | wg *sync.WaitGroup 23 | } 24 | 25 | // NewFileScanner creates a new file scanner with the specified configuration 26 | func NewFileScanner(fileManager FileManager, filter *FileFilter, haveProgress bool) *FileScanner { 27 | return &FileScanner{ 28 | fileManager: fileManager, 29 | filter: filter, 30 | ProgressChan: make(chan int64), 31 | haveProgress: haveProgress, 32 | mutex: &sync.Mutex{}, 33 | wg: &sync.WaitGroup{}, 34 | } 35 | } 36 | 37 | // ProgressBarScanner initializes and displays a progress bar for file scanning 38 | func (s *FileScanner) ProgressBarScanner(dir string) { 39 | var totalScanSize int64 40 | filepath.Walk(dir, func(path string, info os.FileInfo, err error) error { 41 | if info == nil { 42 | return nil 43 | } 44 | 45 | if s.filter.MatchesFilters(info, path) { 46 | totalScanSize += info.Size() 47 | } 48 | 49 | return nil 50 | }) 51 | 52 | bar := progressbar.NewOptions64( 53 | totalScanSize, 54 | progressbar.OptionSetDescription("Scanning files..."), 55 | progressbar.OptionSetWriter(os.Stderr), 56 | progressbar.OptionShowBytes(true), 57 | progressbar.OptionSetWidth(10), 58 | progressbar.OptionThrottle(65*time.Millisecond), 59 | progressbar.OptionShowCount(), 60 | progressbar.OptionOnCompletion(func() { 61 | fmt.Fprint(os.Stderr, "\n") 62 | }), 63 | progressbar.OptionSpinnerType(14), 64 | progressbar.OptionFullWidth(), 65 | progressbar.OptionSetRenderBlankState(true)) 66 | 67 | go func() { 68 | for incr := range s.ProgressChan { 69 | bar.Add64(incr) 70 | } 71 | }() 72 | } 73 | 74 | // ScanFilesCurrentLevel scans files in the current directory level only 75 | func (s *FileScanner) ScanFilesCurrentLevel(dir string) (toDeleteMap map[string]string, totalClearSize int64) { 76 | toDeleteMap = make(map[string]string) 77 | entries, err := os.ReadDir(dir) 78 | if err != nil { 79 | panic(err) 80 | } 81 | 82 | for _, entry := range entries { 83 | info, err := entry.Info() 84 | if err != nil { 85 | panic(err) 86 | } 87 | 88 | if info.IsDir() { 89 | continue 90 | } 91 | 92 | if s.filter.MatchesFilters(info, filepath.Join(dir, entry.Name())) { 93 | toDeleteMap[filepath.Join(dir, entry.Name())] = utils.FormatSize(info.Size()) 94 | totalClearSize += info.Size() 95 | 96 | if s.haveProgress { 97 | s.ProgressChan <- info.Size() 98 | } 99 | } 100 | 101 | } 102 | return toDeleteMap, totalClearSize 103 | } 104 | 105 | // ScanFilesRecursively scans files in the directory and all subdirectories 106 | func (s *FileScanner) ScanFilesRecursively(dir string) (toDeleteMap map[string]string, totalClearSize int64) { 107 | toDeleteMap = make(map[string]string) 108 | taskCh := make(chan os.FileInfo, runtime.NumCPU()) 109 | 110 | filepath.Walk(dir, func(path string, info os.FileInfo, err error) error { 111 | if info == nil || info.IsDir() { 112 | return nil 113 | } 114 | 115 | if err != nil { 116 | return nil 117 | } 118 | 119 | s.wg.Add(1) 120 | go func(path string, info os.FileInfo) { 121 | // Acquire token from channel first 122 | taskCh <- info 123 | defer func() { <-taskCh }() // Release token when done 124 | defer s.wg.Done() 125 | 126 | if s.filter.MatchesFilters(info, path) { 127 | s.mutex.Lock() 128 | 129 | toDeleteMap[path] = utils.FormatSize(info.Size()) 130 | totalClearSize += info.Size() 131 | 132 | s.mutex.Unlock() 133 | 134 | if s.haveProgress { 135 | s.ProgressChan <- info.Size() 136 | } 137 | } 138 | 139 | }(path, info) 140 | 141 | return nil 142 | }) 143 | 144 | s.wg.Wait() 145 | 146 | return toDeleteMap, totalClearSize 147 | } 148 | 149 | // ScanEmptySubFolders finds all empty subdirectories in the given path 150 | func (s *FileScanner) ScanEmptySubFolders(dir string) []string { 151 | emptyDirs := make([]string, 0) 152 | 153 | filepath.WalkDir(dir, func(path string, info os.DirEntry, err error) error { 154 | if info == nil && !info.IsDir() { 155 | return nil 156 | } 157 | if s.fileManager.IsEmptyDir(path) { 158 | emptyDirs = append(emptyDirs, path) 159 | } 160 | 161 | return nil 162 | }) 163 | 164 | return emptyDirs 165 | } 166 | -------------------------------------------------------------------------------- /internal/tests/integration/runner/cli_trash_test.go: -------------------------------------------------------------------------------- 1 | package runner_test 2 | 3 | import ( 4 | "os" 5 | "time" 6 | 7 | "testing" 8 | 9 | "github.com/pashkov256/deletor/internal/cli/config" 10 | "github.com/pashkov256/deletor/internal/filemanager" 11 | "github.com/pashkov256/deletor/internal/rules" 12 | "github.com/pashkov256/deletor/internal/runner" 13 | "github.com/stretchr/testify/assert" 14 | ) 15 | 16 | // mockFileManager implements filemanager.FileManager interface 17 | type mockFileManager struct { 18 | deletedFiles []string 19 | trashedFiles []string 20 | } 21 | 22 | func (m *mockFileManager) DeleteFile(path string) { 23 | m.deletedFiles = append(m.deletedFiles, path) 24 | } 25 | 26 | func (m *mockFileManager) MoveFileToTrash(filePath string) { 27 | m.trashedFiles = append(m.trashedFiles, filePath) 28 | } 29 | 30 | func (m *mockFileManager) NewFileFilter(minSize, maxSize int64, extensions map[string]struct{}, exclude []string, olderThan, newerThan time.Time) *filemanager.FileFilter { 31 | return &filemanager.FileFilter{ 32 | MinSize: minSize, 33 | MaxSize: maxSize, 34 | Exclude: exclude, 35 | Extensions: extensions, 36 | OlderThan: olderThan, 37 | NewerThan: newerThan, 38 | } 39 | } 40 | 41 | func (m *mockFileManager) WalkFilesWithFilter(callback func(fi os.FileInfo, path string), dir string, filter *filemanager.FileFilter) { 42 | // No operation for mock 43 | } 44 | 45 | func (m *mockFileManager) MoveFilesToTrash(dir string, extensions []string, exclude []string, minSize, maxSize int64, olderThan, newerThan time.Time) { 46 | // No operation for mock 47 | } 48 | 49 | func (m *mockFileManager) DeleteFiles(dir string, extensions []string, exclude []string, minSize, maxSize int64, olderThan, newerThan time.Time) { 50 | // No operation for mock 51 | } 52 | 53 | func (m *mockFileManager) DeleteEmptySubfolders(dir string) { 54 | // No operation for mock 55 | } 56 | 57 | func (m *mockFileManager) IsEmptyDir(dir string) bool { 58 | return true 59 | } 60 | 61 | func (m *mockFileManager) ExpandTilde(path string) string { 62 | return path 63 | } 64 | 65 | func (m *mockFileManager) CalculateDirSize(path string) int64 { 66 | return 0 67 | } 68 | 69 | func TestRunCLI_TrashFlag(t *testing.T) { 70 | 71 | tests := []struct { 72 | name string 73 | config *config.Config 74 | expectDelete bool 75 | expectTrash bool 76 | }{ 77 | { 78 | name: "Should permanently delete files when trash flag is false", 79 | config: &config.Config{ 80 | Extensions: []string{".txt"}, 81 | SkipConfirm: true, 82 | IncludeSubdirs: true, 83 | MoveFileToTrash: false, 84 | }, 85 | expectDelete: true, 86 | expectTrash: false, 87 | }, 88 | { 89 | name: "Should move files to trash when trash flag is true - without confirmation", 90 | config: &config.Config{ 91 | Extensions: []string{".txt"}, 92 | SkipConfirm: true, 93 | IncludeSubdirs: true, 94 | MoveFileToTrash: true, 95 | }, 96 | expectDelete: false, 97 | expectTrash: true, 98 | }, 99 | { 100 | name: "Should move files to trash when trash flag is true - with confirmation", 101 | config: &config.Config{ 102 | Extensions: []string{".txt"}, 103 | SkipConfirm: false, 104 | IncludeSubdirs: true, 105 | MoveFileToTrash: true, 106 | }, 107 | expectDelete: false, 108 | expectTrash: true, 109 | }, 110 | } 111 | 112 | for _, tt := range tests { 113 | t.Run(tt.name, func(t *testing.T) { 114 | testDir, cleanup := setupTestDir(t) 115 | defer cleanup() 116 | 117 | tt.config.Directory = testDir 118 | 119 | if !tt.config.SkipConfirm { 120 | tmpFile, err := os.CreateTemp("", "input_*") 121 | assert.NoError(t, err) 122 | defer os.Remove(tmpFile.Name()) 123 | 124 | _, err = tmpFile.WriteString("y\n") 125 | assert.NoError(t, err) 126 | tmpFile.Close() 127 | 128 | oldStdin := os.Stdin 129 | defer func() { os.Stdin = oldStdin }() 130 | 131 | // Opening a temporary file as stdin 132 | file, err := os.Open(tmpFile.Name()) 133 | assert.NoError(t, err) 134 | os.Stdin = file 135 | defer file.Close() 136 | } 137 | 138 | // Create mock file manager 139 | mockFm := &mockFileManager{ 140 | deletedFiles: make([]string, 0), 141 | trashedFiles: make([]string, 0), 142 | } 143 | 144 | r := rules.NewRules() 145 | 146 | // Run the CLI 147 | runner.RunCLI(mockFm, r, tt.config) 148 | 149 | if tt.expectDelete { 150 | assert.Equal(t, len(mockFm.trashedFiles), 0, "No files should be moved to trash") 151 | assert.Equal(t, len(mockFm.deletedFiles), 4, "Expected files to be deleted") 152 | } 153 | 154 | if tt.expectTrash { 155 | assert.Equal(t, len(mockFm.trashedFiles), 4, "Expected files to be moved to trash") 156 | assert.Equal(t, len(mockFm.deletedFiles), 0, "No files should be deleted") 157 | } 158 | }) 159 | } 160 | } 161 | -------------------------------------------------------------------------------- /internal/tui/tabs/clean/log_tab.go: -------------------------------------------------------------------------------- 1 | package clean 2 | 3 | import ( 4 | "fmt" 5 | "strings" 6 | "time" 7 | 8 | tea "github.com/charmbracelet/bubbletea" 9 | "github.com/charmbracelet/lipgloss" 10 | "github.com/pashkov256/deletor/internal/logging" 11 | "github.com/pashkov256/deletor/internal/tui/interfaces" 12 | "github.com/pashkov256/deletor/internal/tui/options" 13 | "github.com/pashkov256/deletor/internal/tui/styles" 14 | "github.com/pashkov256/deletor/internal/utils" 15 | ) 16 | 17 | type LogTab struct { 18 | model interfaces.CleanModel 19 | stats *logging.ScanStatistics 20 | startTime time.Time 21 | totalStats *logging.ScanStatistics 22 | } 23 | 24 | func (t *LogTab) Init() tea.Cmd { 25 | // Initialize with empty statistics and program start time 26 | t.startTime = time.Now() 27 | t.stats = &logging.ScanStatistics{ 28 | StartTime: t.startTime, 29 | Directory: t.model.GetCurrentPath(), 30 | OperationType: "none", 31 | } 32 | // Initialize total statistics 33 | t.totalStats = &logging.ScanStatistics{ 34 | StartTime: t.startTime, 35 | Directory: t.model.GetCurrentPath(), 36 | OperationType: "total", 37 | TotalFiles: 0, 38 | TotalSize: 0, 39 | DeletedFiles: 0, 40 | DeletedSize: 0, 41 | TrashedFiles: 0, 42 | TrashedSize: 0, 43 | IgnoredFiles: 0, 44 | IgnoredSize: 0, 45 | } 46 | return nil 47 | } 48 | 49 | func (t *LogTab) Update(msg tea.Msg) tea.Cmd { 50 | return nil 51 | } 52 | 53 | func (t *LogTab) View() string { 54 | var content strings.Builder 55 | 56 | // Check if statistics are enabled 57 | if !t.model.GetOptionState()[options.ShowStatistics] { 58 | return styles.InfoStyle.Render("\n⚠️ Statistics display is disabled. Enable 'Show statistics' in Options tab (F3). ⚠️") 59 | } 60 | 61 | tableStyle := lipgloss.NewStyle(). 62 | BorderStyle(lipgloss.RoundedBorder()). 63 | BorderForeground(lipgloss.Color("#666666")). 64 | Padding(1, 2) 65 | 66 | labelStyle := lipgloss.NewStyle(). 67 | Foreground(lipgloss.Color("#888888")). 68 | Width(25). 69 | Align(lipgloss.Left) 70 | 71 | valueStyle := lipgloss.NewStyle(). 72 | Foreground(lipgloss.Color("#FFFFFF")). 73 | PaddingLeft(1) 74 | 75 | // Initialize stats if nil 76 | if t.stats == nil { 77 | t.stats = &logging.ScanStatistics{ 78 | StartTime: t.startTime, 79 | Directory: t.model.GetCurrentPath(), 80 | OperationType: "none", 81 | } 82 | } 83 | 84 | // Format duration - use program start time 85 | duration := time.Since(t.startTime) 86 | durationStr := fmt.Sprintf("%.2f seconds", duration.Seconds()) 87 | 88 | // Format time - use program start time 89 | timeStr := t.startTime.Format("02.01.2006 15:04:05 ") 90 | 91 | rows := []struct { 92 | label string 93 | value string 94 | }{ 95 | {"🔄 Last operation", t.stats.OperationType}, 96 | {"📂 Directory", t.stats.Directory}, 97 | {"⏰ Start Time", timeStr}, 98 | {"⏱️ Program lifetime", durationStr}, 99 | {"📝 Total Files", fmt.Sprintf("%d", t.totalStats.TotalFiles)}, 100 | {"💾 Total Size", utils.FormatSize(t.totalStats.TotalSize)}, 101 | {"🗑️ Deleted Files", fmt.Sprintf("%d", t.totalStats.DeletedFiles)}, 102 | {"📈 Deleted Size", utils.FormatSize(t.totalStats.DeletedSize)}, 103 | {"♻️ Trashed Files", fmt.Sprintf("%d", t.totalStats.TrashedFiles)}, 104 | {"📈 Trashed Size", utils.FormatSize(t.totalStats.TrashedSize)}, 105 | {"🚫 Ignored Files", fmt.Sprintf("%d", t.totalStats.IgnoredFiles)}, 106 | {"📈 Ignored Size", utils.FormatSize(t.totalStats.IgnoredSize)}, 107 | } 108 | 109 | // Create table content 110 | var tableContent strings.Builder 111 | for _, row := range rows { 112 | tableContent.WriteString(labelStyle.Render(row.label)) 113 | tableContent.WriteString(valueStyle.Render(row.value)) 114 | tableContent.WriteString("\n") 115 | 116 | if row.label == "💾 Total Size" || row.label == "📈 Trashed Size" || row.label == "🗑️ Deleted Size" || row.label == "📈 Deleted Size" || row.label == "⏱️ Program lifetime" { 117 | tableContent.WriteString("\n") 118 | } 119 | 120 | } 121 | 122 | // Render table with border 123 | content.WriteString(tableStyle.Render(tableContent.String())) 124 | content.WriteString(styles.PathStyle.Render(fmt.Sprintf("\n\nLog are stored in: %s", logging.GetLogFilePath()))) 125 | return content.String() 126 | } 127 | 128 | func (t *LogTab) UpdateStats(stats *logging.ScanStatistics) { 129 | if stats != nil { 130 | // Update current operation stats 131 | t.stats = stats 132 | t.stats.StartTime = t.startTime 133 | 134 | // Update total statistics 135 | t.totalStats.TotalFiles += stats.TotalFiles 136 | t.totalStats.TotalSize += stats.TotalSize 137 | t.totalStats.DeletedFiles += stats.DeletedFiles 138 | t.totalStats.DeletedSize += stats.DeletedSize 139 | t.totalStats.TrashedFiles += stats.TrashedFiles 140 | t.totalStats.TrashedSize += stats.TrashedSize 141 | t.totalStats.IgnoredFiles += stats.IgnoredFiles 142 | t.totalStats.IgnoredSize += stats.IgnoredSize 143 | 144 | // Force a redraw by sending a nil message to the model 145 | t.model.Update(nil) 146 | } 147 | } 148 | -------------------------------------------------------------------------------- /internal/filemanager/operations.go: -------------------------------------------------------------------------------- 1 | package filemanager 2 | 3 | import ( 4 | "os" 5 | "path/filepath" 6 | "runtime" 7 | "strings" 8 | "sync" 9 | "sync/atomic" 10 | "time" 11 | 12 | "github.com/Bios-Marcel/wastebasket/v2" 13 | "github.com/pashkov256/deletor/internal/utils" 14 | ) 15 | 16 | // WalkFilesWithFilter traverses files in a directory with concurrent processing 17 | // and applies the given filter to each file 18 | func (f *defaultFileManager) WalkFilesWithFilter(callback func(fi os.FileInfo, path string), dir string, filter *FileFilter) { 19 | taskCh := make(chan struct{}, runtime.NumCPU()) 20 | var wg sync.WaitGroup 21 | 22 | filepath.Walk(dir, func(path string, info os.FileInfo, err error) error { 23 | if info == nil { 24 | return nil 25 | } 26 | 27 | if err != nil { 28 | return nil 29 | } 30 | 31 | wg.Add(1) 32 | go func(path string, info os.FileInfo) { 33 | defer wg.Done() 34 | // Acquire token from channel first 35 | taskCh <- struct{}{} 36 | defer func() { <-taskCh }() // Release token when done 37 | if filter.MatchesFilters(info, path) { 38 | callback(info, path) 39 | } 40 | }(path, info) 41 | return nil 42 | }) 43 | 44 | wg.Wait() 45 | } 46 | 47 | // DeleteFiles removes files matching the specified criteria from the given directory 48 | func (f *defaultFileManager) DeleteFiles(dir string, extensions []string, exclude []string, minSize, maxSize int64, olderThan, newerThan time.Time) { 49 | callback := func(fi os.FileInfo, path string) { 50 | os.Remove(path) 51 | } 52 | fileFilter := f.NewFileFilter(minSize, maxSize, utils.ParseExtToMap(extensions), exclude, olderThan, newerThan) 53 | f.WalkFilesWithFilter(callback, dir, fileFilter) 54 | } 55 | 56 | // DeleteEmptySubfolders removes all empty directories in the given path 57 | func (f *defaultFileManager) DeleteEmptySubfolders(dir string) { 58 | emptyDirs := make([]string, 0) 59 | 60 | filepath.WalkDir(dir, func(path string, info os.DirEntry, err error) error { 61 | if info == nil || !info.IsDir() { 62 | return nil 63 | } 64 | 65 | if f.IsEmptyDir(path) { 66 | emptyDirs = append(emptyDirs, path) 67 | } 68 | 69 | return nil 70 | }) 71 | 72 | for i := len(emptyDirs) - 1; i >= 0; i-- { 73 | os.Remove(emptyDirs[i]) 74 | } 75 | } 76 | 77 | // CalculateDirSize computes the total size of all files in a directory 78 | // Uses concurrent processing with limits to handle large directories efficiently 79 | func (f *defaultFileManager) CalculateDirSize(path string) int64 { 80 | // For very large directories, return a placeholder value immediately 81 | // to avoid blocking the UI 82 | _, err := os.Stat(path) 83 | if err != nil { 84 | return 0 85 | } 86 | 87 | // If it's a very large directory (like C: or Program Files) 88 | // just return 0 immediately to prevent lag 89 | if strings.HasSuffix(path, ":\\") || strings.Contains(path, "Program Files") { 90 | return 0 91 | } 92 | 93 | var totalSize int64 = 0 94 | 95 | // Use a channel to limit concurrency 96 | semaphore := make(chan struct{}, 10) 97 | var wg sync.WaitGroup 98 | 99 | // Create a function to process a directory 100 | var processDir func(string) int64 101 | processDir = func(dirPath string) int64 { 102 | var size int64 = 0 103 | entries, err := os.ReadDir(dirPath) 104 | if err != nil { 105 | return 0 106 | } 107 | 108 | for _, entry := range entries { 109 | // Skip hidden files and directories unless enabled 110 | if strings.HasPrefix(entry.Name(), ".") { 111 | continue 112 | } 113 | 114 | fullPath := filepath.Join(dirPath, entry.Name()) 115 | if entry.IsDir() { 116 | // Process directories with concurrency limits 117 | wg.Add(1) 118 | go func(p string) { 119 | semaphore <- struct{}{} 120 | defer func() { 121 | <-semaphore 122 | wg.Done() 123 | }() 124 | dirSize := processDir(p) 125 | atomic.AddInt64(&totalSize, dirSize) 126 | }(fullPath) 127 | } else { 128 | // Process files directly 129 | info, err := entry.Info() 130 | if err == nil { 131 | fileSize := info.Size() 132 | atomic.AddInt64(&totalSize, fileSize) 133 | size += fileSize 134 | } 135 | } 136 | } 137 | return size 138 | } 139 | 140 | // Start processing 141 | processDir(path) 142 | 143 | wg.Wait() 144 | 145 | return totalSize 146 | } 147 | 148 | // MoveFilesToTrash moves files matching the criteria to the system's recycle bin 149 | func (f *defaultFileManager) MoveFilesToTrash(dir string, extensions []string, exclude []string, minSize, maxSize int64, olderThan, newerThan time.Time) { 150 | callback := func(fi os.FileInfo, path string) { 151 | f.MoveFileToTrash(path) 152 | } 153 | 154 | fileFilter := f.NewFileFilter(minSize, maxSize, utils.ParseExtToMap(extensions), exclude, olderThan, newerThan) 155 | f.WalkFilesWithFilter(callback, dir, fileFilter) 156 | } 157 | 158 | // MoveFileToTrash moves a single file to the system's recycle bin 159 | func (f *defaultFileManager) MoveFileToTrash(filePath string) { 160 | wastebasket.Trash(filePath) 161 | } 162 | 163 | // DeleteFile deletes a single file 164 | func (f *defaultFileManager) DeleteFile(filePath string) { 165 | os.Remove(filePath) 166 | } 167 | -------------------------------------------------------------------------------- /internal/tests/unit/cache/unix_internal_test.go: -------------------------------------------------------------------------------- 1 | //go:build linux || darwin 2 | // +build linux darwin 3 | 4 | package cache 5 | 6 | import ( 7 | "fmt" 8 | "os" 9 | "path/filepath" 10 | "testing" 11 | 12 | "github.com/pashkov256/deletor/internal/cache" 13 | ) 14 | 15 | func TestDeleteFileWithUnixAPI(t *testing.T) { 16 | tempDir := t.TempDir() 17 | 18 | t.Run("successful deletion of regular file", func(t *testing.T) { 19 | testFile := filepath.Join(tempDir, "test_file.txt") 20 | err := os.WriteFile(testFile, []byte("test content"), 0644) 21 | if err != nil { 22 | t.Fatalf("Failed to create test file: %v", err) 23 | } 24 | 25 | err = cache.DeleteFileWithUnixAPI(testFile) 26 | if err != nil { 27 | t.Errorf("DeleteFileWithUnixAPI failed: %v", err) 28 | } 29 | 30 | if _, err := os.Stat(testFile); !os.IsNotExist(err) { 31 | t.Errorf("File still exists after deletion") 32 | } 33 | }) 34 | 35 | t.Run("file with incorrect permissions gets chmod before deletion", func(t *testing.T) { 36 | testFile := filepath.Join(tempDir, "restricted_file.txt") 37 | err := os.WriteFile(testFile, []byte("restricted content"), 0600) 38 | if err != nil { 39 | t.Fatalf("Failed to create test file: %v", err) 40 | } 41 | 42 | err = cache.DeleteFileWithUnixAPI(testFile) 43 | if err != nil { 44 | t.Errorf("DeleteFileWithUnixAPI failed: %v", err) 45 | } 46 | 47 | if _, err := os.Stat(testFile); !os.IsNotExist(err) { 48 | t.Errorf("File still exists after deletion") 49 | } 50 | }) 51 | 52 | t.Run("file with correct permissions 0744", func(t *testing.T) { 53 | testFile := filepath.Join(tempDir, "correct_perms.txt") 54 | err := os.WriteFile(testFile, []byte("content"), 0744) 55 | if err != nil { 56 | t.Fatalf("Failed to create test file: %v", err) 57 | } 58 | 59 | err = cache.DeleteFileWithUnixAPI(testFile) 60 | if err != nil { 61 | t.Errorf("DeleteFileWithUnixAPI failed: %v", err) 62 | } 63 | 64 | if _, err := os.Stat(testFile); !os.IsNotExist(err) { 65 | t.Errorf("File still exists after deletion") 66 | } 67 | }) 68 | 69 | t.Run("nonexistent file returns error", func(t *testing.T) { 70 | nonexistentFile := filepath.Join(tempDir, "nonexistent.txt") 71 | 72 | err := cache.DeleteFileWithUnixAPI(nonexistentFile) 73 | if err == nil { 74 | t.Errorf("Expected error for nonexistent file, got nil") 75 | } 76 | }) 77 | 78 | t.Run("directory instead of file returns error", func(t *testing.T) { 79 | testDir := filepath.Join(tempDir, "test_directory") 80 | err := os.Mkdir(testDir, 0755) 81 | if err != nil { 82 | t.Fatalf("Failed to create test directory: %v", err) 83 | } 84 | 85 | err = cache.DeleteFileWithUnixAPI(testDir) 86 | if err == nil { 87 | t.Errorf("Expected error when trying to delete directory, got nil") 88 | } 89 | 90 | os.RemoveAll(testDir) 91 | }) 92 | 93 | t.Run("read-only file gets proper permissions before deletion", func(t *testing.T) { 94 | testFile := filepath.Join(tempDir, "readonly.txt") 95 | err := os.WriteFile(testFile, []byte("readonly content"), 0444) 96 | if err != nil { 97 | t.Fatalf("Failed to create test file: %v", err) 98 | } 99 | 100 | err = cache.DeleteFileWithUnixAPI(testFile) 101 | if err != nil { 102 | t.Errorf("DeleteFileWithUnixAPI failed: %v", err) 103 | } 104 | 105 | if _, err := os.Stat(testFile); !os.IsNotExist(err) { 106 | t.Errorf("File still exists after deletion") 107 | } 108 | }) 109 | } 110 | 111 | func TestDeleteFileWithWindowsAPI(t *testing.T) { 112 | t.Run("stub function returns nil", func(t *testing.T) { 113 | err := cache.DeleteFileWithWindowsAPI("/any/path") 114 | if err != nil { 115 | t.Errorf("Expected nil from stub function, got: %v", err) 116 | } 117 | }) 118 | } 119 | 120 | func BenchmarkDeleteFileWithUnixAPI(b *testing.B) { 121 | tempDir := b.TempDir() 122 | 123 | b.ResetTimer() 124 | for i := 0; i < b.N; i++ { 125 | testFile := filepath.Join(tempDir, fmt.Sprintf("bench_file_%d.txt", i)) 126 | err := os.WriteFile(testFile, []byte("benchmark content"), 0644) 127 | if err != nil { 128 | b.Fatalf("Failed to create test file: %v", err) 129 | } 130 | 131 | err = cache.DeleteFileWithUnixAPI(testFile) 132 | if err != nil { 133 | b.Fatalf("DeleteFileWithUnixAPI failed: %v", err) 134 | } 135 | } 136 | } 137 | 138 | func TestDeleteFileWithUnixAPI_Symlink(t *testing.T) { 139 | tempDir := t.TempDir() 140 | 141 | originalFile := filepath.Join(tempDir, "original.txt") 142 | err := os.WriteFile(originalFile, []byte("original content"), 0644) 143 | if err != nil { 144 | t.Fatalf("Failed to create original file: %v", err) 145 | } 146 | 147 | symlinkFile := filepath.Join(tempDir, "symlink.txt") 148 | err = os.Symlink(originalFile, symlinkFile) 149 | if err != nil { 150 | t.Fatalf("Failed to create symlink: %v", err) 151 | } 152 | 153 | err = cache.DeleteFileWithUnixAPI(symlinkFile) 154 | if err != nil { 155 | t.Errorf("DeleteFileWithUnixAPI failed on symlink: %v", err) 156 | } 157 | 158 | if _, err := os.Lstat(symlinkFile); !os.IsNotExist(err) { 159 | t.Errorf("Symlink still exists after deletion") 160 | } 161 | 162 | if _, err := os.Stat(originalFile); os.IsNotExist(err) { 163 | t.Errorf("Original file was deleted when removing symlink") 164 | } 165 | } 166 | -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | # Contributor Covenant Code of Conduct 2 | 3 | ## Our Pledge 4 | 5 | We as members, contributors, and leaders pledge to make participation in our 6 | community a harassment-free experience for everyone, regardless of age, body 7 | size, visible or invisible disability, ethnicity, sex characteristics, gender 8 | identity and expression, level of experience, education, socio-economic status, 9 | nationality, personal appearance, race, religion, or sexual identity 10 | and orientation. 11 | 12 | We pledge to act and interact in ways that contribute to an open, welcoming, 13 | diverse, inclusive, and healthy community. 14 | 15 | ## Our Standards 16 | 17 | Examples of behavior that contributes to a positive environment for our 18 | community include: 19 | 20 | * Demonstrating empathy and kindness toward other people 21 | * Being respectful of differing opinions, viewpoints, and experiences 22 | * Giving and gracefully accepting constructive feedback 23 | * Accepting responsibility and apologizing to those affected by our mistakes, 24 | and learning from the experience 25 | * Focusing on what is best not just for us as individuals, but for the 26 | overall community 27 | 28 | Examples of unacceptable behavior include: 29 | 30 | * The use of sexualized language or imagery, and sexual attention or 31 | advances of any kind 32 | * Trolling, insulting or derogatory comments, and personal or political attacks 33 | * Public or private harassment 34 | * Publishing others' private information, such as a physical or email 35 | address, without their explicit permission 36 | * Other conduct which could reasonably be considered inappropriate in a 37 | professional setting 38 | 39 | ## Enforcement Responsibilities 40 | 41 | Community leaders are responsible for clarifying and enforcing our standards of 42 | acceptable behavior and will take appropriate and fair corrective action in 43 | response to any behavior that they deem inappropriate, threatening, offensive, 44 | or harmful. 45 | 46 | Community leaders have the right and responsibility to remove, edit, or reject 47 | comments, commits, code, wiki edits, issues, and other contributions that are 48 | not aligned to this Code of Conduct, and will communicate reasons for moderation 49 | decisions when appropriate. 50 | 51 | ## Scope 52 | 53 | This Code of Conduct applies within all community spaces, and also applies when 54 | an individual is officially representing the community in public spaces. 55 | Examples of representing our community include using an official e-mail address, 56 | posting via an official social media account, or acting as an appointed 57 | representative at an online or offline event. 58 | 59 | ## Enforcement 60 | 61 | Instances of abusive, harassing, or otherwise unacceptable behavior may be 62 | reported to the community leaders responsible for enforcement at 63 | . 64 | All complaints will be reviewed and investigated promptly and fairly. 65 | 66 | All community leaders are obligated to respect the privacy and security of the 67 | reporter of any incident. 68 | 69 | ## Enforcement Guidelines 70 | 71 | Community leaders will follow these Community Impact Guidelines in determining 72 | the consequences for any action they deem in violation of this Code of Conduct: 73 | 74 | ### 1. Correction 75 | 76 | **Community Impact**: Use of inappropriate language or other behavior deemed 77 | unprofessional or unwelcome in the community. 78 | 79 | **Consequence**: A private, written warning from community leaders, providing 80 | clarity around the nature of the violation and an explanation of why the 81 | behavior was inappropriate. A public apology may be requested. 82 | 83 | ### 2. Warning 84 | 85 | **Community Impact**: A violation through a single incident or series 86 | of actions. 87 | 88 | **Consequence**: A warning with consequences for continued behavior. No 89 | interaction with the people involved, including unsolicited interaction with 90 | those enforcing the Code of Conduct, for a specified period of time. This 91 | includes avoiding interactions in community spaces as well as external channels 92 | like social media. Violating these terms may lead to a temporary or 93 | permanent ban. 94 | 95 | ### 3. Temporary Ban 96 | 97 | **Community Impact**: A serious violation of community standards, including 98 | sustained inappropriate behavior. 99 | 100 | **Consequence**: A temporary ban from any sort of interaction or public 101 | communication with the community for a specified period of time. No public or 102 | private interaction with the people involved, including unsolicited interaction 103 | with those enforcing the Code of Conduct, is allowed during this period. 104 | Violating these terms may lead to a permanent ban. 105 | 106 | ### 4. Permanent Ban 107 | 108 | **Community Impact**: Demonstrating a pattern of violation of community 109 | standards, including sustained inappropriate behavior, harassment of an 110 | individual, or aggression toward or disparagement of classes of individuals. 111 | 112 | **Consequence**: A permanent ban from any sort of public interaction within 113 | the community. 114 | 115 | ## Attribution 116 | 117 | This Code of Conduct is adapted from the [Contributor Covenant][homepage], 118 | version 2.0, available at 119 | https://www.contributor-covenant.org/version/2/0/code_of_conduct.html. 120 | 121 | Community Impact Guidelines were inspired by [Mozilla's code of conduct 122 | enforcement ladder](https://github.com/mozilla/diversity). 123 | 124 | [homepage]: https://www.contributor-covenant.org 125 | 126 | For answers to common questions about this code of conduct, see the FAQ at 127 | https://www.contributor-covenant.org/faq. Translations are available at 128 | https://www.contributor-covenant.org/translations. 129 | -------------------------------------------------------------------------------- /internal/tests/unit/cache/manager_test.go: -------------------------------------------------------------------------------- 1 | package cache 2 | 3 | import ( 4 | "os" 5 | "path/filepath" 6 | "runtime" 7 | "slices" 8 | "sort" 9 | "testing" 10 | 11 | "github.com/pashkov256/deletor/internal/cache" 12 | "github.com/pashkov256/deletor/internal/filemanager" 13 | ) 14 | 15 | func TestScanAllLocations(t *testing.T) { 16 | fm := filemanager.NewFileManager() 17 | Os := cache.OS(runtime.GOOS) 18 | t.Run("successful scanning of empty directory", func(t *testing.T) { 19 | tempDir1 := t.TempDir() 20 | tempDir2 := t.TempDir() 21 | locs := []cache.CacheLocation{ 22 | {Path: tempDir1, Type: "system"}, 23 | {Path: tempDir2, Type: "system"}, 24 | } 25 | testManager := &cache.Manager{ 26 | Os: Os, 27 | Locations: locs, 28 | Filemanager: fm, 29 | } 30 | results := testManager.ScanAllLocations() 31 | infoTemp1, statErr1 := os.Stat(tempDir1) 32 | if statErr1 != nil { 33 | t.Fatal(statErr1) 34 | } 35 | infoTemp2, statErr2 := os.Stat(tempDir2) 36 | if statErr2 != nil { 37 | t.Fatal(statErr2) 38 | } 39 | expected := []cache.ScanResult{ 40 | {FileCount: 1, Path: tempDir1, Size: infoTemp1.Size(), Error: nil}, //directories are being counted in FileCount as 1 41 | {FileCount: 1, Path: tempDir2, Size: infoTemp2.Size(), Error: nil}, //directories are being counted in FileCount as 1 42 | } 43 | sort.Slice(results, func(i, j int) bool { 44 | return results[i].Path < results[j].Path 45 | }) 46 | sort.Slice(expected, func(i, j int) bool { 47 | return expected[i].Path < expected[j].Path 48 | }) 49 | if len(results) != len(locs) { 50 | t.Fatalf("Expected %v scan results, got %v", len(locs), len(results)) 51 | } 52 | for i, res := range results { 53 | if res.FileCount != expected[i].FileCount { 54 | t.Errorf("Expected %v files in scan result %v, got %v", expected[i].FileCount, i, res.FileCount) 55 | } 56 | if res.Path != expected[i].Path { 57 | t.Errorf("Expected file path %v in scan result %v, got %v", expected[i].Path, i, res.Path) 58 | } 59 | if res.Size != expected[i].Size { 60 | t.Errorf("Expected %v size in scan result %v, got %v", expected[i].Size, i, res.Size) 61 | } 62 | if res.Error != nil { 63 | t.Errorf("Error in scan result %v: %v", i, res.Error) 64 | } 65 | } 66 | }) 67 | t.Run("successful scanning of nonempty directories", func(t *testing.T) { 68 | tempDirA := t.TempDir() 69 | tempDirB := t.TempDir() 70 | file1 := filepath.Join(tempDirA, "testfile1.txt") 71 | file2 := filepath.Join(tempDirB, "testfile2.txt") 72 | file3 := filepath.Join(tempDirB, "testfile3.txt") 73 | content1 := []byte("testfile1") 74 | content2 := []byte("testfile2") 75 | content3 := []byte("testfile3") 76 | os.WriteFile(file1, []byte(content1), 0644) 77 | os.WriteFile(file2, []byte(content2), 0644) 78 | os.WriteFile(file3, []byte(content3), 0644) 79 | locs := []cache.CacheLocation{ 80 | {Path: tempDirA, Type: "system"}, 81 | {Path: tempDirB, Type: "system"}, 82 | } 83 | testManager := &cache.Manager{ 84 | Os: Os, 85 | Locations: locs, 86 | Filemanager: fm, 87 | } 88 | results := testManager.ScanAllLocations() 89 | infoTempA, statErr1 := os.Stat(tempDirA) 90 | if statErr1 != nil { 91 | t.Fatal(statErr1) 92 | } 93 | infoTempB, statErr2 := os.Stat(tempDirB) 94 | if statErr2 != nil { 95 | t.Fatal(statErr2) 96 | } 97 | expected := []cache.ScanResult{ 98 | {FileCount: 2, Path: tempDirA, Size: (infoTempA.Size() + int64(len(content1))), Error: nil}, //directories are being counted in FileCount as 1 99 | {FileCount: 3, Path: tempDirB, Size: (infoTempB.Size() + int64(len(content2)+len(content3))), Error: nil}, //directories are being counted in FileCount as 1 100 | } 101 | sort.Slice(results, func(i, j int) bool { 102 | return results[i].Path < results[j].Path 103 | }) 104 | sort.Slice(expected, func(i, j int) bool { 105 | return expected[i].Path < expected[j].Path 106 | }) 107 | if len(results) != len(locs) { 108 | t.Fatalf("Expected %v scan results, got %v", len(locs), len(results)) 109 | } 110 | for i, res := range results { 111 | if res.FileCount != expected[i].FileCount { 112 | t.Errorf("Expected %v files in scan result %v, got %v", expected[i].FileCount, i, res.FileCount) 113 | } 114 | if res.Path != expected[i].Path { 115 | t.Errorf("Expected %v filepath in scan result %v, got %v", expected[i].Path, i, res.Path) 116 | } 117 | if res.Size != expected[i].Size { 118 | t.Errorf("Expected %v size in scan result %v, got %v", expected[i].Size, i, res.Size) 119 | } 120 | if res.Error != nil { 121 | t.Errorf("Error in result %v: %v", i, res.Error) 122 | } 123 | } 124 | }) 125 | t.Run("successful cross-platform scanning", func(t *testing.T) { 126 | m := cache.NewCacheManager(fm) 127 | results := m.ScanAllLocations() 128 | var expectedPaths = []string{} 129 | switch runtime.GOOS { 130 | case "windows": 131 | expectedPaths = []string{ 132 | filepath.Join(os.Getenv("LOCALAPPDATA"), "Temp"), 133 | filepath.Join(os.Getenv("LOCALAPPDATA"), "Microsoft", "Windows", "Explorer"), 134 | } 135 | case "linux": 136 | home, _ := os.UserHomeDir() 137 | expectedPaths = []string{ 138 | "/tmp", 139 | "/var/tmp", 140 | filepath.Join(home, ".cache"), 141 | } 142 | default: 143 | t.Skip("Unsupported OS") 144 | } 145 | sort.Slice(results, func(i, j int) bool { 146 | return results[i].Path < results[j].Path 147 | }) 148 | slices.Sort(expectedPaths) 149 | if len(results) != len(expectedPaths) { 150 | t.Fatalf("Expected %d results, got %d", len(expectedPaths), len(results)) 151 | } 152 | for i, result := range results { 153 | if result.Path != expectedPaths[i] { 154 | t.Errorf("Expected path %q but got %q", expectedPaths[i], result.Path) 155 | } 156 | } 157 | }) 158 | } 159 | -------------------------------------------------------------------------------- /internal/cli/config/config_test.go: -------------------------------------------------------------------------------- 1 | package config_test 2 | 3 | import ( 4 | "flag" 5 | "os" 6 | "testing" 7 | "time" 8 | 9 | "github.com/pashkov256/deletor/internal/cli/config" 10 | "github.com/stretchr/testify/assert" 11 | ) 12 | 13 | // resetFlags properly resets flag state between tests 14 | func resetFlags() { 15 | flag.CommandLine = flag.NewFlagSet(os.Args[0], flag.ExitOnError) 16 | } 17 | 18 | // TestDefaultValues verifies default config when no flags are provided 19 | func TestDefaultValues(t *testing.T) { 20 | resetFlags() 21 | oldArgs := os.Args 22 | defer func() { os.Args = oldArgs }() 23 | os.Args = []string{"cmd"} 24 | 25 | cfg := config.GetFlags() 26 | 27 | assert.Equal(t, ".", cfg.Directory) // Default should be current directory 28 | assert.Nil(t, cfg.Extensions) 29 | assert.Zero(t, cfg.MinSize) 30 | assert.Zero(t, cfg.MaxSize) 31 | assert.Nil(t, cfg.Exclude) 32 | assert.False(t, cfg.IncludeSubdirs) 33 | assert.False(t, cfg.ShowProgress) 34 | assert.False(t, cfg.IsCLIMode) 35 | assert.False(t, cfg.HaveProgress) 36 | assert.False(t, cfg.SkipConfirm) 37 | assert.False(t, cfg.DeleteEmptyFolders) 38 | assert.True(t, cfg.OlderThan.IsZero()) 39 | assert.True(t, cfg.NewerThan.IsZero()) 40 | assert.False(t, cfg.MoveFileToTrash) 41 | assert.False(t, cfg.UseRules) 42 | } 43 | 44 | // TestDirectoryFlag verifies -d flag parsing 45 | func TestDirectoryFlag(t *testing.T) { 46 | resetFlags() 47 | oldArgs := os.Args 48 | defer func() { os.Args = oldArgs }() 49 | os.Args = []string{"cmd", "-d", "/test/path"} 50 | 51 | cfg := config.GetFlags() 52 | assert.Equal(t, "/test/path", cfg.Directory) 53 | } 54 | 55 | // TestExtensionsFlag verifies -e flag parsing 56 | func TestExtensionsFlag(t *testing.T) { 57 | resetFlags() 58 | oldArgs := os.Args 59 | defer func() { os.Args = oldArgs }() 60 | os.Args = []string{"cmd", "-e", "txt,log"} 61 | 62 | cfg := config.GetFlags() 63 | assert.Equal(t, []string{".txt", ".log"}, cfg.Extensions) 64 | } 65 | 66 | // TestExcludeFlag verifies --exclude flag parsing 67 | func TestExcludeFlag(t *testing.T) { 68 | resetFlags() 69 | oldArgs := os.Args 70 | defer func() { os.Args = oldArgs }() 71 | os.Args = []string{"cmd", "--exclude", "temp,backup"} 72 | 73 | cfg := config.GetFlags() 74 | assert.Equal(t, []string{"temp", "backup"}, cfg.Exclude) 75 | } 76 | 77 | // TestMinSizeFlag verifies --min-size flag parsing 78 | func TestMinSizeFlag(t *testing.T) { 79 | resetFlags() 80 | oldArgs := os.Args 81 | defer func() { os.Args = oldArgs }() 82 | os.Args = []string{"cmd", "--min-size", "10MB"} 83 | 84 | cfg := config.GetFlags() 85 | assert.Equal(t, int64(10*1024*1024), cfg.MinSize) 86 | } 87 | 88 | // TestMaxSizeFlag verifies --max-size flag parsing 89 | func TestMaxSizeFlag(t *testing.T) { 90 | resetFlags() 91 | oldArgs := os.Args 92 | defer func() { os.Args = oldArgs }() 93 | os.Args = []string{"cmd", "--max-size", "1GB"} 94 | 95 | cfg := config.GetFlags() 96 | assert.Equal(t, int64(1024*1024*1024), cfg.MaxSize) 97 | } 98 | 99 | // TestOlderFlag verifies --older flag parsing 100 | func TestOlderFlag(t *testing.T) { 101 | resetFlags() 102 | oldArgs := os.Args 103 | defer func() { os.Args = oldArgs }() 104 | 105 | os.Args = []string{"cmd", "--older", "1day"} 106 | 107 | cfg := config.GetFlags() 108 | 109 | expected := time.Now().Add(-24 * time.Hour) 110 | assert.WithinDuration(t, expected, cfg.OlderThan, 5*time.Second) 111 | } 112 | 113 | func TestNewerFlag(t *testing.T) { 114 | resetFlags() 115 | oldArgs := os.Args 116 | defer func() { os.Args = oldArgs }() 117 | os.Args = []string{"cmd", "--newer", "1hour"} // Use hours format 118 | 119 | cfg := config.GetFlags() 120 | 121 | // Calculate expected time 122 | expected := time.Now().Add(-1 * time.Hour) 123 | 124 | // Use more lenient time comparison 125 | assert.WithinDuration(t, expected, cfg.NewerThan, 5*time.Second) 126 | } 127 | 128 | // TestBooleanFlags verifies boolean flag parsing 129 | func TestBooleanFlags(t *testing.T) { 130 | testCases := []struct { 131 | flag string 132 | check func(*config.Config) bool 133 | }{ 134 | {"--cli", func(c *config.Config) bool { return c.IsCLIMode }}, 135 | {"--progress", func(c *config.Config) bool { return c.HaveProgress }}, // FIXED HERE 136 | {"--subdirs", func(c *config.Config) bool { return c.IncludeSubdirs }}, 137 | {"--skip-confirm", func(c *config.Config) bool { return c.SkipConfirm }}, 138 | {"--prune-empty", func(c *config.Config) bool { return c.DeleteEmptyFolders }}, 139 | } 140 | 141 | for _, tc := range testCases { 142 | t.Run(tc.flag, func(t *testing.T) { 143 | resetFlags() 144 | oldArgs := os.Args 145 | defer func() { os.Args = oldArgs }() 146 | 147 | os.Args = []string{"cmd", tc.flag} 148 | 149 | cfg := config.GetFlags() 150 | assert.True(t, tc.check(cfg), "Flag %s should be true", tc.flag) 151 | }) 152 | } 153 | } 154 | 155 | // TestAllFlagsTogether verifies all flags work together 156 | func TestAllFlagsTogether(t *testing.T) { 157 | resetFlags() 158 | oldArgs := os.Args 159 | defer func() { os.Args = oldArgs }() 160 | 161 | os.Args = []string{ 162 | "cmd", 163 | "-d", "/full/path", 164 | "-e", "go,mod", 165 | "--exclude", "vendor,node_modules", 166 | "--min-size", "1MB", 167 | "--max-size", "10MB", 168 | "--older", "7day", // ✅ fixed from "7d" 169 | "--newer", "1hour", // also safer format 170 | "--cli", 171 | "--progress", 172 | "--subdirs", 173 | "--skip-confirm", 174 | "--prune-empty", 175 | } 176 | 177 | cfg := config.GetFlags() 178 | 179 | expectedOlder := time.Now().Add(-7 * 24 * time.Hour) 180 | expectedNewer := time.Now().Add(-1 * time.Hour) 181 | 182 | assert.Equal(t, "/full/path", cfg.Directory) 183 | assert.Equal(t, []string{".go", ".mod"}, cfg.Extensions) 184 | assert.Equal(t, []string{"vendor", "node_modules"}, cfg.Exclude) 185 | assert.Equal(t, int64(1024*1024), cfg.MinSize) 186 | assert.Equal(t, int64(10*1024*1024), cfg.MaxSize) 187 | 188 | assert.WithinDuration(t, expectedOlder, cfg.OlderThan, 5*time.Second) 189 | assert.WithinDuration(t, expectedNewer, cfg.NewerThan, 5*time.Second) 190 | 191 | assert.True(t, cfg.IsCLIMode) 192 | assert.True(t, cfg.HaveProgress) 193 | assert.True(t, cfg.IncludeSubdirs) 194 | assert.True(t, cfg.SkipConfirm) 195 | assert.True(t, cfg.DeleteEmptyFolders) 196 | } 197 | -------------------------------------------------------------------------------- /internal/tui/styles/styles.go: -------------------------------------------------------------------------------- 1 | package styles 2 | 3 | import "github.com/charmbracelet/lipgloss" 4 | 5 | var buttonColor = lipgloss.Color("#1E90FF") 6 | var buttonFocusedColor = lipgloss.Color("#1570CC") 7 | var buttonWidth = 30 8 | 9 | // Common styles for the entire application 10 | var ( 11 | // Base styles 12 | AppStyle = lipgloss.NewStyle().Padding(0, 2) 13 | 14 | // Title styles 15 | TitleStyle = lipgloss.NewStyle(). 16 | Foreground(lipgloss.Color("#FFFDF5")). 17 | Background(lipgloss.Color("#1E90FF")). 18 | Padding(0, 1).MarginTop(2).Bold(true).Italic(true).Underline(true) 19 | 20 | ListTitleStyle = lipgloss.NewStyle(). 21 | Foreground(lipgloss.Color("#FFFDF5")). 22 | Background(lipgloss.Color("#1E90FF")). 23 | Padding(0, 1).Bold(true) 24 | 25 | SelectedFileItem = lipgloss.NewStyle().Foreground(lipgloss.Color("#07f767")).Bold(true) 26 | 27 | DocStyle = lipgloss.NewStyle(). 28 | Padding(1, 1) 29 | 30 | MenuItem = lipgloss.NewStyle(). 31 | Foreground(lipgloss.Color("#FFFFFF")).MarginBottom(1) 32 | 33 | // nolint:staticcheck 34 | SelectedMenuItemStyle = MenuItem.Copy(). 35 | Foreground(lipgloss.Color("#1E90FF")).Bold(true) 36 | 37 | // Input styles 38 | StandardInputStyle = lipgloss.NewStyle(). 39 | Border(lipgloss.RoundedBorder()). 40 | BorderForeground(lipgloss.Color("#666666")). 41 | Padding(0, 0). 42 | Width(87) 43 | 44 | StandardInputFocusedStyle = lipgloss.NewStyle(). 45 | Border(lipgloss.RoundedBorder()). 46 | BorderForeground(lipgloss.Color("#1E90FF")). 47 | Padding(0, 0). 48 | Width(87) 49 | 50 | // Text input styles 51 | TextInputPromptStyle = lipgloss.NewStyle(). 52 | Foreground(lipgloss.Color("#1E90FF")) 53 | 54 | TextInputTextStyle = lipgloss.NewStyle(). 55 | Foreground(lipgloss.Color("#FFFFFF")) 56 | 57 | TextInputCursorStyle = lipgloss.NewStyle(). 58 | Foreground(lipgloss.Color("#FF6666")) 59 | 60 | // Button styles 61 | StandardButtonStyle = lipgloss.NewStyle(). 62 | Background(buttonColor). 63 | Foreground(lipgloss.Color("#fff")). 64 | Width(buttonWidth). 65 | Bold(true). 66 | AlignHorizontal(lipgloss.Center) 67 | 68 | StandardButtonFocusedStyle = lipgloss.NewStyle(). 69 | Background(buttonFocusedColor). 70 | Foreground(lipgloss.Color("#fff")). 71 | Width(buttonWidth). 72 | Bold(true). 73 | AlignHorizontal(lipgloss.Center) 74 | 75 | // Special button styles (for delete and launch) 76 | DeleteButtonStyle = lipgloss.NewStyle(). 77 | Foreground(lipgloss.Color("#fff")). 78 | Background(lipgloss.Color("#FF6666 ")). 79 | BorderForeground(lipgloss.Color("#FF6666")). 80 | Width(buttonWidth). 81 | AlignHorizontal(lipgloss.Center) 82 | 83 | DeleteButtonFocusedStyle = lipgloss.NewStyle(). 84 | Foreground(lipgloss.Color("#fff")). 85 | Background(lipgloss.Color("#CC5252")). 86 | Padding(0, 1). 87 | Width(buttonWidth). 88 | AlignHorizontal(lipgloss.Center) 89 | 90 | LaunchButtonStyle = lipgloss.NewStyle(). 91 | Foreground(lipgloss.Color("#fff")). 92 | Background(lipgloss.Color("#42bd48")). 93 | Width(buttonWidth). 94 | Bold(true). 95 | AlignHorizontal(lipgloss.Center) 96 | 97 | LaunchButtonFocusedStyle = lipgloss.NewStyle(). 98 | Background(lipgloss.Color("#2E7D32")). 99 | Foreground(lipgloss.Color("#fff")). 100 | Width(buttonWidth). 101 | Bold(true). 102 | AlignHorizontal(lipgloss.Center) 103 | 104 | // Tab styles 105 | TabStyle = lipgloss.NewStyle(). 106 | Border(lipgloss.Border{ 107 | Bottom: "─", 108 | }). 109 | BorderForeground(lipgloss.Color("#666666")). 110 | Padding(0, 1). 111 | MarginRight(1) 112 | 113 | // nolint:staticcheck 114 | ActiveTabStyle = TabStyle.Copy(). 115 | Border(lipgloss.RoundedBorder()). 116 | BorderForeground(lipgloss.Color("#1E90FF")). 117 | Foreground(lipgloss.Color("#1E90FF")). 118 | Bold(true) 119 | 120 | // List styles 121 | ListStyle = lipgloss.NewStyle(). 122 | Border(lipgloss.RoundedBorder()). 123 | BorderForeground(lipgloss.Color("#666666")). 124 | Padding(0, 0). 125 | Width(87). 126 | Height(9) 127 | 128 | // nolint:staticcheck 129 | ListFocusedStyle = ListStyle.Copy(). 130 | Border(lipgloss.DoubleBorder()). 131 | BorderForeground(lipgloss.Color("#0067cf")) 132 | 133 | // Item styles 134 | ItemStyle = lipgloss.NewStyle(). 135 | Foreground(lipgloss.Color("#dddddd")) 136 | 137 | SelectedItemStyle = lipgloss.NewStyle(). 138 | Foreground(lipgloss.Color("#1E90FF")). 139 | Background(lipgloss.Color("#0066ff")). 140 | Bold(true) 141 | 142 | // Option styles 143 | OptionStyle = lipgloss.NewStyle(). 144 | Foreground(lipgloss.Color("#FFFDF5")) 145 | 146 | SelectedOptionStyle = lipgloss.NewStyle(). 147 | Foreground(lipgloss.Color("#ad58b3")). 148 | Bold(true) 149 | 150 | OptionFocusedStyle = lipgloss.NewStyle(). 151 | Foreground(lipgloss.Color("#5f5fd7")). 152 | Background(lipgloss.Color("#333333")) 153 | 154 | ErrorStyle = lipgloss.NewStyle(). 155 | Foreground(lipgloss.Color("#FF0000")). 156 | Bold(true) 157 | 158 | SuccessStyle = lipgloss.NewStyle(). 159 | Foreground(lipgloss.Color("#00FF00")). 160 | Bold(true). 161 | Padding(0, 1). 162 | Margin(1, 0). 163 | Border(lipgloss.RoundedBorder()). 164 | BorderForeground(lipgloss.Color("#00FF00")) 165 | 166 | PathStyle = lipgloss.NewStyle(). 167 | Foreground(lipgloss.Color("#666666")). 168 | Italic(true) 169 | 170 | InfoStyle = lipgloss.NewStyle(). 171 | Foreground(lipgloss.Color("#ebd700")). 172 | Padding(0, 1) 173 | 174 | ScanResultHeaderStyle = lipgloss.NewStyle(). 175 | Foreground(lipgloss.Color("#666666")). 176 | Bold(true). 177 | Padding(0, 1) 178 | 179 | ScanResultPathStyle = lipgloss.NewStyle(). 180 | Foreground(lipgloss.Color("#FFFFFF")). 181 | Padding(0, 1) 182 | 183 | ScanResultSizeStyle = lipgloss.NewStyle(). 184 | Foreground(lipgloss.Color("#fff")). 185 | Bold(true). 186 | Padding(0, 1) 187 | 188 | ScanResultFilesStyle = lipgloss.NewStyle(). 189 | Foreground(lipgloss.Color("#fff")). 190 | Bold(true). 191 | Padding(0, 1) 192 | 193 | ScanResultEmptyStyle = lipgloss.NewStyle(). 194 | Foreground(lipgloss.Color("#fff")). 195 | Italic(true). 196 | Padding(0, 1) 197 | 198 | ScanResultSizeCellStyle = lipgloss.NewStyle(). 199 | Foreground(lipgloss.Color("#fff")). 200 | Padding(0, 1). 201 | Align(lipgloss.Right) 202 | 203 | ScanResultFilesCellStyle = lipgloss.NewStyle(). 204 | Foreground(lipgloss.Color("#fff")). 205 | Padding(0, 1). 206 | Align(lipgloss.Right) 207 | ) 208 | -------------------------------------------------------------------------------- /internal/tests/unit/validation/validator_test.go: -------------------------------------------------------------------------------- 1 | package validation 2 | 3 | import ( 4 | "errors" 5 | "os" 6 | "path/filepath" 7 | "strings" 8 | "testing" 9 | 10 | "github.com/pashkov256/deletor/internal/validation" 11 | ) 12 | 13 | func setupTestDir(t *testing.T) string { 14 | dir := filepath.Join(os.TempDir(), "deletor_test") 15 | if err := os.MkdirAll(dir, 0755); err != nil { 16 | t.Fatalf("Failed to create test directory: %v", err) 17 | } 18 | return dir 19 | } 20 | 21 | func cleanupTestDir(t *testing.T, dir string) { 22 | if err := os.RemoveAll(dir); err != nil { 23 | t.Errorf("Failed to clean up test directory: %v", err) 24 | } 25 | } 26 | 27 | func TestValidator_ValidateSize(t *testing.T) { 28 | validator := validation.NewValidator() 29 | 30 | tests := []struct { 31 | name string 32 | size string 33 | wantErr bool 34 | }{ 35 | // Valid cases 36 | {"valid size with space", "10 mb", false}, 37 | {"valid size without space", "10mb", false}, 38 | {"valid size with decimal", "10.5 mb", false}, 39 | {"valid size with GB", "1 gb", false}, 40 | {"valid size with KB", "100 kb", false}, 41 | {"valid size with B", "1024 b", false}, 42 | 43 | // Invalid cases 44 | {"invalid format", "10m", true}, 45 | {"invalid unit", "10 tb", true}, 46 | {"empty size", "", true}, 47 | {"negative size", "-10 mb", true}, 48 | {"invalid decimal", "10.5.5 mb", true}, 49 | {"no number", "mb", true}, 50 | } 51 | 52 | for _, tt := range tests { 53 | t.Run(tt.name, func(t *testing.T) { 54 | err := validator.ValidateSize(tt.size) 55 | if (err != nil) != tt.wantErr { 56 | t.Errorf("ValidateSize(%q) error = %v, wantErr %v", tt.size, err, tt.wantErr) 57 | } 58 | }) 59 | } 60 | } 61 | 62 | func TestValidator_ValidatePath(t *testing.T) { 63 | validator := validation.NewValidator() 64 | testDir := setupTestDir(t) 65 | defer cleanupTestDir(t, testDir) 66 | 67 | tests := []struct { 68 | name string 69 | path string 70 | optional bool 71 | wantErr bool 72 | }{ 73 | // Valid cases 74 | {"valid path", testDir, false, false}, 75 | {"empty path optional", "", true, false}, 76 | 77 | // Invalid cases 78 | {"empty path not optional", "", false, true}, 79 | {"non-existent path", "/nonexistent/path", false, true}, 80 | {"invalid path format", "invalid/path/format", false, true}, 81 | } 82 | 83 | for _, tt := range tests { 84 | t.Run(tt.name, func(t *testing.T) { 85 | err := validator.ValidatePath(tt.path, tt.optional) 86 | if (err != nil) != tt.wantErr { 87 | t.Errorf("ValidatePath(%q, %v) error = %v, wantErr %v", 88 | tt.path, tt.optional, err, tt.wantErr) 89 | } 90 | }) 91 | } 92 | } 93 | 94 | func TestValidator_ValidateExtension(t *testing.T) { 95 | validator := validation.NewValidator() 96 | 97 | tests := []struct { 98 | name string 99 | ext string 100 | wantErr bool 101 | }{ 102 | // Valid cases 103 | {"valid extension", "png", false}, 104 | {"valid extension uppercase", "PNG", false}, 105 | {"valid extension mixed case", "Png", false}, 106 | {"valid extension with numbers", "mp4", false}, 107 | 108 | // Invalid cases 109 | {"invalid extension with dot", ".png", true}, 110 | {"invalid extension with space", "p n g", true}, 111 | {"empty extension", "", true}, 112 | {"invalid characters", "png!", true}, 113 | } 114 | 115 | for _, tt := range tests { 116 | t.Run(tt.name, func(t *testing.T) { 117 | err := validator.ValidateExtension(tt.ext) 118 | if (err != nil) != tt.wantErr { 119 | t.Errorf("ValidateExtension(%q) error = %v, wantErr %v", 120 | tt.ext, err, tt.wantErr) 121 | } 122 | }) 123 | } 124 | } 125 | 126 | func TestValidateTimeDuration(t *testing.T) { 127 | errString := "expected format: number followed by time unit (sec, min, hour, day, week, month, year)" 128 | tests := []struct { 129 | name string 130 | input string 131 | expected error 132 | }{ 133 | // Valid cases 134 | {"Valid seconds singular", "1 sec", nil}, 135 | {"Valid seconds plural", "10 secs", nil}, 136 | {"Valid minutes singular", "1 min", nil}, 137 | {"Valid minutes plural", "30 mins", nil}, 138 | {"Valid hours singular", "1 hour", nil}, 139 | {"Valid hours plural", "24 hours", nil}, 140 | {"Valid days singular", "1 day", nil}, 141 | {"Valid days plural", "7 days", nil}, 142 | {"Valid weeks singular", "1 week", nil}, 143 | {"Valid weeks plural", "4 weeks", nil}, 144 | {"Valid months singular", "1 month", nil}, 145 | {"Valid months plural", "12 months", nil}, 146 | {"Valid years singular", "1 year", nil}, 147 | {"Valid years plural", "5 years", nil}, 148 | {"Valid with space", "5 sec", nil}, 149 | {"Valid with multiple spaces", "10 secs", nil}, 150 | {"Valid uppercase units", "1 WEEK", nil}, 151 | {"Valid mixed case units", "1 mOnTh", nil}, 152 | {"Valid large number", "9999999999999 years", nil}, 153 | {"Valid no space", "5sec", nil}, 154 | {"Valid minimal space", "5 sec", nil}, 155 | {"Valid extra space", "5 sec", nil}, 156 | {"Newline character", "10\nsec", nil}, 157 | {"Tab character", "10\tsec", nil}, 158 | {"Unit without s", "2 year", nil}, 159 | {"Unit with extra s", "1 years", nil}, 160 | 161 | // Invalid cases 162 | {"Empty string", "", errors.New(errString)}, 163 | {"Only spaces", " ", errors.New(errString)}, 164 | {"Missing number", "sec", errors.New(errString)}, 165 | {"Missing unit", "10", errors.New(errString)}, 166 | {"Invalid unit", "10 apples", errors.New(errString)}, 167 | {"Negative number", "-5 sec", errors.New(errString)}, 168 | {"Decimal number", "5.5 sec", errors.New(errString)}, 169 | {"Trailing characters", "10 sec!", errors.New(errString)}, 170 | {"Leading characters", "about 10 sec", errors.New(errString)}, 171 | {"Multiple numbers", "10 20 sec", errors.New(errString)}, 172 | {"Invalid plural form", "1 secss", errors.New(errString)}, 173 | {"Invalid time unit", "10 lightyears", errors.New(errString)}, 174 | {"Special characters", "#$% sec", errors.New(errString)}, 175 | {"Scientific notation", "1e5 sec", errors.New(errString)}, 176 | {"Comma separated", "1,000 sec", errors.New(errString)}, 177 | } 178 | 179 | v := validation.NewValidator() 180 | 181 | for _, tt := range tests { 182 | t.Run(tt.name, func(t *testing.T) { 183 | err := v.ValidateTimeDuration(tt.input) 184 | if tt.expected == nil { 185 | if err != nil { 186 | t.Errorf("Input: %s", tt.input) 187 | } 188 | } else { 189 | if err != nil { 190 | if !strings.Contains(err.Error(), tt.expected.Error()) { 191 | t.Errorf("ValidateTimeDuration(%q) error = %v, want containing %v", tt.input, err, tt.expected) 192 | } 193 | } else { 194 | t.Errorf("ValidateTimeDuration(%q) expected error but got nil", tt.input) 195 | } 196 | } 197 | }) 198 | } 199 | } 200 | -------------------------------------------------------------------------------- /internal/tests/unit/logging/storage_test.go: -------------------------------------------------------------------------------- 1 | package logging 2 | 3 | import ( 4 | "os" 5 | "testing" 6 | "time" 7 | 8 | "github.com/pashkov256/deletor/internal/logging" 9 | "github.com/pashkov256/deletor/internal/logging/storage" 10 | ) 11 | 12 | func setupTestStorage(t *testing.T) (*storage.FileStorage, string) { 13 | t.Helper() 14 | tempDir, err := os.MkdirTemp("", "storage_test_*") 15 | if err != nil { 16 | t.Fatalf("Failed to create temp dir: %v", err) 17 | } 18 | 19 | return storage.NewFileStorage(tempDir), tempDir 20 | } 21 | 22 | func cleanupTestStorage(t *testing.T, tempDir string) { 23 | t.Helper() 24 | 25 | if err := os.RemoveAll(tempDir); err != nil { 26 | t.Errorf("Failed to remove temp dir: %v", err) 27 | } 28 | } 29 | 30 | func TestSaveAndReadStatistics(t *testing.T) { 31 | fs, tempDir := setupTestStorage(t) 32 | defer cleanupTestStorage(t, tempDir) 33 | 34 | stats := &logging.ScanStatistics{ 35 | TotalFiles: 100, 36 | TotalSize: 1000, 37 | DeletedFiles: 50, 38 | DeletedSize: 500, 39 | TrashedFiles: 30, 40 | TrashedSize: 300, 41 | IgnoredFiles: 20, 42 | IgnoredSize: 200, 43 | StartTime: time.Now(), 44 | EndTime: time.Now().Add(time.Hour), 45 | Directory: "/test/dir", 46 | OperationType: "scan", 47 | } 48 | 49 | if err := fs.SaveStatistics(stats); err != nil { 50 | t.Fatalf("Failed to save statistics: %v", err) 51 | } 52 | 53 | readStats, err := fs.GetStatistics("test") 54 | if err != nil { 55 | t.Fatalf("Failed to read statistics: %v", err) 56 | } 57 | if readStats.TotalFiles != stats.TotalFiles { 58 | t.Errorf("TotalFiles = %v, want %v", readStats.TotalFiles, stats.TotalFiles) 59 | } 60 | if readStats.TotalSize != stats.TotalSize { 61 | t.Errorf("TotalSize = %v, want %v", readStats.TotalSize, stats.TotalSize) 62 | } 63 | if readStats.DeletedFiles != stats.DeletedFiles { 64 | t.Errorf("DeletedFiles = %v, want %v", readStats.DeletedFiles, stats.DeletedFiles) 65 | } 66 | if readStats.TrashedFiles != stats.TrashedFiles { 67 | t.Errorf("TrashedFiles = %v, want %v", readStats.TrashedFiles, stats.TrashedFiles) 68 | } 69 | if readStats.IgnoredFiles != stats.IgnoredFiles { 70 | t.Errorf("IgnoredFiles = %v, want %v", readStats.IgnoredFiles, stats.IgnoredFiles) 71 | } 72 | if readStats.Directory != stats.Directory { 73 | t.Errorf("Directory = %v, want %v", readStats.Directory, stats.Directory) 74 | } 75 | if readStats.OperationType != stats.OperationType { 76 | t.Errorf("OperationType = %v, want %v", readStats.OperationType, stats.OperationType) 77 | } 78 | } 79 | 80 | func TestSaveAndReadOperations(t *testing.T) { 81 | fs, tempDir := setupTestStorage(t) 82 | defer cleanupTestStorage(t, tempDir) 83 | 84 | // Create test operations 85 | operations := []*logging.FileOperation{ 86 | { 87 | FilePath: "/test/file1.txt", 88 | FileSize: 100, 89 | OperationType: logging.OperationDeleted, 90 | Reason: "Test deletion", 91 | RuleApplied: "size_rule", 92 | }, 93 | { 94 | FilePath: "/test/file2.txt", 95 | FileSize: 200, 96 | OperationType: logging.OperationTrashed, 97 | Reason: "Test trash", 98 | RuleApplied: "date_rule", 99 | }, 100 | { 101 | FilePath: "/test/file3.txt", 102 | FileSize: 300, 103 | OperationType: logging.OperationIgnored, 104 | Reason: "Test ignore", 105 | RuleApplied: "pattern_rule", 106 | }, 107 | } 108 | 109 | for _, op := range operations { 110 | if err := fs.SaveOperation(op); err != nil { 111 | t.Fatalf("Failed to save operation: %v", err) 112 | } 113 | } 114 | 115 | readOps, err := fs.GetOperations("test") 116 | if err != nil { 117 | t.Fatalf("Failed to read operations: %v", err) 118 | } 119 | 120 | if len(readOps) != len(operations) { 121 | t.Fatalf("Expected %d operations, got %d", len(operations), len(readOps)) 122 | } 123 | 124 | for i, op := range operations { 125 | readOp := readOps[i] 126 | if readOp.FilePath != op.FilePath { 127 | t.Errorf("Operation %d: FilePath = %v, want %v", i, readOp.FilePath, op.FilePath) 128 | } 129 | if readOp.FileSize != op.FileSize { 130 | t.Errorf("Operation %d: FileSize = %v, want %v", i, readOp.FileSize, op.FileSize) 131 | } 132 | if readOp.OperationType != op.OperationType { 133 | t.Errorf("Operation %d: OperationType = %v, want %v", i, readOp.OperationType, op.OperationType) 134 | } 135 | if readOp.Reason != op.Reason { 136 | t.Errorf("Operation %d: Reason = %v, want %v", i, readOp.Reason, op.Reason) 137 | } 138 | if readOp.RuleApplied != op.RuleApplied { 139 | t.Errorf("Operation %d: RuleApplied = %v, want %v", i, readOp.RuleApplied, op.RuleApplied) 140 | } 141 | } 142 | } 143 | 144 | func TestConcurrentStorageOperations(t *testing.T) { 145 | fs, tempDir := setupTestStorage(t) 146 | defer cleanupTestStorage(t, tempDir) 147 | 148 | stats := &logging.ScanStatistics{ 149 | TotalFiles: 100, 150 | TotalSize: 1000, 151 | DeletedFiles: 50, 152 | DeletedSize: 500, 153 | TrashedFiles: 30, 154 | TrashedSize: 300, 155 | IgnoredFiles: 20, 156 | IgnoredSize: 200, 157 | StartTime: time.Now(), 158 | EndTime: time.Now().Add(time.Hour), 159 | Directory: "/test/dir", 160 | OperationType: "scan", 161 | } 162 | 163 | op := &logging.FileOperation{ 164 | FilePath: "/test/file1.txt", 165 | FileSize: 100, 166 | OperationType: logging.OperationDeleted, 167 | Reason: "Test deletion", 168 | RuleApplied: "size_rule", 169 | } 170 | 171 | done := make(chan bool) 172 | for i := 0; i < 10; i++ { 173 | go func(id int) { 174 | if err := fs.SaveStatistics(stats); err != nil { 175 | t.Errorf("Failed to save statistics in goroutine %d: %v", id, err) 176 | } 177 | 178 | if err := fs.SaveOperation(op); err != nil { 179 | t.Errorf("Failed to save operation in goroutine %d: %v", id, err) 180 | } 181 | 182 | if _, err := fs.GetStatistics("test"); err != nil { 183 | t.Errorf("Failed to read statistics in goroutine %d: %v", id, err) 184 | } 185 | 186 | if _, err := fs.GetOperations("test"); err != nil { 187 | t.Errorf("Failed to read operations in goroutine %d: %v", id, err) 188 | } 189 | 190 | done <- true 191 | }(i) 192 | } 193 | 194 | for i := 0; i < 10; i++ { 195 | <-done 196 | } 197 | 198 | readStats, err := fs.GetStatistics("test") 199 | if err != nil { 200 | t.Fatalf("Failed to read final statistics: %v", err) 201 | } 202 | 203 | if readStats.TotalFiles != stats.TotalFiles { 204 | t.Errorf("Final TotalFiles = %v, want %v", readStats.TotalFiles, stats.TotalFiles) 205 | } 206 | 207 | readOps, err := fs.GetOperations("test") 208 | if err != nil { 209 | t.Fatalf("Failed to read final operations: %v", err) 210 | } 211 | 212 | if len(readOps) == 0 { 213 | t.Error("Expected at least one operation after concurrent writes") 214 | } 215 | } 216 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |
4 | 5 |11 | Manage and delete files efficiently with an interactive TUI and scriptable CLI. 12 |
13 | 14 |
39 |
40 |
45 | deletor is featured as "Tool of The Week" (June 10, 2025) on Terminal Trove
46 |
47 |
48 |
49 |
50 |