├── .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 |

2 | deletor 3 |

4 | 5 |

6 | MIT 7 | codecov 8 | 9 | 10 |

11 | Manage and delete files efficiently with an interactive TUI and scriptable CLI. 12 |

13 | 14 |
15 |

16 | 17 | 18 | 19 | Deletor is a handy file deletion tool that combines a powerful text interface (**TUI**) with visual directory navigation, and classic command line mode (**CLI**). With it, you can quickly find and delete files by filters, send them to the trash or completely erase them, as well as clear the cache, both interactively and through scripts. 20 | 21 | ## Features 22 | - 🖥️ **Interactive TUI**: Modern text-based user interface for easy file navigation and management 23 | - 🖱️ **Mouse Support**: Full mouse support for selection, scrolling, and interaction 24 | - 🔢 **Multi-Selection**: Select multiple files at once for batch operations 25 | - ♻️ **Safe Deletion: Files**: Are moved to the system trash/recycle bin instead of permanent deletion 26 | - 🧹 **OS Cache Cleaner**: Free up space by deleting temporary system cache 27 | - 🛠️ **Deep Customization** Shape the tool to behave exactly how you need 28 | - 🧠 **Rules System**: Save your filter settings and preferences for quick access 29 | - 📖 **Log Operations**: Log the various fields and look at the tui table, or parse the file 30 | - ⏳ **Modification Time Filter**: Delete files older,newer than X days/hours/minutes 31 | - 📏 **Size Filter**: Deletes only files larger than the specified size 32 | - 🗑️ **Extensions Filter**: Deletes files with specified extensions 33 | - 📂 **Directory Navigation**: Easy navigation through directories with arrow keys 34 | - 🎯 **Quick Selection**: Select and delete files with keyboard shortcuts 35 | - ✅ **Confirmation Prompt**: Optional confirmation before deleting files 36 | 37 | --- 38 |

39 | Project Banner 40 |

41 | 42 | ## 43 | 44 |

45 | deletor is featured as "Tool of The Week" (June 10, 2025) on Terminal Trove 46 | 47 | 48 | Terminal Trove Tool of The Week 49 | 50 |

