Implement collect CLI tool
- Add Go implementation with modular architecture - Support --name flag for exact filename matching - Support --match flag for directory glob pattern matching - Create tar.gz and zip archives preserving directory structure - Handle errors with appropriate exit codes - Skip files with permission errors gracefully - Add comprehensive test suite with 11 test cases
This commit is contained in:
parent
216461fa96
commit
eb88ef97c0
8 changed files with 684 additions and 0 deletions
8
archiver/archiver.go
Normal file
8
archiver/archiver.go
Normal file
|
@ -0,0 +1,8 @@
|
||||||
|
package archiver
|
||||||
|
|
||||||
|
import "github.com/atomaka/collect/collector"
|
||||||
|
|
||||||
|
// Archiver defines the interface for creating archives
|
||||||
|
type Archiver interface {
|
||||||
|
Create(outputPath string, files []collector.FileEntry) error
|
||||||
|
}
|
86
archiver/tar.go
Normal file
86
archiver/tar.go
Normal file
|
@ -0,0 +1,86 @@
|
||||||
|
package archiver
|
||||||
|
|
||||||
|
import (
|
||||||
|
"archive/tar"
|
||||||
|
"compress/gzip"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
|
||||||
|
"github.com/atomaka/collect/collector"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TarArchiver creates tar.gz archives
|
||||||
|
type TarArchiver struct{}
|
||||||
|
|
||||||
|
// NewTarArchiver creates a new tar archiver
|
||||||
|
func NewTarArchiver() *TarArchiver {
|
||||||
|
return &TarArchiver{}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create creates a tar.gz archive with the collected files
|
||||||
|
func (a *TarArchiver) Create(outputPath string, files []collector.FileEntry) error {
|
||||||
|
// Create output file
|
||||||
|
outFile, err := os.Create(outputPath)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to create output file: %w", err)
|
||||||
|
}
|
||||||
|
defer outFile.Close()
|
||||||
|
|
||||||
|
// Create gzip writer
|
||||||
|
gzipWriter := gzip.NewWriter(outFile)
|
||||||
|
defer gzipWriter.Close()
|
||||||
|
|
||||||
|
// Create tar writer
|
||||||
|
tarWriter := tar.NewWriter(gzipWriter)
|
||||||
|
defer tarWriter.Close()
|
||||||
|
|
||||||
|
// Add each file to the archive
|
||||||
|
for _, file := range files {
|
||||||
|
if err := a.addFileToTar(tarWriter, file); err != nil {
|
||||||
|
return fmt.Errorf("failed to add file %s: %w", file.Path, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// addFileToTar adds a single file to the tar archive
|
||||||
|
func (a *TarArchiver) addFileToTar(tw *tar.Writer, file collector.FileEntry) error {
|
||||||
|
// Open the file
|
||||||
|
f, err := os.Open(file.FullPath)
|
||||||
|
if err != nil {
|
||||||
|
// Skip files we can't read with a warning
|
||||||
|
if os.IsPermission(err) {
|
||||||
|
fmt.Fprintf(os.Stderr, "Warning: Cannot read file: %s\n", file.FullPath)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer f.Close()
|
||||||
|
|
||||||
|
// Get file info
|
||||||
|
info, err := f.Stat()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create tar header
|
||||||
|
header, err := tar.FileInfoHeader(info, "")
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Use the relative path in the archive
|
||||||
|
header.Name = filepath.ToSlash(file.Path)
|
||||||
|
|
||||||
|
// Write header
|
||||||
|
if err := tw.WriteHeader(header); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Copy file contents
|
||||||
|
_, err = io.Copy(tw, f)
|
||||||
|
return err
|
||||||
|
}
|
85
archiver/zip.go
Normal file
85
archiver/zip.go
Normal file
|
@ -0,0 +1,85 @@
|
||||||
|
package archiver
|
||||||
|
|
||||||
|
import (
|
||||||
|
"archive/zip"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
|
||||||
|
"github.com/atomaka/collect/collector"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ZipArchiver creates zip archives
|
||||||
|
type ZipArchiver struct{}
|
||||||
|
|
||||||
|
// NewZipArchiver creates a new zip archiver
|
||||||
|
func NewZipArchiver() *ZipArchiver {
|
||||||
|
return &ZipArchiver{}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create creates a zip archive with the collected files
|
||||||
|
func (a *ZipArchiver) Create(outputPath string, files []collector.FileEntry) error {
|
||||||
|
// Create output file
|
||||||
|
outFile, err := os.Create(outputPath)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to create output file: %w", err)
|
||||||
|
}
|
||||||
|
defer outFile.Close()
|
||||||
|
|
||||||
|
// Create zip writer
|
||||||
|
zipWriter := zip.NewWriter(outFile)
|
||||||
|
defer zipWriter.Close()
|
||||||
|
|
||||||
|
// Add each file to the archive
|
||||||
|
for _, file := range files {
|
||||||
|
if err := a.addFileToZip(zipWriter, file); err != nil {
|
||||||
|
return fmt.Errorf("failed to add file %s: %w", file.Path, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// addFileToZip adds a single file to the zip archive
|
||||||
|
func (a *ZipArchiver) addFileToZip(zw *zip.Writer, file collector.FileEntry) error {
|
||||||
|
// Open the file
|
||||||
|
f, err := os.Open(file.FullPath)
|
||||||
|
if err != nil {
|
||||||
|
// Skip files we can't read with a warning
|
||||||
|
if os.IsPermission(err) {
|
||||||
|
fmt.Fprintf(os.Stderr, "Warning: Cannot read file: %s\n", file.FullPath)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer f.Close()
|
||||||
|
|
||||||
|
// Get file info
|
||||||
|
info, err := f.Stat()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create zip file header
|
||||||
|
header, err := zip.FileInfoHeader(info)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Use the relative path in the archive
|
||||||
|
header.Name = filepath.ToSlash(file.Path)
|
||||||
|
|
||||||
|
// Set compression method
|
||||||
|
header.Method = zip.Deflate
|
||||||
|
|
||||||
|
// Create writer for this file
|
||||||
|
writer, err := zw.CreateHeader(header)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Copy file contents
|
||||||
|
_, err = io.Copy(writer, f)
|
||||||
|
return err
|
||||||
|
}
|
118
collector/collector.go
Normal file
118
collector/collector.go
Normal file
|
@ -0,0 +1,118 @@
|
||||||
|
package collector
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"io/fs"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// FileEntry represents a file to be archived
|
||||||
|
type FileEntry struct {
|
||||||
|
Path string // Relative path from sourceDir
|
||||||
|
FullPath string // Absolute path for reading
|
||||||
|
}
|
||||||
|
|
||||||
|
// Collector handles file collection based on a matcher
|
||||||
|
type Collector struct {
|
||||||
|
matcher Matcher
|
||||||
|
}
|
||||||
|
|
||||||
|
// New creates a new collector with the specified matcher
|
||||||
|
func New(matcher Matcher) *Collector {
|
||||||
|
return &Collector{
|
||||||
|
matcher: matcher,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Collect walks the source directory and collects matching files
|
||||||
|
func (c *Collector) Collect(sourceDir string) ([]FileEntry, error) {
|
||||||
|
// Clean and convert to absolute path
|
||||||
|
absSourceDir, err := filepath.Abs(sourceDir)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to get absolute path: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if source directory exists
|
||||||
|
info, err := os.Stat(absSourceDir)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("source directory error: %w", err)
|
||||||
|
}
|
||||||
|
if !info.IsDir() {
|
||||||
|
return nil, fmt.Errorf("source path is not a directory: %s", sourceDir)
|
||||||
|
}
|
||||||
|
|
||||||
|
var files []FileEntry
|
||||||
|
|
||||||
|
err = filepath.WalkDir(absSourceDir, func(path string, d fs.DirEntry, err error) error {
|
||||||
|
if err != nil {
|
||||||
|
// Log permission errors but continue walking
|
||||||
|
if os.IsPermission(err) {
|
||||||
|
fmt.Fprintf(os.Stderr, "Warning: Permission denied: %s\n", path)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get file info
|
||||||
|
info, err := d.Info()
|
||||||
|
if err != nil {
|
||||||
|
if os.IsPermission(err) {
|
||||||
|
fmt.Fprintf(os.Stderr, "Warning: Cannot stat file: %s\n", path)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Skip symlinks
|
||||||
|
if info.Mode()&os.ModeSymlink != 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if this file should be included
|
||||||
|
if c.matcher.ShouldInclude(path, info) {
|
||||||
|
// Calculate relative path from source directory
|
||||||
|
relPath, err := filepath.Rel(absSourceDir, path)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to get relative path: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clean the relative path to ensure consistent formatting
|
||||||
|
relPath = filepath.ToSlash(relPath)
|
||||||
|
|
||||||
|
files = append(files, FileEntry{
|
||||||
|
Path: relPath,
|
||||||
|
FullPath: path,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("walk error: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if any files were found
|
||||||
|
if len(files) == 0 {
|
||||||
|
return nil, fmt.Errorf("no files found matching criteria")
|
||||||
|
}
|
||||||
|
|
||||||
|
return files, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetArchiveFormat determines the archive format from the filename
|
||||||
|
func GetArchiveFormat(filename string) string {
|
||||||
|
lower := strings.ToLower(filename)
|
||||||
|
|
||||||
|
if strings.HasSuffix(lower, ".tar.gz") || strings.HasSuffix(lower, ".tgz") {
|
||||||
|
return "tar.gz"
|
||||||
|
}
|
||||||
|
|
||||||
|
if strings.HasSuffix(lower, ".zip") {
|
||||||
|
return "zip"
|
||||||
|
}
|
||||||
|
|
||||||
|
return ""
|
||||||
|
}
|
119
collector/matcher.go
Normal file
119
collector/matcher.go
Normal file
|
@ -0,0 +1,119 @@
|
||||||
|
package collector
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Matcher determines if a file should be included in the collection
|
||||||
|
type Matcher interface {
|
||||||
|
ShouldInclude(path string, info os.FileInfo) bool
|
||||||
|
}
|
||||||
|
|
||||||
|
// NameMatcher matches files by exact name
|
||||||
|
type NameMatcher struct {
|
||||||
|
name string
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewNameMatcher creates a matcher for exact filename matching
|
||||||
|
func NewNameMatcher(name string) *NameMatcher {
|
||||||
|
return &NameMatcher{name: name}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ShouldInclude returns true if the file matches the exact name
|
||||||
|
func (m *NameMatcher) ShouldInclude(path string, info os.FileInfo) bool {
|
||||||
|
if info.IsDir() {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return info.Name() == m.name
|
||||||
|
}
|
||||||
|
|
||||||
|
// PatternMatcher matches files within directories matching a glob pattern
|
||||||
|
type PatternMatcher struct {
|
||||||
|
pattern string
|
||||||
|
matchedDirs map[string]bool
|
||||||
|
patternSegments []string
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewPatternMatcher creates a matcher for directory pattern matching
|
||||||
|
func NewPatternMatcher(pattern string) *PatternMatcher {
|
||||||
|
// Remove trailing slash if present
|
||||||
|
pattern = strings.TrimSuffix(pattern, "/")
|
||||||
|
|
||||||
|
return &PatternMatcher{
|
||||||
|
pattern: pattern,
|
||||||
|
matchedDirs: make(map[string]bool),
|
||||||
|
patternSegments: strings.Split(pattern, string(os.PathSeparator)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ShouldInclude returns true if the file is within a directory matching the pattern
|
||||||
|
func (m *PatternMatcher) ShouldInclude(path string, info os.FileInfo) bool {
|
||||||
|
// For directories, check if they match the pattern and cache the result
|
||||||
|
if info.IsDir() {
|
||||||
|
matched, err := m.dirMatchesPattern(path)
|
||||||
|
if err == nil && matched {
|
||||||
|
m.matchedDirs[path] = true
|
||||||
|
}
|
||||||
|
return false // Don't include the directory itself, only files within
|
||||||
|
}
|
||||||
|
|
||||||
|
// For files, check if any parent directory is in the matched set
|
||||||
|
dir := filepath.Dir(path)
|
||||||
|
for {
|
||||||
|
if m.matchedDirs[dir] {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// Also check if this directory matches the pattern (in case we haven't seen it yet)
|
||||||
|
if matched, err := m.dirMatchesPattern(dir); err == nil && matched {
|
||||||
|
m.matchedDirs[dir] = true
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
parent := filepath.Dir(dir)
|
||||||
|
if parent == dir || parent == "." {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
dir = parent
|
||||||
|
}
|
||||||
|
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// dirMatchesPattern checks if a directory path matches the glob pattern
|
||||||
|
func (m *PatternMatcher) dirMatchesPattern(dirPath string) (bool, error) {
|
||||||
|
// Get the directory name
|
||||||
|
dirName := filepath.Base(dirPath)
|
||||||
|
|
||||||
|
// For simple patterns (no path separators), just match the directory name
|
||||||
|
if len(m.patternSegments) == 1 {
|
||||||
|
return filepath.Match(m.pattern, dirName)
|
||||||
|
}
|
||||||
|
|
||||||
|
// For complex patterns, we need to match the full path segments
|
||||||
|
pathSegments := strings.Split(dirPath, string(os.PathSeparator))
|
||||||
|
|
||||||
|
// Try to match the pattern segments against the path segments
|
||||||
|
if len(pathSegments) < len(m.patternSegments) {
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check each pattern segment against the corresponding path segment
|
||||||
|
for i := 0; i < len(m.patternSegments); i++ {
|
||||||
|
// Start from the end of both slices
|
||||||
|
patternIdx := len(m.patternSegments) - 1 - i
|
||||||
|
pathIdx := len(pathSegments) - 1 - i
|
||||||
|
|
||||||
|
matched, err := filepath.Match(m.patternSegments[patternIdx], pathSegments[pathIdx])
|
||||||
|
if err != nil {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
if !matched {
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return true, nil
|
||||||
|
}
|
3
go.mod
Normal file
3
go.mod
Normal file
|
@ -0,0 +1,3 @@
|
||||||
|
module github.com/atomaka/collect
|
||||||
|
|
||||||
|
go 1.21
|
103
main.go
Normal file
103
main.go
Normal file
|
@ -0,0 +1,103 @@
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"flag"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
|
||||||
|
"github.com/atomaka/collect/archiver"
|
||||||
|
"github.com/atomaka/collect/collector"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
exitSuccess = 0
|
||||||
|
exitNoFiles = 1
|
||||||
|
exitArchiveError = 2
|
||||||
|
exitInvalidArgs = 3
|
||||||
|
)
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
// Define flags
|
||||||
|
nameFlag := flag.String("name", "", "Match exact filename")
|
||||||
|
matchFlag := flag.String("match", "", "Match directory pattern")
|
||||||
|
|
||||||
|
// Custom usage message
|
||||||
|
flag.Usage = func() {
|
||||||
|
fmt.Fprintf(os.Stderr, "Usage: %s [--name <filename> | --match <pattern>] <source-dir> <output-archive>\n\n", os.Args[0])
|
||||||
|
fmt.Fprintf(os.Stderr, "Collects files recursively matching specific criteria and archives them.\n\n")
|
||||||
|
fmt.Fprintf(os.Stderr, "Options:\n")
|
||||||
|
flag.PrintDefaults()
|
||||||
|
fmt.Fprintf(os.Stderr, "\nExamples:\n")
|
||||||
|
fmt.Fprintf(os.Stderr, " %s --name .mise.toml ./ backup.tgz\n", os.Args[0])
|
||||||
|
fmt.Fprintf(os.Stderr, " %s --match 'aet-*/' ./ backup.zip\n", os.Args[0])
|
||||||
|
}
|
||||||
|
|
||||||
|
flag.Parse()
|
||||||
|
|
||||||
|
// Validate flags
|
||||||
|
if (*nameFlag == "" && *matchFlag == "") || (*nameFlag != "" && *matchFlag != "") {
|
||||||
|
fmt.Fprintf(os.Stderr, "Error: Exactly one of --name or --match must be specified\n\n")
|
||||||
|
flag.Usage()
|
||||||
|
os.Exit(exitInvalidArgs)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check positional arguments
|
||||||
|
args := flag.Args()
|
||||||
|
if len(args) != 2 {
|
||||||
|
fmt.Fprintf(os.Stderr, "Error: Expected 2 arguments (source directory and output archive), got %d\n\n", len(args))
|
||||||
|
flag.Usage()
|
||||||
|
os.Exit(exitInvalidArgs)
|
||||||
|
}
|
||||||
|
|
||||||
|
sourceDir := args[0]
|
||||||
|
outputPath := args[1]
|
||||||
|
|
||||||
|
// Determine archive format
|
||||||
|
format := collector.GetArchiveFormat(outputPath)
|
||||||
|
if format == "" {
|
||||||
|
fmt.Fprintf(os.Stderr, "Error: Unsupported archive format. Use .tar.gz, .tgz, or .zip\n")
|
||||||
|
os.Exit(exitInvalidArgs)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create matcher
|
||||||
|
var matcher collector.Matcher
|
||||||
|
if *nameFlag != "" {
|
||||||
|
matcher = collector.NewNameMatcher(*nameFlag)
|
||||||
|
} else {
|
||||||
|
matcher = collector.NewPatternMatcher(*matchFlag)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create collector and collect files
|
||||||
|
c := collector.New(matcher)
|
||||||
|
files, err := c.Collect(sourceDir)
|
||||||
|
if err != nil {
|
||||||
|
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
|
||||||
|
if err.Error() == "no files found matching criteria" {
|
||||||
|
os.Exit(exitNoFiles)
|
||||||
|
}
|
||||||
|
os.Exit(exitArchiveError)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Report number of files found
|
||||||
|
fmt.Printf("Found %d files to archive\n", len(files))
|
||||||
|
|
||||||
|
// Create appropriate archiver
|
||||||
|
var arch archiver.Archiver
|
||||||
|
switch format {
|
||||||
|
case "tar.gz":
|
||||||
|
arch = archiver.NewTarArchiver()
|
||||||
|
case "zip":
|
||||||
|
arch = archiver.NewZipArchiver()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create archive
|
||||||
|
if err := arch.Create(outputPath, files); err != nil {
|
||||||
|
fmt.Fprintf(os.Stderr, "Error creating archive: %v\n", err)
|
||||||
|
os.Exit(exitArchiveError)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get absolute path for cleaner output
|
||||||
|
absOutput, _ := filepath.Abs(outputPath)
|
||||||
|
fmt.Printf("Archive created successfully: %s\n", absOutput)
|
||||||
|
}
|
162
test.sh
Executable file
162
test.sh
Executable file
|
@ -0,0 +1,162 @@
|
||||||
|
#!/bin/bash
|
||||||
|
|
||||||
|
set -e
|
||||||
|
|
||||||
|
# Colors for output
|
||||||
|
RED='\033[0;31m'
|
||||||
|
GREEN='\033[0;32m'
|
||||||
|
YELLOW='\033[1;33m'
|
||||||
|
NC='\033[0m' # No Color
|
||||||
|
|
||||||
|
# Test counter
|
||||||
|
TESTS_PASSED=0
|
||||||
|
TESTS_FAILED=0
|
||||||
|
|
||||||
|
# Function to run a test
|
||||||
|
run_test() {
|
||||||
|
local test_name="$1"
|
||||||
|
local command="$2"
|
||||||
|
local expected_exit_code="${3:-0}"
|
||||||
|
|
||||||
|
echo -n "Testing $test_name... "
|
||||||
|
|
||||||
|
# Run command and capture exit code
|
||||||
|
set +e
|
||||||
|
eval "$command" > /tmp/test_output.txt 2>&1
|
||||||
|
local exit_code=$?
|
||||||
|
set -e
|
||||||
|
|
||||||
|
if [ $exit_code -eq $expected_exit_code ]; then
|
||||||
|
echo -e "${GREEN}PASSED${NC}"
|
||||||
|
((TESTS_PASSED++))
|
||||||
|
return 0
|
||||||
|
else
|
||||||
|
echo -e "${RED}FAILED${NC}"
|
||||||
|
echo " Expected exit code: $expected_exit_code, got: $exit_code"
|
||||||
|
echo " Output:"
|
||||||
|
cat /tmp/test_output.txt | sed 's/^/ /'
|
||||||
|
((TESTS_FAILED++))
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
# Function to verify archive contents
|
||||||
|
verify_archive_contents() {
|
||||||
|
local archive="$1"
|
||||||
|
local expected_files="$2"
|
||||||
|
|
||||||
|
echo -n " Verifying contents of $archive... "
|
||||||
|
|
||||||
|
if [[ "$archive" == *.tgz || "$archive" == *.tar.gz ]]; then
|
||||||
|
actual_files=$(tar -tzf "$archive" | sort | tr '\n' ' ' | sed 's/ $//')
|
||||||
|
elif [[ "$archive" == *.zip ]]; then
|
||||||
|
actual_files=$(unzip -l "$archive" | grep -v "Archive:" | grep -v "Length" | grep -v -- "--------" | grep -v "files" | awk '{print $4}' | grep -v '^$' | sort | tr '\n' ' ' | sed 's/ $//')
|
||||||
|
else
|
||||||
|
echo -e "${RED}Unknown archive format${NC}"
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
expected_sorted=$(echo "$expected_files" | tr ' ' '\n' | sort | tr '\n' ' ' | sed 's/ $//')
|
||||||
|
|
||||||
|
if [ "$actual_files" = "$expected_sorted" ]; then
|
||||||
|
echo -e "${GREEN}OK${NC}"
|
||||||
|
return 0
|
||||||
|
else
|
||||||
|
echo -e "${RED}FAILED${NC}"
|
||||||
|
echo " Expected: $expected_sorted"
|
||||||
|
echo " Got: $actual_files"
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
echo -e "${YELLOW}=== Collect CLI Test Suite ===${NC}\n"
|
||||||
|
|
||||||
|
# Clean up any previous test artifacts
|
||||||
|
echo "Cleaning up previous test artifacts..."
|
||||||
|
rm -rf test test-*.tgz test-*.zip collect
|
||||||
|
|
||||||
|
# Build the tool
|
||||||
|
echo "Building collect tool..."
|
||||||
|
if ! go build -o collect; then
|
||||||
|
echo -e "${RED}Failed to build collect tool${NC}"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
echo -e "${GREEN}Build successful${NC}\n"
|
||||||
|
|
||||||
|
# Create test directory structure
|
||||||
|
echo "Setting up test environment..."
|
||||||
|
mkdir -p test/subdir/aet-bin test/subdir/aet-config test/other test/deep/nested/aet-tools
|
||||||
|
echo "test config" > test/.mise.toml
|
||||||
|
echo "another config" > test/subdir/.mise.toml
|
||||||
|
echo "binary" > test/subdir/aet-bin/tool
|
||||||
|
echo "config" > test/subdir/aet-config/settings.conf
|
||||||
|
echo "other file" > test/other/file.txt
|
||||||
|
echo "deep tool" > test/deep/nested/aet-tools/deep.sh
|
||||||
|
echo "not in aet dir" > test/deep/nested/regular.txt
|
||||||
|
echo -e "${GREEN}Test environment ready${NC}\n"
|
||||||
|
|
||||||
|
# Test 1: Name matching with tar.gz
|
||||||
|
run_test "name matching (.mise.toml) with tar.gz" \
|
||||||
|
"./collect --name .mise.toml test/ test-name.tgz"
|
||||||
|
verify_archive_contents "test-name.tgz" ".mise.toml subdir/.mise.toml"
|
||||||
|
|
||||||
|
# Test 2: Pattern matching with zip
|
||||||
|
run_test "pattern matching (aet-*) with zip" \
|
||||||
|
"./collect --match 'aet-*' test/ test-pattern.zip"
|
||||||
|
verify_archive_contents "test-pattern.zip" "subdir/aet-bin/tool subdir/aet-config/settings.conf deep/nested/aet-tools/deep.sh"
|
||||||
|
|
||||||
|
# Test 3: Pattern matching with tgz
|
||||||
|
run_test "pattern matching (aet-*) with tgz" \
|
||||||
|
"./collect --match 'aet-*' test/ test-pattern.tgz"
|
||||||
|
verify_archive_contents "test-pattern.tgz" "deep/nested/aet-tools/deep.sh subdir/aet-bin/tool subdir/aet-config/settings.conf"
|
||||||
|
|
||||||
|
# Test 4: No files found (should exit with code 1)
|
||||||
|
run_test "no files found error" \
|
||||||
|
"./collect --name nonexistent.file test/ test-empty.zip" 1
|
||||||
|
|
||||||
|
# Test 5: Invalid arguments - no flags
|
||||||
|
run_test "invalid arguments - no flags" \
|
||||||
|
"./collect test/ output.zip" 3
|
||||||
|
|
||||||
|
# Test 6: Invalid arguments - both flags
|
||||||
|
run_test "invalid arguments - both flags" \
|
||||||
|
"./collect --name .mise.toml --match 'aet-*' test/ output.zip" 3
|
||||||
|
|
||||||
|
# Test 7: Invalid arguments - missing output file
|
||||||
|
run_test "invalid arguments - missing output file" \
|
||||||
|
"./collect --name .mise.toml test/" 3
|
||||||
|
|
||||||
|
# Test 8: Invalid archive format
|
||||||
|
run_test "invalid archive format" \
|
||||||
|
"./collect --name .mise.toml test/ output.txt" 3
|
||||||
|
|
||||||
|
# Test 9: Non-existent source directory
|
||||||
|
run_test "non-existent source directory" \
|
||||||
|
"./collect --name .mise.toml nonexistent/ output.zip" 2
|
||||||
|
|
||||||
|
# Test 10: Help flag
|
||||||
|
run_test "help flag output" \
|
||||||
|
"./collect --help" 0
|
||||||
|
|
||||||
|
# Test 11: Name matching with subdirectory pattern
|
||||||
|
echo "subdir config" > test/subdir/aet-bin/.mise.toml
|
||||||
|
run_test "name matching finds files in pattern dirs too" \
|
||||||
|
"./collect --name .mise.toml test/ test-name-all.tgz"
|
||||||
|
verify_archive_contents "test-name-all.tgz" ".mise.toml subdir/.mise.toml subdir/aet-bin/.mise.toml"
|
||||||
|
|
||||||
|
# Clean up
|
||||||
|
echo -e "\nCleaning up..."
|
||||||
|
rm -rf test test-*.tgz test-*.zip collect /tmp/test_output.txt
|
||||||
|
|
||||||
|
# Summary
|
||||||
|
echo -e "\n${YELLOW}=== Test Summary ===${NC}"
|
||||||
|
echo -e "Tests passed: ${GREEN}$TESTS_PASSED${NC}"
|
||||||
|
echo -e "Tests failed: ${RED}$TESTS_FAILED${NC}"
|
||||||
|
|
||||||
|
if [ $TESTS_FAILED -eq 0 ]; then
|
||||||
|
echo -e "\n${GREEN}All tests passed!${NC}"
|
||||||
|
exit 0
|
||||||
|
else
|
||||||
|
echo -e "\n${RED}Some tests failed!${NC}"
|
||||||
|
exit 1
|
||||||
|
fi
|
Loading…
Add table
Reference in a new issue