Repository: wagoodman/dive (language: go)

Issue:
Add support for Containerfile in `build` command
**What happened**:

```
$ dive build my-folder -t my-img:v0.1
Building image...
[+] Building 0.1s (1/1) FINISHED                                                                                                                             docker:desktop-linux
 => [internal] load build definition from Dockerfile                                                                                                                         0.0s
 => => transferring dockerfile: 2B                                                                                                                                           0.0s
ERROR: failed to solve: failed to read dockerfile: open Dockerfile: no such file or directory

View build details: docker-desktop://dashboard/build/desktop-linux/desktop-linux/uqu79lpwm9igb05ztacitkdga
cannot build image
exit status 1
```

**What you expected to happen**:

```
$ dive build my-folder -t my-img:v0.1
Building image...
[+] Building 0.6s (8/8) FINISHED                                                                                                                             docker:desktop-linux
 => [internal] load build definition from Dockerfile                                                                                                                         0.0s
 => => transferring dockerfile: 134B                                                                                                                                         0.0s
 => [internal] load metadata for registry.access.redhat.com/ubi9/ubi-micro:latest                                                                                            0.5s
 => [internal] load .dockerignore                                                                                                                                            0.0s
 => => transferring context: 2B                                                                                                                                              0.0s
 => [internal] load build context                                                                                                                                            0.0s
 => => transferring context: 7.67kB                                                                                                                                          0.0s
 => [1/4] FROM ...............................                                                                                                                                    0.0s
 => exporting to image                                                                                                                                                       0.0s
 => => exporting layers                                                                                                                                                      0.0s
 => => writing image sha256:34126ca8646db858da1aa321533b736829fec5bd7a763b4aefeb78d137afceff                                                                                 0.0s
 => => naming to .........                                                                                                                            0.0s

View build details: docker-desktop://dashboard/build/desktop-linux/desktop-linux/721o2x2iq0eyjcpaxb2q83uv4

What's Next?
  View a summary of image vulnerabilities and recommendations → docker scout quickview
Analyzing image...
Building cache...
```
and displaying dive 

**How to reproduce it (as minimally and precisely as possible)**:

Having a Containerfile instead of a Dockerfile

Thanks to that Dive will be compliant with Containerfile instead (podman, buildah)



Discussion / hints:

Fix patch:
diff --git a/dive/image/docker/build.go b/dive/image/docker/build.go
index 935029f2..09119899 100644
--- a/dive/image/docker/build.go
+++ b/dive/image/docker/build.go
@@ -1,27 +1,84 @@
 package docker
 
 import (
-	"os"
+	"fmt"
+	"path/filepath"
+	"strings"
+
+	"github.com/scylladb/go-set/strset"
+	"github.com/spf13/afero"
+)
+
+const (
+	defaultDockerfileName    = "Dockerfile"
+	defaultContainerfileName = "Containerfile"
 )
 
