diff --git a/cmd/ops/main.go b/cmd/ops/main.go index ec21980..6646a8a 100644 --- a/cmd/ops/main.go +++ b/cmd/ops/main.go @@ -12,8 +12,6 @@ import ( "sean_seannery/opsfile/internal" ) -const opsFileName string = "Opsfile" - func main() { slog.SetLogLoggerLevel(slog.LevelWarn) @@ -21,7 +19,7 @@ func main() { if errors.Is(err, internal.ErrHelp) { // Best-effort: show available commands alongside help if dir, dirErr := resolveOpsfileDir(flags.Directory); dirErr == nil { - opsfilePath := filepath.Join(dir, opsFileName) + opsfilePath := filepath.Join(dir, internal.OpsFileName) if parsed, perr := internal.ParseOpsFile(opsfilePath); perr == nil { fmt.Fprintln(os.Stderr) formatCommandList(os.Stderr, opsfilePath, parsed.Commands, parsed.CommandOrder, parsed.EnvOrder) @@ -43,27 +41,27 @@ func main() { if flags.Directory != "" { dir = flags.Directory } else { - dir, err = getClosestOpsfilePath() + dir, err = internal.FindOpsfileDir() if err != nil { slog.Error("finding Opsfile: " + err.Error()) os.Exit(1) } } - parsed, err := internal.ParseOpsFile(filepath.Join(dir, opsFileName)) + parsed, err := internal.ParseOpsFile(filepath.Join(dir, internal.OpsFileName)) if err != nil { slog.Error("parsing Opsfile: " + err.Error()) os.Exit(1) } - envFileVars, err := loadEnvFile(flags.EnvFile, dir) + envFileVars, err := internal.LoadEnvFile(flags.EnvFile, dir) if err != nil { slog.Error(err.Error()) os.Exit(1) } if flags.List { - absPath := filepath.Join(dir, opsFileName) + absPath := filepath.Join(dir, internal.OpsFileName) displayPath := absPath if cwd, cwdErr := os.Getwd(); cwdErr == nil { if rel, relErr := filepath.Rel(cwd, absPath); relErr == nil { @@ -97,54 +95,10 @@ func main() { } // resolveOpsfileDir returns the directory containing the Opsfile, preferring -// flagDir when set and falling back to getClosestOpsfilePath. +// flagDir when set and falling back to internal.FindOpsfileDir. func resolveOpsfileDir(flagDir string) (string, error) { if flagDir != "" { return flagDir, nil } - return getClosestOpsfilePath() -} - -const defaultEnvFileName = ".ops_secrets.env" - -// loadEnvFile resolves and parses the env file. If envFilePath is set, that -// file is used (error if missing). Otherwise, .ops_secrets.env next to the -// Opsfile is loaded if present (silently skipped if absent). -func loadEnvFile(envFilePath, opsfileDir string) (internal.OpsVariables, error) { - if envFilePath != "" { - return internal.ParseEnvFile(envFilePath) - } - defaultPath := filepath.Join(opsfileDir, defaultEnvFileName) - if _, err := os.Stat(defaultPath); err != nil { - return nil, nil // absent default is silent no-op - } - return internal.ParseEnvFile(defaultPath) -} - -// getClosestOpsfilePath returns the directory containing the nearest Opsfile, -// walking up the directory tree from the current working directory. -func getClosestOpsfilePath() (string, error) { - workingDir, err := os.Getwd() - if err != nil { - return "", fmt.Errorf("getting working directory: %w", err) - } - slog.Info("Working Directory: " + workingDir) - - currPath := workingDir - file, err := os.Stat(filepath.Join(currPath, opsFileName)) - - // ignore folders named 'Opsfile' - for (err != nil && os.IsNotExist(err)) || (err == nil && file.IsDir()) { - slog.Info("Opsfile not found in " + currPath) - - if currPath == filepath.Dir(currPath) { - return "", errors.New("could not find Opsfile in any parent directory") - } - currPath = filepath.Dir(currPath) - file, err = os.Stat(filepath.Join(currPath, opsFileName)) - } - if err != nil { - return "", fmt.Errorf("stat %s: %w", currPath, err) - } - return currPath, nil + return internal.FindOpsfileDir() } diff --git a/cmd/ops/main_test.go b/cmd/ops/main_test.go deleted file mode 100644 index c95b600..0000000 --- a/cmd/ops/main_test.go +++ /dev/null @@ -1,77 +0,0 @@ -package main - -import ( - "os" - "path/filepath" - "testing" - - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" -) - -// realPath resolves symlinks so tests work on macOS where /var -> /private/var. -func realPath(t *testing.T, path string) string { - t.Helper() - resolved, err := filepath.EvalSymlinks(path) - require.NoError(t, err, "EvalSymlinks(%q)", path) - return resolved -} - -func TestGetClosestOpsfilePath_FoundInCwd(t *testing.T) { - tmp := realPath(t, t.TempDir()) - err := os.WriteFile(filepath.Join(tmp, "Opsfile"), []byte(""), 0o644) - require.NoError(t, err) - - orig, _ := os.Getwd() - t.Cleanup(func() { os.Chdir(orig) }) - os.Chdir(tmp) - - got, err := getClosestOpsfilePath() - require.NoError(t, err) - assert.Equal(t, tmp, got) -} - -func TestGetClosestOpsfilePath_FoundInParent(t *testing.T) { - parent := realPath(t, t.TempDir()) - child := filepath.Join(parent, "subdir") - require.NoError(t, os.Mkdir(child, 0o755)) - require.NoError(t, os.WriteFile(filepath.Join(parent, "Opsfile"), []byte(""), 0o644)) - - orig, _ := os.Getwd() - t.Cleanup(func() { os.Chdir(orig) }) - os.Chdir(child) - - got, err := getClosestOpsfilePath() - require.NoError(t, err) - assert.Equal(t, parent, got) -} - -func TestGetClosestOpsfilePath_NotFound(t *testing.T) { - tmp := realPath(t, t.TempDir()) - - orig, _ := os.Getwd() - t.Cleanup(func() { os.Chdir(orig) }) - os.Chdir(tmp) - - _, err := getClosestOpsfilePath() - require.Error(t, err) - assert.ErrorContains(t, err, "could not find Opsfile") -} - -func TestGetClosestOpsfilePath_DirectoryNamedOpsfileSkipped(t *testing.T) { - parent := realPath(t, t.TempDir()) - child := filepath.Join(parent, "subdir") - require.NoError(t, os.Mkdir(child, 0o755)) - // Create a directory named "Opsfile" in child — should be skipped. - require.NoError(t, os.Mkdir(filepath.Join(child, "Opsfile"), 0o755)) - // Place the real Opsfile in parent. - require.NoError(t, os.WriteFile(filepath.Join(parent, "Opsfile"), []byte(""), 0o644)) - - orig, _ := os.Getwd() - t.Cleanup(func() { os.Chdir(orig) }) - os.Chdir(child) - - got, err := getClosestOpsfilePath() - require.NoError(t, err) - assert.Equal(t, parent, got, "directory named Opsfile should be skipped") -} diff --git a/internal/envfile_parser.go b/internal/envfile_parser.go index 517473f..d6070d1 100644 --- a/internal/envfile_parser.go +++ b/internal/envfile_parser.go @@ -4,9 +4,26 @@ import ( "bufio" "fmt" "os" + "path/filepath" "strings" ) +const DefaultEnvFileName = ".ops_secrets.env" + +// LoadEnvFile resolves and parses the env file. If envFilePath is set, that +// file is used (error if missing). Otherwise, DefaultEnvFileName next to the +// Opsfile is loaded if present (silently skipped if absent). +func LoadEnvFile(envFilePath, opsfileDir string) (OpsVariables, error) { + if envFilePath != "" { + return ParseEnvFile(envFilePath) + } + defaultPath := filepath.Join(opsfileDir, DefaultEnvFileName) + if _, err := os.Stat(defaultPath); err != nil { + return nil, nil // absent default is silent no-op + } + return ParseEnvFile(defaultPath) +} + // ParseEnvFile reads and parses a .env-format file at the given path. // It returns the declared variables as an OpsVariables map. // diff --git a/internal/envfile_parser_test.go b/internal/envfile_parser_test.go index be95dd2..f3b189e 100644 --- a/internal/envfile_parser_test.go +++ b/internal/envfile_parser_test.go @@ -163,3 +163,47 @@ func TestParseEnvFile_ErrorFormat(t *testing.T) { assert.True(t, len(err.Error()) > 0) assert.Contains(t, err.Error(), `env-file "`+missingPath+`"`) } + +// --- LoadEnvFile tests --- + +func TestLoadEnvFile_ExplicitPathLoadsFile(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "custom.env") + err := os.WriteFile(path, []byte("KEY=value\n"), 0o644) + require.NoError(t, err) + + vars, err := LoadEnvFile(path, dir) + require.NoError(t, err) + assert.Equal(t, "value", vars["KEY"]) +} + +func TestLoadEnvFile_ExplicitPathMissingReturnsError(t *testing.T) { + _, err := LoadEnvFile("/nonexistent/custom.env", t.TempDir()) + require.Error(t, err) + assert.ErrorContains(t, err, "env-file") +} + +func TestLoadEnvFile_DefaultFileLoadedWhenPresent(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, DefaultEnvFileName) + err := os.WriteFile(path, []byte("SECRET=abc\n"), 0o644) + require.NoError(t, err) + + vars, err := LoadEnvFile("", dir) + require.NoError(t, err) + assert.Equal(t, "abc", vars["SECRET"]) +} + +func TestLoadEnvFile_DefaultFileAbsentIsNoOp(t *testing.T) { + // No .ops_secrets.env in dir — should return nil, nil silently. + vars, err := LoadEnvFile("", t.TempDir()) + require.NoError(t, err) + assert.Nil(t, vars) +} + +func TestLoadEnvFile_EmptyPathsUsesDefault(t *testing.T) { + // Both envFilePath and opsfileDir empty — default path won't exist, so no-op. + vars, err := LoadEnvFile("", t.TempDir()) + require.NoError(t, err) + assert.Nil(t, vars) +} diff --git a/internal/opsfile_parser.go b/internal/opsfile_parser.go index 132dbd6..2beb1d8 100644 --- a/internal/opsfile_parser.go +++ b/internal/opsfile_parser.go @@ -5,6 +5,7 @@ import ( "errors" "fmt" "os" + "path/filepath" "slices" "strings" ) @@ -324,6 +325,34 @@ func (p *parser) joinLastShellLine(suffix string) { } } +// OpsFileName is the conventional name of the Opsfile that ops searches for. +const OpsFileName = "Opsfile" + +// FindOpsfileDir returns the directory containing the nearest Opsfile, walking +// up the directory tree from the current working directory (same discovery +// pattern as git). Directories named "Opsfile" are ignored. +func FindOpsfileDir() (string, error) { + workingDir, err := os.Getwd() + if err != nil { + return "", fmt.Errorf("getting working directory: %w", err) + } + + currPath := workingDir + file, err := os.Stat(filepath.Join(currPath, OpsFileName)) + + for (err != nil && os.IsNotExist(err)) || (err == nil && file.IsDir()) { + if currPath == filepath.Dir(currPath) { + return "", errors.New("could not find Opsfile in any parent directory") + } + currPath = filepath.Dir(currPath) + file, err = os.Stat(filepath.Join(currPath, OpsFileName)) + } + if err != nil { + return "", fmt.Errorf("stat %s: %w", currPath, err) + } + return currPath, nil +} + // validate checks post-parse invariants. func (p *parser) validate() error { if len(p.commands) == 0 && len(p.variables) == 0 { diff --git a/internal/opsfile_parser_test.go b/internal/opsfile_parser_test.go index 16510a5..757d342 100644 --- a/internal/opsfile_parser_test.go +++ b/internal/opsfile_parser_test.go @@ -747,3 +747,72 @@ my-cmd: assert.Equal(t, "-echo hello", lines[0].Text) assert.True(t, lines[0].IgnoreError) } + +// --- FindOpsfileDir tests --- + +// realPath resolves symlinks so tests work on macOS where /var -> /private/var. +func realPath(t *testing.T, path string) string { + t.Helper() + resolved, err := filepath.EvalSymlinks(path) + require.NoError(t, err, "EvalSymlinks(%q)", path) + return resolved +} + +func TestFindOpsfileDir_FoundInCwd(t *testing.T) { + tmp := realPath(t, t.TempDir()) + err := os.WriteFile(filepath.Join(tmp, OpsFileName), []byte(""), 0o644) + require.NoError(t, err) + + orig, _ := os.Getwd() + t.Cleanup(func() { os.Chdir(orig) }) + os.Chdir(tmp) + + got, err := FindOpsfileDir() + require.NoError(t, err) + assert.Equal(t, tmp, got) +} + +func TestFindOpsfileDir_FoundInParent(t *testing.T) { + parent := realPath(t, t.TempDir()) + child := filepath.Join(parent, "subdir") + require.NoError(t, os.Mkdir(child, 0o755)) + require.NoError(t, os.WriteFile(filepath.Join(parent, OpsFileName), []byte(""), 0o644)) + + orig, _ := os.Getwd() + t.Cleanup(func() { os.Chdir(orig) }) + os.Chdir(child) + + got, err := FindOpsfileDir() + require.NoError(t, err) + assert.Equal(t, parent, got) +} + +func TestFindOpsfileDir_NotFound(t *testing.T) { + tmp := realPath(t, t.TempDir()) + + orig, _ := os.Getwd() + t.Cleanup(func() { os.Chdir(orig) }) + os.Chdir(tmp) + + _, err := FindOpsfileDir() + require.Error(t, err) + assert.ErrorContains(t, err, "could not find Opsfile") +} + +func TestFindOpsfileDir_DirectoryNamedOpsfileSkipped(t *testing.T) { + parent := realPath(t, t.TempDir()) + child := filepath.Join(parent, "subdir") + require.NoError(t, os.Mkdir(child, 0o755)) + // Create a directory named "Opsfile" in child — should be skipped. + require.NoError(t, os.Mkdir(filepath.Join(child, OpsFileName), 0o755)) + // Place the real Opsfile in parent. + require.NoError(t, os.WriteFile(filepath.Join(parent, OpsFileName), []byte(""), 0o644)) + + orig, _ := os.Getwd() + t.Cleanup(func() { os.Chdir(orig) }) + os.Chdir(child) + + got, err := FindOpsfileDir() + require.NoError(t, err) + assert.Equal(t, parent, got, "directory named Opsfile should be skipped") +}