diff --git a/internal/checker/checker_test.go b/internal/checker/checker_test.go new file mode 100644 index 0000000..d6bc462 --- /dev/null +++ b/internal/checker/checker_test.go @@ -0,0 +1,405 @@ +package checker + +import ( + "bytes" + "context" + "testing" + "time" + + "github.com/spf13/afero" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "sneak.berlin/go/mfer/mfer" +) + +func TestStatusString(t *testing.T) { + tests := []struct { + status Status + expected string + }{ + {StatusOK, "OK"}, + {StatusMissing, "MISSING"}, + {StatusSizeMismatch, "SIZE_MISMATCH"}, + {StatusHashMismatch, "HASH_MISMATCH"}, + {StatusExtra, "EXTRA"}, + {StatusError, "ERROR"}, + {Status(99), "UNKNOWN"}, + } + + for _, tt := range tests { + t.Run(tt.expected, func(t *testing.T) { + assert.Equal(t, tt.expected, tt.status.String()) + }) + } +} + +// createTestManifest creates a manifest file in the filesystem with the given files. +func createTestManifest(t *testing.T, fs afero.Fs, manifestPath string, files map[string][]byte) { + t.Helper() + + builder := mfer.NewBuilder() + for path, content := range files { + reader := bytes.NewReader(content) + _, err := builder.AddFile(path, int64(len(content)), time.Now(), reader, nil) + require.NoError(t, err) + } + + var buf bytes.Buffer + require.NoError(t, builder.Build(&buf)) + require.NoError(t, afero.WriteFile(fs, manifestPath, buf.Bytes(), 0644)) +} + +// createFilesOnDisk creates the given files on the filesystem. +func createFilesOnDisk(t *testing.T, fs afero.Fs, basePath string, files map[string][]byte) { + t.Helper() + + for path, content := range files { + fullPath := basePath + "/" + path + require.NoError(t, fs.MkdirAll(basePath, 0755)) + require.NoError(t, afero.WriteFile(fs, fullPath, content, 0644)) + } +} + +func TestNewChecker(t *testing.T) { + t.Run("valid manifest", func(t *testing.T) { + fs := afero.NewMemMapFs() + files := map[string][]byte{ + "file1.txt": []byte("hello"), + "file2.txt": []byte("world"), + } + createTestManifest(t, fs, "/manifest.mf", files) + + chk, err := NewChecker("/manifest.mf", "/", fs) + require.NoError(t, err) + assert.NotNil(t, chk) + assert.Equal(t, int64(2), chk.FileCount()) + }) + + t.Run("missing manifest", func(t *testing.T) { + fs := afero.NewMemMapFs() + _, err := NewChecker("/nonexistent.mf", "/", fs) + assert.Error(t, err) + }) + + t.Run("invalid manifest", func(t *testing.T) { + fs := afero.NewMemMapFs() + require.NoError(t, afero.WriteFile(fs, "/bad.mf", []byte("not a manifest"), 0644)) + _, err := NewChecker("/bad.mf", "/", fs) + assert.Error(t, err) + }) +} + +func TestCheckerFileCountAndTotalBytes(t *testing.T) { + fs := afero.NewMemMapFs() + files := map[string][]byte{ + "small.txt": []byte("hi"), + "medium.txt": []byte("hello world"), + "large.txt": bytes.Repeat([]byte("x"), 1000), + } + createTestManifest(t, fs, "/manifest.mf", files) + + chk, err := NewChecker("/manifest.mf", "/", fs) + require.NoError(t, err) + + assert.Equal(t, int64(3), chk.FileCount()) + assert.Equal(t, int64(2+11+1000), chk.TotalBytes()) +} + +func TestCheckAllFilesOK(t *testing.T) { + fs := afero.NewMemMapFs() + files := map[string][]byte{ + "file1.txt": []byte("content one"), + "file2.txt": []byte("content two"), + } + createTestManifest(t, fs, "/manifest.mf", files) + createFilesOnDisk(t, fs, "/data", files) + + chk, err := NewChecker("/manifest.mf", "/data", fs) + require.NoError(t, err) + + results := make(chan Result, 10) + err = chk.Check(context.Background(), results, nil) + require.NoError(t, err) + + var resultList []Result + for r := range results { + resultList = append(resultList, r) + } + + assert.Len(t, resultList, 2) + for _, r := range resultList { + assert.Equal(t, StatusOK, r.Status, "file %s should be OK", r.Path) + } +} + +func TestCheckMissingFile(t *testing.T) { + fs := afero.NewMemMapFs() + files := map[string][]byte{ + "exists.txt": []byte("I exist"), + "missing.txt": []byte("I don't exist on disk"), + } + createTestManifest(t, fs, "/manifest.mf", files) + // Only create one file + createFilesOnDisk(t, fs, "/data", map[string][]byte{ + "exists.txt": []byte("I exist"), + }) + + chk, err := NewChecker("/manifest.mf", "/data", fs) + require.NoError(t, err) + + results := make(chan Result, 10) + err = chk.Check(context.Background(), results, nil) + require.NoError(t, err) + + var okCount, missingCount int + for r := range results { + switch r.Status { + case StatusOK: + okCount++ + case StatusMissing: + missingCount++ + assert.Equal(t, "missing.txt", r.Path) + } + } + + assert.Equal(t, 1, okCount) + assert.Equal(t, 1, missingCount) +} + +func TestCheckSizeMismatch(t *testing.T) { + fs := afero.NewMemMapFs() + files := map[string][]byte{ + "file.txt": []byte("original content"), + } + createTestManifest(t, fs, "/manifest.mf", files) + // Create file with different size + createFilesOnDisk(t, fs, "/data", map[string][]byte{ + "file.txt": []byte("short"), + }) + + chk, err := NewChecker("/manifest.mf", "/data", fs) + require.NoError(t, err) + + results := make(chan Result, 10) + err = chk.Check(context.Background(), results, nil) + require.NoError(t, err) + + r := <-results + assert.Equal(t, StatusSizeMismatch, r.Status) + assert.Equal(t, "file.txt", r.Path) +} + +func TestCheckHashMismatch(t *testing.T) { + fs := afero.NewMemMapFs() + originalContent := []byte("original content") + files := map[string][]byte{ + "file.txt": originalContent, + } + createTestManifest(t, fs, "/manifest.mf", files) + // Create file with same size but different content + differentContent := []byte("different contnt") // same length (16 bytes) but different + require.Equal(t, len(originalContent), len(differentContent), "test requires same length") + createFilesOnDisk(t, fs, "/data", map[string][]byte{ + "file.txt": differentContent, + }) + + chk, err := NewChecker("/manifest.mf", "/data", fs) + require.NoError(t, err) + + results := make(chan Result, 10) + err = chk.Check(context.Background(), results, nil) + require.NoError(t, err) + + r := <-results + assert.Equal(t, StatusHashMismatch, r.Status) + assert.Equal(t, "file.txt", r.Path) +} + +func TestCheckWithProgress(t *testing.T) { + fs := afero.NewMemMapFs() + files := map[string][]byte{ + "file1.txt": bytes.Repeat([]byte("a"), 100), + "file2.txt": bytes.Repeat([]byte("b"), 200), + } + createTestManifest(t, fs, "/manifest.mf", files) + createFilesOnDisk(t, fs, "/data", files) + + chk, err := NewChecker("/manifest.mf", "/data", fs) + require.NoError(t, err) + + results := make(chan Result, 10) + progress := make(chan CheckStatus, 10) + + err = chk.Check(context.Background(), results, progress) + require.NoError(t, err) + + // Drain results + for range results { + } + + // Check progress was sent + var progressUpdates []CheckStatus + for p := range progress { + progressUpdates = append(progressUpdates, p) + } + + assert.NotEmpty(t, progressUpdates) + // Final progress should show all files checked + final := progressUpdates[len(progressUpdates)-1] + assert.Equal(t, int64(2), final.TotalFiles) + assert.Equal(t, int64(2), final.CheckedFiles) + assert.Equal(t, int64(300), final.TotalBytes) + assert.Equal(t, int64(300), final.CheckedBytes) + assert.Equal(t, int64(0), final.Failures) +} + +func TestCheckContextCancellation(t *testing.T) { + fs := afero.NewMemMapFs() + // Create many files to ensure we have time to cancel + files := make(map[string][]byte) + for i := 0; i < 100; i++ { + files[string(rune('a'+i%26))+".txt"] = bytes.Repeat([]byte("x"), 1000) + } + createTestManifest(t, fs, "/manifest.mf", files) + createFilesOnDisk(t, fs, "/data", files) + + chk, err := NewChecker("/manifest.mf", "/data", fs) + require.NoError(t, err) + + ctx, cancel := context.WithCancel(context.Background()) + cancel() // Cancel immediately + + results := make(chan Result, 200) + err = chk.Check(ctx, results, nil) + assert.ErrorIs(t, err, context.Canceled) +} + +func TestFindExtraFiles(t *testing.T) { + fs := afero.NewMemMapFs() + // Manifest only contains file1 + manifestFiles := map[string][]byte{ + "file1.txt": []byte("in manifest"), + } + createTestManifest(t, fs, "/manifest.mf", manifestFiles) + + // Disk has file1 and file2 + createFilesOnDisk(t, fs, "/data", map[string][]byte{ + "file1.txt": []byte("in manifest"), + "file2.txt": []byte("extra file"), + }) + + chk, err := NewChecker("/manifest.mf", "/data", fs) + require.NoError(t, err) + + results := make(chan Result, 10) + err = chk.FindExtraFiles(context.Background(), results) + require.NoError(t, err) + + var extras []Result + for r := range results { + extras = append(extras, r) + } + + assert.Len(t, extras, 1) + assert.Equal(t, "file2.txt", extras[0].Path) + assert.Equal(t, StatusExtra, extras[0].Status) + assert.Equal(t, "not in manifest", extras[0].Message) +} + +func TestFindExtraFilesContextCancellation(t *testing.T) { + fs := afero.NewMemMapFs() + files := map[string][]byte{"file.txt": []byte("data")} + createTestManifest(t, fs, "/manifest.mf", files) + createFilesOnDisk(t, fs, "/data", files) + + chk, err := NewChecker("/manifest.mf", "/data", fs) + require.NoError(t, err) + + ctx, cancel := context.WithCancel(context.Background()) + cancel() // Cancel immediately + + results := make(chan Result, 10) + err = chk.FindExtraFiles(ctx, results) + assert.ErrorIs(t, err, context.Canceled) +} + +func TestCheckNilChannels(t *testing.T) { + fs := afero.NewMemMapFs() + files := map[string][]byte{"file.txt": []byte("data")} + createTestManifest(t, fs, "/manifest.mf", files) + createFilesOnDisk(t, fs, "/data", files) + + chk, err := NewChecker("/manifest.mf", "/data", fs) + require.NoError(t, err) + + // Should not panic with nil channels + err = chk.Check(context.Background(), nil, nil) + assert.NoError(t, err) +} + +func TestFindExtraFilesNilChannel(t *testing.T) { + fs := afero.NewMemMapFs() + files := map[string][]byte{"file.txt": []byte("data")} + createTestManifest(t, fs, "/manifest.mf", files) + createFilesOnDisk(t, fs, "/data", files) + + chk, err := NewChecker("/manifest.mf", "/data", fs) + require.NoError(t, err) + + // Should not panic with nil channel + err = chk.FindExtraFiles(context.Background(), nil) + assert.NoError(t, err) +} + +func TestCheckSubdirectories(t *testing.T) { + fs := afero.NewMemMapFs() + files := map[string][]byte{ + "dir1/file1.txt": []byte("content1"), + "dir1/dir2/file2.txt": []byte("content2"), + "dir1/dir2/dir3/deep.txt": []byte("deep content"), + } + createTestManifest(t, fs, "/manifest.mf", files) + + // Create files with full directory structure + for path, content := range files { + fullPath := "/data/" + path + require.NoError(t, fs.MkdirAll("/data/dir1/dir2/dir3", 0755)) + require.NoError(t, afero.WriteFile(fs, fullPath, content, 0644)) + } + + chk, err := NewChecker("/manifest.mf", "/data", fs) + require.NoError(t, err) + + results := make(chan Result, 10) + err = chk.Check(context.Background(), results, nil) + require.NoError(t, err) + + var okCount int + for r := range results { + assert.Equal(t, StatusOK, r.Status, "file %s should be OK", r.Path) + okCount++ + } + assert.Equal(t, 3, okCount) +} + +func TestCheckEmptyManifest(t *testing.T) { + fs := afero.NewMemMapFs() + // Create manifest with no files + createTestManifest(t, fs, "/manifest.mf", map[string][]byte{}) + + chk, err := NewChecker("/manifest.mf", "/data", fs) + require.NoError(t, err) + + assert.Equal(t, int64(0), chk.FileCount()) + assert.Equal(t, int64(0), chk.TotalBytes()) + + results := make(chan Result, 10) + err = chk.Check(context.Background(), results, nil) + require.NoError(t, err) + + var count int + for range results { + count++ + } + assert.Equal(t, 0, count) +} diff --git a/internal/cli/entry_test.go b/internal/cli/entry_test.go index 6e9f437..4fc87f5 100644 --- a/internal/cli/entry_test.go +++ b/internal/cli/entry_test.go @@ -485,6 +485,42 @@ func TestGenerateAtomicWriteCleansUpOnError(t *testing.T) { assert.False(t, tmpExists, "temp file should be cleaned up after failed generation") } +func TestGenerateValidatesInputPaths(t *testing.T) { + fs := afero.NewMemMapFs() + + // Create one valid directory + require.NoError(t, fs.MkdirAll("/validdir", 0755)) + require.NoError(t, afero.WriteFile(fs, "/validdir/file.txt", []byte("content"), 0644)) + + t.Run("nonexistent path fails fast", func(t *testing.T) { + opts := testOpts([]string{"mfer", "generate", "-q", "-o", "/output.mf", "/nonexistent"}, fs) + exitCode := RunWithOptions(opts) + assert.Equal(t, 1, exitCode) + stderr := opts.Stderr.(*bytes.Buffer).String() + assert.Contains(t, stderr, "path does not exist") + assert.Contains(t, stderr, "/nonexistent") + }) + + t.Run("mix of valid and invalid paths fails fast", func(t *testing.T) { + opts := testOpts([]string{"mfer", "generate", "-q", "-o", "/output.mf", "/validdir", "/alsononexistent"}, fs) + exitCode := RunWithOptions(opts) + assert.Equal(t, 1, exitCode) + stderr := opts.Stderr.(*bytes.Buffer).String() + assert.Contains(t, stderr, "path does not exist") + assert.Contains(t, stderr, "/alsononexistent") + + // Output file should not have been created + exists, _ := afero.Exists(fs, "/output.mf") + assert.False(t, exists, "output file should not exist when path validation fails") + }) + + t.Run("valid paths succeed", func(t *testing.T) { + opts := testOpts([]string{"mfer", "generate", "-q", "-o", "/output.mf", "/validdir"}, fs) + exitCode := RunWithOptions(opts) + assert.Equal(t, 0, exitCode) + }) +} + func TestCheckDetectsManifestCorruption(t *testing.T) { fs := afero.NewMemMapFs() rng := rand.New(rand.NewSource(42)) diff --git a/internal/cli/gen.go b/internal/cli/gen.go index 310d7df..f1d87f0 100644 --- a/internal/cli/gen.go +++ b/internal/cli/gen.go @@ -54,13 +54,18 @@ func (mfa *CLIApp) generateManifestOperation(ctx *cli.Context) error { return err } } else { - // Collect all paths first + // Collect and validate all paths first paths := make([]string, 0, args.Len()) for i := 0; i < args.Len(); i++ { - ap, err := filepath.Abs(args.Get(i)) + inputPath := args.Get(i) + ap, err := filepath.Abs(inputPath) if err != nil { return err } + // Validate path exists before adding to list + if exists, _ := afero.Exists(mfa.Fs, ap); !exists { + return fmt.Errorf("path does not exist: %s", inputPath) + } log.Debugf("enumerating path: %s", ap) paths = append(paths, ap) } diff --git a/internal/scanner/scanner_test.go b/internal/scanner/scanner_test.go new file mode 100644 index 0000000..67edc98 --- /dev/null +++ b/internal/scanner/scanner_test.go @@ -0,0 +1,362 @@ +package scanner + +import ( + "bytes" + "context" + "testing" + "time" + + "github.com/spf13/afero" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestNew(t *testing.T) { + s := New() + assert.NotNil(t, s) + assert.Equal(t, int64(0), s.FileCount()) + assert.Equal(t, int64(0), s.TotalBytes()) +} + +func TestNewWithOptions(t *testing.T) { + t.Run("nil options", func(t *testing.T) { + s := NewWithOptions(nil) + assert.NotNil(t, s) + }) + + t.Run("with options", func(t *testing.T) { + fs := afero.NewMemMapFs() + opts := &Options{ + IncludeDotfiles: true, + FollowSymLinks: true, + Fs: fs, + } + s := NewWithOptions(opts) + assert.NotNil(t, s) + }) +} + +func TestEnumerateFile(t *testing.T) { + fs := afero.NewMemMapFs() + require.NoError(t, afero.WriteFile(fs, "/test.txt", []byte("hello world"), 0644)) + + s := NewWithOptions(&Options{Fs: fs}) + err := s.EnumerateFile("/test.txt") + require.NoError(t, err) + + assert.Equal(t, int64(1), s.FileCount()) + assert.Equal(t, int64(11), s.TotalBytes()) + + files := s.Files() + require.Len(t, files, 1) + assert.Equal(t, "test.txt", files[0].Path) + assert.Equal(t, int64(11), files[0].Size) +} + +func TestEnumerateFileMissing(t *testing.T) { + fs := afero.NewMemMapFs() + s := NewWithOptions(&Options{Fs: fs}) + err := s.EnumerateFile("/nonexistent.txt") + assert.Error(t, err) +} + +func TestEnumeratePath(t *testing.T) { + fs := afero.NewMemMapFs() + require.NoError(t, fs.MkdirAll("/testdir/subdir", 0755)) + require.NoError(t, afero.WriteFile(fs, "/testdir/file1.txt", []byte("one"), 0644)) + require.NoError(t, afero.WriteFile(fs, "/testdir/file2.txt", []byte("two"), 0644)) + require.NoError(t, afero.WriteFile(fs, "/testdir/subdir/file3.txt", []byte("three"), 0644)) + + s := NewWithOptions(&Options{Fs: fs}) + err := s.EnumeratePath("/testdir", nil) + require.NoError(t, err) + + assert.Equal(t, int64(3), s.FileCount()) + assert.Equal(t, int64(3+3+5), s.TotalBytes()) +} + +func TestEnumeratePathWithProgress(t *testing.T) { + fs := afero.NewMemMapFs() + require.NoError(t, fs.MkdirAll("/testdir", 0755)) + require.NoError(t, afero.WriteFile(fs, "/testdir/file1.txt", []byte("one"), 0644)) + require.NoError(t, afero.WriteFile(fs, "/testdir/file2.txt", []byte("two"), 0644)) + + s := NewWithOptions(&Options{Fs: fs}) + progress := make(chan EnumerateStatus, 10) + + err := s.EnumeratePath("/testdir", progress) + require.NoError(t, err) + + var updates []EnumerateStatus + for p := range progress { + updates = append(updates, p) + } + + assert.NotEmpty(t, updates) + // Final update should show all files + final := updates[len(updates)-1] + assert.Equal(t, int64(2), final.FilesFound) + assert.Equal(t, int64(6), final.BytesFound) +} + +func TestEnumeratePaths(t *testing.T) { + fs := afero.NewMemMapFs() + require.NoError(t, fs.MkdirAll("/dir1", 0755)) + require.NoError(t, fs.MkdirAll("/dir2", 0755)) + require.NoError(t, afero.WriteFile(fs, "/dir1/a.txt", []byte("aaa"), 0644)) + require.NoError(t, afero.WriteFile(fs, "/dir2/b.txt", []byte("bbb"), 0644)) + + s := NewWithOptions(&Options{Fs: fs}) + err := s.EnumeratePaths(nil, "/dir1", "/dir2") + require.NoError(t, err) + + assert.Equal(t, int64(2), s.FileCount()) +} + +func TestExcludeDotfiles(t *testing.T) { + fs := afero.NewMemMapFs() + require.NoError(t, fs.MkdirAll("/testdir/.hidden", 0755)) + require.NoError(t, afero.WriteFile(fs, "/testdir/visible.txt", []byte("visible"), 0644)) + require.NoError(t, afero.WriteFile(fs, "/testdir/.hidden.txt", []byte("hidden"), 0644)) + require.NoError(t, afero.WriteFile(fs, "/testdir/.hidden/inside.txt", []byte("inside"), 0644)) + + t.Run("exclude by default", func(t *testing.T) { + s := NewWithOptions(&Options{Fs: fs, IncludeDotfiles: false}) + err := s.EnumeratePath("/testdir", nil) + require.NoError(t, err) + + assert.Equal(t, int64(1), s.FileCount()) + files := s.Files() + assert.Equal(t, "visible.txt", files[0].Path) + }) + + t.Run("include when enabled", func(t *testing.T) { + s := NewWithOptions(&Options{Fs: fs, IncludeDotfiles: true}) + err := s.EnumeratePath("/testdir", nil) + require.NoError(t, err) + + assert.Equal(t, int64(3), s.FileCount()) + }) +} + +func TestPathIsHidden(t *testing.T) { + tests := []struct { + path string + hidden bool + }{ + {"file.txt", false}, + {".hidden", true}, + {"dir/file.txt", false}, + {"dir/.hidden", true}, + {".dir/file.txt", true}, + {"/absolute/path", false}, + {"/absolute/.hidden", true}, + {"./relative", false}, // path.Clean removes leading ./ + {"a/b/c/.d/e", true}, + } + + for _, tt := range tests { + t.Run(tt.path, func(t *testing.T) { + assert.Equal(t, tt.hidden, pathIsHidden(tt.path), "pathIsHidden(%q)", tt.path) + }) + } +} + +func TestToManifest(t *testing.T) { + fs := afero.NewMemMapFs() + require.NoError(t, fs.MkdirAll("/testdir", 0755)) + require.NoError(t, afero.WriteFile(fs, "/testdir/file1.txt", []byte("content one"), 0644)) + require.NoError(t, afero.WriteFile(fs, "/testdir/file2.txt", []byte("content two"), 0644)) + + s := NewWithOptions(&Options{Fs: fs}) + err := s.EnumeratePath("/testdir", nil) + require.NoError(t, err) + + var buf bytes.Buffer + err = s.ToManifest(context.Background(), &buf, nil) + require.NoError(t, err) + + // Manifest should have magic bytes + assert.True(t, buf.Len() > 0) + assert.Equal(t, "ZNAVSRFG", string(buf.Bytes()[:8])) +} + +func TestToManifestWithProgress(t *testing.T) { + fs := afero.NewMemMapFs() + require.NoError(t, fs.MkdirAll("/testdir", 0755)) + require.NoError(t, afero.WriteFile(fs, "/testdir/file.txt", bytes.Repeat([]byte("x"), 1000), 0644)) + + s := NewWithOptions(&Options{Fs: fs}) + err := s.EnumeratePath("/testdir", nil) + require.NoError(t, err) + + var buf bytes.Buffer + progress := make(chan ScanStatus, 10) + + err = s.ToManifest(context.Background(), &buf, progress) + require.NoError(t, err) + + var updates []ScanStatus + for p := range progress { + updates = append(updates, p) + } + + assert.NotEmpty(t, updates) + // Final update should show completion + final := updates[len(updates)-1] + assert.Equal(t, int64(1), final.TotalFiles) + assert.Equal(t, int64(1), final.ScannedFiles) + assert.Equal(t, int64(1000), final.TotalBytes) + assert.Equal(t, int64(1000), final.ScannedBytes) +} + +func TestToManifestContextCancellation(t *testing.T) { + fs := afero.NewMemMapFs() + require.NoError(t, fs.MkdirAll("/testdir", 0755)) + // Create many files to ensure we have time to cancel + for i := 0; i < 100; i++ { + name := string(rune('a'+i%26)) + string(rune('0'+i/26)) + ".txt" + require.NoError(t, afero.WriteFile(fs, "/testdir/"+name, bytes.Repeat([]byte("x"), 100), 0644)) + } + + s := NewWithOptions(&Options{Fs: fs}) + err := s.EnumeratePath("/testdir", nil) + require.NoError(t, err) + + ctx, cancel := context.WithCancel(context.Background()) + cancel() // Cancel immediately + + var buf bytes.Buffer + err = s.ToManifest(ctx, &buf, nil) + assert.ErrorIs(t, err, context.Canceled) +} + +func TestToManifestEmptyScanner(t *testing.T) { + fs := afero.NewMemMapFs() + s := NewWithOptions(&Options{Fs: fs}) + + var buf bytes.Buffer + err := s.ToManifest(context.Background(), &buf, nil) + require.NoError(t, err) + + // Should still produce a valid manifest + assert.True(t, buf.Len() > 0) + assert.Equal(t, "ZNAVSRFG", string(buf.Bytes()[:8])) +} + +func TestFilesCopiesSlice(t *testing.T) { + fs := afero.NewMemMapFs() + require.NoError(t, afero.WriteFile(fs, "/test.txt", []byte("hello"), 0644)) + + s := NewWithOptions(&Options{Fs: fs}) + require.NoError(t, s.EnumerateFile("/test.txt")) + + files1 := s.Files() + files2 := s.Files() + + // Should be different slices + assert.NotSame(t, &files1[0], &files2[0]) +} + +func TestEnumerateFS(t *testing.T) { + fs := afero.NewMemMapFs() + require.NoError(t, fs.MkdirAll("/testdir/sub", 0755)) + require.NoError(t, afero.WriteFile(fs, "/testdir/file.txt", []byte("hello"), 0644)) + require.NoError(t, afero.WriteFile(fs, "/testdir/sub/nested.txt", []byte("world"), 0644)) + + // Create a basepath filesystem + baseFs := afero.NewBasePathFs(fs, "/testdir") + + s := NewWithOptions(&Options{Fs: fs}) + err := s.EnumerateFS(baseFs, "/testdir", nil) + require.NoError(t, err) + + assert.Equal(t, int64(2), s.FileCount()) +} + +func TestSendEnumerateStatusNonBlocking(t *testing.T) { + // Channel with no buffer - send should not block + ch := make(chan EnumerateStatus) + + // This should not block + done := make(chan bool) + go func() { + sendEnumerateStatus(ch, EnumerateStatus{FilesFound: 1}) + done <- true + }() + + select { + case <-done: + // Success - did not block + case <-time.After(100 * time.Millisecond): + t.Fatal("sendEnumerateStatus blocked on full channel") + } +} + +func TestSendScanStatusNonBlocking(t *testing.T) { + // Channel with no buffer - send should not block + ch := make(chan ScanStatus) + + done := make(chan bool) + go func() { + sendScanStatus(ch, ScanStatus{ScannedFiles: 1}) + done <- true + }() + + select { + case <-done: + // Success - did not block + case <-time.After(100 * time.Millisecond): + t.Fatal("sendScanStatus blocked on full channel") + } +} + +func TestSendStatusNilChannel(t *testing.T) { + // Should not panic with nil channel + sendEnumerateStatus(nil, EnumerateStatus{}) + sendScanStatus(nil, ScanStatus{}) +} + +func TestFileEntryFields(t *testing.T) { + fs := afero.NewMemMapFs() + now := time.Now().Truncate(time.Second) + require.NoError(t, afero.WriteFile(fs, "/test.txt", []byte("content"), 0644)) + require.NoError(t, fs.Chtimes("/test.txt", now, now)) + + s := NewWithOptions(&Options{Fs: fs}) + require.NoError(t, s.EnumerateFile("/test.txt")) + + files := s.Files() + require.Len(t, files, 1) + + entry := files[0] + assert.Equal(t, "test.txt", entry.Path) + assert.Contains(t, entry.AbsPath, "test.txt") + assert.Equal(t, int64(7), entry.Size) + // Mtime should be set (within a second of now) + assert.WithinDuration(t, now, entry.Mtime, 2*time.Second) +} + +func TestLargeFileEnumeration(t *testing.T) { + fs := afero.NewMemMapFs() + require.NoError(t, fs.MkdirAll("/testdir", 0755)) + + // Create 100 files + for i := 0; i < 100; i++ { + name := "/testdir/" + string(rune('a'+i%26)) + string(rune('0'+i/26%10)) + ".txt" + require.NoError(t, afero.WriteFile(fs, name, []byte("data"), 0644)) + } + + s := NewWithOptions(&Options{Fs: fs}) + progress := make(chan EnumerateStatus, 200) + + err := s.EnumeratePath("/testdir", progress) + require.NoError(t, err) + + // Drain channel + for range progress { + } + + assert.Equal(t, int64(100), s.FileCount()) + assert.Equal(t, int64(400), s.TotalBytes()) // 100 * 4 bytes +}