-func buildImageFromCli(buildArgs []string) (string, error) {
-	iidfile, err := os.CreateTemp("/tmp", "dive.*.iid")
+func buildImageFromCli(fs afero.Fs, buildArgs []string) (string, error) {
+	iidfile, err := afero.TempFile(fs, "", "dive.*.iid")
 	if err != nil {
 		return "", err
 	}
-	defer os.Remove(iidfile.Name())
+	defer fs.Remove(iidfile.Name()) // nolint:errcheck
 	defer iidfile.Close()
 
-	allArgs := append([]string{"--iidfile", iidfile.Name()}, buildArgs...)
+	var allArgs []string
+	if isFileFlagsAreSet(buildArgs, "-f", "--file") {
+		allArgs = append([]string{"--iidfile", iidfile.Name()}, buildArgs...)
+	} else {
+		containerFilePath, err := tryFindContainerfile(fs, buildArgs)
+		if err != nil {
+			return "", err
+		}
+		allArgs = append([]string{"--iidfile", iidfile.Name(), "-f", containerFilePath}, buildArgs...)
+	}
+
 	err = runDockerCmd("build", allArgs...)
 	if err != nil {
 		return "", err
 	}
 
-	imageId, err := os.ReadFile(iidfile.Name())
+	imageId, err := afero.ReadFile(fs, iidfile.Name())
 	if err != nil {
 		return "", err
 	}
 
 	return string(imageId), nil
 }
+
+// isFileFlagsAreSet Checks if specified flags are present in the argument list.
+func isFileFlagsAreSet(args []string, flags ...string) bool {
+	flagSet := strset.New(flags...)
+	for i, arg := range args {
+		if flagSet.Has(arg) && i+1 < len(args) {
+			return true
+		}
+	}
+	return false
+}
+
+// tryFindContainerfile loops through provided build arguments and tries to find a Containerfile or a Dockerfile.
+func tryFindContainerfile(fs afero.Fs, buildArgs []string) (string, error) {
+	// Look for a build context within the provided build arguments.
+	// Test build arguments one by one to find a valid path containing default names of `Containerfile` or a `Dockerfile` (in that order).
+	candidates := []string{
+		defaultContainerfileName,                  // Containerfile
+		strings.ToLower(defaultContainerfileName), // containerfile
+		defaultDockerfileName,                     // Dockerfile
+		strings.ToLower(defaultDockerfileName),    // dockerfile
+	}
+
+	for _, arg := range buildArgs {
+		fileInfo, err := fs.Stat(arg)
+		if err == nil && fileInfo.IsDir() {
+			for _, candidate := range candidates {
+				filePath := filepath.Join(arg, candidate)
+				if exists, _ := afero.Exists(fs, filePath); exists {
+					return filePath, nil
+				}
+			}
+		}
+	}
+
+	return "", fmt.Errorf("could not find Containerfile or Dockerfile\n")
+}
diff --git a/dive/image/docker/engine_resolver.go b/dive/image/docker/engine_resolver.go
index af47b98f..11f7c0d7 100644
--- a/dive/image/docker/engine_resolver.go
+++ b/dive/image/docker/engine_resolver.go
@@ -2,6 +2,7 @@ package docker
 
 import (
 	"fmt"
+	"github.com/spf13/afero"
 	"io"
 	"net/http"
 	"os"
@@ -43,7 +44,7 @@ func (r *engineResolver) Fetch(id string) (*image.Image, error) {
 }
 
 func (r *engineResolver) Build(args []string) (*image.Image, error) {
-	id, err := buildImageFromCli(args)
+	id, err := buildImageFromCli(afero.NewOsFs(), args)
 	if err != nil {
 		return nil, err
 	}
diff --git a/go.mod b/go.mod
index 8ff5a32a..40cc911c 100644
--- a/go.mod
+++ b/go.mod
@@ -16,17 +16,20 @@ require (
 	github.com/lunixbochs/vtclean v1.0.0
 	github.com/mitchellh/go-homedir v1.1.0
 	github.com/phayes/permbits v0.0.0-20190612203442-39d7c581d2ee
+	github.com/scylladb/go-set v1.0.2
 	github.com/sergi/go-diff v1.3.1
 	github.com/sirupsen/logrus v1.9.3
 	github.com/spf13/afero v1.14.0
 	github.com/spf13/cobra v1.9.1
 	github.com/spf13/viper v1.20.1
+	github.com/stretchr/testify v1.10.0
 	golang.org/x/net v0.38.0
 )
 
 require (
 	github.com/Microsoft/go-winio v0.4.14 // indirect
 	github.com/containerd/log v0.1.0 // indirect
+	github.com/davecgh/go-spew v1.1.1 // indirect
 	github.com/distribution/reference v0.6.0 // indirect
 	github.com/docker/docker-credential-helpers v0.8.2 // indirect
 	github.com/docker/go-connections v0.4.0 // indirect
@@ -52,6 +55,7 @@ require (
 	github.com/opencontainers/image-spec v1.0.2 // indirect
 	github.com/pelletier/go-toml/v2 v2.2.3 // indirect
 	github.com/pkg/errors v0.9.1 // indirect
+	github.com/pmezard/go-difflib v1.0.0 // indirect
 	github.com/rivo/uniseg v0.1.0 // indirect
 	github.com/sagikazarmark/locafero v0.7.0 // indirect
 	github.com/sourcegraph/conc v0.3.0 // indirect
diff --git a/go.sum b/go.sum
index 15b8e93a..126c060e 100644
--- a/go.sum
+++ b/go.sum
@@ -34,6 +34,8 @@ github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkp
 github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
 github.com/fatih/color v1.18.0 h1:S8gINlzdQ840/4pfAwic/ZE0djQEH3wM94VfqLTZcOM=
 github.com/fatih/color v1.18.0/go.mod h1:4FelSpRwEGDpQ12mAdzqdOukCy4u8WUtOY6lkT/6HfU=
+github.com/fatih/set v0.2.1 h1:nn2CaJyknWE/6txyUDGwysr3G5QC6xWB/PtVjPBbeaA=
+github.com/fatih/set v0.2.1/go.mod h1:+RKtMCH+favT2+3YecHGxcc0b4KyVWA1QWWJUs4E0CI=
 github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg=
 github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U=
 github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8=
@@ -118,6 +120,8 @@ github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/f
 github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
 github.com/sagikazarmark/locafero v0.7.0 h1:5MqpDsTGNDhY8sGp0Aowyf0qKsPrhewaLSsFaodPcyo=
 github.com/sagikazarmark/locafero v0.7.0/go.mod h1:2za3Cg5rMaTMoG/2Ulr9AwtFaIppKXTRYnozin4aB5k=
+github.com/scylladb/go-set v1.0.2 h1:SkvlMCKhP0wyyct6j+0IHJkBkSZL+TDzZ4E7f7BCcRE=
+github.com/scylladb/go-set v1.0.2/go.mod h1:DkpGd78rljTxKAnTDPFqXSGxvETQnJyuSOQwsHycqfs=
 github.com/sergi/go-diff v1.3.1 h1:xkr+Oxo4BOQKmkn/B9eMK0g5Kg/983T9DqqPHwYqD+8=
 github.com/sergi/go-diff v1.3.1/go.mod h1:aMJSSKb2lpPvRNec0+w3fl7LP9IOFzdc9Pa4NFbPK1I=
 github.com/sirupsen/logrus v1.4.1/go.mod h1:ni0Sbl8bgC9z8RoU9G6nDWqqs/fq4eDPysMBDgk/93Q=


Test patch:
diff --git a/dive/image/docker/build_test.go b/dive/image/docker/build_test.go
new file mode 100644
index 00000000..64081e42
--- /dev/null
+++ b/dive/image/docker/build_test.go
@@ -0,0 +1,231 @@
+package docker
+
+import (
+	"github.com/stretchr/testify/require"
+	"path/filepath"
+	"testing"
+
+	"github.com/spf13/afero"
+	"github.com/stretchr/testify/assert"
+)
+
+func TestIsFileFlagsAreSet(t *testing.T) {
+	tests := []struct {
+		name     string
+		args     []string
+		flags    []string
+		expected bool
+	}{
+		{
+			name:     "flag present in the middle with value",
+			args:     []string{"arg1", "-f", "dockerfile", "arg2"},
+			flags:    []string{"-f"},
+			expected: true,
+		},
+		{
+			name:     "flag present at the beginning with value",
+			args:     []string{"-f", "dockerfile", "arg1", "arg2"},
+			flags:    []string{"-f"},
+			expected: true,
+		},
+		{
+			name:     "flag present at the end with no value",
+			args:     []string{"arg1", "arg2", "-f"},
+			flags:    []string{"-f"},
+			expected: false,
+		},
+		{
+			name:     "flag not present",
+			args:     []string{"arg1", "arg2", "arg3"},
+			flags:    []string{"-f"},
+			expected: false,
+		},
+		{
+			name:     "one of multiple flags present",
+			args:     []string{"arg1", "--file", "dockerfile", "arg2"},
+			flags:    []string{"-f", "--file"},
+			expected: true,
+		},
+		{
+			name:     "none of multiple flags present",
+			args:     []string{"arg1", "-x", "value", "arg2"},
+			flags:    []string{"-f", "--file"},
+			expected: false,
+		},
+		{
+			name:     "empty args",
+			args:     []string{},
+			flags:    []string{"-f", "--file"},
+			expected: false,
+		},
+		{
+			name:     "empty flags",
+			args:     []string{"arg1", "-f", "value"},
+			flags:    []string{},
+			expected: false,
+		},
+		{
+			name:     "flag with multiple values",
+			args:     []string{"arg1", "-f", "value1", "value2"},
+			flags:    []string{"-f"},
+			expected: true,
+		},
+	}
+
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			result := isFileFlagsAreSet(tt.args, tt.flags...)
+			assert.Equal(t, tt.expected, result, "isFileFlagsAreSet() = %v, want %v", result, tt.expected)
+		})
+	}
+}
+
+func TestTryFindContainerfile(t *testing.T) {
+	tests := []struct {
+		name           string
+		buildArgs      []string
+		setupFs        func(t testing.TB, fs afero.Fs)
+		expectedPath   string
+		expectedErrMsg string
+	}{
+		{
+			name:      "find Containerfile (uppercase)",
+			buildArgs: []string{"testdir"},
+			setupFs: func(t testing.TB, fs afero.Fs) {
+				create(t, fs, "testdir/Containerfile", "FROM alpine")
+			},
+			expectedPath:   filepath.Join("testdir", "Containerfile"),
+			expectedErrMsg: "",
+		},
+		{
+			name:      "find containerfile (lowercase)",
+			buildArgs: []string{"testdir"},
+			setupFs: func(t testing.TB, fs afero.Fs) {
+				create(t, fs, "testdir/containerfile", "FROM alpine")
+			},
+			expectedPath:   filepath.Join("testdir", "containerfile"),
+			expectedErrMsg: "",
+		},
+		{
+			name:      "find Dockerfile when no Containerfile exists",
+			buildArgs: []string{"testdir"},
+			setupFs: func(t testing.TB, fs afero.Fs) {
+				create(t, fs, "testdir/Dockerfile", "FROM alpine")
+			},
+			expectedPath:   filepath.Join("testdir", "Dockerfile"),
+			expectedErrMsg: "",
+		},
+		{
+			name:      "find dockerfile (lowercase)",
+			buildArgs: []string{"testdir"},
+			setupFs: func(t testing.TB, fs afero.Fs) {
+				create(t, fs, "testdir/dockerfile", "FROM alpine")
+			},
+			expectedPath:   filepath.Join("testdir", "dockerfile"),
+			expectedErrMsg: "",
+		},
+		{
+			name:      "prefer Containerfile over Dockerfile",
+			buildArgs: []string{"testdir"},
+			setupFs: func(t testing.TB, fs afero.Fs) {
+				create(t, fs, "testdir/Containerfile", "FROM alpine")
+				create(t, fs, "testdir/Dockerfile", "FROM ubuntu")
+			},
+			expectedPath:   filepath.Join("testdir", "Containerfile"),
+			expectedErrMsg: "",
+		},
+		{
+			name:           "non-existent directory",
+			buildArgs:      []string{"nonexistentdir"},
+			setupFs:        func(t testing.TB, fs afero.Fs) {},
+			expectedPath:   "",
+			expectedErrMsg: "could not find Containerfile or Dockerfile",
+		},
+		{
+			name:           "empty build args",
+			buildArgs:      []string{},
+			setupFs:        func(t testing.TB, fs afero.Fs) {},
+			expectedPath:   "",
+			expectedErrMsg: "could not find Containerfile or Dockerfile",
+		},
+		{
+			name:      "directory exists but no container files",
+			buildArgs: []string{"testdir"},
+			setupFs: func(t testing.TB, fs afero.Fs) {
+				create(t, fs, "testdir/somefile.txt", "content")
+			},
+			expectedPath:   "",
+			expectedErrMsg: "could not find Containerfile or Dockerfile",
+		},
+		{
+			name:      "find in second directory",
+			buildArgs: []string{"firstdir", "seconddir"},
+			setupFs: func(t testing.TB, fs afero.Fs) {
+				err := fs.MkdirAll("firstdir", 0755)
+				require.NoError(t, err, "Failed to create directory: firstdir")
+				create(t, fs, "seconddir/Dockerfile", "FROM alpine")
+			},
+			expectedPath:   filepath.Join("seconddir", "Dockerfile"),
+			expectedErrMsg: "",
+		},
+		{
+			name:      "find in first directory when both have files",
+			buildArgs: []string{"firstdir", "seconddir"},
+			setupFs: func(t testing.TB, fs afero.Fs) {
+				create(t, fs, "firstdir/Containerfile", "FROM alpine")
+				create(t, fs, "seconddir/Dockerfile", "FROM ubuntu")
+			},
+			expectedPath:   filepath.Join("firstdir", "Containerfile"),
+			expectedErrMsg: "",
+		},
+		{
+			name:      "file argument not a directory",
+			buildArgs: []string{"testfile.txt"},
+			setupFs: func(t testing.TB, fs afero.Fs) {
+				create(t, fs, "testfile.txt", "content")
+			},
+			expectedPath:   "",
+			expectedErrMsg: "could not find Containerfile or Dockerfile",
+		},
+		{
+			name:      "mixed args with valid directory",
+			buildArgs: []string{"testfile.txt", "testdir"},
+			setupFs: func(t testing.TB, fs afero.Fs) {
+				create(t, fs, "testfile.txt", "content")
+				create(t, fs, "testdir/Dockerfile", "FROM alpine")
+			},
+			expectedPath:   filepath.Join("testdir", "Dockerfile"),
+			expectedErrMsg: "",
+		},
+	}
+
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			fs := afero.NewMemMapFs()
+			tt.setupFs(t, fs)
+
+			result, err := tryFindContainerfile(fs, tt.buildArgs)
+
+			if tt.expectedErrMsg != "" {
+				assert.Error(t, err)
+				assert.Contains(t, err.Error(), tt.expectedErrMsg)
+			} else {
+				assert.NoError(t, err)
+				assert.Equal(t, tt.expectedPath, result)
+			}
+		})
+	}
+}
+
+func create(t testing.TB, fs afero.Fs, path, contents string) {
+	t.Helper()
+
+	dir := filepath.Dir(path)
+	if dir != "." {
+		err := fs.MkdirAll(dir, 0755)
+		require.NoError(t, err, "Failed to create directory: %s", dir)
+	}
+
+	err := afero.WriteFile(fs, path, []byte(contents), 0644)
+	require.NoError(t, err, "Failed to write file: %s", path)
+}