51 | 52 | 53 | 54 | 55 | ## 📦 Installation 56 | 57 | 58 | Packaging status 59 | 60 | 61 | ### Using Go 62 | ```bash 63 | go install github.com/pashkov256/deletor@latest 64 | ``` 65 | 66 | 67 | 68 | ## 🛠 Usage 69 | 70 | ### TUI Mode (default): 71 | 72 | ```bash 73 | deletor 74 | ``` 75 | ### CLI Mode (with filters): 76 | ```bash 77 | deletor -cli -d ~/Downloads -e mp4,zip --min-size 10mb -subdirs --exclude data,backup 78 | ``` 79 | ### Dev launch: 80 | ```bash 81 | go run . -cli -d ~/Downloads -e mp4,zip --min-size 10mb -subdirs --exclude data,backup 82 | ``` 83 | 84 | ### ⚙️ CLI Flags 85 | 86 | | Flags | Description | 87 | |----------------|-----------------------------------------------------------------------------| 88 | | `-e` | Comma-separated list of extensions (e.g., `mp4,zip,jpg`). | 89 | | `-d` | Path to the file search directory. | 90 | | `--min-size` | Minimum file size to delete (e.g., `10kb`, `1mb`, `1gb`). | 91 | | `--max-size` | Maximum file size to delete (e.g., `10kb`, `1mb`, `1gb`). | 92 | | `--older` | Modification time older than (e.g., `1sec`, `2min`, `3hour`, `4day`). | 93 | | `--newer` | Modification time newer than (e.g., `1sec`, `2min`, `3hour`, `4day`). | 94 | | `--exclude` | Exclude specific files/paths (e.g., `data`, `backup`). | 95 | | `-subdirs` | Include subdirectories in scan. Default is false. | 96 | | `-prune-empty` | Delete empty folders after scan. | 97 | | `-rules` | Running with values from the rules | 98 | | `-progress` | Display a progress bar during file scanning. | 99 | | `-skip-confirm`| Skip the confirmation of deletion. | 100 | 101 | 102 | ## ✨ The Power of Dual Modes: TUI and CLI 103 | 104 | - TUI mode provides a user-friendly way to navigate and manage files visually, ideal for manual cleanups and exploration. 105 | 106 | - CLI mode is perfect for automation, scripting, and quick one-liners. It's essential for server environments, cron jobs, and integrating into larger toolchains. 107 | 108 | 109 | 110 | ## 🛠 Contributing 111 | We welcome and appreciate any contributions to Deletor! 112 | There are many ways you can help us grow and improve: 113 | 114 | - **🐛 Report Bugs** — Found an issue? Let us know by opening an issue. 115 | - **💡 Suggest Features** — Got an idea for a new feature? We'd love to hear it! 116 | - **📚 Improve Documentation** — Help us make the docs even clearer and easier to use. 117 | - **💻 Submit Code** — Fix a bug, refactor code, or add new functionality by submitting a pull request. 118 | 119 | Before contributing, please take a moment to read our [CONTRIBUTING.md](https://github.com/pashkov256/deletor/blob/main/CONTRIBUTING.md) guide. 120 | It explains how to set up the project, coding standards, and the process for submitting contributions. 121 | 122 | Together, we can make Deletor even better! 🚀 123 | 124 | 125 | ## AI docs 126 | https://code2tutorial.com/tutorial/3aac813f-99c2-453f-819f-c80e4322e068/index.md 127 | 128 | 129 | 130 | ## 📜 License 131 | This project is distributed under the **MIT** license. 132 | 133 | --- 134 | ### Thank you to these wonderful people for their contributions! 135 | 136 | 137 | 138 | 139 | -------------------------------------------------------------------------------- /internal/tui/tabs/clean/main_tab.go: -------------------------------------------------------------------------------- 1 | package clean 2 | 3 | import ( 4 | "fmt" 5 | "path/filepath" 6 | "strings" 7 | 8 | "github.com/charmbracelet/bubbles/list" 9 | tea "github.com/charmbracelet/bubbletea" 10 | "github.com/charmbracelet/lipgloss" 11 | zone "github.com/lrstanley/bubblezone" 12 | "github.com/pashkov256/deletor/internal/models" 13 | "github.com/pashkov256/deletor/internal/tui/interfaces" 14 | "github.com/pashkov256/deletor/internal/tui/styles" 15 | "github.com/pashkov256/deletor/internal/utils" 16 | ) 17 | 18 | type MainTab struct { 19 | model interfaces.CleanModel 20 | } 21 | 22 | func (t *MainTab) Init() tea.Cmd { return nil } 23 | func (t *MainTab) Update(msg tea.Msg) tea.Cmd { return nil } 24 | 25 | func (t *MainTab) View() string { 26 | var content strings.Builder 27 | 28 | // Path input 29 | pathStyle := styles.StandardInputStyle 30 | if t.model.GetFocusedElement() == "pathInput" { 31 | pathStyle = styles.StandardInputFocusedStyle 32 | } 33 | content.WriteString(zone.Mark("main_path_input", pathStyle.Render("Current Path: "+t.model.GetPathInput().View()))) 34 | content.WriteString("\n") 35 | 36 | // If no path is set, show only the start button 37 | if t.model.GetCurrentPath() == "" { 38 | startButtonStyle := styles.LaunchButtonStyle 39 | if t.model.GetFocusedElement() == "startButton" { 40 | startButtonStyle = styles.LaunchButtonFocusedStyle 41 | } 42 | content.WriteString("\n") 43 | content.WriteString(zone.Mark("main_start_button", startButtonStyle.Render("📂 Launch"))) 44 | content.WriteString("\n") 45 | } else { 46 | // Show full interface when path is set 47 | extStyle := styles.StandardInputStyle 48 | if t.model.GetFocusedElement() == "extInput" { 49 | extStyle = styles.StandardInputFocusedStyle 50 | } 51 | content.WriteString(zone.Mark("main_ext_input", extStyle.Render("Extensions: "+t.model.GetExtInput().View()))) 52 | content.WriteString("\n\n") 53 | 54 | if !t.model.GetShowDirs() { 55 | // Show selected files info if any are selected 56 | if t.model.GetSelectedCount() > 0 { 57 | content.WriteString(styles.ListTitleStyle.Render(fmt.Sprintf("Selected files (%d) • Selected size: %s", 58 | t.model.GetSelectedCount(), utils.FormatSize(t.model.GetSelectedSize())))) 59 | } else { 60 | // Show total files info 61 | content.WriteString(styles.ListTitleStyle.Render(fmt.Sprintf("All files (%d) • Total size: %s", 62 | t.model.GetFilteredCount(), utils.FormatSize(t.model.GetFilteredSize())))) 63 | } 64 | 65 | } else { 66 | content.WriteString(styles.ListTitleStyle.Render(fmt.Sprintf("Directories in %s (%d)", 67 | filepath.Base(t.model.GetCurrentPath()), len(t.model.GetDirList().Items())))) 68 | } 69 | content.WriteString("\n") 70 | // List content 71 | var listContent strings.Builder 72 | 73 | // List items 74 | var activeList list.Model 75 | if t.model.GetShowDirs() { 76 | activeList = t.model.GetDirList() 77 | } else { 78 | activeList = t.model.GetList() 79 | } 80 | 81 | if len(activeList.Items()) == 0 { 82 | if !t.model.GetShowDirs() { 83 | listContent.WriteString("No files match your filters. Try changing extensions or size filters.") 84 | } else { 85 | listContent.WriteString("No directories found in this location.") 86 | } 87 | } else { 88 | items := activeList.Items() 89 | selectedIndex := activeList.Index() 90 | totalItems := len(items) 91 | 92 | visibleItems := 10 93 | if visibleItems > totalItems { 94 | visibleItems = totalItems 95 | } 96 | 97 | startIdx := 0 98 | if selectedIndex > visibleItems-3 && totalItems > visibleItems { 99 | startIdx = selectedIndex - (visibleItems / 2) 100 | if startIdx+visibleItems > totalItems { 101 | startIdx = totalItems - visibleItems 102 | } 103 | } 104 | if startIdx < 0 { 105 | startIdx = 0 106 | } 107 | 108 | endIdx := startIdx + visibleItems 109 | if endIdx > totalItems { 110 | endIdx = totalItems 111 | } 112 | 113 | for i := startIdx; i < endIdx; i++ { 114 | item := items[i].(models.CleanItem) 115 | icon := utils.GetFileIcon(item.Size, item.Path, item.IsDir) 116 | filename := filepath.Base(item.Path) 117 | sizeStr := "" 118 | if item.Size >= 0 && !item.IsDir { 119 | sizeStr = utils.FormatSize(item.Size) 120 | } else if item.Size == -1 { 121 | sizeStr = "UP TO DIR" 122 | } else if item.IsDir { 123 | sizeStr = "DIR" 124 | } 125 | prefix := " " 126 | style := lipgloss.NewStyle() 127 | 128 | if i == selectedIndex { 129 | prefix = "> " 130 | style = style.Foreground(lipgloss.Color("#FFFFFF")).Background(lipgloss.Color("#0066ff")).Bold(true) 131 | } else if item.IsDir && item.Size != -1 { 132 | style = style.Foreground(lipgloss.Color("#ccc")) 133 | } else if item.Size == -1 { 134 | style = style.Foreground(lipgloss.Color("#578cdb")) 135 | } 136 | 137 | // Check if file is selected 138 | if !t.model.GetShowDirs() && t.model.GetSelectedFiles()[item.Path] { 139 | style = styles.SelectedFileItem 140 | } 141 | 142 | const iconWidth = 3 143 | const filenameWidth = 70 144 | const sizeWidth = 10 145 | 146 | iconDisplay := fmt.Sprintf("%-*s", iconWidth, icon) 147 | displayName := filename 148 | if len(displayName) > filenameWidth { 149 | displayName = displayName[:filenameWidth-3] + "..." 150 | } 151 | sizeDisplay := fmt.Sprintf("%-*s", sizeWidth, sizeStr) 152 | 153 | line := fmt.Sprintf("%s%s%-*s%s", 154 | prefix, 155 | iconDisplay, 156 | filenameWidth, displayName, 157 | sizeDisplay) 158 | 159 | listContent.WriteString(style.Render(line)) 160 | listContent.WriteString("\n") 161 | } 162 | 163 | if totalItems > visibleItems { 164 | scrollInfo := fmt.Sprintf("\nShowing %d-%d of %d items (%.0f%%)", 165 | startIdx+1, endIdx, totalItems, 166 | float64(selectedIndex+1)/float64(totalItems)*100) 167 | listContent.WriteString(lipgloss.NewStyle().Italic(true).Foreground(lipgloss.Color("#999999")).Render(scrollInfo)) 168 | } 169 | } 170 | 171 | listStyle := styles.ListStyle 172 | if t.model.GetFocusedElement() == "list" { 173 | listStyle = styles.ListFocusedStyle 174 | } 175 | content.WriteString(listStyle.Render(listContent.String())) 176 | 177 | // Buttons section 178 | content.WriteString("\n\n") 179 | if t.model.GetFocusedElement() == "dirButton" { 180 | content.WriteString(zone.Mark("main_dir_button", styles.StandardButtonFocusedStyle.Render("➡️ Show directories"))) 181 | } else { 182 | content.WriteString(zone.Mark("main_dir_button", styles.StandardButtonStyle.Render("➡️ Show directories"))) 183 | } 184 | content.WriteString(" ") 185 | 186 | if t.model.GetFocusedElement() == "deleteButton" { 187 | content.WriteString(zone.Mark("main_delete_button", styles.DeleteButtonFocusedStyle.Render("🗑️ Start cleaning"))) 188 | } else { 189 | content.WriteString(zone.Mark("main_delete_button", styles.DeleteButtonStyle.Render("🗑️ Start cleaning"))) 190 | } 191 | content.WriteString("\n") 192 | } 193 | 194 | return content.String() 195 | } 196 | -------------------------------------------------------------------------------- /internal/tests/integration/runner/cli_test.go: -------------------------------------------------------------------------------- 1 | package runner_test 2 | 3 | import ( 4 | "fmt" 5 | "os" 6 | "path/filepath" 7 | "testing" 8 | "time" 9 | 10 | "github.com/pashkov256/deletor/internal/cli/config" 11 | "github.com/pashkov256/deletor/internal/filemanager" 12 | "github.com/pashkov256/deletor/internal/rules" 13 | "github.com/pashkov256/deletor/internal/runner" 14 | "github.com/stretchr/testify/assert" 15 | ) 16 | 17 | func setupTestDir(t *testing.T) (string, func()) { 18 | tempDir, err := os.MkdirTemp("", "deletor_test_*") 19 | assert.NoError(t, err) 20 | 21 | files := map[string]string{ 22 | "test1.txt": "content1", 23 | "test2.txt": "content2", 24 | "test3.doc": "content3", 25 | "test4.doc": "content4", 26 | "test5.pdf": "content5", 27 | "exclude.txt": "exclude content", 28 | "subdir/test6.txt": "content6", 29 | "subdir/empty/": "", 30 | } 31 | 32 | for path, content := range files { 33 | fullPath := filepath.Join(tempDir, path) 34 | if path == "subdir/empty/" { 35 | err := os.MkdirAll(fullPath, 0755) 36 | assert.NoError(t, err) 37 | continue 38 | } 39 | err := os.MkdirAll(filepath.Dir(fullPath), 0755) 40 | assert.NoError(t, err) 41 | err = os.WriteFile(fullPath, []byte(content), 0644) 42 | assert.NoError(t, err) 43 | } 44 | 45 | // Возвращаем функцию очистки 46 | cleanup := func() { 47 | os.RemoveAll(tempDir) 48 | } 49 | 50 | return tempDir, cleanup 51 | } 52 | 53 | func countFilesAndDirs(dir string) (int, int) { 54 | var fileCount, dirCount int 55 | fmt.Printf("\nScanning directory: %s\n", dir) 56 | filepath.Walk(dir, func(path string, info os.FileInfo, err error) error { 57 | if err != nil { 58 | fmt.Printf("Error accessing path %s: %v\n", path, err) 59 | return nil 60 | } 61 | if info.IsDir() { 62 | dirCount++ 63 | fmt.Printf("Found directory: %s\n", path) 64 | } else { 65 | fileCount++ 66 | fmt.Printf("Found file: %s\n", path) 67 | } 68 | return nil 69 | }) 70 | fmt.Printf("Total files: %d, Total directories: %d\n", fileCount, dirCount) 71 | return fileCount, dirCount 72 | } 73 | 74 | func TestRunCLI_BasicFileOperations(t *testing.T) { 75 | testDir, cleanup := setupTestDir(t) 76 | defer cleanup() 77 | 78 | tests := []struct { 79 | name string 80 | config *config.Config 81 | expectedFiles int 82 | expectedDirs int 83 | }{ 84 | { 85 | name: "Delete by extension", 86 | config: &config.Config{ 87 | Directory: testDir, 88 | Extensions: []string{".txt"}, 89 | SkipConfirm: true, 90 | IncludeSubdirs: true, 91 | JsonLogsEnabled: true, 92 | }, 93 | expectedFiles: 3, // remaining .doc and .pdf files 94 | expectedDirs: 3, 95 | }, 96 | { 97 | name: "Delete by size", 98 | config: &config.Config{ 99 | Directory: testDir, 100 | MinSize: 1, 101 | MaxSize: 5, 102 | SkipConfirm: true, 103 | IncludeSubdirs: true, 104 | }, 105 | expectedFiles: 3, // remaining files larger than 5 bytes 106 | expectedDirs: 3, 107 | }, 108 | { 109 | name: "Delete by time", 110 | config: &config.Config{ 111 | Directory: testDir, 112 | OlderThan: time.Now().Add(-time.Hour), 113 | SkipConfirm: true, 114 | IncludeSubdirs: true, 115 | JsonLogsEnabled: true, 116 | }, 117 | expectedFiles: 3, // all files are newly created, but some are deleted 118 | expectedDirs: 3, 119 | }, 120 | { 121 | name: "Delete with exclude", 122 | config: &config.Config{ 123 | Directory: testDir, 124 | Extensions: []string{".txt"}, 125 | Exclude: []string{"exclude"}, 126 | SkipConfirm: true, 127 | IncludeSubdirs: true, 128 | }, 129 | expectedFiles: 3, // remaining .doc, .pdf files and exclude.txt 130 | expectedDirs: 3, 131 | }, 132 | } 133 | 134 | for _, tt := range tests { 135 | t.Run(tt.name, func(t *testing.T) { 136 | fm := filemanager.NewFileManager() 137 | r := rules.NewRules() 138 | 139 | runner.RunCLI(fm, r, tt.config) 140 | 141 | fileCount, dirCount := countFilesAndDirs(testDir) 142 | assert.Equal(t, tt.expectedFiles, fileCount, "File count mismatch") 143 | assert.Equal(t, tt.expectedDirs, dirCount, "Directory count mismatch") 144 | }) 145 | } 146 | } 147 | 148 | func TestRunCLI_DirectoryOperations(t *testing.T) { 149 | testDir, cleanup := setupTestDir(t) 150 | defer cleanup() 151 | 152 | tests := []struct { 153 | name string 154 | config *config.Config 155 | expectedFiles int 156 | expectedDirs int 157 | }{ 158 | { 159 | name: "Single directory scan", 160 | config: &config.Config{ 161 | Directory: testDir, 162 | Extensions: []string{".txt"}, 163 | SkipConfirm: true, 164 | IncludeSubdirs: false, 165 | }, 166 | expectedFiles: 4, // remaining .doc, .pdf files and files in subdir 167 | expectedDirs: 3, 168 | }, 169 | { 170 | name: "Recursive directory scan", 171 | config: &config.Config{ 172 | Directory: testDir, 173 | Extensions: []string{".txt"}, 174 | SkipConfirm: true, 175 | IncludeSubdirs: true, 176 | }, 177 | expectedFiles: 3, // remaining .doc and .pdf files 178 | expectedDirs: 3, 179 | }, 180 | { 181 | name: "Delete empty folders", 182 | config: &config.Config{ 183 | Directory: testDir, 184 | SkipConfirm: true, 185 | IncludeSubdirs: true, 186 | DeleteEmptyFolders: true, 187 | }, 188 | expectedFiles: 0, 189 | expectedDirs: 0, 190 | }, 191 | } 192 | 193 | for _, tt := range tests { 194 | t.Run(tt.name, func(t *testing.T) { 195 | fm := filemanager.NewFileManager() 196 | r := rules.NewRules() 197 | 198 | runner.RunCLI(fm, r, tt.config) 199 | 200 | fileCount, dirCount := countFilesAndDirs(testDir) 201 | assert.Equal(t, tt.expectedFiles, fileCount, "File count mismatch") 202 | assert.Equal(t, tt.expectedDirs, dirCount, "Directory count mismatch") 203 | }) 204 | } 205 | } 206 | 207 | func TestRunCLI_UserInteraction(t *testing.T) { 208 | testDir, cleanup := setupTestDir(t) 209 | defer cleanup() 210 | 211 | t.Run("Skip confirmation", func(t *testing.T) { 212 | config := &config.Config{ 213 | Directory: testDir, 214 | Extensions: []string{".txt"}, 215 | SkipConfirm: true, 216 | IncludeSubdirs: true, 217 | } 218 | 219 | fm := filemanager.NewFileManager() 220 | r := rules.NewRules() 221 | 222 | runner.RunCLI(fm, r, config) 223 | 224 | fileCount, dirCount := countFilesAndDirs(testDir) 225 | assert.Equal(t, 3, fileCount, "Should have 3 files remaining (.doc and .pdf files)") 226 | assert.Equal(t, 3, dirCount, "Should have 3 directories remaining") 227 | }) 228 | 229 | // Тест с подтверждением 230 | t.Run("With confirmation", func(t *testing.T) { 231 | tmpFile, err := os.CreateTemp("", "input_*") 232 | assert.NoError(t, err) 233 | defer os.Remove(tmpFile.Name()) 234 | 235 | _, err = tmpFile.WriteString("y\n") 236 | assert.NoError(t, err) 237 | tmpFile.Close() 238 | 239 | oldStdin := os.Stdin 240 | defer func() { os.Stdin = oldStdin }() 241 | 242 | // Открываем временный файл как stdin 243 | file, err := os.Open(tmpFile.Name()) 244 | assert.NoError(t, err) 245 | os.Stdin = file 246 | defer file.Close() 247 | 248 | config := &config.Config{ 249 | Directory: testDir, 250 | Extensions: []string{".doc"}, 251 | SkipConfirm: false, 252 | IncludeSubdirs: true, 253 | } 254 | 255 | fm := filemanager.NewFileManager() 256 | r := rules.NewRules() 257 | 258 | runner.RunCLI(fm, r, config) 259 | 260 | fileCount, dirCount := countFilesAndDirs(testDir) 261 | assert.Equal(t, 1, fileCount, "Should have 1 file remaining (.pdf file)") 262 | assert.Equal(t, 3, dirCount, "Should have 3 directories remaining") 263 | }) 264 | } 265 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | github.com/Bios-Marcel/wastebasket/v2 v2.0.3 h1:TkoDPcSqluhLGE+EssHu7UGmLgUEkWg7kNyHyyJ3Q9g= 2 | github.com/Bios-Marcel/wastebasket/v2 v2.0.3/go.mod h1:769oPCv6eH7ugl90DYIsWwjZh4hgNmMS3Zuhe1bH6KU= 3 | github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4= 4 | github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI= 5 | github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k= 6 | github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8= 7 | github.com/charmbracelet/bubbles v0.18.0 h1:PYv1A036luoBGroX6VWjQIE9Syf2Wby2oOl/39KLfy0= 8 | github.com/charmbracelet/bubbles v0.18.0/go.mod h1:08qhZhtIwzgrtBjAcJnij1t1H0ZRjwHyGsy6AL11PSw= 9 | github.com/charmbracelet/bubbletea v1.3.5 h1:JAMNLTbqMOhSwoELIr0qyP4VidFq72/6E9j7HHmRKQc= 10 | github.com/charmbracelet/bubbletea v1.3.5/go.mod h1:TkCnmH+aBd4LrXhXcqrKiYwRs7qyQx5rBgH5fVY3v54= 11 | github.com/charmbracelet/colorprofile v0.3.1 h1:k8dTHMd7fgw4bnFd7jXTLZrSU/CQrKnL3m+AxCzDz40= 12 | github.com/charmbracelet/colorprofile v0.3.1/go.mod h1:/GkGusxNs8VB/RSOh3fu0TJmQ4ICMMPApIIVn0KszZ0= 13 | github.com/charmbracelet/lipgloss v1.1.0 h1:vYXsiLHVkK7fp74RkV7b2kq9+zDLoEU4MZoFqR/noCY= 14 | github.com/charmbracelet/lipgloss v1.1.0/go.mod h1:/6Q8FR2o+kj8rz4Dq0zQc3vYf7X+B0binUUBwA0aL30= 15 | github.com/charmbracelet/x/ansi v0.9.2 h1:92AGsQmNTRMzuzHEYfCdjQeUzTrgE1vfO5/7fEVoXdY= 16 | github.com/charmbracelet/x/ansi v0.9.2/go.mod h1:3RQDQ6lDnROptfpWuUVIUG64bD2g2BgntdxH0Ya5TeE= 17 | github.com/charmbracelet/x/cellbuf v0.0.13 h1:/KBBKHuVRbq1lYx5BzEHBAFBP8VcQzJejZ/IA3iR28k= 18 | github.com/charmbracelet/x/cellbuf v0.0.13/go.mod h1:xe0nKWGd3eJgtqZRaN9RjMtK7xUYchjzPr7q6kcvCCs= 19 | github.com/charmbracelet/x/term v0.2.1 h1:AQeHeLZ1OqSXhrAWpYUtZyX1T3zVxfpZuEQMIQaGIAQ= 20 | github.com/charmbracelet/x/term v0.2.1/go.mod h1:oQ4enTYFV7QN4m0i9mzHrViD7TQKvNEEkHUMCmsxdUg= 21 | github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 22 | github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= 23 | github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 24 | github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4= 25 | github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM= 26 | github.com/fatih/color v1.16.0 h1:zmkK9Ngbjj+K0yRhTVONQh1p/HknKYSlNT+vZCzyokM= 27 | github.com/fatih/color v1.16.0/go.mod h1:fL2Sau1YI5c0pdGEVCbKQbLXB6edEj1ZgiY4NijnWvE= 28 | github.com/gobwas/glob v0.2.3 h1:A4xDbljILXROh+kObIiy5kIaPYD8e96x1tgBhUI5J+Y= 29 | github.com/gobwas/glob v0.2.3/go.mod h1:d3Ez4x06l9bZtSvzIay5+Yzi0fmZzPgnTbPcKjJAkT8= 30 | github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= 31 | github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= 32 | github.com/k0kubun/go-ansi v0.0.0-20180517002512-3bf9e2903213/go.mod h1:vNUNkEQ1e29fT/6vq2aBdFsgNPmy8qMdSay1npru+Sw= 33 | github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc= 34 | github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw= 35 | github.com/lrstanley/bubblezone v1.0.0 h1:bIpUaBilD42rAQwlg/4u5aTqVAt6DSRKYZuSdmkr8UA= 36 | github.com/lrstanley/bubblezone v1.0.0/go.mod h1:kcTekA8HE/0Ll2bWzqHlhA2c513KDNLW7uDfDP4Mly8= 37 | github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY= 38 | github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= 39 | github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA= 40 | github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg= 41 | github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= 42 | github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= 43 | github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= 44 | github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4= 45 | github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88= 46 | github.com/mattn/go-runewidth v0.0.12/go.mod h1:RAqKPSqVFrSLVXbA8x7dzmKdmGzieGRCM46jaSJTDAk= 47 | github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc= 48 | github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= 49 | github.com/mitchellh/colorstring v0.0.0-20190213212951-d06e56a500db h1:62I3jR2EmQ4l5rM/4FEfDWcRD+abF5XlKShorW5LRoQ= 50 | github.com/mitchellh/colorstring v0.0.0-20190213212951-d06e56a500db/go.mod h1:l0dey0ia/Uv7NcFFVbCLtqEBQbrT4OCwCSKTEv6enCw= 51 | github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 h1:ZK8zHtRHOkbHy6Mmr5D264iyp3TiX5OmNcI5cIARiQI= 52 | github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6/go.mod h1:CJlz5H+gyd6CUWT45Oy4q24RdLyn7Md9Vj2/ldJBSIo= 53 | github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA= 54 | github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo= 55 | github.com/muesli/reflow v0.3.0 h1:IFsN6K9NfGtjeggFP+68I4chLZV2yIKsXJFNZ+eWh6s= 56 | github.com/muesli/reflow v0.3.0/go.mod h1:pbwTDkVPibjO2kyvBQRBxTWEEGDGq0FlB1BIKtnHY/8= 57 | github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc= 58 | github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk= 59 | github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= 60 | github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 61 | github.com/rivo/uniseg v0.1.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= 62 | github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= 63 | github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= 64 | github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= 65 | github.com/sahilm/fuzzy v0.1.1-0.20230530133925-c48e322e2a8f h1:MvTmaQdww/z0Q4wrYjDSCcZ78NoftLQyHBSLW/Cx79Y= 66 | github.com/sahilm/fuzzy v0.1.1-0.20230530133925-c48e322e2a8f/go.mod h1:VFvziUEIMCrT6A6tw2RFIXPXXmzXbOsSHF0DOI8ZK9Y= 67 | github.com/schollz/progressbar/v3 v3.14.2 h1:EducH6uNLIWsr560zSV1KrTeUb/wZGAHqyMFIEa99ks= 68 | github.com/schollz/progressbar/v3 v3.14.2/go.mod h1:aQAZQnhF4JGFtRJiw/eobaXpsqpVQAftEQ+hLGXaRc4= 69 | github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= 70 | github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= 71 | github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= 72 | github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= 73 | github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no= 74 | github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM= 75 | golang.org/x/exp v0.0.0-20220909182711-5c715a9e8561 h1:MDc5xs78ZrZr3HMQugiXOAkSZtfTpbJLDr/lwfgO53E= 76 | golang.org/x/exp v0.0.0-20220909182711-5c715a9e8561/go.mod h1:cyybsKvd6eL0RnXn6p/Grxp8F5bW7iYuBgsNCOHpMYE= 77 | golang.org/x/sync v0.15.0 h1:KWH3jNZsfyT6xfAfKiz6MRNmd46ByHDYaZ7KSkCtdW8= 78 | golang.org/x/sync v0.15.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= 79 | golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 80 | golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 81 | golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 82 | golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= 83 | golang.org/x/sys v0.33.0 h1:q3i8TbbEz+JRD9ywIRlyRAQbM0qF7hu24q3teo2hbuw= 84 | golang.org/x/sys v0.33.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= 85 | golang.org/x/term v0.17.0/go.mod h1:lLRBjIVuehSbZlaOtGMbcMncT+aqLLLmKrsjNrUguwk= 86 | golang.org/x/term v0.28.0 h1:/Ts8HFuMR2E6IP/jlo7QVLZHggjKQbhu/7H0LJFr3Gg= 87 | golang.org/x/term v0.28.0/go.mod h1:Sw/lC2IAUZ92udQNf3WodGtn4k/XoLyZoh8v/8uiwek= 88 | golang.org/x/text v0.26.0 h1:P42AVeLghgTYr4+xUnTRKDMqpar+PtX7KWuNQL21L8M= 89 | golang.org/x/text v0.26.0/go.mod h1:QK15LZJUUQVJxhz7wXgxSy/CJaTFjd0G+YLonydOVQA= 90 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= 91 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 92 | gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= 93 | gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 94 | -------------------------------------------------------------------------------- /internal/tui/views/cache.go: -------------------------------------------------------------------------------- 1 | package views 2 | 3 | import ( 4 | "fmt" 5 | "runtime" 6 | "strconv" 7 | "strings" 8 | 9 | "github.com/charmbracelet/bubbles/textinput" 10 | tea "github.com/charmbracelet/bubbletea" 11 | "github.com/charmbracelet/lipgloss" 12 | zone "github.com/lrstanley/bubblezone" 13 | "github.com/pashkov256/deletor/internal/cache" 14 | "github.com/pashkov256/deletor/internal/filemanager" 15 | "github.com/pashkov256/deletor/internal/tui/help" 16 | "github.com/pashkov256/deletor/internal/tui/options" 17 | "github.com/pashkov256/deletor/internal/tui/styles" 18 | "github.com/pashkov256/deletor/internal/utils" 19 | ) 20 | 21 | type CacheModel struct { 22 | OptionState map[string]bool 23 | FocusedElement string 24 | cacheManager cache.Manager 25 | filemanager filemanager.FileManager 26 | scanResults []cache.ScanResult 27 | isScanning bool 28 | status string 29 | } 30 | 31 | type CachePath struct { 32 | Path string 33 | Size string 34 | } 35 | 36 | func InitialCacheModel(fm filemanager.FileManager) *CacheModel { 37 | return &CacheModel{ 38 | cacheManager: *cache.NewCacheManager(fm), 39 | filemanager: fm, 40 | OptionState: options.DefaultCacheOptionState, 41 | FocusedElement: "option1", 42 | status: "", 43 | } 44 | } 45 | 46 | const pathWidth = 60 47 | const sizeWidth = 15 48 | const filesWidth = 10 49 | 50 | func (m *CacheModel) Init() tea.Cmd { 51 | return textinput.Blink 52 | } 53 | 54 | func (m *CacheModel) View() string { 55 | var content strings.Builder 56 | 57 | content.WriteString("\n") 58 | content.WriteString("Select cache types to clear:\n") 59 | for optionIndex, name := range options.DefaultCacheOption { 60 | style := styles.OptionStyle 61 | if m.OptionState[name] { 62 | style = styles.SelectedOptionStyle 63 | } 64 | if m.FocusedElement == fmt.Sprintf("option%d", optionIndex+1) { 65 | style = styles.OptionFocusedStyle 66 | } 67 | content.WriteString(fmt.Sprintf("%-4s", fmt.Sprintf("%d.", optionIndex+1))) 68 | 69 | emoji := "" 70 | switch name { 71 | case options.SystemCache: 72 | emoji = "💻" 73 | } 74 | 75 | optionContent := fmt.Sprintf("[%s] %s %-20s", map[bool]string{true: "✓", false: "○"}[m.OptionState[name]], emoji, name) 76 | content.WriteString(zone.Mark(fmt.Sprintf("cache_option_%d", optionIndex+1), style.Render(optionContent))) 77 | content.WriteString("\n") 78 | } 79 | 80 | if len(m.scanResults) > 0 { 81 | content.WriteString("\n\n") 82 | 83 | // nolint:staticcheck 84 | pathStyle := styles.ScanResultPathStyle.Copy().Width(pathWidth).Align(lipgloss.Left) 85 | // nolint:staticcheck 86 | sizeStyle := styles.ScanResultSizeStyle.Copy().Width(sizeWidth).Align(lipgloss.Right) 87 | // nolint:staticcheck 88 | filesStyle := styles.ScanResultFilesStyle.Copy().Width(filesWidth).Align(lipgloss.Right) 89 | 90 | header := lipgloss.JoinHorizontal(lipgloss.Top, 91 | pathStyle.Render("Directory"), 92 | sizeStyle.Render("Size"), 93 | filesStyle.Render("Files"), 94 | ) 95 | content.WriteString(styles.ScanResultHeaderStyle.Render(header)) 96 | content.WriteString("\n") 97 | 98 | // Separator line 99 | separator := styles.ScanResultHeaderStyle.Render(strings.Repeat("─", pathWidth+sizeWidth+filesWidth)) 100 | content.WriteString(separator) 101 | content.WriteString("\n") 102 | 103 | var totalSize int64 104 | var totalFiles int64 105 | 106 | // Results 107 | for _, result := range m.scanResults { 108 | pathCell := pathStyle.Render(result.Path) 109 | sizeCell := sizeStyle.Render(utils.FormatSize(result.Size)) 110 | filesCell := filesStyle.Render(fmt.Sprintf("%d", result.FileCount)) 111 | content.WriteString(lipgloss.JoinHorizontal(lipgloss.Top, pathCell, sizeCell, filesCell)) 112 | content.WriteString("\n") 113 | totalSize += result.Size 114 | totalFiles += result.FileCount 115 | } 116 | 117 | content.WriteString(separator) 118 | content.WriteString("\n") 119 | 120 | totalLabel := pathStyle.Render("Total\n\n") 121 | totalSizeStr := sizeStyle.Render(utils.FormatSize(totalSize)) 122 | totalFilesStr := filesStyle.Render(fmt.Sprintf("%d", totalFiles)) 123 | content.WriteString(lipgloss.JoinHorizontal(lipgloss.Top, totalLabel, totalSizeStr, totalFilesStr)) 124 | } else if m.isScanning { 125 | content.WriteString("\n") 126 | content.WriteString(styles.InfoStyle.Render("🔍 Scanning...")) 127 | } else { 128 | content.WriteString("\n") 129 | content.WriteString(styles.ScanResultEmptyStyle.Render("Press 'Scan now' to see cache locations \n")) 130 | } 131 | 132 | // Show status message if exists 133 | if m.status != "" { 134 | content.WriteString("\n") 135 | content.WriteString(styles.InfoStyle.Render(m.status)) 136 | } 137 | 138 | content.WriteString("\n") 139 | scanBtn := styles.LaunchButtonStyle.Render("🔍 Scan now") 140 | deleteBtn := styles.DeleteButtonStyle.Render("🗑️ Delete selected") 141 | 142 | switch m.FocusedElement { 143 | case "scanButton": 144 | scanBtn = styles.LaunchButtonFocusedStyle.Render("🔍 Scan now") 145 | case "deleteButton": 146 | deleteBtn = styles.DeleteButtonFocusedStyle.Render("🗑️ Delete selected") 147 | } 148 | 149 | content.WriteString(zone.Mark("cache_scan_button", scanBtn)) 150 | content.WriteString(" ") 151 | content.WriteString(zone.Mark("cache_delete_button", deleteBtn)) 152 | content.WriteString("\n\n") 153 | content.WriteString("\n" + help.NavigateHelpText) 154 | return zone.Scan(content.String()) 155 | } 156 | 157 | func (m *CacheModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { 158 | switch msg := msg.(type) { 159 | case tea.KeyMsg: 160 | switch msg.String() { 161 | case "tab": 162 | return m.handleTab() 163 | case "shift+tab": 164 | return m.handleShiftTab() 165 | case "enter", " ": 166 | return m.handleSpace() 167 | } 168 | case tea.MouseMsg: 169 | // nolint:staticcheck 170 | if msg.Type == tea.MouseLeft && msg.Action == tea.MouseActionPress { 171 | // Handle option clicks 172 | for i := range options.DefaultCacheOption { 173 | if zone.Get(fmt.Sprintf("cache_option_%d", i+1)).InBounds(msg) { 174 | m.FocusedElement = fmt.Sprintf("option%d", i+1) 175 | return m.handleSpace() 176 | } 177 | } 178 | 179 | // Handle scan button click 180 | if zone.Get("cache_scan_button").InBounds(msg) { 181 | m.FocusedElement = "scanButton" 182 | return m.handleSpace() 183 | } 184 | 185 | // Handle delete button click 186 | if zone.Get("cache_delete_button").InBounds(msg) { 187 | m.FocusedElement = "deleteButton" 188 | return m.handleSpace() 189 | } 190 | } 191 | } 192 | return m, nil 193 | } 194 | 195 | func (m *CacheModel) handleTab() (tea.Model, tea.Cmd) { 196 | switch m.FocusedElement { 197 | case "option1": 198 | m.FocusedElement = "scanButton" 199 | case "scanButton": 200 | m.FocusedElement = "deleteButton" 201 | case "deleteButton": 202 | m.FocusedElement = "option1" 203 | default: 204 | m.FocusedElement = "option1" 205 | } 206 | return m, nil 207 | } 208 | 209 | func (m *CacheModel) handleShiftTab() (tea.Model, tea.Cmd) { 210 | switch m.FocusedElement { 211 | case "option1": 212 | m.FocusedElement = "deleteButton" 213 | case "scanButton": 214 | m.FocusedElement = "option1" 215 | case "deleteButton": 216 | m.FocusedElement = "scanButton" 217 | default: 218 | m.FocusedElement = "option1" 219 | } 220 | return m, nil 221 | } 222 | 223 | func (m *CacheModel) handleSpace() (tea.Model, tea.Cmd) { 224 | if strings.HasPrefix(m.FocusedElement, "option") { 225 | optionNum := strings.TrimPrefix(m.FocusedElement, "option") 226 | idx, err := strconv.Atoi(optionNum) 227 | if err != nil { 228 | return m, nil 229 | } 230 | if idx < 1 || idx > len(options.DefaultCacheOption) { 231 | return m, nil 232 | } 233 | idx-- 234 | 235 | optName := options.DefaultCacheOption[idx] 236 | m.OptionState[optName] = !m.OptionState[optName] 237 | 238 | m.FocusedElement = "option" + optionNum 239 | 240 | return m, nil 241 | } else if m.FocusedElement == "scanButton" { 242 | m.isScanning = true 243 | m.scanResults = nil 244 | m.status = "" // Clear status when scanning 245 | 246 | results := m.cacheManager.ScanAllLocations() 247 | m.scanResults = results 248 | m.isScanning = false 249 | 250 | return m, nil 251 | } else if m.FocusedElement == "deleteButton" { 252 | if runtime.GOOS == "darwin" { 253 | m.status = "Currently only Windows and linux is supported for cache clearing\n" 254 | } else { 255 | m.cacheManager.ClearCache() 256 | m.scanResults = []cache.ScanResult{} 257 | m.status = "Cache clearing completed\n" 258 | } 259 | return m, nil 260 | } 261 | return m, nil 262 | } 263 | -------------------------------------------------------------------------------- /internal/tests/unit/logging/logger_test.go: -------------------------------------------------------------------------------- 1 | package logging 2 | 3 | import ( 4 | "encoding/json" 5 | "fmt" 6 | "os" 7 | "path/filepath" 8 | "strings" 9 | "testing" 10 | "time" 11 | 12 | "github.com/pashkov256/deletor/internal/logging" 13 | ) 14 | 15 | // setupTestLogger creates a temporary logger for testing 16 | func setupTestLogger(t *testing.T) (*logging.Logger, string) { 17 | t.Helper() 18 | 19 | tempDir, err := os.MkdirTemp("", "logger_test_*") 20 | if err != nil { 21 | t.Fatalf("Failed to create temp dir: %v", err) 22 | } 23 | 24 | logPath := filepath.Join(tempDir, "test.log") 25 | 26 | logger, err := logging.NewLogger(logPath, nil) 27 | if err != nil { 28 | t.Fatalf("Failed to create logger: %v", err) 29 | } 30 | 31 | return logger, tempDir 32 | } 33 | 34 | // cleanupTestLogger removes temporary test files 35 | func cleanupTestLogger(t *testing.T, logger *logging.Logger, tempDir string) { 36 | t.Helper() 37 | 38 | if err := logger.Close(); err != nil { 39 | t.Errorf("Failed to close logger: %v", err) 40 | } 41 | 42 | if err := os.RemoveAll(tempDir); err != nil { 43 | t.Errorf("Failed to remove temp dir: %v", err) 44 | } 45 | } 46 | 47 | func TestNewLogger(t *testing.T) { 48 | tests := []struct { 49 | name string 50 | ConfigPath string 51 | setup func() error 52 | cleanup func() error 53 | wantErr bool 54 | description string 55 | }{ 56 | { 57 | name: "Valid config path", 58 | ConfigPath: filepath.Join(os.TempDir(), "valid_test.log"), 59 | setup: func() error { 60 | return os.MkdirAll(filepath.Dir(filepath.Join(os.TempDir(), "valid_test.log")), 0755) 61 | }, 62 | cleanup: func() error { 63 | return os.RemoveAll(filepath.Join(os.TempDir(), "valid_test.log")) 64 | }, 65 | wantErr: false, 66 | description: "Should create logger with valid config path", 67 | }, 68 | { 69 | name: "Invalid config path", 70 | ConfigPath: "", 71 | setup: func() error { return nil }, 72 | cleanup: func() error { return nil }, 73 | wantErr: true, 74 | description: "Should fail with empty config path", 75 | }, 76 | { 77 | name: "Non-existent directory", 78 | ConfigPath: filepath.Join(os.TempDir(), "nonexistent", "test.log"), 79 | setup: func() error { return nil }, 80 | cleanup: func() error { return nil }, 81 | wantErr: false, 82 | description: "Should create directory and logger", 83 | }, 84 | } 85 | 86 | for _, tt := range tests { 87 | t.Run(tt.name, func(t *testing.T) { 88 | if err := tt.setup(); err != nil { 89 | t.Fatalf("Setup failed: %v", err) 90 | } 91 | defer tt.cleanup() 92 | 93 | logger, err := logging.NewLogger(tt.ConfigPath, nil) 94 | if (err != nil) != tt.wantErr { 95 | t.Errorf("NewLogger() error = %v, wantErr %v", err, tt.wantErr) 96 | } 97 | if err == nil { 98 | logger.Close() 99 | } 100 | }) 101 | } 102 | } 103 | 104 | func TestLogLevels(t *testing.T) { 105 | logger, tempDir := setupTestLogger(t) 106 | defer cleanupTestLogger(t, logger, tempDir) 107 | 108 | tests := []struct { 109 | name string 110 | level logging.LogLevel 111 | message string 112 | }{ 113 | {"INFO level", logging.INFO, "Test info message"}, 114 | {"DEBUG level", logging.DEBUG, "Test debug message"}, 115 | {"ERROR level", logging.ERROR, "Test error message"}, 116 | } 117 | 118 | for _, tt := range tests { 119 | t.Run(tt.name, func(t *testing.T) { 120 | if err := logger.Log(tt.level, tt.message); err != nil { 121 | t.Errorf("Log() error = %v", err) 122 | } 123 | }) 124 | } 125 | 126 | // Read and verify all log entries 127 | data, err := os.ReadFile(logger.ConfigPath) 128 | if err != nil { 129 | t.Fatalf("Failed to read log file: %v", err) 130 | } 131 | 132 | // Split the file content into individual log entries 133 | entries := strings.Split(strings.TrimSpace(string(data)), "\n") 134 | if len(entries) != len(tests) { 135 | t.Fatalf("Expected %d log entries, got %d", len(tests), len(entries)) 136 | } 137 | 138 | // Verify each log entry 139 | for i, entryStr := range entries { 140 | var entry logging.LogEntry 141 | if err := json.Unmarshal([]byte(entryStr), &entry); err != nil { 142 | t.Fatalf("Failed to unmarshal log entry %d: %v", i, err) 143 | } 144 | 145 | expected := tests[i] 146 | if entry.Level != expected.level { 147 | t.Errorf("Entry %d: Log level = %v, want %v", i, entry.Level, expected.level) 148 | } 149 | if entry.Message != expected.message { 150 | t.Errorf("Entry %d: Message = %v, want %v", i, entry.Message, expected.message) 151 | } 152 | } 153 | } 154 | 155 | func TestScanStatistics(t *testing.T) { 156 | 157 | logger, tempDir := setupTestLogger(t) 158 | defer cleanupTestLogger(t, logger, tempDir) 159 | 160 | stats := &logging.ScanStatistics{ 161 | TotalFiles: 100, 162 | TotalSize: 1000, 163 | DeletedFiles: 50, 164 | DeletedSize: 500, 165 | TrashedFiles: 30, 166 | TrashedSize: 300, 167 | IgnoredFiles: 20, 168 | IgnoredSize: 200, 169 | StartTime: time.Now(), 170 | EndTime: time.Now().Add(time.Hour), 171 | Directory: "/test/dir", 172 | OperationType: "scan", 173 | } 174 | 175 | // Test stats update 176 | logger.UpdateStats(stats) 177 | 178 | // Log an entry with stats 179 | if err := logger.Log(logging.INFO, "Test stats message"); err != nil { 180 | t.Errorf("Log() error = %v", err) 181 | } 182 | 183 | // Read and verify the log entry 184 | data, err := os.ReadFile(logger.ConfigPath) 185 | if err != nil { 186 | t.Fatalf("Failed to read log file: %v", err) 187 | } 188 | 189 | var entry logging.LogEntry 190 | if err := json.Unmarshal(data, &entry); err != nil { 191 | t.Fatalf("Failed to unmarshal log entry: %v", err) 192 | } 193 | 194 | if entry.Stats == nil { 195 | t.Fatal("Stats should not be nil") 196 | } 197 | 198 | if entry.Stats.TotalFiles != stats.TotalFiles { 199 | t.Errorf("TotalFiles = %v, want %v", entry.Stats.TotalFiles, stats.TotalFiles) 200 | } 201 | if entry.Stats.TotalSize != stats.TotalSize { 202 | t.Errorf("TotalSize = %v, want %v", entry.Stats.TotalSize, stats.TotalSize) 203 | } 204 | } 205 | 206 | func TestFileOperations(t *testing.T) { 207 | tests := []struct { 208 | name string 209 | filePath string 210 | size int64 211 | opType logging.OperationType 212 | reason string 213 | rule string 214 | expectedType logging.OperationType 215 | }{ 216 | { 217 | name: "Delete operation", 218 | filePath: "/test/file1.txt", 219 | size: 100, 220 | opType: logging.OperationDeleted, 221 | reason: "Test deletion", 222 | rule: "size_rule", 223 | expectedType: logging.OperationDeleted, 224 | }, 225 | { 226 | name: "Trash operation", 227 | filePath: "/test/file2.txt", 228 | size: 200, 229 | opType: logging.OperationTrashed, 230 | reason: "Test trash", 231 | rule: "date_rule", 232 | expectedType: logging.OperationTrashed, 233 | }, 234 | { 235 | name: "Ignore operation", 236 | filePath: "/test/file3.txt", 237 | size: 300, 238 | opType: logging.OperationIgnored, 239 | reason: "Test ignore", 240 | rule: "pattern_rule", 241 | expectedType: logging.OperationIgnored, 242 | }, 243 | } 244 | 245 | for _, tt := range tests { 246 | t.Run(tt.name, func(t *testing.T) { 247 | op := logging.NewFileOperation(tt.filePath, tt.size, tt.opType, tt.reason, tt.rule) 248 | 249 | if op.FilePath != tt.filePath { 250 | t.Errorf("FilePath = %v, want %v", op.FilePath, tt.filePath) 251 | } 252 | if op.FileSize != tt.size { 253 | t.Errorf("FileSize = %v, want %v", op.FileSize, tt.size) 254 | } 255 | if op.OperationType != tt.expectedType { 256 | t.Errorf("OperationType = %v, want %v", op.OperationType, tt.expectedType) 257 | } 258 | if op.Reason != tt.reason { 259 | t.Errorf("Reason = %v, want %v", op.Reason, tt.reason) 260 | } 261 | if op.RuleApplied != tt.rule { 262 | t.Errorf("RuleApplied = %v, want %v", op.RuleApplied, tt.rule) 263 | } 264 | }) 265 | } 266 | } 267 | 268 | func TestConcurrentLogging(t *testing.T) { 269 | logger, tempDir := setupTestLogger(t) 270 | defer cleanupTestLogger(t, logger, tempDir) 271 | 272 | done := make(chan bool) 273 | for i := 0; i < 10; i++ { 274 | go func(id int) { 275 | for j := 0; j < 10; j++ { 276 | msg := fmt.Sprintf("Concurrent log message %d-%d", id, j) 277 | if err := logger.Log(logging.INFO, msg); err != nil { 278 | t.Errorf("Log() error in goroutine %d: %v", id, err) 279 | } 280 | } 281 | done <- true 282 | }(i) 283 | } 284 | 285 | for i := 0; i < 10; i++ { 286 | <-done 287 | } 288 | 289 | data, err := os.ReadFile(logger.ConfigPath) 290 | if err != nil { 291 | t.Fatalf("Failed to read log file: %v", err) 292 | } 293 | 294 | lines := strings.Split(string(data), "\n") 295 | // Subtract 1 for the empty line at the end 296 | if len(lines)-1 != 100 { 297 | t.Errorf("Expected 100 log entries, got %d", len(lines)-1) 298 | } 299 | } 300 | --------------------------------------------------------------------------------