diff --git a/README.md b/README.md index ef0ca56..945a805 100644 --- a/README.md +++ b/README.md @@ -108,6 +108,29 @@ buildtree "docker-project/ └── settings.yaml" ``` +## Exporting Project Structure for LLM Context + +Buildtree now includes a powerful feature that allows you to export your entire project structure with file contents to a single file, perfect for providing context to Large Language Models (LLMs). + +### Key Features + +- **Complete project context**: Export all files with their paths and contents in a structured format +- **Smart filtering**: Include only relevant files by extension with `--filter` +- **Size control**: Limit file size with `--max-size` (default: 100kb) +- **Ignore patterns**: Skip directories like `.git` and `node_modules` by default +- **Hidden files**: Control inclusion of hidden files with `--include-hidden` + +### Usage + +```bash +buildtree -s OUTPUT_FILE [DIRECTORY] [OPTIONS] +``` + +This example will collect the contents of all files with the *.go, *.txt, *.md extension and combine them into a single file with relative paths. +```bash +buildtree -s export-structure.txt ./project-dir -f go,txt,md -I vendor +``` + ## Use Cases - Quickly test LLM-generated file structures - Create educational examples for documentation diff --git a/cmd/buildtree/main.go b/cmd/buildtree/main.go index de71259..0f4bbea 100644 --- a/cmd/buildtree/main.go +++ b/cmd/buildtree/main.go @@ -5,9 +5,12 @@ import ( "fmt" "io" "os" + "strings" "github.com/neomen/buildtree/internal/builder" + "github.com/neomen/buildtree/internal/exporter" "github.com/neomen/buildtree/internal/parser" + "github.com/neomen/buildtree/internal/utils" ) var ( @@ -16,7 +19,7 @@ var ( date = "unknown" ) -// Добавим интерфейсы для зависимостей, чтобы можно было мокировать их в тестах +// Add interfaces for dependencies so that you can mock them in tests type parserInterface interface { ParseInput(input string) (*parser.Node, error) } @@ -25,7 +28,7 @@ type builderInterface interface { BuildTree(root *parser.Node, maxDepth int) error } -// Реальные реализации +// Real implementations type realParser struct{} type realBuilder struct{} @@ -37,26 +40,79 @@ func (r *realBuilder) BuildTree(root *parser.Node, maxDepth int) error { return builder.BuildTree(root, maxDepth) } -// Вынесем основную логику в отдельную функцию для тестирования +// Let's put the main logic in a separate function for testing func run(args []string, stdin io.Reader, stdout io.Writer, stderr io.Writer, p parserInterface, b builderInterface) int { - // Создаем новый набор флагов для каждого вызова + // First, we look for the -s or --structure flag in the arguments + var saveStructureValue string + var newArgs []string + skipNext := false + + // We go through all the arguments to find the -s flag + for i, arg := range args { + if skipNext { + skipNext = false + continue + } + + // Checking the long shape of the flag + if arg == "-s" || arg == "--structure" { + if i+1 < len(args) { + saveStructureValue = args[i+1] + skipNext = true + continue + } + // Checking the form with equality + } else if strings.HasPrefix(arg, "-s=") || strings.HasPrefix(arg, "--structure=") { + parts := strings.SplitN(arg, "=", 2) + if len(parts) == 2 { + saveStructureValue = parts[1] + continue + } + } + + // Saving arguments, except for the found flag + newArgs = append(newArgs, arg) + } + + // Creating a new set of flags flags := flag.NewFlagSet("buildtree", flag.ContinueOnError) - flags.SetOutput(stderr) // Устанавливаем вывод ошибок флагов в stderr + flags.SetOutput(stderr) + // Defining all flags filePath := flags.String("input-file", "", "Path to file containing directory structure") helpFlag := flags.Bool("help", false, "Show help") maxDepth := flags.Int("max-depth", 20, "Maximum nesting depth allowed (0 = no limit)") versionFlag := flags.Bool("version", false, "Show version information") + + // A flag for exporting the structure (needed for parsing other flags) + saveStructure := flags.String("s", "", "Save current directory structure with file contents to specified file") + + // Additional flags for export + structureFilter := flags.String("filter", "", "Comma-separated list of file extensions to include (e.g., go,js,txt)") + ignoreDirs := flags.String("ignore-dir", "", "Comma-separated list of directories to ignore (in addition to .git and node_modules)") + maxSize := flags.String("max-size", "100kb", "Maximum file size to include (e.g., 100kb, 1mb)") + includeHidden := flags.Bool("include-hidden", false, "Include hidden files and directories (starting with .)") + + // Adding Aliases flags.StringVar(filePath, "i", "", "Alias for --input-file") flags.IntVar(maxDepth, "d", 20, "Alias for --max-depth") flags.BoolVar(versionFlag, "v", false, "Alias for --version") flags.BoolVar(helpFlag, "h", false, "Alias for --help") + flags.StringVar(saveStructure, "structure", "", "Alias for --save-structure") + flags.StringVar(structureFilter, "f", "", "Alias for --filter") + flags.StringVar(ignoreDirs, "I", "", "Alias for --ignore-dir (uppercase i)") - // Парсим аргументы - if err := flags.Parse(args); err != nil { + // Parse the remaining arguments + if err := flags.Parse(newArgs); err != nil { return 1 } + // If we found the -s flag manually, we use its value + if saveStructureValue != "" { + *saveStructure = saveStructureValue + } + + // Processing of standard flags if *helpFlag { printHelp(stdout) return 0 @@ -67,19 +123,68 @@ func run(args []string, stdin io.Reader, stdout io.Writer, stderr io.Writer, p p return 0 } + // FLAG HANDLING -s + if *saveStructure != "" { + // Checking for conflicts + if *filePath != "" { + fmt.Fprintln(stderr, "Error: -s/--structure cannot be used with --input-file") + return 1 + } + + // Defining the root directory + rootDir := "." + args := flags.Args() + if len(args) > 0 { + rootDir = args[0] + } + + // Processing filters + var filters []string + if *structureFilter != "" { + filters = strings.Split(*structureFilter, ",") + for i := range filters { + filters[i] = strings.TrimSpace(filters[i]) + } + } + + // Processing ignored directories + var ignoreList []string + if *ignoreDirs != "" { + ignoreList = strings.Split(*ignoreDirs, ",") + for i := range ignoreList { + ignoreList[i] = strings.TrimSpace(ignoreList[i]) + } + } + + // Parse the maximum size + maxSizeBytes, err := utils.ParseSize(*maxSize) + if err != nil { + fmt.Fprintf(stderr, "Error parsing max-size: %v\n", err) + return 1 + } + + // Export the structure + if err := exporter.ExportStructure(*saveStructure, rootDir, filters, ignoreList, maxSizeBytes, *includeHidden); err != nil { + fmt.Fprintf(stderr, "Error exporting structure: %v\n", err) + return 1 + } + return 0 + } + + // Normal processing (creating a structure) input := getInput(*filePath, stdin, flags, stderr) if input == "" { return 1 } - // Parse the input structure + // Parsing the structure root, err := p.ParseInput(input) if err != nil { fmt.Fprintf(stderr, "Error parsing input: %v\n", err) return 1 } - // Build the file structure + // Building a tree if err := b.BuildTree(root, *maxDepth); err != nil { fmt.Fprintf(stderr, "Error building tree: %v\n", err) return 1 @@ -115,6 +220,11 @@ func printHelp(w io.Writer) { fmt.Fprintln(w, " -d, --max-depth N Maximum nesting depth allowed (0=unlimited, default:20)") fmt.Fprintln(w, " -h, --help Show this help message") fmt.Fprintln(w, " -v, --version Show version information") + fmt.Fprintln(w, " -s, --structure FILE Save current directory structure with file contents to FILE") + fmt.Fprintln(w, " -f, --filter EXTS Comma-separated list of file extensions to include (e.g., go,js,txt)") + fmt.Fprintln(w, " -I, --ignore-dir DIRS Comma-separated list of directories to ignore (in addition to .git and node_modules)") + fmt.Fprintln(w, " --max-size SIZE Maximum file size to include (default: 100kb, e.g., 100kb, 1mb)") + fmt.Fprintln(w, " -H, --include-hidden Include hidden files and directories (starting with .)") fmt.Fprintln(w, "\nExamples:") fmt.Fprintln(w, " buildtree \"project/\n├── src/\n│ └── main.go\"") fmt.Fprintln(w, " buildtree --input-file structure.txt") diff --git a/internal/exporter/exporter.go b/internal/exporter/exporter.go new file mode 100644 index 0000000..e95f7b9 --- /dev/null +++ b/internal/exporter/exporter.go @@ -0,0 +1,147 @@ +package exporter + +import ( + "fmt" + "io/fs" + "os" + "path/filepath" + "strings" +) + +// ExportStructure saves the current directory structure with file contents to the specified file +// with filtering options +func ExportStructure(outputFile, rootDir string, filters []string, ignoreDirs []string, maxSize int64, includeHidden bool) error { + // Convert relative path to absolute + rootDirAbs, err := filepath.Abs(rootDir) + if err != nil { + return fmt.Errorf("failed to get absolute path for root directory: %w", err) + } + + // Check the existence of the root directory BEFORE creating the output file + info, err := os.Stat(rootDirAbs) + if os.IsNotExist(err) { + return fmt.Errorf("root directory does not exist: %s", rootDirAbs) + } + if err != nil { + return fmt.Errorf("error checking root directory: %w", err) + } + if !info.IsDir() { + return fmt.Errorf("root path is not a directory: %s", rootDirAbs) + } + + // Get absolute path of output file + outputFileAbs, err := filepath.Abs(outputFile) + if err != nil { + return fmt.Errorf("failed to get absolute path for output file: %w", err) + } + + // Create or truncate the output file + f, err := os.Create(outputFile) + if err != nil { + return fmt.Errorf("failed to create output file: %w", err) + } + defer f.Close() + + // Default ignore directories + defaultIgnore := []string{".git", "node_modules"} + if len(ignoreDirs) == 0 { + ignoreDirs = defaultIgnore + } else { + // Add default ignore directories to user-provided ones + ignoreDirs = append(ignoreDirs, defaultIgnore...) + } + + // Walk through the directory + return filepath.WalkDir(rootDirAbs, func(path string, d fs.DirEntry, err error) error { + if err != nil { + return err + } + + // Skip the output file itself to avoid recursion + pathAbs, err := filepath.Abs(path) + if err != nil { + return fmt.Errorf("failed to get absolute path for %s: %w", path, err) + } + if filepath.Clean(pathAbs) == filepath.Clean(outputFileAbs) { + return nil + } + + // Check if directory should be ignored + if d.IsDir() { + dirName := d.Name() + + // Check for hidden directories (unless includeHidden is true) + if !includeHidden && strings.HasPrefix(dirName, ".") { + return fs.SkipDir + } + + for _, ignore := range ignoreDirs { + if dirName == ignore { + return fs.SkipDir + } + } + } + + // Process only files + if !d.IsDir() { + fileName := d.Name() + + // Check for hidden files (unless includeHidden is true) + if !includeHidden && strings.HasPrefix(fileName, ".") { + return nil + } + + // Check file size + info, err := d.Info() + if err != nil { + return fmt.Errorf("failed to get file info for %s: %w", path, err) + } + + if maxSize > 0 && info.Size() > maxSize { + return nil + } + + // Apply filters if specified + if len(filters) > 0 { + ext := strings.TrimPrefix(filepath.Ext(d.Name()), ".") + matched := false + for _, filter := range filters { + if strings.EqualFold(ext, filter) { + matched = true + break + } + } + if !matched { + return nil + } + } + + // Get relative path + relPath, err := filepath.Rel(rootDirAbs, path) + if err != nil { + return fmt.Errorf("failed to get relative path for %s: %w", path, err) + } + + // Replacing backslashes with straight ones + relPath = filepath.ToSlash(relPath) + + // Read file content + content, err := os.ReadFile(path) + if err != nil { + return fmt.Errorf("failed to read file %s: %w", path, err) + } + + // Write header and content + if _, err := fmt.Fprintf(f, "# %s\n", relPath); err != nil { + return err + } + if _, err := f.Write(content); err != nil { + return err + } + if _, err := f.WriteString("\n\n"); err != nil { + return err + } + } + return nil + }) +} diff --git a/internal/exporter/exporter_test.go b/internal/exporter/exporter_test.go new file mode 100644 index 0000000..bb7bf9a --- /dev/null +++ b/internal/exporter/exporter_test.go @@ -0,0 +1,355 @@ +package exporter + +import ( + "os" + "path/filepath" + "strings" + "testing" +) + +func TestExportStructure_BasicExport(t *testing.T) { + // Creating a temporary test structure + tempDir := t.TempDir() + createTestFiles(tempDir, map[string]string{ + "file1.txt": "Content 1", + "file2.go": "Content 2", + "dir1/file3.md": "Content 3", + }) + // Creating the output file + outputFile := filepath.Join(tempDir, "export.txt") + // Exporting the structure + err := ExportStructure(outputFile, tempDir, nil, nil, 0, false) + if err != nil { + t.Fatalf("Unexpected error: %v", err) + } + // Checking the contents + content, err := os.ReadFile(outputFile) + if err != nil { + t.Fatalf("Failed to read output file: %v", err) + } + // Check that all files are exported + expectedFiles := []string{"file1.txt", "file2.go", "dir1/file3.md"} + for _, file := range expectedFiles { + // Convert to platform-independent slash format for comparison + expectedPath := filepath.ToSlash(file) + if !strings.Contains(string(content), "# "+expectedPath) { + t.Errorf("Expected file %s in export, but not found", expectedPath) + } + if !strings.Contains(string(content), "Content") { + t.Errorf("Expected content for %s in export, but not found", expectedPath) + } + } +} + +func TestExportStructure_WithExtensionFilter(t *testing.T) { + // Creating a temporary test structure + tempDir := t.TempDir() + createTestFiles(tempDir, map[string]string{ + "file1.txt": "Text content", + "file2.go": "Go content", + "file3.md": "Markdown content", + "dir1/file4.go": "Go content 2", + }) + // Creating the output file + outputFile := filepath.Join(tempDir, "export.txt") + // Exporting only .go files + err := ExportStructure(outputFile, tempDir, []string{"go"}, nil, 0, false) + if err != nil { + t.Fatalf("Unexpected error: %v", err) + } + // Checking the contents + content, err := os.ReadFile(outputFile) + if err != nil { + t.Fatalf("Failed to read output file: %v", err) + } + // Check that only .go files are exported + expectedPaths := []string{"file2.go", "dir1/file4.go"} + for _, path := range expectedPaths { + expectedPath := filepath.ToSlash(path) + if !strings.Contains(string(content), "# "+expectedPath) { + t.Errorf("Expected %s in export, but not found", expectedPath) + } + } + + // Check that other files are not exported + excludedPaths := []string{"file1.txt", "file3.md"} + for _, path := range excludedPaths { + expectedPath := filepath.ToSlash(path) + if strings.Contains(string(content), "# "+expectedPath) { + t.Errorf("%s should not be in export", expectedPath) + } + } +} + +func TestExportStructure_WithIgnoreDirs(t *testing.T) { + // Creating a temporary test structure + tempDir := t.TempDir() + createTestFiles(tempDir, map[string]string{ + "file1.txt": "Content 1", + "dir1/file2.txt": "Content 2", + "dir2/file3.txt": "Content 3", + "node_modules/file4.txt": "Content 4", + ".git/file5.txt": "Content 5", + }) + // Creating the output file + outputFile := filepath.Join(tempDir, "export.txt") + // Exporting, ignoring dir2 and node_modules + err := ExportStructure(outputFile, tempDir, nil, []string{"dir2"}, 0, false) + if err != nil { + t.Fatalf("Unexpected error: %v", err) + } + // Checking the contents + content, err := os.ReadFile(outputFile) + if err != nil { + t.Fatalf("Failed to read output file: %v", err) + } + + // Check that dir1 is exported + dir1Path := filepath.ToSlash("dir1/file2.txt") + if !strings.Contains(string(content), "# "+dir1Path) { + t.Error("Expected " + dir1Path + " in export, but not found") + } + + // Check that dir2 is not exported + dir2Path := filepath.ToSlash("dir2/file3.txt") + if strings.Contains(string(content), "# "+dir2Path) { + t.Error(dir2Path + " should not be in export") + } + + // Check that node_modules is not exported (by default) + nodeModulesPath := filepath.ToSlash("node_modules/file4.txt") + if strings.Contains(string(content), "# "+nodeModulesPath) { + t.Error(nodeModulesPath + " should not be in export") + } + + // Check that .git is not exported (by default) + gitPath := filepath.ToSlash(".git/file5.txt") + if strings.Contains(string(content), "# "+gitPath) { + t.Error(gitPath + " should not be in export") + } +} + +func TestExportStructure_WithMaxSize(t *testing.T) { + // Creating a temporary test structure + tempDir := t.TempDir() + createTestFiles(tempDir, map[string]string{ + "small.txt": "Small content", + "large.txt": strings.Repeat("a", 200*1024), // 200KB + }) + // Creating the output file + outputFile := filepath.Join(tempDir, "export.txt") + // Exporting with a limit of 100KB + err := ExportStructure(outputFile, tempDir, nil, nil, 100*1024, false) + if err != nil { + t.Fatalf("Unexpected error: %v", err) + } + // Checking the contents + content, err := os.ReadFile(outputFile) + if err != nil { + t.Fatalf("Failed to read output file: %v", err) + } + + // Check that the small file has been exported + smallPath := filepath.ToSlash("small.txt") + if !strings.Contains(string(content), "# "+smallPath) { + t.Error("Expected " + smallPath + " in export, but not found") + } + + // Check that the large file has not been exported + largePath := filepath.ToSlash("large.txt") + if strings.Contains(string(content), "# "+largePath) { + t.Error(largePath + " should not be in export") + } +} + +func TestExportStructure_WithHiddenFiles(t *testing.T) { + // Creating a temporary test structure + tempDir := t.TempDir() + createTestFiles(tempDir, map[string]string{ + "file.txt": "Regular file", + ".hidden.txt": "Hidden file", + "dir1/.hidden2": "Hidden in dir", + ".gitignore": "Git ignore", + "dir2/file.txt": "File in dir", + "dir2/.env": "Env file", + }) + // Creating the output file + outputFile := filepath.Join(tempDir, "export.txt") + + // First we check without including hidden files + err := ExportStructure(outputFile, tempDir, nil, nil, 0, false) + if err != nil { + t.Fatalf("Unexpected error: %v", err) + } + // Checking the contents + content, err := os.ReadFile(outputFile) + if err != nil { + t.Fatalf("Failed to read output file: %v", err) + } + + // Check that hidden files are not exported + hiddenFiles := []string{".hidden.txt", "dir1/.hidden2", ".gitignore", "dir2/.env"} + for _, file := range hiddenFiles { + expectedPath := filepath.ToSlash(file) + if strings.Contains(string(content), "# "+expectedPath) { + t.Errorf("%s should not be in export", expectedPath) + } + } + + // Now we check with the inclusion of hidden files + err = ExportStructure(outputFile, tempDir, nil, nil, 0, true) + if err != nil { + t.Fatalf("Unexpected error: %v", err) + } + // Checking the content + content, err = os.ReadFile(outputFile) + if err != nil { + t.Fatalf("Failed to read output file: %v", err) + } + + // Check that the hidden files have been exported + for _, file := range hiddenFiles { + expectedPath := filepath.ToSlash(file) + if !strings.Contains(string(content), "# "+expectedPath) { + t.Errorf("%s should be in export", expectedPath) + } + } +} + +func TestExportStructure_SkipOutputFile(t *testing.T) { + // Creating a temporary test structure + tempDir := t.TempDir() + createTestFiles(tempDir, map[string]string{ + "file1.txt": "Content 1", + "file2.txt": "Content 2", + }) + // Creating the output file INSIDE the exported directory + outputFile := filepath.Join(tempDir, "export.txt") + // Exporting the structure + err := ExportStructure(outputFile, tempDir, nil, nil, 0, false) + if err != nil { + t.Fatalf("Unexpected error: %v", err) + } + // Checking the content + content, err := os.ReadFile(outputFile) + if err != nil { + t.Fatalf("Failed to read output file: %v", err) + } + + // Check that the output file is not included in the export + outputFileName := filepath.Base(outputFile) + if strings.Contains(string(content), "# "+outputFileName) { + t.Error(outputFileName + " should not be in its own export") + } + + // Check that other files have been exported + expectedFiles := []string{"file1.txt", "file2.txt"} + for _, file := range expectedFiles { + expectedPath := filepath.ToSlash(file) + if !strings.Contains(string(content), "# "+expectedPath) { + t.Errorf("Expected %s in export, but not found", expectedPath) + } + } +} + +func TestExportStructure_EmptyDirectory(t *testing.T) { + // Creating an empty temporary directory + tempDir := t.TempDir() + outputFile := filepath.Join(tempDir, "export.txt") + // Exporting the structure + err := ExportStructure(outputFile, tempDir, nil, nil, 0, false) + if err != nil { + t.Fatalf("Unexpected error: %v", err) + } + // Checking the content + content, err := os.ReadFile(outputFile) + if err != nil { + t.Fatalf("Failed to read output file: %v", err) + } + // Check that the export is empty + if len(content) > 0 { + t.Error("Export from empty directory should be empty") + } +} + +func TestExportStructure_InvalidRootDir(t *testing.T) { + // Creating a temporary directory for the output file + tempDir := t.TempDir() + outputFile := filepath.Join(tempDir, "export.txt") + // Using a non-existent directory + invalidDir := filepath.Join(tempDir, "nonexistent") + // Trying to export + err := ExportStructure(outputFile, invalidDir, nil, nil, 0, false) + if err == nil { + t.Fatal("Expected error for invalid root directory, but got none") + } + // Check that the output file has not been created + if _, err := os.Stat(outputFile); !os.IsNotExist(err) { + t.Error("Output file should not be created when root directory is invalid") + } +} + +func TestExportStructure_InvalidOutputFile(t *testing.T) { + // Creating a temporary test structure + tempDir := t.TempDir() + createTestFiles(tempDir, map[string]string{ + "file.txt": "Content", + }) + // Trying to use an invalid path for the output file + invalidOutput := string([]rune{0}) // null character + // Trying to export + err := ExportStructure(invalidOutput, tempDir, nil, nil, 0, false) + if err == nil { + t.Fatal("Expected error for invalid output file path, but got none") + } +} + +func TestExportStructure_PermissionError(t *testing.T) { + // Creating a temporary test structure + tempDir := t.TempDir() + // Creating a file with limited rights + restrictedFile := filepath.Join(tempDir, "restricted.txt") + if err := os.WriteFile(restrictedFile, []byte("Secret content"), 0400); err != nil { + t.Fatalf("Failed to create restricted file: %v", err) + } + // Creating the output file + outputFile := filepath.Join(tempDir, "export.txt") + // Exporting the structure + err := ExportStructure(outputFile, tempDir, nil, nil, 0, false) + if err != nil { + t.Fatalf("Unexpected error: %v", err) + } + // Checking the content + content, err := os.ReadFile(outputFile) + if err != nil { + t.Fatalf("Failed to read output file: %v", err) + } + // Check that the file with limited rights is exported anyway + restrictedPath := filepath.ToSlash("restricted.txt") + if !strings.Contains(string(content), "# "+restrictedPath) { + t.Error(restrictedPath + " should be in export despite restricted permissions") + } +} + +// Auxiliary function for creating test files +func createTestFiles(root string, files map[string]string) { + for path, content := range files { + // Convert to system-specific path format + path = filepath.FromSlash(path) + + // Creating a directory, if necessary + dir := filepath.Dir(path) + if dir != "." { + fullDir := filepath.Join(root, dir) + if err := os.MkdirAll(fullDir, 0755); err != nil { + panic(err) + } + } + + // Creating a file + fullPath := filepath.Join(root, path) + if err := os.WriteFile(fullPath, []byte(content), 0644); err != nil { + panic(err) + } + } +} diff --git a/internal/utils/utils.go b/internal/utils/utils.go index 468de87..0d64f7c 100644 --- a/internal/utils/utils.go +++ b/internal/utils/utils.go @@ -1,5 +1,11 @@ package utils +import ( + "fmt" + "strconv" + "strings" +) + // IsTreeSymbol checks if a rune is a tree diagram symbol func IsTreeSymbol(r rune) bool { switch r { @@ -9,3 +15,52 @@ func IsTreeSymbol(r rune) bool { return false } } + +// ParseSize converts a string representation of size to bytes +// Examples: "100", "100b", "100kb", "1mb", "1g" +func ParseSize(sizeStr string) (int64, error) { + sizeStr = strings.TrimSpace(sizeStr) + if len(sizeStr) == 0 { + return 0, fmt.Errorf("empty size string") + } + + // Extract numeric part and unit + var numStr string + var unitStr string + for i, c := range sizeStr { + if c >= '0' && c <= '9' { + numStr += string(c) + } else { + unitStr = strings.TrimSpace(sizeStr[i:]) + break + } + } + + if numStr == "" { + return 0, fmt.Errorf("no numeric part in size string: %s", sizeStr) + } + + num, err := strconv.Atoi(numStr) + if err != nil { + return 0, fmt.Errorf("invalid numeric part: %s", numStr) + } + + // Default to KB if no unit specified + if unitStr == "" { + unitStr = "kb" + } + + // Convert to bytes based on unit + switch strings.ToLower(unitStr) { + case "b", "bytes": + return int64(num), nil + case "k", "kb", "kib", "kilobytes": + return int64(num) * 1024, nil + case "m", "mb", "mib", "megabytes": + return int64(num) * 1024 * 1024, nil + case "g", "gb", "gib", "gigabytes": + return int64(num) * 1024 * 1024 * 1024, nil + default: + return 0, fmt.Errorf("unknown size unit: %s", unitStr) + } +} diff --git a/internal/utils/utils_test.go b/internal/utils/utils_test.go new file mode 100644 index 0000000..e8e4c9c --- /dev/null +++ b/internal/utils/utils_test.go @@ -0,0 +1,29 @@ +package utils + +import "testing" + +func TestParseSize_EdgeCases(t *testing.T) { + tests := []struct { + input string + expected int64 + err bool + }{ + {"0", 0, false}, + {"1g", 1073741824, false}, + {"1024mb", 1073741824, false}, + {"100kb", 102400, false}, + {"-100kb", 0, true}, + {"abc", 0, true}, + {"100xyz", 0, true}, + } + + for _, tt := range tests { + size, err := ParseSize(tt.input) + if (err != nil) != tt.err { + t.Errorf("ParseSize(%q) error = %v, expected error: %v", tt.input, err, tt.err) + } + if size != tt.expected { + t.Errorf("ParseSize(%q) = %d, expected %d", tt.input, size, tt.expected) + } + } +}