//nolint:testpackage // white-box tests exercise unexported internals package mfer import ( "bytes" "context" "testing" "time" "github.com/spf13/afero" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) func TestNewScanner(t *testing.T) { t.Parallel() s := NewScanner() assert.NotNil(t, s) assert.Equal(t, FileCount(0), s.FileCount()) assert.Equal(t, FileSize(0), s.TotalBytes()) } func TestNewScannerWithOptions(t *testing.T) { t.Parallel() t.Run("nil options", func(t *testing.T) { t.Parallel() s := NewScannerWithOptions(nil) assert.NotNil(t, s) }) t.Run("with options", func(t *testing.T) { t.Parallel() fs := afero.NewMemMapFs() opts := &ScannerOptions{ IncludeDotfiles: true, FollowSymLinks: true, Fs: fs, } s := NewScannerWithOptions(opts) assert.NotNil(t, s) }) } func TestScannerEnumerateFile(t *testing.T) { t.Parallel() fs := afero.NewMemMapFs() require.NoError(t, afero.WriteFile(fs, "/test.txt", []byte("hello world"), 0o644)) s := NewScannerWithOptions(&ScannerOptions{Fs: fs}) err := s.EnumerateFile("/test.txt") require.NoError(t, err) assert.Equal(t, FileCount(1), s.FileCount()) assert.Equal(t, FileSize(11), s.TotalBytes()) files := s.Files() require.Len(t, files, 1) assert.Equal(t, RelFilePath("test.txt"), files[0].Path) assert.Equal(t, FileSize(11), files[0].Size) } func TestScannerEnumerateFileMissing(t *testing.T) { t.Parallel() fs := afero.NewMemMapFs() s := NewScannerWithOptions(&ScannerOptions{Fs: fs}) err := s.EnumerateFile("/nonexistent.txt") assert.Error(t, err) } func TestScannerEnumeratePath(t *testing.T) { t.Parallel() fs := afero.NewMemMapFs() require.NoError(t, fs.MkdirAll("/testdir/subdir", 0o755)) require.NoError(t, afero.WriteFile(fs, "/testdir/file1.txt", []byte("one"), 0o644)) require.NoError(t, afero.WriteFile(fs, "/testdir/file2.txt", []byte("two"), 0o644)) require.NoError(t, afero.WriteFile(fs, "/testdir/subdir/file3.txt", []byte("three"), 0o644)) s := NewScannerWithOptions(&ScannerOptions{Fs: fs}) err := s.EnumeratePath("/testdir", nil) require.NoError(t, err) assert.Equal(t, FileCount(3), s.FileCount()) assert.Equal(t, FileSize(3+3+5), s.TotalBytes()) } func TestScannerEnumeratePathWithProgress(t *testing.T) { t.Parallel() fs := afero.NewMemMapFs() require.NoError(t, fs.MkdirAll("/testdir", 0o755)) require.NoError(t, afero.WriteFile(fs, "/testdir/file1.txt", []byte("one"), 0o644)) require.NoError(t, afero.WriteFile(fs, "/testdir/file2.txt", []byte("two"), 0o644)) s := NewScannerWithOptions(&ScannerOptions{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, FileCount(2), final.FilesFound) assert.Equal(t, FileSize(6), final.BytesFound) } func TestScannerEnumeratePaths(t *testing.T) { t.Parallel() fs := afero.NewMemMapFs() require.NoError(t, fs.MkdirAll("/dir1", 0o755)) require.NoError(t, fs.MkdirAll("/dir2", 0o755)) require.NoError(t, afero.WriteFile(fs, "/dir1/a.txt", []byte("aaa"), 0o644)) require.NoError(t, afero.WriteFile(fs, "/dir2/b.txt", []byte("bbb"), 0o644)) s := NewScannerWithOptions(&ScannerOptions{Fs: fs}) err := s.EnumeratePaths(nil, "/dir1", "/dir2") require.NoError(t, err) assert.Equal(t, FileCount(2), s.FileCount()) } func TestScannerExcludeDotfiles(t *testing.T) { t.Parallel() fs := afero.NewMemMapFs() require.NoError(t, fs.MkdirAll("/testdir/.hidden", 0o755)) require.NoError(t, afero.WriteFile(fs, "/testdir/visible.txt", []byte("visible"), 0o644)) require.NoError(t, afero.WriteFile(fs, "/testdir/.hidden.txt", []byte("hidden"), 0o644)) require.NoError(t, afero.WriteFile(fs, "/testdir/.hidden/inside.txt", []byte("inside"), 0o644)) t.Run("exclude by default", func(t *testing.T) { t.Parallel() s := NewScannerWithOptions(&ScannerOptions{Fs: fs, IncludeDotfiles: false}) err := s.EnumeratePath("/testdir", nil) require.NoError(t, err) assert.Equal(t, FileCount(1), s.FileCount()) files := s.Files() assert.Equal(t, RelFilePath("visible.txt"), files[0].Path) }) t.Run("include when enabled", func(t *testing.T) { t.Parallel() s := NewScannerWithOptions(&ScannerOptions{Fs: fs, IncludeDotfiles: true}) err := s.EnumeratePath("/testdir", nil) require.NoError(t, err) assert.Equal(t, FileCount(3), s.FileCount()) }) } func TestScannerToManifest(t *testing.T) { t.Parallel() fs := afero.NewMemMapFs() require.NoError(t, fs.MkdirAll("/testdir", 0o755)) require.NoError(t, afero.WriteFile(fs, "/testdir/file1.txt", []byte("content one"), 0o644)) require.NoError(t, afero.WriteFile(fs, "/testdir/file2.txt", []byte("content two"), 0o644)) s := NewScannerWithOptions(&ScannerOptions{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.Positive(t, buf.Len()) assert.Equal(t, MAGIC, string(buf.Bytes()[:8])) } func TestScannerToManifestWithProgress(t *testing.T) { t.Parallel() fs := afero.NewMemMapFs() require.NoError(t, fs.MkdirAll("/testdir", 0o755)) require.NoError(t, afero.WriteFile(fs, "/testdir/file.txt", bytes.Repeat([]byte("x"), 1000), 0o644)) s := NewScannerWithOptions(&ScannerOptions{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, FileCount(1), final.TotalFiles) assert.Equal(t, FileCount(1), final.ScannedFiles) assert.Equal(t, FileSize(1000), final.TotalBytes) assert.Equal(t, FileSize(1000), final.ScannedBytes) } func TestScannerToManifestContextCancellation(t *testing.T) { t.Parallel() fs := afero.NewMemMapFs() require.NoError(t, fs.MkdirAll("/testdir", 0o755)) // Create many files to ensure we have time to cancel for i := range 100 { 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), 0o644)) } s := NewScannerWithOptions(&ScannerOptions{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 TestScannerToManifestEmptyScanner(t *testing.T) { t.Parallel() fs := afero.NewMemMapFs() s := NewScannerWithOptions(&ScannerOptions{Fs: fs}) var buf bytes.Buffer err := s.ToManifest(context.Background(), &buf, nil) require.NoError(t, err) // Should still produce a valid manifest assert.Positive(t, buf.Len()) assert.Equal(t, MAGIC, string(buf.Bytes()[:8])) } func TestScannerFilesCopiesSlice(t *testing.T) { t.Parallel() fs := afero.NewMemMapFs() require.NoError(t, afero.WriteFile(fs, "/test.txt", []byte("hello"), 0o644)) s := NewScannerWithOptions(&ScannerOptions{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 TestScannerEnumerateFS(t *testing.T) { t.Parallel() fs := afero.NewMemMapFs() require.NoError(t, fs.MkdirAll("/testdir/sub", 0o755)) require.NoError(t, afero.WriteFile(fs, "/testdir/file.txt", []byte("hello"), 0o644)) require.NoError(t, afero.WriteFile(fs, "/testdir/sub/nested.txt", []byte("world"), 0o644)) // Create a basepath filesystem baseFs := afero.NewBasePathFs(fs, "/testdir") s := NewScannerWithOptions(&ScannerOptions{Fs: fs}) err := s.EnumerateFS(baseFs, "/testdir", nil) require.NoError(t, err) assert.Equal(t, FileCount(2), s.FileCount()) } func TestSendEnumerateStatusNonBlocking(t *testing.T) { t.Parallel() // Nobody receives, so a blocking send would hang the test into its timeout. ch := make(chan EnumerateStatus) sendEnumerateStatus(ch, EnumerateStatus{FilesFound: 1}) } func TestSendScanStatusNonBlocking(t *testing.T) { t.Parallel() // Nobody receives, so a blocking send would hang the test into its timeout. ch := make(chan ScanStatus) sendScanStatus(ch, ScanStatus{ScannedFiles: 1}) } func TestSendStatusNilChannel(t *testing.T) { t.Parallel() // Should not panic with nil channel sendEnumerateStatus(nil, EnumerateStatus{}) sendScanStatus(nil, ScanStatus{}) } func TestScannerFileEntryFields(t *testing.T) { t.Parallel() fs := afero.NewMemMapFs() now := time.Now().Truncate(time.Second) require.NoError(t, afero.WriteFile(fs, "/test.txt", []byte("content"), 0o644)) require.NoError(t, fs.Chtimes("/test.txt", now, now)) s := NewScannerWithOptions(&ScannerOptions{Fs: fs}) require.NoError(t, s.EnumerateFile("/test.txt")) files := s.Files() require.Len(t, files, 1) entry := files[0] assert.Equal(t, RelFilePath("test.txt"), entry.Path) assert.Contains(t, string(entry.AbsPath), "test.txt") assert.Equal(t, FileSize(7), entry.Size) // Mtime should be set (within a second of now) assert.WithinDuration(t, now, time.Time(entry.Mtime), 2*time.Second) } func TestScannerLargeFileEnumeration(t *testing.T) { t.Parallel() fs := afero.NewMemMapFs() require.NoError(t, fs.MkdirAll("/testdir", 0o755)) // Create 100 files for i := range 100 { name := "/testdir/" + string(rune('a'+i%26)) + string(rune('0'+i/26%10)) + ".txt" require.NoError(t, afero.WriteFile(fs, name, []byte("data"), 0o644)) } s := NewScannerWithOptions(&ScannerOptions{Fs: fs}) progress := make(chan EnumerateStatus, 200) err := s.EnumeratePath("/testdir", progress) require.NoError(t, err) // progress is fully buffered and closed; no draining needed assert.Equal(t, FileCount(100), s.FileCount()) assert.Equal(t, FileSize(400), s.TotalBytes()) // 100 * 4 bytes } func TestIsHiddenPath(t *testing.T) { t.Parallel() tests := []struct { path string hidden bool }{ {testFileName, 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}, {".", false}, // current directory is not hidden (#14) {"/", false}, // root is not hidden {"./", false}, // current directory with trailing slash {"./file.txt", false}, // file in current directory } for _, tt := range tests { t.Run(tt.path, func(t *testing.T) { t.Parallel() assert.Equal(t, tt.hidden, IsHiddenPath(tt.path), "IsHiddenPath(%q)", tt.path) }) } }