diff --git a/.gitignore b/.gitignore index cde0123..e866ce6 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,4 @@ dist/ +gh_foundations +github-foundations-cli diff --git a/README.md b/README.md index 438e8c8..1b21cb3 100644 --- a/README.md +++ b/README.md @@ -101,6 +101,13 @@ Where `` is one of the following: - repos: - `--ghas`, `-g` List repositories with GHAS enabled. + +Notes: + +- The `repos` command parses `repositories/terragrunt.hcl` files. It now supports Terragrunt `locals {}` blocks and replacements of `local.` references inside the `inputs` object. If parsing with Viper fails, the tool falls back to native HCL parsing. +- Logging during `list repos` is suppressed to ensure clean machine-readable output (e.g. `['org/repo1', 'org/repo2']` or `[]`). + + ### Help Display help for the tool. diff --git a/cmd/gen/repository_set/repository_set.go b/cmd/gen/repository_set/repository_set.go index f72e179..98b86bf 100644 --- a/cmd/gen/repository_set/repository_set.go +++ b/cmd/gen/repository_set/repository_set.go @@ -29,7 +29,12 @@ var GenRepositorySetCmd = &cobra.Command{ zone.NewGlobal() var repositorySet *githubfoundations.RepositorySetInput if terraformerStateFile != "" { - repositorySet = genFromTerraformerFile(terraformerStateFile) + var err error + repositorySet, err = genFromTerraformerFile(terraformerStateFile) + if err != nil { + fmt.Println("Error reading terraformer state file:", err) + os.Exit(1) + } } else { var err error repositorySet, err = runInteractive() diff --git a/cmd/gen/repository_set/terraformerState.go b/cmd/gen/repository_set/terraformerState.go index b9c9c30..d21c44d 100644 --- a/cmd/gen/repository_set/terraformerState.go +++ b/cmd/gen/repository_set/terraformerState.go @@ -1,20 +1,20 @@ package repositoryset import ( - "gh_foundations/internal/pkg/functions" - "log" - "os" - - "github.com/tidwall/gjson" - - githubfoundations "gh_foundations/internal/pkg/types/github_foundations" + "os" + "log" + "gh_foundations/internal/pkg/functions" + githubfoundations "gh_foundations/internal/pkg/types/github_foundations" + "github.com/tidwall/gjson" ) -func genFromTerraformerFile(stateFile string) *githubfoundations.RepositorySetInput { - stateBytes, err := os.ReadFile(stateFile) - if err != nil { - log.Fatalf("Error reading state file %s. %s", stateFile, err.Error()) - } + +func genFromTerraformerFile(stateFile string) (*githubfoundations.RepositorySetInput, error) { + stateBytes, err := os.ReadFile(stateFile) + if err != nil { + log.Printf("terraformerState: error reading state file %s: %v", stateFile, err) + return nil, err + } result := gjson.Parse(string(stateBytes)) list := result.Get("modules.0.resources").Map() @@ -51,5 +51,5 @@ func genFromTerraformerFile(stateFile string) *githubfoundations.RepositorySetIn repository.UserPermissions = repositoryUserPermissions[repository.Name] } - return repositorySets + return repositorySets, nil } diff --git a/cmd/list/orgs/orgs.go b/cmd/list/orgs/orgs.go index d48d814..82504d8 100644 --- a/cmd/list/orgs/orgs.go +++ b/cmd/list/orgs/orgs.go @@ -16,10 +16,10 @@ import ( var OrgsCmd = &cobra.Command{ Use: "orgs", Short: "List managed organizations's slugs.", - Long: `This command reads the "providers.hcl" files in the "providers" directory and lists the organization slugs that are managed by the tool.`, + Long: `This command reads the "providers.hcl" or "root.hcl" files in the organizations directory and lists the organization slugs that are managed by the tool.`, Args: func(cmd *cobra.Command, args []string) error { if len(args) < 1 { - return errors.New("requires the path of the \"providers\" directory") + return errors.New("requires the path of the organizations directory") } return nil }, diff --git a/cmd/list/repos/repos.go b/cmd/list/repos/repos.go index d2774cb..dec61a6 100644 --- a/cmd/list/repos/repos.go +++ b/cmd/list/repos/repos.go @@ -4,17 +4,22 @@ Copyright © 2024 NAME HERE package list import ( + "encoding/json" "errors" "fmt" "gh_foundations/internal/pkg/functions" "gh_foundations/internal/pkg/types/status" + "io" "log" + "sort" "strings" "github.com/spf13/cobra" ) var ghas bool +var verbose bool +var outputFormat string var ReposCmd = &cobra.Command{ Use: "repos", @@ -30,9 +35,15 @@ var ReposCmd = &cobra.Command{ reposDir := args[0] + // Suppress all logs unless verbose requested + if !verbose { + log.SetOutput(io.Discard) + } orgSet, err := functions.FindManagedRepos(reposDir) if err != nil { - log.Fatalf("Error in findManagedRepos: %s", err) + log.Printf("list repos: error discovering repositories: %v", err) + fmt.Println("[]") + return } repoList := flattenRepos(orgSet) @@ -43,17 +54,25 @@ var ReposCmd = &cobra.Command{ log.Printf("Found %d repositories with GHAS enabled\n", len(repoList)) } - repos := "[]" - if len(repoList) > 0 { - repos = fmt.Sprintf("['%s']", strings.Join(repoList, "', '")) + sort.Strings(repoList) + if outputFormat == "json" { + data, _ := json.Marshal(repoList) // data is always slice of strings, marshal can't fail + fmt.Println(string(data)) + return } - - fmt.Println(repos) + // Default legacy format + if len(repoList) == 0 { + fmt.Println("[]") + return + } + fmt.Printf("['%s']\n", strings.Join(repoList, "', '")) }, } func init() { ReposCmd.Flags().BoolVarP(&ghas, "ghas", "g", false, "List repositories with GHAS enabled") + ReposCmd.Flags().BoolVarP(&verbose, "verbose", "v", false, "Show diagnostic logs while listing") + ReposCmd.Flags().StringVarP(&outputFormat, "format", "f", "legacy", "Output format: legacy | json") } // Return only the names of the repositories managed by the tool diff --git a/internal/pkg/functions/status.go b/internal/pkg/functions/status.go index 6d64883..a8022c5 100644 --- a/internal/pkg/functions/status.go +++ b/internal/pkg/functions/status.go @@ -27,13 +27,15 @@ func findOrgsFromFilenames(hclFiles []string) map[string][]string { // List all of the organizations managed by the tool's slugs func FindManagedOrgSlugs(orgsDir string) ([]string, error) { - orgFiles, err := findConfigFiles(orgsDir, "providers.hcl") - if err != nil { - log.Fatalf("Error in findOrgFiles: %s", err) + // Look for both providers.hcl and root.hcl files for backward compatibility + // and to support the new Terragrunt naming convention + orgFiles, err := findConfigFiles(orgsDir, "providers.hcl", "root.hcl") + if err != nil { + log.Printf("FindManagedOrgSlugs: error finding org files: %v", err) return make([]string, 0), err - } + } - // Walk the orgFiles and get all the providers.hcl files + // Walk the orgFiles and get all the providers.hcl and root.hcl files var orgs []string for _, file := range orgFiles { log.Printf("Working on file: %s\n", file) @@ -55,28 +57,27 @@ func FindManagedOrgSlugs(orgsDir string) ([]string, error) { // List all of the relevant configs managed by the tool // The first parameter is the root directory to search in -// The second parameter is the file name pattern to match +// The second parameter is the file name pattern(s) to match func findConfigFiles(rootDir string, fileNamePattern ...string) ([]string, error) { - // There should be 1 or 0 file name patterns to match - patternString := "" - if len(fileNamePattern) == 1 { - patternString = fileNamePattern[0] + // Default to "repositories/terragrunt.hcl" if no patterns provided + patterns := fileNamePattern + if len(patterns) == 0 { + patterns = []string{"repositories/terragrunt.hcl"} } - var hclFiles []string err := filepath.Walk(rootDir, func(path string, info os.FileInfo, err error) error { if err != nil { return err } - // Find files that match the fileNamePattern. Default to "repositories/terragrunt.hcl" - if patternString == "" { - patternString = "repositories/terragrunt.hcl" - } - if strings.HasSuffix(path, patternString) { - hclFiles = append(hclFiles, path) + // Check if the path matches any of the patterns + for _, pattern := range patterns { + if strings.HasSuffix(path, pattern) { + hclFiles = append(hclFiles, path) + break // Only add each file once + } } return nil @@ -91,21 +92,19 @@ func findConfigFiles(rootDir string, fileNamePattern ...string) ([]string, error // List all of the repositories managed by the tool func FindManagedRepos(reposDir string) (status.OrgSet, error) { - files, err := findConfigFiles(reposDir) - - orgFiles := findOrgsFromFilenames(files) - - if err != nil { - log.Fatalf("Error in findOrgFiles: %s", err) + files, err := findConfigFiles(reposDir) + if err != nil { + log.Printf("FindManagedRepos: error finding repo files: %v", err) return status.OrgSet{}, err - } + } + orgFiles := findOrgsFromFilenames(files) // Get the absolute path of the root directory - absRootPath, err := filepath.Abs(reposDir) - if err != nil { - log.Fatalf("Error in filepath.Abs: %s", err) + absRootPath, err := filepath.Abs(reposDir) + if err != nil { + log.Printf("FindManagedRepos: filepath.Abs error: %v", err) return status.OrgSet{}, err - } + } var orgSet status.OrgSet orgSet.OrgProjectSets = make(map[string]status.OrgProjectSet) @@ -137,11 +136,11 @@ func FindManagedRepos(reposDir string) (status.OrgSet, error) { Path: file, } - inputs, err := hclFile.GetInputsFromFile() - if err != nil { - log.Fatalf(`Error in getInputsFromFile: %s`, err) - return orgSet, err - } + inputs, err := hclFile.GetInputsFromFile() + if err != nil { + log.Printf("FindManagedRepos: skipping file %s due to parse error: %v", file, err) + continue + } log.Printf("Repository Set has %d private repositories and %d public repositories", len(inputs.PrivateRepositories), len(inputs.PublicRepositories)) var repoSet githubfoundations.RepositorySetInput diff --git a/internal/pkg/functions/status_test.go b/internal/pkg/functions/status_test.go new file mode 100644 index 0000000..2fd9b56 --- /dev/null +++ b/internal/pkg/functions/status_test.go @@ -0,0 +1,308 @@ +package functions + +import ( + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// TestFindConfigFiles_SinglePattern tests finding files with a single pattern +func TestFindConfigFiles_SinglePattern(t *testing.T) { + // Create temporary directory structure + tmpDir, err := os.MkdirTemp("", "test-findconfig-*") + require.NoError(t, err) + defer os.RemoveAll(tmpDir) + + // Create test files + projectDir := filepath.Join(tmpDir, "project1", "org1", "repositories") + err = os.MkdirAll(projectDir, 0755) + require.NoError(t, err) + + testFile := filepath.Join(projectDir, "terragrunt.hcl") + err = os.WriteFile(testFile, []byte("# test file"), 0644) + require.NoError(t, err) + + // Test finding files with single pattern + files, err := findConfigFiles(tmpDir, "repositories/terragrunt.hcl") + require.NoError(t, err) + assert.Equal(t, 1, len(files)) + assert.Contains(t, files[0], "repositories/terragrunt.hcl") +} + +// TestFindConfigFiles_MultiplePatterns tests finding files with multiple patterns +func TestFindConfigFiles_MultiplePatterns(t *testing.T) { + tmpDir, err := os.MkdirTemp("", "test-findconfig-multi-*") + require.NoError(t, err) + defer os.RemoveAll(tmpDir) + + // Create test files with different patterns + org1Dir := filepath.Join(tmpDir, "org1") + err = os.MkdirAll(org1Dir, 0755) + require.NoError(t, err) + + org2Dir := filepath.Join(tmpDir, "org2") + err = os.MkdirAll(org2Dir, 0755) + require.NoError(t, err) + + // Create providers.hcl + providersFile := filepath.Join(org1Dir, "providers.hcl") + err = os.WriteFile(providersFile, []byte("# providers file"), 0644) + require.NoError(t, err) + + // Create root.hcl + rootFile := filepath.Join(org2Dir, "root.hcl") + err = os.WriteFile(rootFile, []byte("# root file"), 0644) + require.NoError(t, err) + + // Test finding files with multiple patterns + files, err := findConfigFiles(tmpDir, "providers.hcl", "root.hcl") + require.NoError(t, err) + assert.Equal(t, 2, len(files)) + + // Check both files were found + foundProviders := false + foundRoot := false + for _, f := range files { + if filepath.Base(f) == "providers.hcl" { + foundProviders = true + } + if filepath.Base(f) == "root.hcl" { + foundRoot = true + } + } + assert.True(t, foundProviders, "providers.hcl should be found") + assert.True(t, foundRoot, "root.hcl should be found") +} + +// TestFindConfigFiles_DefaultPattern tests the default pattern behavior +func TestFindConfigFiles_DefaultPattern(t *testing.T) { + tmpDir, err := os.MkdirTemp("", "test-findconfig-default-*") + require.NoError(t, err) + defer os.RemoveAll(tmpDir) + + // Create repositories/terragrunt.hcl (default pattern) + repoDir := filepath.Join(tmpDir, "project", "org", "repositories") + err = os.MkdirAll(repoDir, 0755) + require.NoError(t, err) + + testFile := filepath.Join(repoDir, "terragrunt.hcl") + err = os.WriteFile(testFile, []byte("# test file"), 0644) + require.NoError(t, err) + + // Test with no pattern (should use default) + files, err := findConfigFiles(tmpDir) + require.NoError(t, err) + assert.Equal(t, 1, len(files)) + assert.Contains(t, files[0], "repositories/terragrunt.hcl") +} + +// TestFindConfigFiles_NoMatches tests when no files match the pattern +func TestFindConfigFiles_NoMatches(t *testing.T) { + tmpDir, err := os.MkdirTemp("", "test-findconfig-nomatch-*") + require.NoError(t, err) + defer os.RemoveAll(tmpDir) + + // Create a file that doesn't match + err = os.WriteFile(filepath.Join(tmpDir, "other.txt"), []byte("# other file"), 0644) + require.NoError(t, err) + + // Test finding files that don't exist + files, err := findConfigFiles(tmpDir, "nonexistent.hcl") + require.NoError(t, err) + assert.Equal(t, 0, len(files)) +} + +// TestFindConfigFiles_EmptyDirectory tests with an empty directory +func TestFindConfigFiles_EmptyDirectory(t *testing.T) { + tmpDir, err := os.MkdirTemp("", "test-findconfig-empty-*") + require.NoError(t, err) + defer os.RemoveAll(tmpDir) + + files, err := findConfigFiles(tmpDir, "test.hcl") + require.NoError(t, err) + assert.Equal(t, 0, len(files)) +} + +// TestFindConfigFiles_InvalidDirectory tests with an invalid directory +func TestFindConfigFiles_InvalidDirectory(t *testing.T) { + files, err := findConfigFiles("/nonexistent/directory/path", "test.hcl") + assert.Error(t, err) + assert.Nil(t, files) +} + +// TestFindOrgsFromFilenames tests extracting org names from file paths +func TestFindOrgsFromFilenames(t *testing.T) { + testCases := []struct { + name string + files []string + expected map[string][]string + }{ + { + name: "single org single file", + files: []string{ + "/path/to/org1/repos/terragrunt.hcl", + }, + expected: map[string][]string{ + "org1": {"/path/to/org1/repos/terragrunt.hcl"}, + }, + }, + { + name: "multiple orgs", + files: []string{ + "/path/to/org1/repos/terragrunt.hcl", + "/path/to/org2/repos/terragrunt.hcl", + }, + expected: map[string][]string{ + "org1": {"/path/to/org1/repos/terragrunt.hcl"}, + "org2": {"/path/to/org2/repos/terragrunt.hcl"}, + }, + }, + { + name: "single org multiple files", + files: []string{ + "/path/to/org1/repos/terragrunt.hcl", + "/another/path/org1/repos/other.hcl", + }, + expected: map[string][]string{ + "org1": { + "/path/to/org1/repos/terragrunt.hcl", + "/another/path/org1/repos/other.hcl", + }, + }, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + result := findOrgsFromFilenames(tc.files) + assert.Equal(t, tc.expected, result) + }) + } +} + +// TestFindManagedOrgSlugs_WithProvidersHcl tests org discovery with providers.hcl +func TestFindManagedOrgSlugs_WithProvidersHcl(t *testing.T) { + tmpDir, err := os.MkdirTemp("", "test-orgs-providers-*") + require.NoError(t, err) + defer os.RemoveAll(tmpDir) + + // Create org directory + orgDir := filepath.Join(tmpDir, "org1") + err = os.MkdirAll(orgDir, 0755) + require.NoError(t, err) + + // Create providers.hcl with organization_name + providersContent := ` +locals { + organization_name = "test-org-1" +} +` + err = os.WriteFile(filepath.Join(orgDir, "providers.hcl"), []byte(providersContent), 0644) + require.NoError(t, err) + + orgs, err := FindManagedOrgSlugs(tmpDir) + require.NoError(t, err) + assert.Equal(t, 1, len(orgs)) + assert.Contains(t, orgs, "test-org-1") +} + +// TestFindManagedOrgSlugs_WithRootHcl tests org discovery with root.hcl +func TestFindManagedOrgSlugs_WithRootHcl(t *testing.T) { + tmpDir, err := os.MkdirTemp("", "test-orgs-root-*") + require.NoError(t, err) + defer os.RemoveAll(tmpDir) + + // Create org directory + orgDir := filepath.Join(tmpDir, "org2") + err = os.MkdirAll(orgDir, 0755) + require.NoError(t, err) + + // Create root.hcl with organization_name + rootContent := ` +locals { + organization_name = "test-org-2" +} +` + err = os.WriteFile(filepath.Join(orgDir, "root.hcl"), []byte(rootContent), 0644) + require.NoError(t, err) + + orgs, err := FindManagedOrgSlugs(tmpDir) + require.NoError(t, err) + assert.Equal(t, 1, len(orgs)) + assert.Contains(t, orgs, "test-org-2") +} + +// TestFindManagedOrgSlugs_Mixed tests org discovery with both file types +func TestFindManagedOrgSlugs_Mixed(t *testing.T) { + tmpDir, err := os.MkdirTemp("", "test-orgs-mixed-*") + require.NoError(t, err) + defer os.RemoveAll(tmpDir) + + // Create org1 with providers.hcl + org1Dir := filepath.Join(tmpDir, "org1") + err = os.MkdirAll(org1Dir, 0755) + require.NoError(t, err) + providersContent := ` +locals { + organization_name = "org-from-providers" +} +` + err = os.WriteFile(filepath.Join(org1Dir, "providers.hcl"), []byte(providersContent), 0644) + require.NoError(t, err) + + // Create org2 with root.hcl + org2Dir := filepath.Join(tmpDir, "org2") + err = os.MkdirAll(org2Dir, 0755) + require.NoError(t, err) + rootContent := ` +locals { + organization_name = "org-from-root" +} +` + err = os.WriteFile(filepath.Join(org2Dir, "root.hcl"), []byte(rootContent), 0644) + require.NoError(t, err) + + orgs, err := FindManagedOrgSlugs(tmpDir) + require.NoError(t, err) + assert.Equal(t, 2, len(orgs)) + assert.Contains(t, orgs, "org-from-providers") + assert.Contains(t, orgs, "org-from-root") +} + +// TestFindManagedOrgSlugs_NoOrgName tests when file has no organization_name +func TestFindManagedOrgSlugs_NoOrgName(t *testing.T) { + tmpDir, err := os.MkdirTemp("", "test-orgs-noname-*") + require.NoError(t, err) + defer os.RemoveAll(tmpDir) + + // Create org directory with file that has no organization_name + orgDir := filepath.Join(tmpDir, "org1") + err = os.MkdirAll(orgDir, 0755) + require.NoError(t, err) + + content := ` +locals { + other_value = "something" +} +` + err = os.WriteFile(filepath.Join(orgDir, "providers.hcl"), []byte(content), 0644) + require.NoError(t, err) + + orgs, err := FindManagedOrgSlugs(tmpDir) + require.NoError(t, err) + assert.Equal(t, 0, len(orgs)) +} + +// TestFindManagedOrgSlugs_EmptyDirectory tests with empty directory +func TestFindManagedOrgSlugs_EmptyDirectory(t *testing.T) { + tmpDir, err := os.MkdirTemp("", "test-orgs-empty-*") + require.NoError(t, err) + defer os.RemoveAll(tmpDir) + + orgs, err := FindManagedOrgSlugs(tmpDir) + require.NoError(t, err) + assert.Equal(t, 0, len(orgs)) +} diff --git a/internal/pkg/types/terragrunt/terragrunt.go b/internal/pkg/types/terragrunt/terragrunt.go index 5ab7547..895c60b 100644 --- a/internal/pkg/types/terragrunt/terragrunt.go +++ b/internal/pkg/types/terragrunt/terragrunt.go @@ -14,14 +14,23 @@ import ( "regexp" "strings" + "github.com/hashicorp/hcl/v2" + "github.com/hashicorp/hcl/v2/hclparse" "github.com/mitchellh/mapstructure" "github.com/spf13/afero" "github.com/spf13/viper" "github.com/tidwall/gjson" + "github.com/zclconf/go-cty/cty" ) var fs = afero.NewOsFs() +// Compile regex patterns once at package initialization for better performance +var ( + // Pattern to match: include "name" { path = find_in_parent_folders("filename") } + includeRegex = regexp.MustCompile(`include\s+["']?\w+["']?\s*{\s*path\s*=\s*find_in_parent_folders\s*\(\s*["']([^"']+)["']\s*\)`) +) + // command creation function for mocking var newCommandExecutor = func(name string, args ...string) types.ICommandExecutor { return &types.CommandExecutor{ @@ -49,156 +58,370 @@ type HCLFile struct { func getRepository(repo map[string]interface{}) (status.Repository, error) { var repository status.Repository - - err := mapstructure.Decode(repo, &repository) - if err != nil { - log.Fatalf("Error in getInputsFromFile mapstructure.Decode: %s", err) - return repository, err + if err := mapstructure.Decode(repo, &repository); err != nil { + return repository, fmt.Errorf("getRepository decode error: %w", err) } - return repository, nil - } // Given a repository map, returned by Viper, return a map of status.Repository -func getRepositoryMap(repoList []map[string]interface{}) (map[string]status.Repository, error) { +// Accept both the historical []map[string]interface{} shape and a direct map[string]interface{} +func getRepositoryMap(raw interface{}) (map[string]status.Repository, error) { repos := make(map[string]status.Repository) - for name, r := range repoList[0] { - details := r.([]map[string]interface{}) - d := details[0] - repo, err := getRepository(d) - if err != nil { - log.Fatalf("Error in getRepositoryMap: %s", err) - return repos, err + // Case 1: []map[string]interface{} (legacy viper decoding) + if list, ok := raw.([]map[string]interface{}); ok && len(list) > 0 { + for name, r := range list[0] { + switch typed := r.(type) { + case []map[string]interface{}: // current nested slice form + if len(typed) == 0 { continue } + repo, err := getRepository(typed[0]) + if err != nil { return repos, err } + repo.Name = name + repos[name] = repo + case map[string]interface{}: // simplified direct object + repo, err := getRepository(typed) + if err != nil { return repos, err } + repo.Name = name + repos[name] = repo + default: + log.Printf("getRepositoryMap: unsupported repository value type for %s: %T", name, r) + } + } + return repos, nil + } + + // Case 2: direct map[string]interface{} + if direct, ok := raw.(map[string]interface{}); ok { + for name, r := range direct { + if typed, ok := r.(map[string]interface{}); ok { + repo, err := getRepository(typed) + if err != nil { return repos, err } + repo.Name = name + repos[name] = repo + } else if typedList, ok := r.([]map[string]interface{}); ok && len(typedList) > 0 { + repo, err := getRepository(typedList[0]) + if err != nil { return repos, err } + repo.Name = name + repos[name] = repo + } else { + log.Printf("getRepositoryMap: unsupported direct repository value type for %s: %T", name, r) + } } - repos[name] = repo + return repos, nil } + + log.Printf("getRepositoryMap: unrecognized raw repository structure: %T", raw) return repos, nil } // Return the locals block from the HCL file as a slice of string slices -func getLocalsBlock(contents string) [][]string { - // The locals are in the form of locals = { key = value } - - // Use regex to find the locals block - lre := regexp.MustCompile(`locals\s*{\n*((.*[^}])\n)+}`) - locals := lre.FindString(contents) +func getLocalsBlock(contents string) (string, [][]string) { + // Very lightweight parser for a single 'locals { ... }' block. + // We iterate line by line once we find the opening 'locals {' until the matching '}'. + lines := strings.Split(contents, "\n") + start := -1 + braceDepth := 0 + for i, line := range lines { + if start == -1 && strings.HasPrefix(strings.TrimSpace(line), "locals") && strings.Contains(line, "{") { + start = i + braceDepth = strings.Count(line, "{") - strings.Count(line, "}") + if braceDepth == 0 { // single line locals { } + break + } + continue + } + if start != -1 { + braceDepth += strings.Count(line, "{") - strings.Count(line, "}") + if braceDepth == 0 { // end of block + end := i + localsBlock := strings.Join(lines[start:end+1], "\n") + innerLines := lines[start+1 : end] + matches := make([][]string, 0) + currentKey := "" + var currentValLines []string + flush := func() { + if currentKey != "" { + val := strings.TrimSpace(strings.Join(currentValLines, "\n")) + matches = append(matches, []string{currentKey, val}) + currentKey = "" + currentValLines = nil + } + } + for _, l := range innerLines { + trimmed := strings.TrimSpace(l) + if trimmed == "" { + continue + } + // detect new key = value line + if eq := strings.Index(trimmed, "="); eq > 0 { + // heuristic: treat as new key if currentKey empty OR line starts with an identifier + left := strings.TrimSpace(trimmed[:eq]) + if regexp.MustCompile(`^[A-Za-z0-9_]+$`).MatchString(left) { + // flush previous + flush() + currentKey = left + currentValLines = []string{strings.TrimSpace(trimmed[eq+1:])} + continue + } + } + // continuation of previous value + if currentKey != "" { + currentValLines = append(currentValLines, trimmed) + } + } + flush() + return localsBlock, matches + } + } + } + return "", make([][]string, 0) +} - if locals == "" { - fmt.Printf("locals not found") - return make([][]string, 0) +// Check if the HCL file has an include block and return the included file path +func getIncludedFilePath(contents string, currentFilePath string) string { + // Use pre-compiled regex for better performance + matches := includeRegex.FindStringSubmatch(contents) + + if len(matches) > 1 { + filename := matches[1] + // Walk up the directory tree to find the file + dir := path.Dir(currentFilePath) + for { + candidatePath := path.Join(dir, filename) + if _, err := fs.Stat(candidatePath); err == nil { + return candidatePath + } + parentDir := path.Dir(dir) + if parentDir == dir { + // Reached root, file not found + break + } + dir = parentDir + } } - // Use regex to find the key-value pairs in the locals block - kvre := regexp.MustCompile(`(.*[^=])=(.*(?:(:?\n.*[^\]])*])*)`) - matches := kvre.FindAllStringSubmatch(locals, -1) + return "" +} + +// Read inputs from a parent file referenced by include +// visitedFiles tracks files we've already processed to prevent circular includes +func getInputsFromIncludedFile(includedFilePath string, visitedFiles map[string]bool) (status.Inputs, error) { + var inputs status.Inputs + + if includedFilePath == "" { + return inputs, nil + } - // Clean up the matches a little - for i, match := range matches { - matches[i][1] = strings.Trim(match[1], " \"") - matches[i][2] = strings.Trim(match[2], " \"") + // Check for circular includes + if visitedFiles[includedFilePath] { + log.Printf("Circular include detected for file: %s\n", includedFilePath) + return inputs, fmt.Errorf("circular include detected for file: %s", includedFilePath) } - return matches + // Mark this file as visited + visitedFiles[includedFilePath] = true + + // Read the included file and parse its inputs + hclFile := HCLFile{Path: includedFilePath} + return hclFile.getInputsFromFileWithVisited(visitedFiles) } + // The locals are in the form of locals = { key = value } // Then, they are referred to as local.key in the configuration // This function replaces the locals with their values func replaceLocals(contents string) string { - matches := getLocalsBlock(contents) - - // Replace the locals with their values - for _, match := range matches { - key := strings.Trim(match[1], " ") - value := strings.Trim(match[2], " ") - - contents = strings.ReplaceAll(contents, key, value) + // Obtain the locals block and individual key/value pairs + localsBlock, matches := getLocalsBlock(contents) + if len(matches) == 0 { + return contents // nothing to do + } + // Remove the entire locals block – we will inline the references + if localsBlock != "" { + contents = strings.Replace(contents, localsBlock, "", 1) } - return contents + // For each local, replace occurrences of local. ONLY (do not replace bare key names) + for _, m := range matches { + if len(m) < 2 { continue } + key := m[0] + value := m[1] + trimmed := strings.TrimSpace(value) + if !(strings.HasPrefix(trimmed, `"`) || strings.HasPrefix(trimmed, "[") || strings.HasPrefix(trimmed, "{") || trimmed == "true" || trimmed == "false" || regexp.MustCompile(`^[0-9]+$`).MatchString(trimmed)) { + value = fmt.Sprintf("\"%s\"", trimmed) + } + contents = strings.ReplaceAll(contents, fmt.Sprintf("local.%s", key), value) + } + return contents } // Given an HCL file, return the inputs func (h *HCLFile) GetInputsFromFile() (status.Inputs, error) { + // Initialize visited files map to prevent circular includes + visitedFiles := make(map[string]bool) + visitedFiles[h.Path] = true + return h.getInputsFromFileWithVisited(visitedFiles) +} +// Internal function that tracks visited files to prevent circular includes and performs parsing +func (h *HCLFile) getInputsFromFileWithVisited(visitedFiles map[string]bool) (status.Inputs, error) { var inputs status.Inputs - viper.SetConfigType("hcl") - viper.SetConfigFile(h.Path) - if err := viper.ReadInConfig(); err != nil { - if _, ok := err.(viper.ConfigFileNotFoundError); ok { - // Config file not found; - log.Fatalf(`GetInputsFromFile: config file not found: %s`, h.Path) - return inputs, err - } else if _, ok := err.(viper.ConfigParseError); ok { - // the viper library can't parse the "locals". Here's a workaround - // read in the file contents and replace the locals with their values - // then write the file to a temporary location and read it in - // again - - // read in the file contents - contents, err := afero.ReadFile(fs, h.Path) - if err != nil { - log.Fatalf(`GetInputsFromFile: unable to read config file: %s`, h.Path) - return inputs, err + // Helper: parse a file path into inputs using viper with locals replacement fallback + // Returns a generic map[string]interface{} representing the 'inputs' object with all cty.Value converted + parsePath := func(filePath string) (map[string]interface{}, error) { + v := viper.New() + v.SetConfigType("hcl") + v.SetConfigFile(filePath) + if err := v.ReadInConfig(); err != nil { + if _, ok := err.(viper.ConfigFileNotFoundError); ok { + return nil, fmt.Errorf("config not found: %s", filePath) + } + // attempt locals replacement fallback + contents, rerr := afero.ReadFile(fs, filePath) + if rerr != nil { return nil, fmt.Errorf("fallback read error: %w", rerr) } + contents = []byte(replaceLocals(string(contents))) + tempPath := "/tmp/" + path.Base(filePath) + if werr := afero.WriteFile(fs, tempPath, contents, 0644); werr != nil { return nil, fmt.Errorf("fallback write error: %w", werr) } + v.SetConfigFile(tempPath) + if rerr2 := v.ReadInConfig(); rerr2 != nil { + // final fallback: native hcl parser + parser := hclparse.NewParser() + file, perr := parser.ParseHCL(contents, filePath) + if perr != nil { return nil, perr } + attrs, _ := file.Body.JustAttributes() + if attr, ok := attrs["inputs"]; ok { + val, diag := attr.Expr.Value(&hcl.EvalContext{}) + if diag.HasErrors() { return nil, fmt.Errorf("eval diagnostics: %s", diag.Error()) } + if val.Type().IsObjectType() { + obj := make(map[string]interface{}) + for k, v := range val.AsValueMap() { obj[k] = ctyToInterface(v) } + return obj, nil + } + } + return nil, nil + } + } + raw := v.Get("inputs") + if raw == nil { return nil, nil } + switch typed := raw.(type) { + case []map[string]interface{}: + if len(typed) > 0 { return typed[0], nil } + return nil, nil + case map[string]interface{}: + return typed, nil + default: + return nil, fmt.Errorf("unsupported inputs structure: %T", raw) + } + } + + top, err := parsePath(h.Path) + if err != nil { + // Non-fatal; just log and continue + log.Printf("GetInputsFromFile: parse error for %s: %v", h.Path, err) + } + + if top == nil { + // Attempt include-based parent resolution first + contents, rerr := afero.ReadFile(fs, h.Path) + if rerr == nil { + included := getIncludedFilePath(string(contents), h.Path) + if included != "" { + parentInputs, ierr := getInputsFromIncludedFile(included, visitedFiles) + if ierr == nil && (len(parentInputs.PrivateRepositories) > 0 || len(parentInputs.PublicRepositories) > 0 || len(parentInputs.DefaultRepositoryTeamPermissions) > 0) { + return parentInputs, nil + } } - // replace the locals with their values - contents = []byte(replaceLocals(string(contents))) - // write the file to a temporary location and read it in again - tempPath := "/tmp/" + path.Base(h.Path) - err = afero.WriteFile(fs, tempPath, contents, 0644) - if err != nil { - log.Fatalf(`GetInputsFromFile: unable to write config file: %s`, h.Path) - return inputs, err + } + // Fallback: walk for root.hcl/providers.hcl upward + searchDir := path.Dir(h.Path) + for i := 0; i < 8 && searchDir != "/"; i++ { // limit depth + for _, candidate := range []string{"root.hcl", "providers.hcl"} { + candidatePath := path.Join(searchDir, candidate) + if _, statErr := fs.Stat(candidatePath); statErr == nil { + parentTop, perr := parsePath(candidatePath) + if perr == nil && parentTop != nil { + top = parentTop + break + } + } } - viper.SetConfigFile(tempPath) - viper.ReadInConfig() - } else { - // Config file was found but another error was produced - log.Fatalf(`GetInputsFromFile: config file found but another error was produced: %s`, h.Path) - return inputs, err + if top != nil { break } + searchDir = path.Dir(searchDir) } } - raw := viper.Get("inputs").([]map[string]interface{}) - for key, input := range raw[0] { - switch key { - case "private_repositories": - repoList := input.([]map[string]interface{}) - repos, err := getRepositoryMap(repoList) - if err != nil { - log.Fatalf("Error in getRepositoryMap: %s", err) - return inputs, err - } - inputs.PrivateRepositories = repos - case "public_repositories": - repoList := input.([]map[string]interface{}) - repos, err := getRepositoryMap(repoList) - if err != nil { - log.Fatalf("Error in getRepositoryMap: %s", err) - return inputs, err - } - inputs.PublicRepositories = repos - case "default_repository_team_permissions": - permissions := make(map[string]string) - inputArr := input.([]map[string]interface{}) - drtpsArr := inputArr[0] - for permission, value := range drtpsArr { - permissions[permission] = value.(string) - } - inputs.DefaultRepositoryTeamPermissions = permissions - default: - log.Fatalf("Unknown input: %s", key) - return inputs, nil + if top == nil { return inputs, nil } + + for key, input := range top { + switch key { + case "private_repositories": + repos, rerr := getRepositoryMap(input) + if rerr != nil { log.Printf("private_repositories parse error in %s: %v", h.Path, rerr); continue } + inputs.PrivateRepositories = repos + case "public_repositories": + repos, rerr := getRepositoryMap(input) + if rerr != nil { log.Printf("public_repositories parse error in %s: %v", h.Path, rerr); continue } + inputs.PublicRepositories = repos + case "default_repository_team_permissions": + permissions := make(map[string]string) + switch typed := input.(type) { + case []map[string]interface{}: + if len(typed) > 0 { + for permission, value := range typed[0] { + if s, ok := value.(string); ok { permissions[permission] = s } + } + } + case map[string]interface{}: + for permission, value := range typed { + if s, ok := value.(string); ok { permissions[permission] = s } + } + } + inputs.DefaultRepositoryTeamPermissions = permissions + default: + // ignore forward-compatible keys + } + } + return inputs, nil +} + +// Recursively convert cty.Value into native Go types (map[string]interface{}, []interface{}, primitives) +func ctyToInterface(v cty.Value) interface{} { + if !v.IsKnown() || v.IsNull() { + return nil + } + t := v.Type() + switch { + case t.IsObjectType() || t.IsMapType(): + result := make(map[string]interface{}) + for k, cv := range v.AsValueMap() { + result[k] = ctyToInterface(cv) + } + return result + case t.IsTupleType() || t.IsListType() || t.IsSetType(): + var list []interface{} + it := v.ElementIterator() + for it.Next() { + _, elem := it.Element() + list = append(list, ctyToInterface(elem)) + } + return list + case t == cty.String: + return v.AsString() + case t == cty.Bool: + return v.True() // AsBool is ambiguous? Use v.True() only if value is true else false via v.False when appropriate + default: + // Attempt numeric + if t == cty.Number { + f, _ := v.AsBigFloat().Float64() + return f } } - - return inputs, nil + return v.GoString() } @@ -213,18 +436,23 @@ func (h *HCLFile) GetLocalsMap() map[string]string { // If the path is set, read the file and return the locals content, err := afero.ReadFile(fs, h.Path) if err != nil { - log.Fatalf(`GetLocalsMap: unable to read config file: %s`, h.Path) + log.Printf(`GetLocalsMap: unable to read config file: %s`, h.Path) return make(map[string]string) } - macthes := getLocalsBlock(string(content)) + _, macthes := getLocalsBlock(string(content)) locals := make(map[string]string) for _, match := range macthes { - locals[match[1]] = match[2] + if len(match) >= 2 { + // Trim any surrounding single or double quotes from the value + value := strings.Trim(match[1], "'\"") + locals[match[0]] = value + } } return locals } + func NewTerragruntPlanFile(name string, modulePath string, moduleDir string, outputFilePath string) (*PlanFile, error) { // If there is a file conflict with the output file, create a new file with a "copy_" prefix if _, err := fs.Stat(outputFilePath); err == nil { diff --git a/internal/pkg/types/terragrunt/terragrunt_test.go b/internal/pkg/types/terragrunt/terragrunt_test.go index c1ffd8d..ce60dea 100644 --- a/internal/pkg/types/terragrunt/terragrunt_test.go +++ b/internal/pkg/types/terragrunt/terragrunt_test.go @@ -6,6 +6,7 @@ import ( "gh_foundations/internal/pkg/types" typeMocks "gh_foundations/internal/pkg/types/mocks" "io" + "path/filepath" "testing" "github.com/spf13/afero" @@ -300,3 +301,404 @@ func (suite *TerragruntArchiveTestSuite) TestPlanFileGetStateExplorerUnsupported fs.Remove(fileName) } + +// New test to validate parsing of inputs with a locals block and local references +func TestGetInputsFromFile_WithLocals(t *testing.T) { + fs = afero.NewMemMapFs() + fileName := "terragrunt.hcl" + contents := ` +locals { + repo_description = "Example repository" +} + +inputs = { + private_repositories = { + sample = { + description = local.repo_description + default_branch = "main" + advance_security = true + has_vulnerability_alerts = true + topics = ["a", "b"] + homepage = "https://example.com" + delete_head_on_merge = true + requires_web_commit_signing = true + dependabot_security_updates = true + protected_branches = ["main"] + allow_auto_merge = false + } + } + public_repositories = {} +} +` + err := afero.WriteFile(fs, fileName, []byte(contents), 0644) + require.NoError(t, err) + + hclFile := HCLFile{Path: fileName} + inputs, err := hclFile.GetInputsFromFile() + require.NoError(t, err) + if len(inputs.PrivateRepositories) != 1 { + t.Fatalf("expected 1 private repo, got %d", len(inputs.PrivateRepositories)) + } + repo := inputs.PrivateRepositories["sample"] + if repo.Description != "Example repository" { + t.Fatalf("expected description to be 'Example repository', got %s", repo.Description) + } +} + +// TestGetIncludedFilePath tests detecting and resolving include blocks +func TestGetIncludedFilePath(t *testing.T) { + fs = afero.NewMemMapFs() + + testCases := []struct { + name string + contents string + currentPath string + setupFiles map[string]string // path -> content + expectedPath string + expectNotFound bool + }{ + { + name: "finds root.hcl in parent directory", + contents: ` +include "root" { + path = find_in_parent_folders("root.hcl") +} +`, + currentPath: "/project/org/repositories/terragrunt.hcl", + setupFiles: map[string]string{ + "/project/org/root.hcl": "# root config", + }, + expectedPath: "/project/org/root.hcl", + }, + { + name: "finds root.hcl in grandparent directory", + contents: ` +include "root" { + path = find_in_parent_folders("root.hcl") +} +`, + currentPath: "/project/org/subdir/repositories/terragrunt.hcl", + setupFiles: map[string]string{ + "/project/org/root.hcl": "# root config", + }, + expectedPath: "/project/org/root.hcl", + }, + { + name: "no include block", + contents: ` +inputs = { + test = "value" +} +`, + currentPath: "/project/terragrunt.hcl", + setupFiles: map[string]string{}, + expectNotFound: true, + }, + { + name: "file not found", + contents: ` +include "root" { + path = find_in_parent_folders("nonexistent.hcl") +} +`, + currentPath: "/project/org/terragrunt.hcl", + setupFiles: map[string]string{}, + expectNotFound: true, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + fs = afero.NewMemMapFs() + + // Setup parent files + for path, content := range tc.setupFiles { + dir := filepath.Dir(path) + err := fs.MkdirAll(dir, 0755) + require.NoError(t, err) + err = afero.WriteFile(fs, path, []byte(content), 0644) + require.NoError(t, err) + } + + result := getIncludedFilePath(tc.contents, tc.currentPath) + + if tc.expectNotFound { + assert.Empty(t, result, "should not find any file") + } else { + assert.Equal(t, tc.expectedPath, result) + } + }) + } +} + +// TestGetInputsFromFile_WithInclude tests resolving inputs from included parent files +func TestGetInputsFromFile_WithInclude(t *testing.T) { + fs = afero.NewMemMapFs() + + // Create parent root.hcl with inputs + rootContent := ` +inputs = { + private_repositories = { + included-repo = { + description = "Repository from parent" + default_branch = "main" + } + } + public_repositories = {} + default_repository_team_permissions = {} +} +` + err := fs.MkdirAll("/project/org", 0755) + require.NoError(t, err) + err = afero.WriteFile(fs, "/project/org/root.hcl", []byte(rootContent), 0644) + require.NoError(t, err) + + // Create child terragrunt.hcl that includes parent + childContent := ` +include "root" { + path = find_in_parent_folders("root.hcl") +} + +locals { + project_name = "test-project" +} +` + err = fs.MkdirAll("/project/org/repositories", 0755) + require.NoError(t, err) + err = afero.WriteFile(fs, "/project/org/repositories/terragrunt.hcl", []byte(childContent), 0644) + require.NoError(t, err) + + // Test reading from child file + hclFile := HCLFile{Path: "/project/org/repositories/terragrunt.hcl"} + inputs, err := hclFile.GetInputsFromFile() + require.NoError(t, err) + + // Should have resolved inputs from parent + assert.Equal(t, 1, len(inputs.PrivateRepositories)) + repo, exists := inputs.PrivateRepositories["included-repo"] + assert.True(t, exists) + assert.Equal(t, "Repository from parent", repo.Description) +} + +// TestGetInputsFromFile_DirectInputs tests reading direct inputs (backward compatibility) +func TestGetInputsFromFile_DirectInputs(t *testing.T) { + fs = afero.NewMemMapFs() + + content := ` +inputs = { + private_repositories = { + direct-repo = { + description = "Direct repository" + default_branch = "main" + } + } + public_repositories = {} + default_repository_team_permissions = {} +} +` + fileName := "terragrunt.hcl" + err := afero.WriteFile(fs, fileName, []byte(content), 0644) + require.NoError(t, err) + + hclFile := HCLFile{Path: fileName} + inputs, err := hclFile.GetInputsFromFile() + require.NoError(t, err) + + assert.Equal(t, 1, len(inputs.PrivateRepositories)) + repo, exists := inputs.PrivateRepositories["direct-repo"] + assert.True(t, exists) + assert.Equal(t, "Direct repository", repo.Description) +} + +// TestGetInputsFromFile_CircularInclude tests circular include detection +func TestGetInputsFromFile_CircularInclude(t *testing.T) { + fs = afero.NewMemMapFs() + + // Create fileA that includes fileB + fileAContent := ` +include "root" { + path = find_in_parent_folders("fileB.hcl") +} +` + err := fs.MkdirAll("/project", 0755) + require.NoError(t, err) + err = afero.WriteFile(fs, "/project/fileA.hcl", []byte(fileAContent), 0644) + require.NoError(t, err) + + // Create fileB that includes fileA (circular) + fileBContent := ` +include "root" { + path = find_in_parent_folders("fileA.hcl") +} +` + err = afero.WriteFile(fs, "/project/fileB.hcl", []byte(fileBContent), 0644) + require.NoError(t, err) + + // Try to read fileA (which will try to include fileB, which tries to include fileA) + hclFile := HCLFile{Path: "/project/fileA.hcl"} + inputs, err := hclFile.GetInputsFromFile() + + // Should return empty inputs without crashing + assert.NoError(t, err) + assert.Equal(t, 0, len(inputs.PrivateRepositories)) + assert.Equal(t, 0, len(inputs.PublicRepositories)) +} + +// TestGetInputsFromFile_NoInputsSection tests files without inputs section +func TestGetInputsFromFile_NoInputsSection(t *testing.T) { + fs = afero.NewMemMapFs() + + content := ` +locals { + project_name = "test" +} +` + fileName := "terragrunt.hcl" + err := afero.WriteFile(fs, fileName, []byte(content), 0644) + require.NoError(t, err) + + hclFile := HCLFile{Path: fileName} + inputs, err := hclFile.GetInputsFromFile() + + // Should return empty inputs without error + assert.NoError(t, err) + assert.Equal(t, 0, len(inputs.PrivateRepositories)) + assert.Equal(t, 0, len(inputs.PublicRepositories)) +} + +// TestGetInputsFromFile_EmptyInputs tests files with empty inputs +func TestGetInputsFromFile_EmptyInputs(t *testing.T) { + fs = afero.NewMemMapFs() + + content := ` +inputs = {} +` + fileName := "terragrunt.hcl" + err := afero.WriteFile(fs, fileName, []byte(content), 0644) + require.NoError(t, err) + + hclFile := HCLFile{Path: fileName} + inputs, err := hclFile.GetInputsFromFile() + + assert.NoError(t, err) + assert.Equal(t, 0, len(inputs.PrivateRepositories)) + assert.Equal(t, 0, len(inputs.PublicRepositories)) +} + +// TestGetInputsFromFile_MultipleRepositories tests parsing multiple repositories +func TestGetInputsFromFile_MultipleRepositories(t *testing.T) { + fs = afero.NewMemMapFs() + + content := ` +inputs = { + private_repositories = { + repo1 = { + description = "First repo" + default_branch = "main" + } + repo2 = { + description = "Second repo" + default_branch = "develop" + } + } + public_repositories = { + public-repo = { + description = "Public repo" + default_branch = "main" + } + } + default_repository_team_permissions = {} +} +` + fileName := "terragrunt.hcl" + err := afero.WriteFile(fs, fileName, []byte(content), 0644) + require.NoError(t, err) + + hclFile := HCLFile{Path: fileName} + inputs, err := hclFile.GetInputsFromFile() + require.NoError(t, err) + + assert.Equal(t, 2, len(inputs.PrivateRepositories)) + assert.Equal(t, 1, len(inputs.PublicRepositories)) + + repo1, exists := inputs.PrivateRepositories["repo1"] + assert.True(t, exists) + assert.Equal(t, "First repo", repo1.Description) + + repo2, exists := inputs.PrivateRepositories["repo2"] + assert.True(t, exists) + assert.Equal(t, "Second repo", repo2.Description) + + publicRepo, exists := inputs.PublicRepositories["public-repo"] + assert.True(t, exists) + assert.Equal(t, "Public repo", publicRepo.Description) +} + +// TestGetLocalsMap tests extracting locals from HCL files +func TestGetLocalsMap(t *testing.T) { + fs = afero.NewMemMapFs() + + testCases := []struct { + name string + content string + expected map[string]string + }{ + { + name: "simple locals", + content: ` +locals { + organization_name = "test-org" + region = "us-east-1" +} +`, + expected: map[string]string{ + "organization_name": "test-org", + "region": "us-east-1", + }, + }, + { + name: "no locals", + content: ` +inputs = { + test = "value" +} +`, + expected: map[string]string{}, + }, + { + name: "locals with various types", + content: ` +locals { + string_value = "test" + number_value = 42 + bool_value = true +} +`, + expected: map[string]string{ + "string_value": "test", + "number_value": "42", + "bool_value": "true", + }, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + fileName := "test.hcl" + err := afero.WriteFile(fs, fileName, []byte(tc.content), 0644) + require.NoError(t, err) + + hclFile := HCLFile{Path: fileName} + result := hclFile.GetLocalsMap() + + for key, expectedValue := range tc.expected { + actualValue, exists := result[key] + assert.True(t, exists, "key %s should exist", key) + assert.Equal(t, expectedValue, actualValue, "value for key %s", key) + } + + // Check no extra keys + assert.Equal(t, len(tc.expected), len(result)) + }) + } +}